font-switcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ // vite-plugin.ts
2
+ import path2 from "path";
3
+
4
+ // src/scanner.ts
5
+ import fs from "fs";
6
+ import path from "path";
7
+ var SYSTEM_FONTS = /* @__PURE__ */ new Set([
8
+ "helvetica",
9
+ "arial",
10
+ "georgia",
11
+ "times",
12
+ "courier",
13
+ "verdana",
14
+ "trebuchet",
15
+ "impact",
16
+ "tahoma",
17
+ "sans-serif",
18
+ "serif",
19
+ "monospace",
20
+ "system-ui"
21
+ ]);
22
+ function scanFontsFromCSS(cssFile) {
23
+ const resolved = path.resolve(cssFile);
24
+ if (!fs.existsSync(resolved)) {
25
+ console.warn(`[font-switcher] cssFile not found: ${resolved}`);
26
+ return [];
27
+ }
28
+ const css = fs.readFileSync(resolved, "utf8");
29
+ const found = [];
30
+ const seen = /* @__PURE__ */ new Set();
31
+ const faceBlockRe = /@font-face\s*\{([^}]+)\}/g;
32
+ for (const m of css.matchAll(faceBlockRe)) {
33
+ const block = m[1];
34
+ const familyM = /font-family\s*:\s*['"]?([^'";,\n]+)['"]?/.exec(block);
35
+ if (!familyM) continue;
36
+ const family = familyM[1].trim().replace(/['"]/g, "");
37
+ const key = `face:${family.toLowerCase()}`;
38
+ if (seen.has(key)) continue;
39
+ seen.add(key);
40
+ const srcDecl = /src\s*:[^;}]*/.exec(block)?.[0] ?? "";
41
+ const urls = [...srcDecl.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)].map((u) => u[1].trim());
42
+ const parseable = urls.find((u) => /\.(woff|ttf|otf)(\?|#|$)/i.test(u));
43
+ const fontFileSrc = parseable ?? urls[0];
44
+ found.push({ family, source: "@font-face", fontFileSrc });
45
+ }
46
+ const varRe = /(--font-[\w-]+)\s*:\s*['"]?([^'",;\n]+)['"]?/g;
47
+ const lengthLike = /^\d|^(normal|bold|italic|oblique|inherit|initial|unset)$/i;
48
+ for (const m of css.matchAll(varRe)) {
49
+ const varName = m[1].trim();
50
+ const family = m[2].trim().replace(/['"]/g, "");
51
+ if (lengthLike.test(family)) continue;
52
+ const key = `var:${varName}`;
53
+ if (!seen.has(key)) {
54
+ seen.add(key);
55
+ found.push({ family, source: "--font-var", varName });
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+ function validateFonts(fonts, detected) {
61
+ const faceNames = new Set(
62
+ detected.filter((d) => d.source === "@font-face").map((d) => d.family.toLowerCase())
63
+ );
64
+ const varNames = new Set(
65
+ detected.filter((d) => d.source === "--font-var").map((d) => d.varName)
66
+ );
67
+ for (const font of fonts) {
68
+ if (font.cssFamily.includes("var(")) {
69
+ const m = font.cssFamily.match(/var\((--font-[\w-]+)/);
70
+ if (m && !varNames.has(m[1])) {
71
+ console.warn(
72
+ `[font-switcher] "${font.key}" references "${m[1]}" \u2014 not found in CSS file`
73
+ );
74
+ }
75
+ } else {
76
+ const first = font.cssFamily.split(",")[0].trim().replace(/['"]/g, "").toLowerCase();
77
+ if (!SYSTEM_FONTS.has(first) && !faceNames.has(first)) {
78
+ console.warn(
79
+ `[font-switcher] "${font.key}" family "${first}" not found in any @font-face declaration`
80
+ );
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // src/capsize.ts
87
+ async function computeTrims(metrics, fontSize) {
88
+ try {
89
+ const { createStyleObject } = await import("@capsizecss/core");
90
+ const style = createStyleObject({ fontSize, fontMetrics: metrics });
91
+ const capTrim = style["::before"].marginBottom;
92
+ const baseTrim = style["::after"].marginTop;
93
+ return { capTrim, baseTrim };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // src/css-gen.ts
100
+ async function generateFontCSS(config) {
101
+ if (config.finalFont) return "";
102
+ const { fontSize = 16, capsizeSelector = ".capsize", fonts } = config;
103
+ const lines = [];
104
+ for (const font of fonts) {
105
+ lines.push(`html[data-font="${font.key}"] { --font-active: ${font.cssFamily}; }`);
106
+ if (capsizeSelector !== false && font.metrics) {
107
+ const trims = await computeTrims(font.metrics, fontSize);
108
+ if (trims) {
109
+ lines.push(
110
+ `html[data-font="${font.key}"] ${capsizeSelector} { --cap-trim: ${trims.capTrim}; --base-trim: ${trims.baseTrim}; }`
111
+ );
112
+ }
113
+ }
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+
118
+ // src/font-features.ts
119
+ import { existsSync } from "fs";
120
+
121
+ // src/feature-labels.ts
122
+ var BROWSER_DEFAULT_ON = /* @__PURE__ */ new Set(["liga", "clig", "calt", "kern", "locl", "mark", "mkmk"]);
123
+ var SKIP_TAGS = /* @__PURE__ */ new Set([
124
+ "DFLT",
125
+ "dflt",
126
+ "locl",
127
+ "mark",
128
+ "mkmk",
129
+ "dist",
130
+ "rlig",
131
+ "curs",
132
+ "abvm",
133
+ "blwm",
134
+ "abvs",
135
+ "blws",
136
+ "pres",
137
+ "psts",
138
+ "haln",
139
+ "half",
140
+ "akhn",
141
+ "rphf",
142
+ "blwf",
143
+ "abvf",
144
+ "pref",
145
+ "rkrf",
146
+ "vatu",
147
+ "cjct",
148
+ "nukt",
149
+ "isol",
150
+ "init",
151
+ "medi",
152
+ "fina",
153
+ "med2",
154
+ "fin2",
155
+ "fin3",
156
+ "vert",
157
+ "vrt2",
158
+ "cfar"
159
+ ]);
160
+ var FEATURE_LABELS = {
161
+ liga: "Standard Ligatures",
162
+ clig: "Contextual Ligatures",
163
+ dlig: "Discretionary Ligatures",
164
+ hlig: "Historical Ligatures",
165
+ calt: "Contextual Alternates",
166
+ kern: "Kerning",
167
+ swsh: "Swash",
168
+ case: "Case-Sensitive Forms",
169
+ onum: "Oldstyle Figures",
170
+ tnum: "Tabular Figures",
171
+ pnum: "Proportional Figures",
172
+ lnum: "Lining Figures",
173
+ frac: "Fractions",
174
+ afrc: "Alternative Fractions",
175
+ sups: "Superscript",
176
+ subs: "Subscript",
177
+ smcp: "Small Capitals",
178
+ c2sc: "Caps to Small Caps",
179
+ pcap: "Petite Capitals",
180
+ cpsp: "Capital Spacing",
181
+ zero: "Slashed Zero",
182
+ ordn: "Ordinals",
183
+ hist: "Historical Forms",
184
+ nalt: "Alternate Annotation",
185
+ salt: "Stylistic Alternates",
186
+ titl: "Titling",
187
+ ...Object.fromEntries(
188
+ Array.from({ length: 20 }, (_, i) => {
189
+ const n = String(i + 1).padStart(2, "0");
190
+ return [`ss${n}`, `Stylistic Set ${i + 1}`];
191
+ })
192
+ ),
193
+ ...Object.fromEntries(
194
+ Array.from({ length: 6 }, (_, i) => {
195
+ const n = String(i + 1).padStart(2, "0");
196
+ return [`cv${n}`, `Character Variant ${i + 1}`];
197
+ })
198
+ )
199
+ };
200
+ function featuresFromTags(tags) {
201
+ return tags.filter((tag) => !SKIP_TAGS.has(tag)).map((tag) => ({
202
+ tag,
203
+ label: FEATURE_LABELS[tag] ?? tag,
204
+ enabled: BROWSER_DEFAULT_ON.has(tag)
205
+ }));
206
+ }
207
+
208
+ // src/font-features.ts
209
+ async function detectFeaturesFromFile(fontFilePath) {
210
+ if (!existsSync(fontFilePath)) return [];
211
+ try {
212
+ const mod = await import("opentype.js");
213
+ const loadSync = mod.loadSync ?? mod.default?.loadSync;
214
+ if (!loadSync) return [];
215
+ const font = loadSync(fontFilePath);
216
+ const tags = /* @__PURE__ */ new Set();
217
+ for (const table of [font.tables.gsub, font.tables.gpos]) {
218
+ for (const rec of table?.featureList?.featureRecords ?? []) {
219
+ if (rec?.featureTag) tags.add(rec.featureTag.trim());
220
+ }
221
+ }
222
+ return featuresFromTags([...tags]);
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ // vite-plugin.ts
229
+ var VIRTUAL_ID = "virtual:font-switcher/styles";
230
+ var RESOLVED_ID = "\0virtual:font-switcher/styles";
231
+ var PROD_WARNING = "\n[font-switcher] Overlay is included in this production build.\n Set finalFont in your config when the client picks a font, then remove the package.\n";
232
+ async function detectAllFeatures(config, root) {
233
+ const cssFile = path2.resolve(root, config.cssFile ?? "src/styles/index.css");
234
+ const detected = scanFontsFromCSS(cssFile);
235
+ const fontFaceMap = /* @__PURE__ */ new Map();
236
+ const varMap = /* @__PURE__ */ new Map();
237
+ for (const d of detected) {
238
+ if (d.source === "@font-face" && d.fontFileSrc) {
239
+ fontFaceMap.set(d.family.toLowerCase(), d.fontFileSrc);
240
+ }
241
+ if (d.source === "--font-var" && d.varName) {
242
+ varMap.set(d.varName, d.family);
243
+ }
244
+ }
245
+ const result = {};
246
+ for (const font of config.fonts) {
247
+ if (font.features?.length) {
248
+ result[font.key] = font.features;
249
+ continue;
250
+ }
251
+ let familyName;
252
+ if (font.cssFamily.includes("var(")) {
253
+ const m = font.cssFamily.match(/var\((--font-[\w-]+)/);
254
+ if (m) {
255
+ const resolved2 = varMap.get(m[1]);
256
+ familyName = resolved2?.split(",")[0].trim().replace(/['"]/g, "");
257
+ }
258
+ } else {
259
+ familyName = font.cssFamily.split(",")[0].trim().replace(/['"]/g, "");
260
+ }
261
+ if (!familyName) continue;
262
+ const fontFileSrc = fontFaceMap.get(familyName.toLowerCase());
263
+ if (!fontFileSrc || fontFileSrc.startsWith("http")) continue;
264
+ const resolved = path2.resolve(root, fontFileSrc.startsWith("/") ? fontFileSrc.slice(1) : fontFileSrc);
265
+ const features = await detectFeaturesFromFile(resolved);
266
+ if (features.length) result[font.key] = features;
267
+ }
268
+ return result;
269
+ }
270
+ function fontSwitcherPlugin(config) {
271
+ let cssCache = null;
272
+ let detectedFeatures = null;
273
+ let root = process.cwd();
274
+ async function buildCSS() {
275
+ if (config.finalFont) return "";
276
+ const cssFile = path2.resolve(root, config.cssFile ?? "src/styles/index.css");
277
+ const detected = scanFontsFromCSS(cssFile);
278
+ validateFonts(config.fonts, detected);
279
+ return generateFontCSS(config);
280
+ }
281
+ async function getDetectedFeatures() {
282
+ if (detectedFeatures === null) {
283
+ detectedFeatures = config.finalFont ? {} : await detectAllFeatures(config, root);
284
+ }
285
+ return detectedFeatures;
286
+ }
287
+ return {
288
+ name: "font-switcher",
289
+ configResolved(resolvedConfig) {
290
+ root = resolvedConfig.root;
291
+ },
292
+ resolveId(id) {
293
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
294
+ },
295
+ async load(id) {
296
+ if (id !== RESOLVED_ID) return;
297
+ if (cssCache === null) cssCache = await buildCSS();
298
+ const features = await getDetectedFeatures();
299
+ const hasFeatures = Object.keys(features).length > 0;
300
+ const hasCSS = !!cssCache;
301
+ if (!hasCSS && !hasFeatures) return 'export default ""';
302
+ return `
303
+ const css = ${JSON.stringify(cssCache ?? "")};
304
+ const features = ${JSON.stringify(features)};
305
+ if (typeof window !== 'undefined') {
306
+ window.__fontSwitcherFeatures = features;
307
+ }
308
+ if (typeof document !== 'undefined' && css) {
309
+ const el = document.createElement('style');
310
+ el.setAttribute('data-font-switcher', '');
311
+ el.textContent = css;
312
+ document.head.appendChild(el);
313
+ }
314
+ export default css;
315
+ `;
316
+ },
317
+ async buildStart() {
318
+ cssCache = await buildCSS();
319
+ detectedFeatures = config.finalFont ? {} : await detectAllFeatures(config, root);
320
+ },
321
+ generateBundle() {
322
+ if (!config.finalFont) {
323
+ this.warn(PROD_WARNING);
324
+ }
325
+ },
326
+ configureServer(server) {
327
+ const cssFile = path2.resolve(root, config.cssFile ?? "src/styles/index.css");
328
+ server.watcher.add(cssFile);
329
+ server.watcher.on("change", async (file) => {
330
+ if (path2.resolve(file) !== cssFile) return;
331
+ cssCache = null;
332
+ detectedFeatures = null;
333
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
334
+ if (mod) server.moduleGraph.invalidateModule(mod);
335
+ server.ws.send({ type: "full-reload" });
336
+ });
337
+ }
338
+ };
339
+ }
340
+ export {
341
+ fontSwitcherPlugin
342
+ };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "font-switcher",
3
+ "version": "0.1.0",
4
+ "description": "Live font-switching overlay for web projects",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/hafeezdaw00d/xxfontswitcherxx.git"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs"
19
+ },
20
+ "./react": {
21
+ "types": "./dist/react/index.d.ts",
22
+ "import": "./dist/react/index.js",
23
+ "require": "./dist/react/index.cjs"
24
+ },
25
+ "./vite": {
26
+ "types": "./dist/vite-plugin.d.ts",
27
+ "import": "./dist/vite-plugin.js",
28
+ "require": "./dist/vite-plugin.cjs"
29
+ },
30
+ "./script": {
31
+ "types": "./dist/script.d.ts",
32
+ "import": "./dist/script.js",
33
+ "require": "./dist/script.cjs"
34
+ }
35
+ },
36
+ "bin": {
37
+ "font-switcher": "dist/bin/cli.js"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "dev": "tsup --watch",
42
+ "typecheck": "tsc --noEmit",
43
+ "prepublishOnly": "npm run build"
44
+ },
45
+ "peerDependencies": {
46
+ "@capsizecss/core": ">=4",
47
+ "opentype.js": ">=1",
48
+ "react": ">=18",
49
+ "vite": ">=5"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@capsizecss/core": {
53
+ "optional": true
54
+ },
55
+ "opentype.js": {
56
+ "optional": true
57
+ },
58
+ "react": {
59
+ "optional": true
60
+ },
61
+ "vite": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "@capsizecss/core": "^4.1.3",
67
+ "@types/node": "^22.0.0",
68
+ "@types/react": "^18.0.0",
69
+ "opentype.js": "^1.3.4",
70
+ "tsup": "^8.0.0",
71
+ "typescript": "^5.0.0",
72
+ "vite": "^6.0.0"
73
+ }
74
+ }