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,377 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // vite-plugin.ts
31
+ var vite_plugin_exports = {};
32
+ __export(vite_plugin_exports, {
33
+ fontSwitcherPlugin: () => fontSwitcherPlugin
34
+ });
35
+ module.exports = __toCommonJS(vite_plugin_exports);
36
+ var import_node_path2 = __toESM(require("path"), 1);
37
+
38
+ // src/scanner.ts
39
+ var import_node_fs = __toESM(require("fs"), 1);
40
+ var import_node_path = __toESM(require("path"), 1);
41
+ var SYSTEM_FONTS = /* @__PURE__ */ new Set([
42
+ "helvetica",
43
+ "arial",
44
+ "georgia",
45
+ "times",
46
+ "courier",
47
+ "verdana",
48
+ "trebuchet",
49
+ "impact",
50
+ "tahoma",
51
+ "sans-serif",
52
+ "serif",
53
+ "monospace",
54
+ "system-ui"
55
+ ]);
56
+ function scanFontsFromCSS(cssFile) {
57
+ const resolved = import_node_path.default.resolve(cssFile);
58
+ if (!import_node_fs.default.existsSync(resolved)) {
59
+ console.warn(`[font-switcher] cssFile not found: ${resolved}`);
60
+ return [];
61
+ }
62
+ const css = import_node_fs.default.readFileSync(resolved, "utf8");
63
+ const found = [];
64
+ const seen = /* @__PURE__ */ new Set();
65
+ const faceBlockRe = /@font-face\s*\{([^}]+)\}/g;
66
+ for (const m of css.matchAll(faceBlockRe)) {
67
+ const block = m[1];
68
+ const familyM = /font-family\s*:\s*['"]?([^'";,\n]+)['"]?/.exec(block);
69
+ if (!familyM) continue;
70
+ const family = familyM[1].trim().replace(/['"]/g, "");
71
+ const key = `face:${family.toLowerCase()}`;
72
+ if (seen.has(key)) continue;
73
+ seen.add(key);
74
+ const srcDecl = /src\s*:[^;}]*/.exec(block)?.[0] ?? "";
75
+ const urls = [...srcDecl.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)].map((u) => u[1].trim());
76
+ const parseable = urls.find((u) => /\.(woff|ttf|otf)(\?|#|$)/i.test(u));
77
+ const fontFileSrc = parseable ?? urls[0];
78
+ found.push({ family, source: "@font-face", fontFileSrc });
79
+ }
80
+ const varRe = /(--font-[\w-]+)\s*:\s*['"]?([^'",;\n]+)['"]?/g;
81
+ const lengthLike = /^\d|^(normal|bold|italic|oblique|inherit|initial|unset)$/i;
82
+ for (const m of css.matchAll(varRe)) {
83
+ const varName = m[1].trim();
84
+ const family = m[2].trim().replace(/['"]/g, "");
85
+ if (lengthLike.test(family)) continue;
86
+ const key = `var:${varName}`;
87
+ if (!seen.has(key)) {
88
+ seen.add(key);
89
+ found.push({ family, source: "--font-var", varName });
90
+ }
91
+ }
92
+ return found;
93
+ }
94
+ function validateFonts(fonts, detected) {
95
+ const faceNames = new Set(
96
+ detected.filter((d) => d.source === "@font-face").map((d) => d.family.toLowerCase())
97
+ );
98
+ const varNames = new Set(
99
+ detected.filter((d) => d.source === "--font-var").map((d) => d.varName)
100
+ );
101
+ for (const font of fonts) {
102
+ if (font.cssFamily.includes("var(")) {
103
+ const m = font.cssFamily.match(/var\((--font-[\w-]+)/);
104
+ if (m && !varNames.has(m[1])) {
105
+ console.warn(
106
+ `[font-switcher] "${font.key}" references "${m[1]}" \u2014 not found in CSS file`
107
+ );
108
+ }
109
+ } else {
110
+ const first = font.cssFamily.split(",")[0].trim().replace(/['"]/g, "").toLowerCase();
111
+ if (!SYSTEM_FONTS.has(first) && !faceNames.has(first)) {
112
+ console.warn(
113
+ `[font-switcher] "${font.key}" family "${first}" not found in any @font-face declaration`
114
+ );
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ // src/capsize.ts
121
+ async function computeTrims(metrics, fontSize) {
122
+ try {
123
+ const { createStyleObject } = await import("@capsizecss/core");
124
+ const style = createStyleObject({ fontSize, fontMetrics: metrics });
125
+ const capTrim = style["::before"].marginBottom;
126
+ const baseTrim = style["::after"].marginTop;
127
+ return { capTrim, baseTrim };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ // src/css-gen.ts
134
+ async function generateFontCSS(config) {
135
+ if (config.finalFont) return "";
136
+ const { fontSize = 16, capsizeSelector = ".capsize", fonts } = config;
137
+ const lines = [];
138
+ for (const font of fonts) {
139
+ lines.push(`html[data-font="${font.key}"] { --font-active: ${font.cssFamily}; }`);
140
+ if (capsizeSelector !== false && font.metrics) {
141
+ const trims = await computeTrims(font.metrics, fontSize);
142
+ if (trims) {
143
+ lines.push(
144
+ `html[data-font="${font.key}"] ${capsizeSelector} { --cap-trim: ${trims.capTrim}; --base-trim: ${trims.baseTrim}; }`
145
+ );
146
+ }
147
+ }
148
+ }
149
+ return lines.join("\n");
150
+ }
151
+
152
+ // src/font-features.ts
153
+ var import_node_fs2 = require("fs");
154
+
155
+ // src/feature-labels.ts
156
+ var BROWSER_DEFAULT_ON = /* @__PURE__ */ new Set(["liga", "clig", "calt", "kern", "locl", "mark", "mkmk"]);
157
+ var SKIP_TAGS = /* @__PURE__ */ new Set([
158
+ "DFLT",
159
+ "dflt",
160
+ "locl",
161
+ "mark",
162
+ "mkmk",
163
+ "dist",
164
+ "rlig",
165
+ "curs",
166
+ "abvm",
167
+ "blwm",
168
+ "abvs",
169
+ "blws",
170
+ "pres",
171
+ "psts",
172
+ "haln",
173
+ "half",
174
+ "akhn",
175
+ "rphf",
176
+ "blwf",
177
+ "abvf",
178
+ "pref",
179
+ "rkrf",
180
+ "vatu",
181
+ "cjct",
182
+ "nukt",
183
+ "isol",
184
+ "init",
185
+ "medi",
186
+ "fina",
187
+ "med2",
188
+ "fin2",
189
+ "fin3",
190
+ "vert",
191
+ "vrt2",
192
+ "cfar"
193
+ ]);
194
+ var FEATURE_LABELS = {
195
+ liga: "Standard Ligatures",
196
+ clig: "Contextual Ligatures",
197
+ dlig: "Discretionary Ligatures",
198
+ hlig: "Historical Ligatures",
199
+ calt: "Contextual Alternates",
200
+ kern: "Kerning",
201
+ swsh: "Swash",
202
+ case: "Case-Sensitive Forms",
203
+ onum: "Oldstyle Figures",
204
+ tnum: "Tabular Figures",
205
+ pnum: "Proportional Figures",
206
+ lnum: "Lining Figures",
207
+ frac: "Fractions",
208
+ afrc: "Alternative Fractions",
209
+ sups: "Superscript",
210
+ subs: "Subscript",
211
+ smcp: "Small Capitals",
212
+ c2sc: "Caps to Small Caps",
213
+ pcap: "Petite Capitals",
214
+ cpsp: "Capital Spacing",
215
+ zero: "Slashed Zero",
216
+ ordn: "Ordinals",
217
+ hist: "Historical Forms",
218
+ nalt: "Alternate Annotation",
219
+ salt: "Stylistic Alternates",
220
+ titl: "Titling",
221
+ ...Object.fromEntries(
222
+ Array.from({ length: 20 }, (_, i) => {
223
+ const n = String(i + 1).padStart(2, "0");
224
+ return [`ss${n}`, `Stylistic Set ${i + 1}`];
225
+ })
226
+ ),
227
+ ...Object.fromEntries(
228
+ Array.from({ length: 6 }, (_, i) => {
229
+ const n = String(i + 1).padStart(2, "0");
230
+ return [`cv${n}`, `Character Variant ${i + 1}`];
231
+ })
232
+ )
233
+ };
234
+ function featuresFromTags(tags) {
235
+ return tags.filter((tag) => !SKIP_TAGS.has(tag)).map((tag) => ({
236
+ tag,
237
+ label: FEATURE_LABELS[tag] ?? tag,
238
+ enabled: BROWSER_DEFAULT_ON.has(tag)
239
+ }));
240
+ }
241
+
242
+ // src/font-features.ts
243
+ async function detectFeaturesFromFile(fontFilePath) {
244
+ if (!(0, import_node_fs2.existsSync)(fontFilePath)) return [];
245
+ try {
246
+ const mod = await import("opentype.js");
247
+ const loadSync = mod.loadSync ?? mod.default?.loadSync;
248
+ if (!loadSync) return [];
249
+ const font = loadSync(fontFilePath);
250
+ const tags = /* @__PURE__ */ new Set();
251
+ for (const table of [font.tables.gsub, font.tables.gpos]) {
252
+ for (const rec of table?.featureList?.featureRecords ?? []) {
253
+ if (rec?.featureTag) tags.add(rec.featureTag.trim());
254
+ }
255
+ }
256
+ return featuresFromTags([...tags]);
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+
262
+ // vite-plugin.ts
263
+ var VIRTUAL_ID = "virtual:font-switcher/styles";
264
+ var RESOLVED_ID = "\0virtual:font-switcher/styles";
265
+ 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";
266
+ async function detectAllFeatures(config, root) {
267
+ const cssFile = import_node_path2.default.resolve(root, config.cssFile ?? "src/styles/index.css");
268
+ const detected = scanFontsFromCSS(cssFile);
269
+ const fontFaceMap = /* @__PURE__ */ new Map();
270
+ const varMap = /* @__PURE__ */ new Map();
271
+ for (const d of detected) {
272
+ if (d.source === "@font-face" && d.fontFileSrc) {
273
+ fontFaceMap.set(d.family.toLowerCase(), d.fontFileSrc);
274
+ }
275
+ if (d.source === "--font-var" && d.varName) {
276
+ varMap.set(d.varName, d.family);
277
+ }
278
+ }
279
+ const result = {};
280
+ for (const font of config.fonts) {
281
+ if (font.features?.length) {
282
+ result[font.key] = font.features;
283
+ continue;
284
+ }
285
+ let familyName;
286
+ if (font.cssFamily.includes("var(")) {
287
+ const m = font.cssFamily.match(/var\((--font-[\w-]+)/);
288
+ if (m) {
289
+ const resolved2 = varMap.get(m[1]);
290
+ familyName = resolved2?.split(",")[0].trim().replace(/['"]/g, "");
291
+ }
292
+ } else {
293
+ familyName = font.cssFamily.split(",")[0].trim().replace(/['"]/g, "");
294
+ }
295
+ if (!familyName) continue;
296
+ const fontFileSrc = fontFaceMap.get(familyName.toLowerCase());
297
+ if (!fontFileSrc || fontFileSrc.startsWith("http")) continue;
298
+ const resolved = import_node_path2.default.resolve(root, fontFileSrc.startsWith("/") ? fontFileSrc.slice(1) : fontFileSrc);
299
+ const features = await detectFeaturesFromFile(resolved);
300
+ if (features.length) result[font.key] = features;
301
+ }
302
+ return result;
303
+ }
304
+ function fontSwitcherPlugin(config) {
305
+ let cssCache = null;
306
+ let detectedFeatures = null;
307
+ let root = process.cwd();
308
+ async function buildCSS() {
309
+ if (config.finalFont) return "";
310
+ const cssFile = import_node_path2.default.resolve(root, config.cssFile ?? "src/styles/index.css");
311
+ const detected = scanFontsFromCSS(cssFile);
312
+ validateFonts(config.fonts, detected);
313
+ return generateFontCSS(config);
314
+ }
315
+ async function getDetectedFeatures() {
316
+ if (detectedFeatures === null) {
317
+ detectedFeatures = config.finalFont ? {} : await detectAllFeatures(config, root);
318
+ }
319
+ return detectedFeatures;
320
+ }
321
+ return {
322
+ name: "font-switcher",
323
+ configResolved(resolvedConfig) {
324
+ root = resolvedConfig.root;
325
+ },
326
+ resolveId(id) {
327
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
328
+ },
329
+ async load(id) {
330
+ if (id !== RESOLVED_ID) return;
331
+ if (cssCache === null) cssCache = await buildCSS();
332
+ const features = await getDetectedFeatures();
333
+ const hasFeatures = Object.keys(features).length > 0;
334
+ const hasCSS = !!cssCache;
335
+ if (!hasCSS && !hasFeatures) return 'export default ""';
336
+ return `
337
+ const css = ${JSON.stringify(cssCache ?? "")};
338
+ const features = ${JSON.stringify(features)};
339
+ if (typeof window !== 'undefined') {
340
+ window.__fontSwitcherFeatures = features;
341
+ }
342
+ if (typeof document !== 'undefined' && css) {
343
+ const el = document.createElement('style');
344
+ el.setAttribute('data-font-switcher', '');
345
+ el.textContent = css;
346
+ document.head.appendChild(el);
347
+ }
348
+ export default css;
349
+ `;
350
+ },
351
+ async buildStart() {
352
+ cssCache = await buildCSS();
353
+ detectedFeatures = config.finalFont ? {} : await detectAllFeatures(config, root);
354
+ },
355
+ generateBundle() {
356
+ if (!config.finalFont) {
357
+ this.warn(PROD_WARNING);
358
+ }
359
+ },
360
+ configureServer(server) {
361
+ const cssFile = import_node_path2.default.resolve(root, config.cssFile ?? "src/styles/index.css");
362
+ server.watcher.add(cssFile);
363
+ server.watcher.on("change", async (file) => {
364
+ if (import_node_path2.default.resolve(file) !== cssFile) return;
365
+ cssCache = null;
366
+ detectedFeatures = null;
367
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
368
+ if (mod) server.moduleGraph.invalidateModule(mod);
369
+ server.ws.send({ type: "full-reload" });
370
+ });
371
+ }
372
+ };
373
+ }
374
+ // Annotate the CommonJS export names for ESM import in node:
375
+ 0 && (module.exports = {
376
+ fontSwitcherPlugin
377
+ });
@@ -0,0 +1,47 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ interface FontFeature {
4
+ tag: string;
5
+ label: string;
6
+ enabled: boolean;
7
+ }
8
+ interface FontMetrics {
9
+ capHeight: number;
10
+ ascent: number;
11
+ descent: number;
12
+ lineGap: number;
13
+ unitsPerEm: number;
14
+ xHeight: number;
15
+ }
16
+ interface FontConfig {
17
+ key: string;
18
+ label: string;
19
+ /** Value for CSS font-family — can be a var() ref or a literal stack */
20
+ cssFamily: string;
21
+ /** Raw font metrics. If @capsizecss/core is installed, trims are computed from these. */
22
+ metrics?: FontMetrics;
23
+ /** OpenType feature settings. If omitted, auto-detected from font file by the Vite plugin. */
24
+ features?: FontFeature[];
25
+ }
26
+ interface FontSwitcherConfig {
27
+ fonts: FontConfig[];
28
+ defaultFont: string;
29
+ /** Path to CSS file scanned for @font-face and --font-* declarations. Default: 'src/styles/index.css' */
30
+ cssFile?: string;
31
+ /** localStorage key for the active font. Default: 'font-switcher-active' */
32
+ storageKey?: string;
33
+ /** URL param that reveals the panel. Default: 'fonts' */
34
+ revealParam?: string;
35
+ /** Keyboard shortcut that toggles the panel. Default: 'ctrl+shift+f' */
36
+ revealShortcut?: string;
37
+ /** CSS selector for capsize elements. Set false to disable trim injection. Default: '.capsize' */
38
+ capsizeSelector?: string | false;
39
+ /** Font size in px used for capsize trim computation. Default: 16 */
40
+ fontSize?: number;
41
+ /** When set: locks the font, disables all switcher output. Set this when client decides, then remove the package. */
42
+ finalFont?: string;
43
+ }
44
+
45
+ declare function fontSwitcherPlugin(config: FontSwitcherConfig): Plugin;
46
+
47
+ export { type FontSwitcherConfig, fontSwitcherPlugin };
@@ -0,0 +1,47 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ interface FontFeature {
4
+ tag: string;
5
+ label: string;
6
+ enabled: boolean;
7
+ }
8
+ interface FontMetrics {
9
+ capHeight: number;
10
+ ascent: number;
11
+ descent: number;
12
+ lineGap: number;
13
+ unitsPerEm: number;
14
+ xHeight: number;
15
+ }
16
+ interface FontConfig {
17
+ key: string;
18
+ label: string;
19
+ /** Value for CSS font-family — can be a var() ref or a literal stack */
20
+ cssFamily: string;
21
+ /** Raw font metrics. If @capsizecss/core is installed, trims are computed from these. */
22
+ metrics?: FontMetrics;
23
+ /** OpenType feature settings. If omitted, auto-detected from font file by the Vite plugin. */
24
+ features?: FontFeature[];
25
+ }
26
+ interface FontSwitcherConfig {
27
+ fonts: FontConfig[];
28
+ defaultFont: string;
29
+ /** Path to CSS file scanned for @font-face and --font-* declarations. Default: 'src/styles/index.css' */
30
+ cssFile?: string;
31
+ /** localStorage key for the active font. Default: 'font-switcher-active' */
32
+ storageKey?: string;
33
+ /** URL param that reveals the panel. Default: 'fonts' */
34
+ revealParam?: string;
35
+ /** Keyboard shortcut that toggles the panel. Default: 'ctrl+shift+f' */
36
+ revealShortcut?: string;
37
+ /** CSS selector for capsize elements. Set false to disable trim injection. Default: '.capsize' */
38
+ capsizeSelector?: string | false;
39
+ /** Font size in px used for capsize trim computation. Default: 16 */
40
+ fontSize?: number;
41
+ /** When set: locks the font, disables all switcher output. Set this when client decides, then remove the package. */
42
+ finalFont?: string;
43
+ }
44
+
45
+ declare function fontSwitcherPlugin(config: FontSwitcherConfig): Plugin;
46
+
47
+ export { type FontSwitcherConfig, fontSwitcherPlugin };