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,82 @@
1
+ interface FontFeature {
2
+ tag: string;
3
+ label: string;
4
+ enabled: boolean;
5
+ }
6
+ interface FontMetrics {
7
+ capHeight: number;
8
+ ascent: number;
9
+ descent: number;
10
+ lineGap: number;
11
+ unitsPerEm: number;
12
+ xHeight: number;
13
+ }
14
+ interface FontConfig {
15
+ key: string;
16
+ label: string;
17
+ /** Value for CSS font-family — can be a var() ref or a literal stack */
18
+ cssFamily: string;
19
+ /** Raw font metrics. If @capsizecss/core is installed, trims are computed from these. */
20
+ metrics?: FontMetrics;
21
+ /** OpenType feature settings. If omitted, auto-detected from font file by the Vite plugin. */
22
+ features?: FontFeature[];
23
+ }
24
+ interface FontSwitcherConfig {
25
+ fonts: FontConfig[];
26
+ defaultFont: string;
27
+ /** Path to CSS file scanned for @font-face and --font-* declarations. Default: 'src/styles/index.css' */
28
+ cssFile?: string;
29
+ /** localStorage key for the active font. Default: 'font-switcher-active' */
30
+ storageKey?: string;
31
+ /** URL param that reveals the panel. Default: 'fonts' */
32
+ revealParam?: string;
33
+ /** Keyboard shortcut that toggles the panel. Default: 'ctrl+shift+f' */
34
+ revealShortcut?: string;
35
+ /** CSS selector for capsize elements. Set false to disable trim injection. Default: '.capsize' */
36
+ capsizeSelector?: string | false;
37
+ /** Font size in px used for capsize trim computation. Default: 16 */
38
+ fontSize?: number;
39
+ /** When set: locks the font, disables all switcher output. Set this when client decides, then remove the package. */
40
+ finalFont?: string;
41
+ }
42
+ interface DetectedFont {
43
+ family: string;
44
+ source: '@font-face' | '--font-var';
45
+ varName?: string;
46
+ /** First local src URL from the @font-face src: declaration. */
47
+ fontFileSrc?: string;
48
+ }
49
+
50
+ declare function defineConfig(config: FontSwitcherConfig): FontSwitcherConfig;
51
+
52
+ declare function scanFontsFromCSS(cssFile: string): DetectedFont[];
53
+ declare function validateFonts(fonts: FontConfig[], detected: DetectedFont[]): void;
54
+
55
+ declare function generateFontCSS(config: FontSwitcherConfig): Promise<string>;
56
+
57
+ /**
58
+ * Returns a small inline script string that applies the stored font before first paint,
59
+ * preventing a flash of the default font on reload.
60
+ *
61
+ * Inject into <head> before other content:
62
+ * <script dangerouslySetInnerHTML={{ __html: getFOUCScript(config) }} />
63
+ */
64
+ declare function getFOUCScript(config: FontSwitcherConfig): string;
65
+
66
+ /**
67
+ * Computes capsize trim values for a font at a given size.
68
+ * Returns null if @capsizecss/core is not installed or computation fails.
69
+ */
70
+ declare function computeTrims(metrics: FontMetrics, fontSize: number): Promise<{
71
+ capTrim: string;
72
+ baseTrim: string;
73
+ } | null>;
74
+ /** Synchronous check — only reliable in CJS/Node environments with require available. */
75
+ declare function isCapsizeAvailable(): boolean;
76
+
77
+ declare function detectFeaturesFromFile(fontFilePath: string): Promise<FontFeature[]>;
78
+
79
+ declare const FEATURE_LABELS: Record<string, string>;
80
+ declare function featuresFromTags(tags: string[]): FontFeature[];
81
+
82
+ export { type DetectedFont, FEATURE_LABELS, type FontConfig, type FontFeature, type FontMetrics, type FontSwitcherConfig, computeTrims, defineConfig, detectFeaturesFromFile, featuresFromTags, generateFontCSS, getFOUCScript, isCapsizeAvailable, scanFontsFromCSS, validateFonts };
package/dist/index.js ADDED
@@ -0,0 +1,282 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/define-config.ts
9
+ function defineConfig(config) {
10
+ return config;
11
+ }
12
+
13
+ // src/scanner.ts
14
+ import fs from "fs";
15
+ import path from "path";
16
+ var SYSTEM_FONTS = /* @__PURE__ */ new Set([
17
+ "helvetica",
18
+ "arial",
19
+ "georgia",
20
+ "times",
21
+ "courier",
22
+ "verdana",
23
+ "trebuchet",
24
+ "impact",
25
+ "tahoma",
26
+ "sans-serif",
27
+ "serif",
28
+ "monospace",
29
+ "system-ui"
30
+ ]);
31
+ function scanFontsFromCSS(cssFile) {
32
+ const resolved = path.resolve(cssFile);
33
+ if (!fs.existsSync(resolved)) {
34
+ console.warn(`[font-switcher] cssFile not found: ${resolved}`);
35
+ return [];
36
+ }
37
+ const css = fs.readFileSync(resolved, "utf8");
38
+ const found = [];
39
+ const seen = /* @__PURE__ */ new Set();
40
+ const faceBlockRe = /@font-face\s*\{([^}]+)\}/g;
41
+ for (const m of css.matchAll(faceBlockRe)) {
42
+ const block = m[1];
43
+ const familyM = /font-family\s*:\s*['"]?([^'";,\n]+)['"]?/.exec(block);
44
+ if (!familyM) continue;
45
+ const family = familyM[1].trim().replace(/['"]/g, "");
46
+ const key = `face:${family.toLowerCase()}`;
47
+ if (seen.has(key)) continue;
48
+ seen.add(key);
49
+ const srcDecl = /src\s*:[^;}]*/.exec(block)?.[0] ?? "";
50
+ const urls = [...srcDecl.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)].map((u) => u[1].trim());
51
+ const parseable = urls.find((u) => /\.(woff|ttf|otf)(\?|#|$)/i.test(u));
52
+ const fontFileSrc = parseable ?? urls[0];
53
+ found.push({ family, source: "@font-face", fontFileSrc });
54
+ }
55
+ const varRe = /(--font-[\w-]+)\s*:\s*['"]?([^'",;\n]+)['"]?/g;
56
+ const lengthLike = /^\d|^(normal|bold|italic|oblique|inherit|initial|unset)$/i;
57
+ for (const m of css.matchAll(varRe)) {
58
+ const varName = m[1].trim();
59
+ const family = m[2].trim().replace(/['"]/g, "");
60
+ if (lengthLike.test(family)) continue;
61
+ const key = `var:${varName}`;
62
+ if (!seen.has(key)) {
63
+ seen.add(key);
64
+ found.push({ family, source: "--font-var", varName });
65
+ }
66
+ }
67
+ return found;
68
+ }
69
+ function validateFonts(fonts, detected) {
70
+ const faceNames = new Set(
71
+ detected.filter((d) => d.source === "@font-face").map((d) => d.family.toLowerCase())
72
+ );
73
+ const varNames = new Set(
74
+ detected.filter((d) => d.source === "--font-var").map((d) => d.varName)
75
+ );
76
+ for (const font of fonts) {
77
+ if (font.cssFamily.includes("var(")) {
78
+ const m = font.cssFamily.match(/var\((--font-[\w-]+)/);
79
+ if (m && !varNames.has(m[1])) {
80
+ console.warn(
81
+ `[font-switcher] "${font.key}" references "${m[1]}" \u2014 not found in CSS file`
82
+ );
83
+ }
84
+ } else {
85
+ const first = font.cssFamily.split(",")[0].trim().replace(/['"]/g, "").toLowerCase();
86
+ if (!SYSTEM_FONTS.has(first) && !faceNames.has(first)) {
87
+ console.warn(
88
+ `[font-switcher] "${font.key}" family "${first}" not found in any @font-face declaration`
89
+ );
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // src/capsize.ts
96
+ async function computeTrims(metrics, fontSize) {
97
+ try {
98
+ const { createStyleObject } = await import("@capsizecss/core");
99
+ const style = createStyleObject({ fontSize, fontMetrics: metrics });
100
+ const capTrim = style["::before"].marginBottom;
101
+ const baseTrim = style["::after"].marginTop;
102
+ return { capTrim, baseTrim };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ function isCapsizeAvailable() {
108
+ try {
109
+ __require.resolve("@capsizecss/core");
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ // src/css-gen.ts
117
+ async function generateFontCSS(config) {
118
+ if (config.finalFont) return "";
119
+ const { fontSize = 16, capsizeSelector = ".capsize", fonts } = config;
120
+ const lines = [];
121
+ for (const font of fonts) {
122
+ lines.push(`html[data-font="${font.key}"] { --font-active: ${font.cssFamily}; }`);
123
+ if (capsizeSelector !== false && font.metrics) {
124
+ const trims = await computeTrims(font.metrics, fontSize);
125
+ if (trims) {
126
+ lines.push(
127
+ `html[data-font="${font.key}"] ${capsizeSelector} { --cap-trim: ${trims.capTrim}; --base-trim: ${trims.baseTrim}; }`
128
+ );
129
+ }
130
+ }
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+
135
+ // src/script.ts
136
+ function getFOUCScript(config) {
137
+ if (config.finalFont) return "";
138
+ const storageKey = config.storageKey ?? "font-switcher-active";
139
+ const validKeys = config.fonts.map((f) => f.key);
140
+ const featureDefaults = {};
141
+ for (const f of config.fonts) {
142
+ if (f.features?.length) {
143
+ featureDefaults[f.key] = Object.fromEntries(f.features.map((ft) => [ft.tag, ft.enabled]));
144
+ }
145
+ }
146
+ return `(function(){try{
147
+ var SK=${JSON.stringify(storageKey)},VK=${JSON.stringify(validKeys)},FD=${JSON.stringify(featureDefaults)},DF=${JSON.stringify(config.defaultFont)};
148
+ var k=localStorage.getItem(SK),af=(k&&VK.indexOf(k)!==-1)?k:DF;
149
+ if(k&&VK.indexOf(k)!==-1){document.documentElement.dataset.font=k;}
150
+ var tags=Object.assign({},FD[af]||{}),sf=localStorage.getItem(SK+'-features');
151
+ if(sf){var pf=JSON.parse(sf);if(pf&&pf[af])Object.assign(tags,pf[af]);}
152
+ var ks=Object.keys(tags),fs=ks.length?ks.map(function(t){return '"'+t+'" '+(tags[t]?1:0);}).join(', '):'normal';
153
+ var rd={textRendering:'auto',webkitSmoothing:'auto',mozSmoothing:'auto'},sr=localStorage.getItem(SK+'-render');
154
+ if(sr){var pr=JSON.parse(sr);for(var rk in pr){rd[rk]=pr[rk];}}
155
+ var css='html {\\n font-feature-settings: '+fs+';\\n text-rendering: '+rd.textRendering+';\\n -webkit-font-smoothing: '+rd.webkitSmoothing+';\\n -moz-osx-font-smoothing: '+rd.mozSmoothing+';\\n}';
156
+ var el=document.getElementById('font-switcher-rt');
157
+ if(!el){el=document.createElement('style');el.id='font-switcher-rt';document.head.appendChild(el);}
158
+ el.textContent=css;
159
+ }catch(e){}})();`;
160
+ }
161
+
162
+ // src/font-features.ts
163
+ import { existsSync } from "fs";
164
+
165
+ // src/feature-labels.ts
166
+ var BROWSER_DEFAULT_ON = /* @__PURE__ */ new Set(["liga", "clig", "calt", "kern", "locl", "mark", "mkmk"]);
167
+ var SKIP_TAGS = /* @__PURE__ */ new Set([
168
+ "DFLT",
169
+ "dflt",
170
+ "locl",
171
+ "mark",
172
+ "mkmk",
173
+ "dist",
174
+ "rlig",
175
+ "curs",
176
+ "abvm",
177
+ "blwm",
178
+ "abvs",
179
+ "blws",
180
+ "pres",
181
+ "psts",
182
+ "haln",
183
+ "half",
184
+ "akhn",
185
+ "rphf",
186
+ "blwf",
187
+ "abvf",
188
+ "pref",
189
+ "rkrf",
190
+ "vatu",
191
+ "cjct",
192
+ "nukt",
193
+ "isol",
194
+ "init",
195
+ "medi",
196
+ "fina",
197
+ "med2",
198
+ "fin2",
199
+ "fin3",
200
+ "vert",
201
+ "vrt2",
202
+ "cfar"
203
+ ]);
204
+ var FEATURE_LABELS = {
205
+ liga: "Standard Ligatures",
206
+ clig: "Contextual Ligatures",
207
+ dlig: "Discretionary Ligatures",
208
+ hlig: "Historical Ligatures",
209
+ calt: "Contextual Alternates",
210
+ kern: "Kerning",
211
+ swsh: "Swash",
212
+ case: "Case-Sensitive Forms",
213
+ onum: "Oldstyle Figures",
214
+ tnum: "Tabular Figures",
215
+ pnum: "Proportional Figures",
216
+ lnum: "Lining Figures",
217
+ frac: "Fractions",
218
+ afrc: "Alternative Fractions",
219
+ sups: "Superscript",
220
+ subs: "Subscript",
221
+ smcp: "Small Capitals",
222
+ c2sc: "Caps to Small Caps",
223
+ pcap: "Petite Capitals",
224
+ cpsp: "Capital Spacing",
225
+ zero: "Slashed Zero",
226
+ ordn: "Ordinals",
227
+ hist: "Historical Forms",
228
+ nalt: "Alternate Annotation",
229
+ salt: "Stylistic Alternates",
230
+ titl: "Titling",
231
+ ...Object.fromEntries(
232
+ Array.from({ length: 20 }, (_, i) => {
233
+ const n = String(i + 1).padStart(2, "0");
234
+ return [`ss${n}`, `Stylistic Set ${i + 1}`];
235
+ })
236
+ ),
237
+ ...Object.fromEntries(
238
+ Array.from({ length: 6 }, (_, i) => {
239
+ const n = String(i + 1).padStart(2, "0");
240
+ return [`cv${n}`, `Character Variant ${i + 1}`];
241
+ })
242
+ )
243
+ };
244
+ function featuresFromTags(tags) {
245
+ return tags.filter((tag) => !SKIP_TAGS.has(tag)).map((tag) => ({
246
+ tag,
247
+ label: FEATURE_LABELS[tag] ?? tag,
248
+ enabled: BROWSER_DEFAULT_ON.has(tag)
249
+ }));
250
+ }
251
+
252
+ // src/font-features.ts
253
+ async function detectFeaturesFromFile(fontFilePath) {
254
+ if (!existsSync(fontFilePath)) return [];
255
+ try {
256
+ const mod = await import("opentype.js");
257
+ const loadSync = mod.loadSync ?? mod.default?.loadSync;
258
+ if (!loadSync) return [];
259
+ const font = loadSync(fontFilePath);
260
+ const tags = /* @__PURE__ */ new Set();
261
+ for (const table of [font.tables.gsub, font.tables.gpos]) {
262
+ for (const rec of table?.featureList?.featureRecords ?? []) {
263
+ if (rec?.featureTag) tags.add(rec.featureTag.trim());
264
+ }
265
+ }
266
+ return featuresFromTags([...tags]);
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+ export {
272
+ FEATURE_LABELS,
273
+ computeTrims,
274
+ defineConfig,
275
+ detectFeaturesFromFile,
276
+ featuresFromTags,
277
+ generateFontCSS,
278
+ getFOUCScript,
279
+ isCapsizeAvailable,
280
+ scanFontsFromCSS,
281
+ validateFonts
282
+ };