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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/bin/cli.js +132 -0
- package/dist/index.cjs +321 -0
- package/dist/index.d.cts +82 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +282 -0
- package/dist/react/index.cjs +429 -0
- package/dist/react/index.d.cts +51 -0
- package/dist/react/index.d.ts +51 -0
- package/dist/react/index.js +405 -0
- package/dist/script.cjs +54 -0
- package/dist/script.d.cts +52 -0
- package/dist/script.d.ts +52 -0
- package/dist/script.js +29 -0
- package/dist/vite-plugin.cjs +377 -0
- package/dist/vite-plugin.d.cts +47 -0
- package/dist/vite-plugin.d.ts +47 -0
- package/dist/vite-plugin.js +342 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hafeez Dawood
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# font-switcher
|
|
2
|
+
|
|
3
|
+
Live font-switching overlay for web projects. Ships a Vite plugin, a React panel, and an inline FOUC-prevention script, so a client can try candidate typefaces on the real site instead of in a mockup.
|
|
4
|
+
|
|
5
|
+
The panel switches `font-family`, toggles OpenType features detected from the font file, and adjusts text-rendering settings. Choices persist in `localStorage`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install font-switcher
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies are all optional — install what you use:
|
|
14
|
+
|
|
15
|
+
| Package | Needed for |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `vite` >= 5 | the Vite plugin |
|
|
18
|
+
| `react` >= 18 | the `<FontSwitcher />` panel |
|
|
19
|
+
| `@capsizecss/core` >= 4 | capsize trim values (`--cap-trim` / `--base-trim`) |
|
|
20
|
+
| `opentype.js` >= 1 | auto-detecting OpenType features from local font files |
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
### 1. Config
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/font-switcher.config.ts
|
|
28
|
+
import {defineConfig} from 'font-switcher';
|
|
29
|
+
|
|
30
|
+
export const fontSwitcherConfig = defineConfig({
|
|
31
|
+
cssFile: 'src/styles/index.css',
|
|
32
|
+
defaultFont: 'sans-2',
|
|
33
|
+
fonts: [
|
|
34
|
+
{key: 'sans-1', label: 'Frutiger', cssFamily: 'var(--font-sans-1)'},
|
|
35
|
+
{key: 'sans-2', label: 'PingFang SC', cssFamily: 'var(--font-sans-2)'},
|
|
36
|
+
{key: 'sans-3', label: 'Gelbique', cssFamily: 'var(--font-sans-3)'},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`cssFamily` can be a `var()` reference or a literal font stack. The plugin scans `cssFile` for `@font-face` blocks and `--font-*` custom properties, and errors if a configured font resolves to nothing.
|
|
42
|
+
|
|
43
|
+
### 2. Vite plugin
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// vite.config.ts
|
|
47
|
+
import {fontSwitcherPlugin} from 'font-switcher/vite';
|
|
48
|
+
import {fontSwitcherConfig} from './src/font-switcher.config';
|
|
49
|
+
|
|
50
|
+
export default defineConfig({
|
|
51
|
+
plugins: [fontSwitcherPlugin(fontSwitcherConfig)],
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The plugin generates the `html[data-font="…"]` rules and exposes them on the virtual module `virtual:font-switcher/styles`. Editing `cssFile` in dev invalidates the module and triggers a full reload.
|
|
56
|
+
|
|
57
|
+
### 3. App
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import {getFOUCScript} from 'font-switcher/script';
|
|
61
|
+
import {FontSwitcher} from 'font-switcher/react';
|
|
62
|
+
import {fontSwitcherConfig} from './font-switcher.config';
|
|
63
|
+
import 'virtual:font-switcher/styles';
|
|
64
|
+
|
|
65
|
+
<head>
|
|
66
|
+
<script dangerouslySetInnerHTML={{__html: getFOUCScript(fontSwitcherConfig)}} />
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
{children}
|
|
70
|
+
<FontSwitcher config={fontSwitcherConfig} />
|
|
71
|
+
</body>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The inline script must run before first paint — it applies the stored font and feature settings synchronously, so a reload doesn't flash the default face.
|
|
75
|
+
|
|
76
|
+
### 4. Consume the active font
|
|
77
|
+
|
|
78
|
+
The plugin sets `--font-active` per `data-font` value. Point your body font at it:
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
body {
|
|
82
|
+
font-family: var(--font-active, var(--font-sans-2));
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Using the panel
|
|
87
|
+
|
|
88
|
+
Hidden by default. Reveal it with either:
|
|
89
|
+
|
|
90
|
+
- the URL param — `?fonts`
|
|
91
|
+
- the keyboard shortcut — <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd>
|
|
92
|
+
|
|
93
|
+
Both are configurable (`revealParam`, `revealShortcut`). Pass `position` to move it: `<FontSwitcher config={config} position="top-left" />`.
|
|
94
|
+
|
|
95
|
+
## Shipping the final font
|
|
96
|
+
|
|
97
|
+
When the client picks a face, set `finalFont`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
export const fontSwitcherConfig = defineConfig({
|
|
101
|
+
finalFont: 'sans-1',
|
|
102
|
+
// …
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
That disables every output path — no generated CSS, no inline script, no panel — and silences the production-build warning the plugin otherwise emits. Then remove the package and hard-code the font.
|
|
107
|
+
|
|
108
|
+
## Config
|
|
109
|
+
|
|
110
|
+
| Key | Type | Default | Purpose |
|
|
111
|
+
| --- | --- | --- | --- |
|
|
112
|
+
| `fonts` | `FontConfig[]` | — | Candidate fonts |
|
|
113
|
+
| `defaultFont` | `string` | — | `key` used when nothing is stored |
|
|
114
|
+
| `cssFile` | `string` | `'src/styles/index.css'` | Scanned for `@font-face` and `--font-*` |
|
|
115
|
+
| `storageKey` | `string` | `'font-switcher-active'` | `localStorage` key |
|
|
116
|
+
| `revealParam` | `string` | `'fonts'` | URL param that shows the panel |
|
|
117
|
+
| `revealShortcut` | `string` | `'ctrl+shift+f'` | Shortcut that toggles the panel |
|
|
118
|
+
| `capsizeSelector` | `string \| false` | `'.capsize'` | Selector receiving trim vars; `false` disables |
|
|
119
|
+
| `fontSize` | `number` | `16` | Font size (px) used for trim computation |
|
|
120
|
+
| `finalFont` | `string` | — | Locks the font, disables all output |
|
|
121
|
+
|
|
122
|
+
`FontConfig` is `{key, label, cssFamily, metrics?, features?}`. Supply `metrics` (`capHeight`, `ascent`, `descent`, `lineGap`, `unitsPerEm`, `xHeight`) to get capsize trims. Supply `features` to override auto-detection.
|
|
123
|
+
|
|
124
|
+
## CLI
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx font-switcher scan --css src/styles/index.css
|
|
128
|
+
npx font-switcher trims --metrics '{"capHeight":719,"ascent":719,"descent":-209,"lineGap":58,"unitsPerEm":1000,"xHeight":523}' --size 13
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`scan` lists the fonts found in a CSS file. `trims` prints `--cap-trim` / `--base-trim` for a set of metrics; requires `@capsizecss/core`.
|
|
132
|
+
|
|
133
|
+
## Exports
|
|
134
|
+
|
|
135
|
+
| Entry | Contents |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `font-switcher` | `defineConfig`, `scanFontsFromCSS`, `validateFonts`, `generateFontCSS`, `getFOUCScript`, `isCapsizeAvailable`, `computeTrims`, `detectFeaturesFromFile`, `featuresFromTags`, `FEATURE_LABELS`, and the types |
|
|
138
|
+
| `font-switcher/vite` | `fontSwitcherPlugin` |
|
|
139
|
+
| `font-switcher/react` | `FontSwitcher` |
|
|
140
|
+
| `font-switcher/script` | `getFOUCScript` |
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/dist/bin/cli.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
|
|
6
|
+
// src/scanner.ts
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
function scanFontsFromCSS(cssFile) {
|
|
10
|
+
const resolved = path.resolve(cssFile);
|
|
11
|
+
if (!fs.existsSync(resolved)) {
|
|
12
|
+
console.warn(`[font-switcher] cssFile not found: ${resolved}`);
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const css = fs.readFileSync(resolved, "utf8");
|
|
16
|
+
const found = [];
|
|
17
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18
|
+
const faceBlockRe = /@font-face\s*\{([^}]+)\}/g;
|
|
19
|
+
for (const m of css.matchAll(faceBlockRe)) {
|
|
20
|
+
const block = m[1];
|
|
21
|
+
const familyM = /font-family\s*:\s*['"]?([^'";,\n]+)['"]?/.exec(block);
|
|
22
|
+
if (!familyM) continue;
|
|
23
|
+
const family = familyM[1].trim().replace(/['"]/g, "");
|
|
24
|
+
const key = `face:${family.toLowerCase()}`;
|
|
25
|
+
if (seen.has(key)) continue;
|
|
26
|
+
seen.add(key);
|
|
27
|
+
const srcDecl = /src\s*:[^;}]*/.exec(block)?.[0] ?? "";
|
|
28
|
+
const urls = [...srcDecl.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)].map((u) => u[1].trim());
|
|
29
|
+
const parseable = urls.find((u) => /\.(woff|ttf|otf)(\?|#|$)/i.test(u));
|
|
30
|
+
const fontFileSrc = parseable ?? urls[0];
|
|
31
|
+
found.push({ family, source: "@font-face", fontFileSrc });
|
|
32
|
+
}
|
|
33
|
+
const varRe = /(--font-[\w-]+)\s*:\s*['"]?([^'",;\n]+)['"]?/g;
|
|
34
|
+
const lengthLike = /^\d|^(normal|bold|italic|oblique|inherit|initial|unset)$/i;
|
|
35
|
+
for (const m of css.matchAll(varRe)) {
|
|
36
|
+
const varName = m[1].trim();
|
|
37
|
+
const family = m[2].trim().replace(/['"]/g, "");
|
|
38
|
+
if (lengthLike.test(family)) continue;
|
|
39
|
+
const key = `var:${varName}`;
|
|
40
|
+
if (!seen.has(key)) {
|
|
41
|
+
seen.add(key);
|
|
42
|
+
found.push({ family, source: "--font-var", varName });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/capsize.ts
|
|
49
|
+
async function computeTrims(metrics, fontSize) {
|
|
50
|
+
try {
|
|
51
|
+
const { createStyleObject } = await import("@capsizecss/core");
|
|
52
|
+
const style = createStyleObject({ fontSize, fontMetrics: metrics });
|
|
53
|
+
const capTrim = style["::before"].marginBottom;
|
|
54
|
+
const baseTrim = style["::after"].marginTop;
|
|
55
|
+
return { capTrim, baseTrim };
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// bin/cli.ts
|
|
62
|
+
var { values, positionals } = parseArgs({
|
|
63
|
+
args: process.argv.slice(2),
|
|
64
|
+
options: {
|
|
65
|
+
css: { type: "string" },
|
|
66
|
+
metrics: { type: "string" },
|
|
67
|
+
size: { type: "string" }
|
|
68
|
+
},
|
|
69
|
+
allowPositionals: true
|
|
70
|
+
});
|
|
71
|
+
var [command] = positionals;
|
|
72
|
+
switch (command) {
|
|
73
|
+
case "scan": {
|
|
74
|
+
const cssFile = values.css ?? "src/styles/index.css";
|
|
75
|
+
const detected = scanFontsFromCSS(cssFile);
|
|
76
|
+
if (!detected.length) {
|
|
77
|
+
console.log(`
|
|
78
|
+
No fonts detected in ${cssFile}
|
|
79
|
+
`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
console.log(`
|
|
83
|
+
Fonts detected in ${cssFile}:
|
|
84
|
+
`);
|
|
85
|
+
for (const f of detected) {
|
|
86
|
+
const tag = f.source === "@font-face" ? "@font-face " : `var(${f.varName})`;
|
|
87
|
+
console.log(` ${f.family.padEnd(28)} [${tag}]`);
|
|
88
|
+
}
|
|
89
|
+
console.log();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "trims": {
|
|
93
|
+
if (!values.metrics) {
|
|
94
|
+
console.error(
|
|
95
|
+
`
|
|
96
|
+
Usage: font-switcher trims --metrics '{"capHeight":719,"ascent":719,"descent":-209,"lineGap":58,"unitsPerEm":1000,"xHeight":523}' --size 13
|
|
97
|
+
`
|
|
98
|
+
);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
let metrics;
|
|
102
|
+
try {
|
|
103
|
+
metrics = JSON.parse(values.metrics);
|
|
104
|
+
} catch {
|
|
105
|
+
console.error("\nInvalid JSON for --metrics\n");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const fontSize = Number(values.size ?? 13);
|
|
109
|
+
const trims = await computeTrims(metrics, fontSize);
|
|
110
|
+
if (!trims) {
|
|
111
|
+
console.error("\n@capsizecss/core not installed or failed to compute. Run: npm install @capsizecss/core\n");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
console.log(`
|
|
115
|
+
--cap-trim: ${trims.capTrim}`);
|
|
116
|
+
console.log(`--base-trim: ${trims.baseTrim}
|
|
117
|
+
`);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
console.log(`
|
|
122
|
+
font-switcher
|
|
123
|
+
|
|
124
|
+
Commands:
|
|
125
|
+
scan --css <file> Detect @font-face and --font-* vars in a CSS file
|
|
126
|
+
Default: src/styles/index.css
|
|
127
|
+
|
|
128
|
+
trims --metrics '<json>' Compute capsize trim values for a font
|
|
129
|
+
--size <px> Font size in px (default: 13)
|
|
130
|
+
Requires @capsizecss/core installed
|
|
131
|
+
`);
|
|
132
|
+
}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
FEATURE_LABELS: () => FEATURE_LABELS,
|
|
34
|
+
computeTrims: () => computeTrims,
|
|
35
|
+
defineConfig: () => defineConfig,
|
|
36
|
+
detectFeaturesFromFile: () => detectFeaturesFromFile,
|
|
37
|
+
featuresFromTags: () => featuresFromTags,
|
|
38
|
+
generateFontCSS: () => generateFontCSS,
|
|
39
|
+
getFOUCScript: () => getFOUCScript,
|
|
40
|
+
isCapsizeAvailable: () => isCapsizeAvailable,
|
|
41
|
+
scanFontsFromCSS: () => scanFontsFromCSS,
|
|
42
|
+
validateFonts: () => validateFonts
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(src_exports);
|
|
45
|
+
|
|
46
|
+
// src/define-config.ts
|
|
47
|
+
function defineConfig(config) {
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/scanner.ts
|
|
52
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
53
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
54
|
+
var SYSTEM_FONTS = /* @__PURE__ */ new Set([
|
|
55
|
+
"helvetica",
|
|
56
|
+
"arial",
|
|
57
|
+
"georgia",
|
|
58
|
+
"times",
|
|
59
|
+
"courier",
|
|
60
|
+
"verdana",
|
|
61
|
+
"trebuchet",
|
|
62
|
+
"impact",
|
|
63
|
+
"tahoma",
|
|
64
|
+
"sans-serif",
|
|
65
|
+
"serif",
|
|
66
|
+
"monospace",
|
|
67
|
+
"system-ui"
|
|
68
|
+
]);
|
|
69
|
+
function scanFontsFromCSS(cssFile) {
|
|
70
|
+
const resolved = import_node_path.default.resolve(cssFile);
|
|
71
|
+
if (!import_node_fs.default.existsSync(resolved)) {
|
|
72
|
+
console.warn(`[font-switcher] cssFile not found: ${resolved}`);
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const css = import_node_fs.default.readFileSync(resolved, "utf8");
|
|
76
|
+
const found = [];
|
|
77
|
+
const seen = /* @__PURE__ */ new Set();
|
|
78
|
+
const faceBlockRe = /@font-face\s*\{([^}]+)\}/g;
|
|
79
|
+
for (const m of css.matchAll(faceBlockRe)) {
|
|
80
|
+
const block = m[1];
|
|
81
|
+
const familyM = /font-family\s*:\s*['"]?([^'";,\n]+)['"]?/.exec(block);
|
|
82
|
+
if (!familyM) continue;
|
|
83
|
+
const family = familyM[1].trim().replace(/['"]/g, "");
|
|
84
|
+
const key = `face:${family.toLowerCase()}`;
|
|
85
|
+
if (seen.has(key)) continue;
|
|
86
|
+
seen.add(key);
|
|
87
|
+
const srcDecl = /src\s*:[^;}]*/.exec(block)?.[0] ?? "";
|
|
88
|
+
const urls = [...srcDecl.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)].map((u) => u[1].trim());
|
|
89
|
+
const parseable = urls.find((u) => /\.(woff|ttf|otf)(\?|#|$)/i.test(u));
|
|
90
|
+
const fontFileSrc = parseable ?? urls[0];
|
|
91
|
+
found.push({ family, source: "@font-face", fontFileSrc });
|
|
92
|
+
}
|
|
93
|
+
const varRe = /(--font-[\w-]+)\s*:\s*['"]?([^'",;\n]+)['"]?/g;
|
|
94
|
+
const lengthLike = /^\d|^(normal|bold|italic|oblique|inherit|initial|unset)$/i;
|
|
95
|
+
for (const m of css.matchAll(varRe)) {
|
|
96
|
+
const varName = m[1].trim();
|
|
97
|
+
const family = m[2].trim().replace(/['"]/g, "");
|
|
98
|
+
if (lengthLike.test(family)) continue;
|
|
99
|
+
const key = `var:${varName}`;
|
|
100
|
+
if (!seen.has(key)) {
|
|
101
|
+
seen.add(key);
|
|
102
|
+
found.push({ family, source: "--font-var", varName });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
function validateFonts(fonts, detected) {
|
|
108
|
+
const faceNames = new Set(
|
|
109
|
+
detected.filter((d) => d.source === "@font-face").map((d) => d.family.toLowerCase())
|
|
110
|
+
);
|
|
111
|
+
const varNames = new Set(
|
|
112
|
+
detected.filter((d) => d.source === "--font-var").map((d) => d.varName)
|
|
113
|
+
);
|
|
114
|
+
for (const font of fonts) {
|
|
115
|
+
if (font.cssFamily.includes("var(")) {
|
|
116
|
+
const m = font.cssFamily.match(/var\((--font-[\w-]+)/);
|
|
117
|
+
if (m && !varNames.has(m[1])) {
|
|
118
|
+
console.warn(
|
|
119
|
+
`[font-switcher] "${font.key}" references "${m[1]}" \u2014 not found in CSS file`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const first = font.cssFamily.split(",")[0].trim().replace(/['"]/g, "").toLowerCase();
|
|
124
|
+
if (!SYSTEM_FONTS.has(first) && !faceNames.has(first)) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[font-switcher] "${font.key}" family "${first}" not found in any @font-face declaration`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/capsize.ts
|
|
134
|
+
async function computeTrims(metrics, fontSize) {
|
|
135
|
+
try {
|
|
136
|
+
const { createStyleObject } = await import("@capsizecss/core");
|
|
137
|
+
const style = createStyleObject({ fontSize, fontMetrics: metrics });
|
|
138
|
+
const capTrim = style["::before"].marginBottom;
|
|
139
|
+
const baseTrim = style["::after"].marginTop;
|
|
140
|
+
return { capTrim, baseTrim };
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function isCapsizeAvailable() {
|
|
146
|
+
try {
|
|
147
|
+
require.resolve("@capsizecss/core");
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/css-gen.ts
|
|
155
|
+
async function generateFontCSS(config) {
|
|
156
|
+
if (config.finalFont) return "";
|
|
157
|
+
const { fontSize = 16, capsizeSelector = ".capsize", fonts } = config;
|
|
158
|
+
const lines = [];
|
|
159
|
+
for (const font of fonts) {
|
|
160
|
+
lines.push(`html[data-font="${font.key}"] { --font-active: ${font.cssFamily}; }`);
|
|
161
|
+
if (capsizeSelector !== false && font.metrics) {
|
|
162
|
+
const trims = await computeTrims(font.metrics, fontSize);
|
|
163
|
+
if (trims) {
|
|
164
|
+
lines.push(
|
|
165
|
+
`html[data-font="${font.key}"] ${capsizeSelector} { --cap-trim: ${trims.capTrim}; --base-trim: ${trims.baseTrim}; }`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/script.ts
|
|
174
|
+
function getFOUCScript(config) {
|
|
175
|
+
if (config.finalFont) return "";
|
|
176
|
+
const storageKey = config.storageKey ?? "font-switcher-active";
|
|
177
|
+
const validKeys = config.fonts.map((f) => f.key);
|
|
178
|
+
const featureDefaults = {};
|
|
179
|
+
for (const f of config.fonts) {
|
|
180
|
+
if (f.features?.length) {
|
|
181
|
+
featureDefaults[f.key] = Object.fromEntries(f.features.map((ft) => [ft.tag, ft.enabled]));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return `(function(){try{
|
|
185
|
+
var SK=${JSON.stringify(storageKey)},VK=${JSON.stringify(validKeys)},FD=${JSON.stringify(featureDefaults)},DF=${JSON.stringify(config.defaultFont)};
|
|
186
|
+
var k=localStorage.getItem(SK),af=(k&&VK.indexOf(k)!==-1)?k:DF;
|
|
187
|
+
if(k&&VK.indexOf(k)!==-1){document.documentElement.dataset.font=k;}
|
|
188
|
+
var tags=Object.assign({},FD[af]||{}),sf=localStorage.getItem(SK+'-features');
|
|
189
|
+
if(sf){var pf=JSON.parse(sf);if(pf&&pf[af])Object.assign(tags,pf[af]);}
|
|
190
|
+
var ks=Object.keys(tags),fs=ks.length?ks.map(function(t){return '"'+t+'" '+(tags[t]?1:0);}).join(', '):'normal';
|
|
191
|
+
var rd={textRendering:'auto',webkitSmoothing:'auto',mozSmoothing:'auto'},sr=localStorage.getItem(SK+'-render');
|
|
192
|
+
if(sr){var pr=JSON.parse(sr);for(var rk in pr){rd[rk]=pr[rk];}}
|
|
193
|
+
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}';
|
|
194
|
+
var el=document.getElementById('font-switcher-rt');
|
|
195
|
+
if(!el){el=document.createElement('style');el.id='font-switcher-rt';document.head.appendChild(el);}
|
|
196
|
+
el.textContent=css;
|
|
197
|
+
}catch(e){}})();`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/font-features.ts
|
|
201
|
+
var import_node_fs2 = require("fs");
|
|
202
|
+
|
|
203
|
+
// src/feature-labels.ts
|
|
204
|
+
var BROWSER_DEFAULT_ON = /* @__PURE__ */ new Set(["liga", "clig", "calt", "kern", "locl", "mark", "mkmk"]);
|
|
205
|
+
var SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
206
|
+
"DFLT",
|
|
207
|
+
"dflt",
|
|
208
|
+
"locl",
|
|
209
|
+
"mark",
|
|
210
|
+
"mkmk",
|
|
211
|
+
"dist",
|
|
212
|
+
"rlig",
|
|
213
|
+
"curs",
|
|
214
|
+
"abvm",
|
|
215
|
+
"blwm",
|
|
216
|
+
"abvs",
|
|
217
|
+
"blws",
|
|
218
|
+
"pres",
|
|
219
|
+
"psts",
|
|
220
|
+
"haln",
|
|
221
|
+
"half",
|
|
222
|
+
"akhn",
|
|
223
|
+
"rphf",
|
|
224
|
+
"blwf",
|
|
225
|
+
"abvf",
|
|
226
|
+
"pref",
|
|
227
|
+
"rkrf",
|
|
228
|
+
"vatu",
|
|
229
|
+
"cjct",
|
|
230
|
+
"nukt",
|
|
231
|
+
"isol",
|
|
232
|
+
"init",
|
|
233
|
+
"medi",
|
|
234
|
+
"fina",
|
|
235
|
+
"med2",
|
|
236
|
+
"fin2",
|
|
237
|
+
"fin3",
|
|
238
|
+
"vert",
|
|
239
|
+
"vrt2",
|
|
240
|
+
"cfar"
|
|
241
|
+
]);
|
|
242
|
+
var FEATURE_LABELS = {
|
|
243
|
+
liga: "Standard Ligatures",
|
|
244
|
+
clig: "Contextual Ligatures",
|
|
245
|
+
dlig: "Discretionary Ligatures",
|
|
246
|
+
hlig: "Historical Ligatures",
|
|
247
|
+
calt: "Contextual Alternates",
|
|
248
|
+
kern: "Kerning",
|
|
249
|
+
swsh: "Swash",
|
|
250
|
+
case: "Case-Sensitive Forms",
|
|
251
|
+
onum: "Oldstyle Figures",
|
|
252
|
+
tnum: "Tabular Figures",
|
|
253
|
+
pnum: "Proportional Figures",
|
|
254
|
+
lnum: "Lining Figures",
|
|
255
|
+
frac: "Fractions",
|
|
256
|
+
afrc: "Alternative Fractions",
|
|
257
|
+
sups: "Superscript",
|
|
258
|
+
subs: "Subscript",
|
|
259
|
+
smcp: "Small Capitals",
|
|
260
|
+
c2sc: "Caps to Small Caps",
|
|
261
|
+
pcap: "Petite Capitals",
|
|
262
|
+
cpsp: "Capital Spacing",
|
|
263
|
+
zero: "Slashed Zero",
|
|
264
|
+
ordn: "Ordinals",
|
|
265
|
+
hist: "Historical Forms",
|
|
266
|
+
nalt: "Alternate Annotation",
|
|
267
|
+
salt: "Stylistic Alternates",
|
|
268
|
+
titl: "Titling",
|
|
269
|
+
...Object.fromEntries(
|
|
270
|
+
Array.from({ length: 20 }, (_, i) => {
|
|
271
|
+
const n = String(i + 1).padStart(2, "0");
|
|
272
|
+
return [`ss${n}`, `Stylistic Set ${i + 1}`];
|
|
273
|
+
})
|
|
274
|
+
),
|
|
275
|
+
...Object.fromEntries(
|
|
276
|
+
Array.from({ length: 6 }, (_, i) => {
|
|
277
|
+
const n = String(i + 1).padStart(2, "0");
|
|
278
|
+
return [`cv${n}`, `Character Variant ${i + 1}`];
|
|
279
|
+
})
|
|
280
|
+
)
|
|
281
|
+
};
|
|
282
|
+
function featuresFromTags(tags) {
|
|
283
|
+
return tags.filter((tag) => !SKIP_TAGS.has(tag)).map((tag) => ({
|
|
284
|
+
tag,
|
|
285
|
+
label: FEATURE_LABELS[tag] ?? tag,
|
|
286
|
+
enabled: BROWSER_DEFAULT_ON.has(tag)
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/font-features.ts
|
|
291
|
+
async function detectFeaturesFromFile(fontFilePath) {
|
|
292
|
+
if (!(0, import_node_fs2.existsSync)(fontFilePath)) return [];
|
|
293
|
+
try {
|
|
294
|
+
const mod = await import("opentype.js");
|
|
295
|
+
const loadSync = mod.loadSync ?? mod.default?.loadSync;
|
|
296
|
+
if (!loadSync) return [];
|
|
297
|
+
const font = loadSync(fontFilePath);
|
|
298
|
+
const tags = /* @__PURE__ */ new Set();
|
|
299
|
+
for (const table of [font.tables.gsub, font.tables.gpos]) {
|
|
300
|
+
for (const rec of table?.featureList?.featureRecords ?? []) {
|
|
301
|
+
if (rec?.featureTag) tags.add(rec.featureTag.trim());
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return featuresFromTags([...tags]);
|
|
305
|
+
} catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
310
|
+
0 && (module.exports = {
|
|
311
|
+
FEATURE_LABELS,
|
|
312
|
+
computeTrims,
|
|
313
|
+
defineConfig,
|
|
314
|
+
detectFeaturesFromFile,
|
|
315
|
+
featuresFromTags,
|
|
316
|
+
generateFontCSS,
|
|
317
|
+
getFOUCScript,
|
|
318
|
+
isCapsizeAvailable,
|
|
319
|
+
scanFontsFromCSS,
|
|
320
|
+
validateFonts
|
|
321
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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 };
|