@describe-me/vitest 0.5.0-next.2 → 0.5.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/README.md +4 -0
- package/dist/font-audit.d.ts +33 -0
- package/dist/font-audit.js +296 -0
- package/dist/font-family-key.d.ts +5 -0
- package/dist/font-family-key.js +14 -0
- package/dist/font-family-list.d.ts +6 -0
- package/dist/font-family-list.js +66 -0
- package/dist/font-shorthand-families.d.ts +6 -0
- package/dist/font-shorthand-families.js +177 -0
- package/dist/font-stylesheet-families.d.ts +6 -0
- package/dist/font-stylesheet-families.js +54 -0
- package/dist/font-usage.d.ts +26 -0
- package/dist/font-usage.js +163 -0
- package/dist/head-stylesheets.d.ts +8 -0
- package/dist/head-stylesheets.js +51 -0
- package/dist/print-diagnostics.d.ts +3 -2
- package/dist/print-diagnostics.js +13 -3
- package/dist/reporter.d.ts +9 -0
- package/dist/reporter.js +50 -9
- package/dist/snapshot-styles.d.ts +17 -0
- package/dist/snapshot-styles.js +65 -0
- package/dist/stable-test-id.d.ts +5 -0
- package/dist/stable-test-id.js +12 -0
- package/dist/stable-test-ids.d.ts +5 -0
- package/dist/stable-test-ids.js +13 -0
- package/dist/system-font-family.d.ts +5 -0
- package/dist/system-font-family.js +78 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -27,6 +27,10 @@ plugin detects where the tests run:
|
|
|
27
27
|
warnings match a run without describe-me. It also works without user-event
|
|
28
28
|
installed.
|
|
29
29
|
|
|
30
|
+
jsdom has no layout, so positioned popovers show in the top-left corner and
|
|
31
|
+
canvas stays blank
|
|
32
|
+
([Limitations in jsdom](https://github.com/grzehub/describe-me#limitations-in-jsdom)).
|
|
33
|
+
|
|
30
34
|
```ts
|
|
31
35
|
import { describeMe } from '@describe-me/vitest/plugin'
|
|
32
36
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type FontMissing, type Manifest, type RemoteStylesheet } from '@describe-me/core/types';
|
|
2
|
+
/** What the audit finds in a manifest. */
|
|
3
|
+
export interface FontAuditResult {
|
|
4
|
+
fontsMissing: FontMissing[];
|
|
5
|
+
remoteStylesheets: RemoteStylesheet[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Finds the font families that frames use but nothing loads, and the hosts
|
|
9
|
+
* that frames load stylesheets from. Snapshots, style chunks and assets are
|
|
10
|
+
* content-addressed, so what it read stays valid for the reporter's lifetime.
|
|
11
|
+
*/
|
|
12
|
+
export declare class FontAudit {
|
|
13
|
+
private readonly outDir;
|
|
14
|
+
private readonly chunks;
|
|
15
|
+
private readonly cssAssets;
|
|
16
|
+
private readonly snapshots;
|
|
17
|
+
constructor(outDir: string);
|
|
18
|
+
/** Walk the manifest in order. A file that cannot be read contributes nothing. */
|
|
19
|
+
audit(manifest: Manifest): FontAuditResult;
|
|
20
|
+
private addMissing;
|
|
21
|
+
/** Only the preview head's `@font-face` rules and font stylesheets count, not its own usage. */
|
|
22
|
+
private headFonts;
|
|
23
|
+
private snapshotFonts;
|
|
24
|
+
private readSnapshotFonts;
|
|
25
|
+
/**
|
|
26
|
+
* Add the CSS assets that stylesheets reach through asset links and
|
|
27
|
+
* `@import`s, transitively, and collect their remote URLs. Any other URL,
|
|
28
|
+
* such as a root-relative one left by a missing file, is ignored.
|
|
29
|
+
*/
|
|
30
|
+
private reach;
|
|
31
|
+
private chunkUsage;
|
|
32
|
+
private cssAsset;
|
|
33
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ASSET_URL_PREFIX, } from '@describe-me/core/types';
|
|
4
|
+
import { cssAssetReferences } from './css-asset-references.js';
|
|
5
|
+
import { fontFamilyKey } from './font-family-key.js';
|
|
6
|
+
import { fontShorthandFamilies } from './font-shorthand-families.js';
|
|
7
|
+
import { fontStylesheetFamilies } from './font-stylesheet-families.js';
|
|
8
|
+
import { fontUsage } from './font-usage.js';
|
|
9
|
+
import { headStylesheets } from './head-stylesheets.js';
|
|
10
|
+
import { isSystemFontFamily } from './system-font-family.js';
|
|
11
|
+
import { snapshotStyles } from './snapshot-styles.js';
|
|
12
|
+
/** A reference counts as level 1, so `--a: var(--b)` to `--c: Foo` still resolves. */
|
|
13
|
+
const MAX_VAR_LEVELS = 3;
|
|
14
|
+
/** The test server, whose URLs `assetsMissing` already covers. */
|
|
15
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']);
|
|
16
|
+
const REMOTE_URL = /^https?:/i;
|
|
17
|
+
function isRemoteUrl(url) {
|
|
18
|
+
return REMOTE_URL.test(url) || url.startsWith('//');
|
|
19
|
+
}
|
|
20
|
+
/** The stored name behind a `describe-me-asset:` URL, or null for any other URL. */
|
|
21
|
+
function assetName(url) {
|
|
22
|
+
if (!url.startsWith(ASSET_URL_PREFIX)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return url.slice(ASSET_URL_PREFIX.length).split(/[?#]/)[0];
|
|
26
|
+
}
|
|
27
|
+
function hostOf(url) {
|
|
28
|
+
const absolute = url.startsWith('//') ? `https:${url}` : url;
|
|
29
|
+
return URL.canParse(absolute) ? new URL(absolute).hostname : null;
|
|
30
|
+
}
|
|
31
|
+
function readText(path) {
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function mergedVariables(usages) {
|
|
40
|
+
const variables = new Map();
|
|
41
|
+
for (const usage of usages) {
|
|
42
|
+
for (const [name, values] of usage.variables) {
|
|
43
|
+
variables.set(name, [...(variables.get(name) ?? []), ...values]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return variables;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The first family of a custom property's value, or the `var()` in its place.
|
|
50
|
+
* A value reads as a `font` shorthand when it parses as one, and as a family
|
|
51
|
+
* list otherwise.
|
|
52
|
+
*/
|
|
53
|
+
function firstFamilyOf(value) {
|
|
54
|
+
const property = fontShorthandFamilies(value) === null ? 'font-family' : 'font';
|
|
55
|
+
const usage = fontUsage(`${property}: ${value}`);
|
|
56
|
+
return { family: usage.families[0], reference: usage.varRefs[0] };
|
|
57
|
+
}
|
|
58
|
+
function resolveVar(reference, variables, level) {
|
|
59
|
+
if (level > MAX_VAR_LEVELS) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const definitions = variables.get(reference.name) ?? [];
|
|
63
|
+
let values = definitions;
|
|
64
|
+
if (definitions.length === 0) {
|
|
65
|
+
values = reference.fallback === undefined ? [] : [reference.fallback];
|
|
66
|
+
}
|
|
67
|
+
return values.flatMap((value) => {
|
|
68
|
+
const first = firstFamilyOf(value);
|
|
69
|
+
if (first.reference) {
|
|
70
|
+
return resolveVar(first.reference, variables, level + 1);
|
|
71
|
+
}
|
|
72
|
+
return first.family === undefined ? [] : [first.family];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Keys of the families remote stylesheets load, and whether any provider is unknown. */
|
|
76
|
+
function remoteFamilies(urls) {
|
|
77
|
+
const loaded = new Set();
|
|
78
|
+
let unknownProvider = false;
|
|
79
|
+
for (const url of urls) {
|
|
80
|
+
const families = fontStylesheetFamilies(url);
|
|
81
|
+
if (families === null) {
|
|
82
|
+
unknownProvider = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const family of families) {
|
|
86
|
+
loaded.add(fontFamilyKey(family));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { loaded, unknownProvider };
|
|
90
|
+
}
|
|
91
|
+
function remoteHosts(urls) {
|
|
92
|
+
const hosts = new Set();
|
|
93
|
+
for (const url of urls) {
|
|
94
|
+
const host = hostOf(url);
|
|
95
|
+
if (host && !LOOPBACK_HOSTS.has(host)) {
|
|
96
|
+
hosts.add(host);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...hosts];
|
|
100
|
+
}
|
|
101
|
+
function parsedJson(json) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(json);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The families that stylesheets and `style` attributes use, with each `var()`
|
|
111
|
+
* resolved against the stylesheets' custom properties. Keyed, with the first
|
|
112
|
+
* spelling, and without system families.
|
|
113
|
+
*/
|
|
114
|
+
function usedFamilies(sheets, attributes) {
|
|
115
|
+
const variables = mergedVariables(sheets);
|
|
116
|
+
const used = new Map();
|
|
117
|
+
const add = (family) => {
|
|
118
|
+
const key = fontFamilyKey(family);
|
|
119
|
+
if (!used.has(key) && !isSystemFontFamily(family)) {
|
|
120
|
+
used.set(key, family);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
for (const usage of [...sheets, ...attributes]) {
|
|
124
|
+
usage.families.forEach(add);
|
|
125
|
+
for (const reference of usage.varRefs) {
|
|
126
|
+
resolveVar(reference, variables, 1).forEach(add);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return used;
|
|
130
|
+
}
|
|
131
|
+
function sortedMissing(missing) {
|
|
132
|
+
return [...missing.values()].sort((left, right) => right.tests - left.tests || left.family.localeCompare(right.family));
|
|
133
|
+
}
|
|
134
|
+
function sortedHosts(frames) {
|
|
135
|
+
return Array.from(frames, ([host, count]) => ({ host, frames: count })).sort((left, right) => right.frames - left.frames || left.host.localeCompare(right.host));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Finds the font families that frames use but nothing loads, and the hosts
|
|
139
|
+
* that frames load stylesheets from. Snapshots, style chunks and assets are
|
|
140
|
+
* content-addressed, so what it read stays valid for the reporter's lifetime.
|
|
141
|
+
*/
|
|
142
|
+
export class FontAudit {
|
|
143
|
+
outDir;
|
|
144
|
+
chunks = new Map();
|
|
145
|
+
cssAssets = new Map();
|
|
146
|
+
snapshots = new Map();
|
|
147
|
+
constructor(outDir) {
|
|
148
|
+
this.outDir = outDir;
|
|
149
|
+
}
|
|
150
|
+
/** Walk the manifest in order. A file that cannot be read contributes nothing. */
|
|
151
|
+
audit(manifest) {
|
|
152
|
+
const head = this.headFonts(manifest.head);
|
|
153
|
+
const missing = new Map();
|
|
154
|
+
const hostFrames = new Map();
|
|
155
|
+
for (const mod of manifest.modules) {
|
|
156
|
+
for (const test of mod.tests) {
|
|
157
|
+
const testMissing = new Map();
|
|
158
|
+
for (const frame of test.frames) {
|
|
159
|
+
const fonts = this.snapshotFonts(frame.snapshot);
|
|
160
|
+
for (const host of fonts.hosts) {
|
|
161
|
+
hostFrames.set(host, (hostFrames.get(host) ?? 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
if (!head.unknownProvider) {
|
|
164
|
+
this.addMissing(testMissing, fonts, head);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const [key, family] of testMissing) {
|
|
168
|
+
const entry = missing.get(key);
|
|
169
|
+
if (entry) {
|
|
170
|
+
entry.tests++;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
missing.set(key, { family, tests: 1, testId: test.id });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { fontsMissing: sortedMissing(missing), remoteStylesheets: sortedHosts(hostFrames) };
|
|
179
|
+
}
|
|
180
|
+
addMissing(testMissing, fonts, head) {
|
|
181
|
+
if (fonts.unknownProvider) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const [key, family] of fonts.used) {
|
|
185
|
+
if (!fonts.loaded.has(key) && !head.loaded.has(key) && !testMissing.has(key)) {
|
|
186
|
+
testMissing.set(key, family);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Only the preview head's `@font-face` rules and font stylesheets count, not its own usage. */
|
|
191
|
+
headFonts(head) {
|
|
192
|
+
const { styles, links } = headStylesheets(head ?? '');
|
|
193
|
+
const reached = this.reach(styles.map((css) => fontUsage(css)), links);
|
|
194
|
+
const remote = remoteFamilies(reached.remoteUrls);
|
|
195
|
+
const loaded = new Set(reached.usages.flatMap((usage) => usage.declared).map(fontFamilyKey));
|
|
196
|
+
for (const key of remote.loaded) {
|
|
197
|
+
loaded.add(key);
|
|
198
|
+
}
|
|
199
|
+
return { loaded, unknownProvider: remote.unknownProvider };
|
|
200
|
+
}
|
|
201
|
+
snapshotFonts(file) {
|
|
202
|
+
const cached = this.snapshots.get(file);
|
|
203
|
+
if (cached) {
|
|
204
|
+
return cached;
|
|
205
|
+
}
|
|
206
|
+
const { fonts, complete } = this.readSnapshotFonts(file);
|
|
207
|
+
if (complete) {
|
|
208
|
+
this.snapshots.set(file, fonts);
|
|
209
|
+
}
|
|
210
|
+
return fonts;
|
|
211
|
+
}
|
|
212
|
+
readSnapshotFonts(file) {
|
|
213
|
+
const json = readText(join(this.outDir, file));
|
|
214
|
+
const styles = snapshotStyles(json === null ? null : parsedJson(json));
|
|
215
|
+
const chunks = styles.chunks.map((hash) => this.chunkUsage(hash));
|
|
216
|
+
const sheets = styles.cssTexts.map((css) => fontUsage(css));
|
|
217
|
+
const reached = this.reach([...sheets, ...chunks.filter((usage) => usage !== null)], styles.stylesheetLinks);
|
|
218
|
+
const attributes = styles.styleAttributes.map((css) => fontUsage(css));
|
|
219
|
+
const remote = remoteFamilies(reached.remoteUrls);
|
|
220
|
+
for (const family of reached.usages.flatMap((usage) => usage.declared)) {
|
|
221
|
+
remote.loaded.add(fontFamilyKey(family));
|
|
222
|
+
}
|
|
223
|
+
const fonts = {
|
|
224
|
+
used: usedFamilies(reached.usages, attributes),
|
|
225
|
+
loaded: remote.loaded,
|
|
226
|
+
unknownProvider: remote.unknownProvider,
|
|
227
|
+
hosts: remoteHosts(reached.remoteUrls),
|
|
228
|
+
};
|
|
229
|
+
return { fonts, complete: json !== null && reached.complete && !chunks.includes(null) };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Add the CSS assets that stylesheets reach through asset links and
|
|
233
|
+
* `@import`s, transitively, and collect their remote URLs. Any other URL,
|
|
234
|
+
* such as a root-relative one left by a missing file, is ignored.
|
|
235
|
+
*/
|
|
236
|
+
reach(sheets, links) {
|
|
237
|
+
const reached = { usages: [...sheets], remoteUrls: [], complete: true };
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
const pending = [];
|
|
240
|
+
const follow = (url) => {
|
|
241
|
+
const name = assetName(url);
|
|
242
|
+
if (isRemoteUrl(url)) {
|
|
243
|
+
reached.remoteUrls.push(url);
|
|
244
|
+
}
|
|
245
|
+
else if (name !== null) {
|
|
246
|
+
pending.push(name);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
links.forEach(follow);
|
|
250
|
+
sheets.forEach((usage) => usage.imports.forEach(follow));
|
|
251
|
+
for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
|
|
252
|
+
if (seen.has(name) || !name.endsWith('.css')) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
seen.add(name);
|
|
256
|
+
const asset = this.cssAsset(name);
|
|
257
|
+
if (asset === null) {
|
|
258
|
+
reached.complete = false;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
reached.usages.push(asset.usage);
|
|
262
|
+
pending.push(...asset.siblings);
|
|
263
|
+
asset.usage.imports.filter(isRemoteUrl).forEach((url) => reached.remoteUrls.push(url));
|
|
264
|
+
}
|
|
265
|
+
return reached;
|
|
266
|
+
}
|
|
267
|
+
chunkUsage(hash) {
|
|
268
|
+
const cached = this.chunks.get(hash);
|
|
269
|
+
if (cached) {
|
|
270
|
+
return cached;
|
|
271
|
+
}
|
|
272
|
+
const css = readText(join(this.outDir, 'styles', `${hash}.css`));
|
|
273
|
+
if (css === null) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const usage = fontUsage(css);
|
|
277
|
+
this.chunks.set(hash, usage);
|
|
278
|
+
return usage;
|
|
279
|
+
}
|
|
280
|
+
cssAsset(name) {
|
|
281
|
+
const cached = this.cssAssets.get(name);
|
|
282
|
+
if (cached) {
|
|
283
|
+
return cached;
|
|
284
|
+
}
|
|
285
|
+
const css = readText(join(this.outDir, 'assets', name));
|
|
286
|
+
if (css === null) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const asset = {
|
|
290
|
+
usage: fontUsage(css),
|
|
291
|
+
siblings: cssAssetReferences(css).filter((sibling) => sibling.endsWith('.css')),
|
|
292
|
+
};
|
|
293
|
+
this.cssAssets.set(name, asset);
|
|
294
|
+
return asset;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Font URLs and CSS spell the gaps in a name as `+`, `-`, `_` or spaces. */
|
|
2
|
+
const SEPARATORS = /[\s+_-]+/g;
|
|
3
|
+
function unquoted(name) {
|
|
4
|
+
const first = name[0];
|
|
5
|
+
const isQuoted = name.length >= 2 && (first === '"' || first === "'") && name.endsWith(first);
|
|
6
|
+
return isQuoted ? name.slice(1, -1) : name;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The key two spellings of one font family share, so `Noto+Sans+Arabic`,
|
|
10
|
+
* `'Noto Sans Arabic'` and `noto-sans-arabic` compare equal.
|
|
11
|
+
*/
|
|
12
|
+
export function fontFamilyKey(name) {
|
|
13
|
+
return unquoted(name.trim().toLowerCase()).replace(SEPARATORS, ' ').trim();
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names in a `font-family` value, unquoted, with their whitespace
|
|
3
|
+
* collapsed. Commas inside quotes or parentheses do not split, so
|
|
4
|
+
* `"Foo, Bar", serif` gives `Foo, Bar` and `serif`, and a `var()` stays whole.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fontFamilyList(value: string): string[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const WHITESPACE = /\s+/g;
|
|
2
|
+
function commentEnd(value, open) {
|
|
3
|
+
const close = value.indexOf('*/', open + 2);
|
|
4
|
+
return close === -1 ? value.length : close + 2;
|
|
5
|
+
}
|
|
6
|
+
/** The unescaped text of the string opening at `open`, and where scanning goes on. */
|
|
7
|
+
function readString(value, open) {
|
|
8
|
+
let text = '';
|
|
9
|
+
let index = open + 1;
|
|
10
|
+
while (index < value.length && value[index] !== value[open]) {
|
|
11
|
+
if (value[index] === '\\') {
|
|
12
|
+
text += value[index + 1] ?? '';
|
|
13
|
+
index += 2;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
text += value[index];
|
|
17
|
+
index++;
|
|
18
|
+
}
|
|
19
|
+
return { text, next: index + 1 };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The names in a `font-family` value, unquoted, with their whitespace
|
|
23
|
+
* collapsed. Commas inside quotes or parentheses do not split, so
|
|
24
|
+
* `"Foo, Bar", serif` gives `Foo, Bar` and `serif`, and a `var()` stays whole.
|
|
25
|
+
*/
|
|
26
|
+
export function fontFamilyList(value) {
|
|
27
|
+
const names = [];
|
|
28
|
+
let current = '';
|
|
29
|
+
let depth = 0;
|
|
30
|
+
let index = 0;
|
|
31
|
+
while (index < value.length) {
|
|
32
|
+
const char = value[index];
|
|
33
|
+
if (char === '/' && value[index + 1] === '*') {
|
|
34
|
+
current += ' ';
|
|
35
|
+
index = commentEnd(value, index);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === '\\') {
|
|
39
|
+
current += depth > 0 ? value.slice(index, index + 2) : (value[index + 1] ?? '');
|
|
40
|
+
index += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if ((char === '"' || char === "'") && depth === 0) {
|
|
44
|
+
const string = readString(value, index);
|
|
45
|
+
current += string.text;
|
|
46
|
+
index = string.next;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === ',' && depth === 0) {
|
|
50
|
+
names.push(current);
|
|
51
|
+
current = '';
|
|
52
|
+
index++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === '(') {
|
|
56
|
+
depth++;
|
|
57
|
+
}
|
|
58
|
+
else if (char === ')') {
|
|
59
|
+
depth = Math.max(0, depth - 1);
|
|
60
|
+
}
|
|
61
|
+
current += char;
|
|
62
|
+
index++;
|
|
63
|
+
}
|
|
64
|
+
names.push(current);
|
|
65
|
+
return names.map((name) => name.replace(WHITESPACE, ' ').trim()).filter((name) => name !== '');
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The family list of a `font` shorthand value, such as `Inter` and
|
|
3
|
+
* `sans-serif` for `600 14px/1.5 Inter, sans-serif`. Null when the value is a
|
|
4
|
+
* keyword or has no size followed by a family.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fontShorthandFamilies(value: string): string[] | null;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { fontFamilyList } from './font-family-list.js';
|
|
2
|
+
/** Values that set the whole shorthand and name no family. */
|
|
3
|
+
const KEYWORDS = new Set([
|
|
4
|
+
'inherit',
|
|
5
|
+
'initial',
|
|
6
|
+
'unset',
|
|
7
|
+
'revert',
|
|
8
|
+
'revert-layer',
|
|
9
|
+
'caption',
|
|
10
|
+
'icon',
|
|
11
|
+
'menu',
|
|
12
|
+
'message-box',
|
|
13
|
+
'small-caption',
|
|
14
|
+
'status-bar',
|
|
15
|
+
]);
|
|
16
|
+
const SIZE_KEYWORDS = new Set([
|
|
17
|
+
'xx-small',
|
|
18
|
+
'x-small',
|
|
19
|
+
'small',
|
|
20
|
+
'medium',
|
|
21
|
+
'large',
|
|
22
|
+
'x-large',
|
|
23
|
+
'xx-large',
|
|
24
|
+
'xxx-large',
|
|
25
|
+
'smaller',
|
|
26
|
+
'larger',
|
|
27
|
+
]);
|
|
28
|
+
const LENGTH_UNITS = new Set([
|
|
29
|
+
'%',
|
|
30
|
+
'px',
|
|
31
|
+
'em',
|
|
32
|
+
'rem',
|
|
33
|
+
'ex',
|
|
34
|
+
'rex',
|
|
35
|
+
'ch',
|
|
36
|
+
'rch',
|
|
37
|
+
'cap',
|
|
38
|
+
'rcap',
|
|
39
|
+
'ic',
|
|
40
|
+
'ric',
|
|
41
|
+
'lh',
|
|
42
|
+
'rlh',
|
|
43
|
+
'vw',
|
|
44
|
+
'vh',
|
|
45
|
+
'vi',
|
|
46
|
+
'vb',
|
|
47
|
+
'vmin',
|
|
48
|
+
'vmax',
|
|
49
|
+
'svw',
|
|
50
|
+
'svh',
|
|
51
|
+
'svi',
|
|
52
|
+
'svb',
|
|
53
|
+
'svmin',
|
|
54
|
+
'svmax',
|
|
55
|
+
'lvw',
|
|
56
|
+
'lvh',
|
|
57
|
+
'lvi',
|
|
58
|
+
'lvb',
|
|
59
|
+
'lvmin',
|
|
60
|
+
'lvmax',
|
|
61
|
+
'dvw',
|
|
62
|
+
'dvh',
|
|
63
|
+
'dvi',
|
|
64
|
+
'dvb',
|
|
65
|
+
'dvmin',
|
|
66
|
+
'dvmax',
|
|
67
|
+
'cqw',
|
|
68
|
+
'cqh',
|
|
69
|
+
'cqi',
|
|
70
|
+
'cqb',
|
|
71
|
+
'cqmin',
|
|
72
|
+
'cqmax',
|
|
73
|
+
'cm',
|
|
74
|
+
'mm',
|
|
75
|
+
'q',
|
|
76
|
+
'in',
|
|
77
|
+
'pt',
|
|
78
|
+
'pc',
|
|
79
|
+
]);
|
|
80
|
+
const SIZE_FUNCTIONS = ['calc(', 'clamp(', 'min(', 'max('];
|
|
81
|
+
const NUMBER = /^[+-]?(?:\d+|\d*\.\d+)$/;
|
|
82
|
+
const UNIT_CHARACTER = /[a-z%]/i;
|
|
83
|
+
const WHITESPACE = /\s/;
|
|
84
|
+
function commentEnd(value, open) {
|
|
85
|
+
const close = value.indexOf('*/', open + 2);
|
|
86
|
+
return close === -1 ? value.length : close + 2;
|
|
87
|
+
}
|
|
88
|
+
function stringEnd(value, open) {
|
|
89
|
+
let index = open + 1;
|
|
90
|
+
while (index < value.length && value[index] !== value[open]) {
|
|
91
|
+
index += value[index] === '\\' ? 2 : 1;
|
|
92
|
+
}
|
|
93
|
+
return index + 1;
|
|
94
|
+
}
|
|
95
|
+
/** Top-level tokens split at whitespace, with `/` as a token of its own. */
|
|
96
|
+
function tokensOf(value) {
|
|
97
|
+
const tokens = [];
|
|
98
|
+
let current = null;
|
|
99
|
+
let depth = 0;
|
|
100
|
+
let index = 0;
|
|
101
|
+
const close = () => {
|
|
102
|
+
if (current !== null) {
|
|
103
|
+
tokens.push(current);
|
|
104
|
+
current = null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
while (index < value.length) {
|
|
108
|
+
const char = value[index];
|
|
109
|
+
if (char === '/' && value[index + 1] === '*') {
|
|
110
|
+
close();
|
|
111
|
+
index = commentEnd(value, index);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (depth === 0 && (WHITESPACE.test(char) || char === '/')) {
|
|
115
|
+
close();
|
|
116
|
+
if (char === '/') {
|
|
117
|
+
tokens.push({ text: '/', start: index });
|
|
118
|
+
}
|
|
119
|
+
index++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
let next = index + 1;
|
|
123
|
+
if (char === '\\') {
|
|
124
|
+
next = index + 2;
|
|
125
|
+
}
|
|
126
|
+
else if (char === '"' || char === "'") {
|
|
127
|
+
next = stringEnd(value, index);
|
|
128
|
+
}
|
|
129
|
+
else if (char === '(') {
|
|
130
|
+
depth++;
|
|
131
|
+
}
|
|
132
|
+
else if (char === ')') {
|
|
133
|
+
depth = Math.max(0, depth - 1);
|
|
134
|
+
}
|
|
135
|
+
current ??= { text: '', start: index };
|
|
136
|
+
current.text += value.slice(index, next);
|
|
137
|
+
index = next;
|
|
138
|
+
}
|
|
139
|
+
close();
|
|
140
|
+
return tokens;
|
|
141
|
+
}
|
|
142
|
+
function isDimension(token) {
|
|
143
|
+
let end = token.length;
|
|
144
|
+
while (end > 0 && UNIT_CHARACTER.test(token[end - 1])) {
|
|
145
|
+
end--;
|
|
146
|
+
}
|
|
147
|
+
const unit = token.slice(end).toLowerCase();
|
|
148
|
+
return NUMBER.test(token.slice(0, end)) && LENGTH_UNITS.has(unit);
|
|
149
|
+
}
|
|
150
|
+
/** A bare number (`600`) is a weight and an angle is no size, so `font: 0/0 a` has none. */
|
|
151
|
+
function isSize(token) {
|
|
152
|
+
const lower = token.toLowerCase();
|
|
153
|
+
return (SIZE_KEYWORDS.has(lower) ||
|
|
154
|
+
SIZE_FUNCTIONS.some((name) => lower.startsWith(name)) ||
|
|
155
|
+
isDimension(token));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The family list of a `font` shorthand value, such as `Inter` and
|
|
159
|
+
* `sans-serif` for `600 14px/1.5 Inter, sans-serif`. Null when the value is a
|
|
160
|
+
* keyword or has no size followed by a family.
|
|
161
|
+
*/
|
|
162
|
+
export function fontShorthandFamilies(value) {
|
|
163
|
+
if (KEYWORDS.has(value.trim().toLowerCase())) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const tokens = tokensOf(value);
|
|
167
|
+
const size = tokens.findIndex((token) => isSize(token.text));
|
|
168
|
+
if (size === -1) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const first = tokens[size + 1]?.text === '/' ? size + 3 : size + 1;
|
|
172
|
+
if (first >= tokens.length) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const families = fontFamilyList(value.slice(tokens[first].start));
|
|
176
|
+
return families.length > 0 ? families : null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The families a remote stylesheet loads, read from its URL. Null when the
|
|
3
|
+
* provider is unknown: Google Fonts, Bunny Fonts and Fontsource on jsDelivr
|
|
4
|
+
* are the ones whose URLs name their families.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fontStylesheetFamilies(url: string): string[] | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const GOOGLE_FONTS_HOST = 'fonts.googleapis.com';
|
|
2
|
+
const GOOGLE_FONTS_PATHS = new Set(['/css', '/css2', '/icon']);
|
|
3
|
+
const BUNNY_FONTS_HOST = 'fonts.bunny.net';
|
|
4
|
+
const BUNNY_FONTS_PATHS = new Set(['/css', '/css2']);
|
|
5
|
+
const JSDELIVR_HOST = 'cdn.jsdelivr.net';
|
|
6
|
+
/** Fontsource names the family of `@fontsource-variable/inter` `Inter Variable`. */
|
|
7
|
+
const FONTSOURCE_SCOPES = new Map([
|
|
8
|
+
['@fontsource', ''],
|
|
9
|
+
['@fontsource-variable', ' Variable'],
|
|
10
|
+
]);
|
|
11
|
+
function parsedUrl(url) {
|
|
12
|
+
const decoded = url.replaceAll('&', '&');
|
|
13
|
+
const absolute = decoded.startsWith('//') ? `https:${decoded}` : decoded;
|
|
14
|
+
return URL.canParse(absolute) ? new URL(absolute) : null;
|
|
15
|
+
}
|
|
16
|
+
/** `family=Open+Sans:400,700|Roboto+Mono`, repeated or not, as `Open Sans` and `Roboto Mono`. */
|
|
17
|
+
function familyParameters(url) {
|
|
18
|
+
return url.searchParams
|
|
19
|
+
.getAll('family')
|
|
20
|
+
.flatMap((value) => value.split('|'))
|
|
21
|
+
.map((family) => family.split(':')[0].trim())
|
|
22
|
+
.filter((family) => family !== '');
|
|
23
|
+
}
|
|
24
|
+
/** `/npm/@fontsource/<package>[@version]/…` as the family the package loads. */
|
|
25
|
+
function fontsourceFamilies(url) {
|
|
26
|
+
const [, registry, scope, name] = url.pathname.split('/');
|
|
27
|
+
const suffix = FONTSOURCE_SCOPES.get(scope);
|
|
28
|
+
const packageName = name?.split('@')[0];
|
|
29
|
+
if (registry !== 'npm' || suffix === undefined || !packageName) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return [`${packageName}${suffix}`];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The families a remote stylesheet loads, read from its URL. Null when the
|
|
36
|
+
* provider is unknown: Google Fonts, Bunny Fonts and Fontsource on jsDelivr
|
|
37
|
+
* are the ones whose URLs name their families.
|
|
38
|
+
*/
|
|
39
|
+
export function fontStylesheetFamilies(url) {
|
|
40
|
+
const parsed = parsedUrl(url);
|
|
41
|
+
if (parsed === null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (parsed.hostname === GOOGLE_FONTS_HOST && GOOGLE_FONTS_PATHS.has(parsed.pathname)) {
|
|
45
|
+
return familyParameters(parsed);
|
|
46
|
+
}
|
|
47
|
+
if (parsed.hostname === BUNNY_FONTS_HOST && BUNNY_FONTS_PATHS.has(parsed.pathname)) {
|
|
48
|
+
return familyParameters(parsed);
|
|
49
|
+
}
|
|
50
|
+
if (parsed.hostname === JSDELIVR_HOST) {
|
|
51
|
+
return fontsourceFamilies(parsed);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|