@vectoriox/iox-ui 5.19.0 → 5.20.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/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { deriveAccentTokens } from './brand';
|
|
2
|
-
import {
|
|
2
|
+
import { fontStackWithout, formatFontFamily } from './fonts';
|
|
3
3
|
import { SPEC_AXES } from './flavor-spec.generated';
|
|
4
4
|
import {
|
|
5
5
|
DesignChoices, DesignTokenRow, FlavorPackV2, FlavorSpecToken, flavorSourceStamp, USER_TOKEN_SOURCE,
|
|
@@ -38,9 +38,16 @@ export function defaultDesignChoices(pack: FlavorPackV2): DesignChoices {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The value a font role holds once the author picks a face: `'<Family>', <the look's own stack>` —
|
|
43
|
+
* e.g. `'Rubik', 'Inter Tight', system-ui, -apple-system, 'Segoe UI', sans-serif`. The look's font
|
|
44
|
+
* follows the author's, so a family that later stops existing (deleted outside the delete guard, a
|
|
45
|
+
* bucket emptied by hand) renders in the pack's font — Board 8's promise — in every renderer, with no
|
|
46
|
+
* runtime check. With no look stack: `system-ui, sans-serif`.
|
|
47
|
+
*/
|
|
48
|
+
export function userFontStack(family: string, packStack?: string | null): string {
|
|
49
|
+
const fallback = fontStackWithout(packStack, family) || USER_FONT_FALLBACK;
|
|
50
|
+
return `${formatFontFamily(family)}, ${fallback}`;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
const toRow = (t: FlavorSpecToken, source: string): DesignTokenRow => ({
|
|
@@ -81,7 +88,7 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
|
|
|
81
88
|
set(BUTTON_RADIUS, choices.pillButtons ? PILL_RADIUS : (buttonRadius?.overridden ? buttonRadius.value : CONTROL_RADIUS));
|
|
82
89
|
|
|
83
90
|
for (const [role, family] of Object.entries(choices.fonts ?? {})) {
|
|
84
|
-
if (typeof family === 'string' && family.trim() !== '') set(role, userFontStack(family), USER_TOKEN_SOURCE);
|
|
91
|
+
if (typeof family === 'string' && family.trim() !== '') set(role, userFontStack(family, byName.get(role)?.value), USER_TOKEN_SOURCE);
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
if (choices.seed && choices.seed.toLowerCase() !== pack.seed.toLowerCase()) {
|
package/src/design-core/fonts.ts
CHANGED
|
@@ -12,28 +12,57 @@ export const FONT_CATALOG: Readonly<Record<string, string>> = SPEC_FONTS;
|
|
|
12
12
|
export type FontFormat = 'woff2' | 'woff' | 'truetype' | 'opentype';
|
|
13
13
|
export type FontStyle = 'normal' | 'italic';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A face's weight (spec `fontDescriptor.weight`): 100–900 for a static cut (OS/2 `usWeightClass`), or
|
|
17
|
+
* the RANGE string `'<min> <max>'` a variable font's `fvar` wght axis covers — emitted as
|
|
18
|
+
* `font-weight: <min> <max>`, so every weight inside it renders from the one file.
|
|
19
|
+
*/
|
|
20
|
+
export type FontWeight = number | string;
|
|
21
|
+
|
|
15
22
|
/** One uploaded font FILE (spec `fontDescriptor`) — a family is all descriptors sharing `family`. */
|
|
16
23
|
export interface FontDescriptor {
|
|
17
24
|
family: string;
|
|
18
|
-
|
|
19
|
-
weight: number;
|
|
25
|
+
weight: FontWeight;
|
|
20
26
|
style: FontStyle;
|
|
21
27
|
url: string;
|
|
22
28
|
format: FontFormat;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Why iox-cms refused a font upload. The server answers `{ code }` beside its message; the admin
|
|
33
|
+
* translates the code (`filebox.fontUpload.<code>`), so the author reads why in their language.
|
|
34
|
+
*/
|
|
35
|
+
export enum FontUploadError {
|
|
36
|
+
NotAFont = 'not_a_font',
|
|
37
|
+
Empty = 'empty',
|
|
38
|
+
Unreadable = 'unreadable',
|
|
39
|
+
TooLarge = 'too_large',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The largest font FILE a studio may upload: 10 MB. A full static or variable Latin/Hebrew/Arabic
|
|
44
|
+
* family is well under it (Inter's variable TTF is 0.9 MB); an unsubsetted CJK family is not, and
|
|
45
|
+
* must be subset first. iox-cms stops reading the request at this size; the admin checks it before
|
|
46
|
+
* sending.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_FONT_FILE_BYTES = 10 * 1024 * 1024;
|
|
49
|
+
|
|
25
50
|
/** The token roles whose values name the families a page renders in. */
|
|
26
51
|
export const FONT_FAMILY_TOKENS = ['font-display', 'font-body', 'font-label', 'font-mono'] as const;
|
|
27
52
|
|
|
28
53
|
const DEFAULT_WEIGHT = 400;
|
|
29
54
|
const MIN_WEIGHT = 100;
|
|
30
55
|
const MAX_WEIGHT = 900;
|
|
56
|
+
/** The bounds CSS gives `font-weight`; a variable font's axis may reach them (Roboto Flex: 100–1000). */
|
|
57
|
+
const CSS_MIN_WEIGHT = 1;
|
|
58
|
+
const CSS_MAX_WEIGHT = 1000;
|
|
31
59
|
const FONT_FORMATS: ReadonlyArray<FontFormat> = ['woff2', 'woff', 'truetype', 'opentype'];
|
|
32
60
|
|
|
33
61
|
/**
|
|
34
62
|
* A CSS string literal, single-quoted: `\` and `'` escaped, line breaks and NUL dropped, and `<` /
|
|
35
63
|
* `>` hex-escaped (`\3c `) — the face CSS is inlined into a `<style>` by the SSR engine, where an
|
|
36
|
-
* uploaded family named `</style><script>` would otherwise close the element.
|
|
64
|
+
* uploaded family named `</style><script>` would otherwise close the element. `parseFontFamilies` is
|
|
65
|
+
* its exact inverse (escapes included), which is what keeps a family's name one value everywhere.
|
|
37
66
|
*/
|
|
38
67
|
export function cssString(value: string): string {
|
|
39
68
|
const body = String(value)
|
|
@@ -45,6 +74,174 @@ export function cssString(value: string): string {
|
|
|
45
74
|
return `'${body}'`;
|
|
46
75
|
}
|
|
47
76
|
|
|
77
|
+
/** ONE family as a `font-family` entry — always quoted, so a family named `Acme, Serif` or `serif` is
|
|
78
|
+
* that family, never a list or a generic. */
|
|
79
|
+
export function formatFontFamily(family: string): string {
|
|
80
|
+
return cssString(String(family ?? '').trim());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Families → a `font-family` stack, each quoted (`formatFontFamily`); `parseFontFamilies` reverses it. */
|
|
84
|
+
export function formatFontFamilies(families: ReadonlyArray<string>): string {
|
|
85
|
+
return (families ?? []).filter(f => String(f ?? '').trim() !== '').map(formatFontFamily).join(', ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const HEX = /[0-9a-f]/i;
|
|
89
|
+
const CSS_WS = /[ \t\n\r\f]/;
|
|
90
|
+
const REPLACEMENT = '\uFFFD';
|
|
91
|
+
|
|
92
|
+
/** A CSS escape at `s[at] === '\\'` → [the character it stands for, the index after it] (CSS Syntax §4.3.7). */
|
|
93
|
+
function readEscape(s: string, at: number, inString: boolean): [string, number] {
|
|
94
|
+
let i = at + 1;
|
|
95
|
+
if (i >= s.length) return ['', i];
|
|
96
|
+
if (inString && /[\n\r\f]/.test(s[i])) return ['', s[i] === '\r' && s[i + 1] === '\n' ? i + 2 : i + 1];
|
|
97
|
+
if (HEX.test(s[i])) {
|
|
98
|
+
let hex = '';
|
|
99
|
+
while (i < s.length && hex.length < 6 && HEX.test(s[i])) hex += s[i++];
|
|
100
|
+
if (s[i] === '\r' && s[i + 1] === '\n') i += 2;
|
|
101
|
+
else if (i < s.length && CSS_WS.test(s[i])) i++;
|
|
102
|
+
const cp = parseInt(hex, 16);
|
|
103
|
+
const valid = cp > 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff);
|
|
104
|
+
return [valid ? String.fromCodePoint(cp) : REPLACEMENT, i];
|
|
105
|
+
}
|
|
106
|
+
const cp = s.codePointAt(i)!;
|
|
107
|
+
const ch = String.fromCodePoint(cp);
|
|
108
|
+
return [ch, i + ch.length];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A stack's entries, each as the family it names and the exact text that named it. */
|
|
112
|
+
function splitFontStack(stack: string | null | undefined): Array<{ family: string; raw: string }> {
|
|
113
|
+
const s = String(stack ?? '');
|
|
114
|
+
const out: Array<{ family: string; raw: string }> = [];
|
|
115
|
+
let i = 0;
|
|
116
|
+
while (i < s.length) {
|
|
117
|
+
while (i < s.length && CSS_WS.test(s[i])) i++;
|
|
118
|
+
if (i >= s.length) break;
|
|
119
|
+
const start = i;
|
|
120
|
+
let family = '';
|
|
121
|
+
const quote = s[i];
|
|
122
|
+
if (quote === '"' || quote === "'") {
|
|
123
|
+
i++;
|
|
124
|
+
while (i < s.length && s[i] !== quote) {
|
|
125
|
+
if (s[i] === '\\') {
|
|
126
|
+
const [ch, next] = readEscape(s, i, true);
|
|
127
|
+
family += ch;
|
|
128
|
+
i = next;
|
|
129
|
+
} else family += s[i++];
|
|
130
|
+
}
|
|
131
|
+
i++;
|
|
132
|
+
while (i < s.length && s[i] !== ',') i++;
|
|
133
|
+
} else {
|
|
134
|
+
const words: string[] = [];
|
|
135
|
+
let word = '';
|
|
136
|
+
while (i < s.length && s[i] !== ',') {
|
|
137
|
+
if (s[i] === '\\') {
|
|
138
|
+
const [ch, next] = readEscape(s, i, false);
|
|
139
|
+
word += ch;
|
|
140
|
+
i = next;
|
|
141
|
+
} else if (CSS_WS.test(s[i])) {
|
|
142
|
+
if (word) words.push(word);
|
|
143
|
+
word = '';
|
|
144
|
+
i++;
|
|
145
|
+
} else word += s[i++];
|
|
146
|
+
}
|
|
147
|
+
if (word) words.push(word);
|
|
148
|
+
family = words.join(' ');
|
|
149
|
+
}
|
|
150
|
+
const raw = s.slice(start, Math.min(i, s.length)).trim();
|
|
151
|
+
if (family.trim() !== '') out.push({ family, raw });
|
|
152
|
+
if (s[i] === ',') i++;
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The entries of `stack` that do NOT name `family`, as written — what a fallback list keeps. */
|
|
158
|
+
export function fontStackWithout(stack: string | null | undefined, family: string | null | undefined): string {
|
|
159
|
+
const key = fontFamilyKey(family);
|
|
160
|
+
return splitFontStack(stack).filter(e => fontFamilyKey(e.family) !== key).map(e => e.raw).join(', ');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A weight as a closed range — a static cut is `{ min: w, max: w }`. */
|
|
164
|
+
export interface FontWeightRange {
|
|
165
|
+
min: number;
|
|
166
|
+
max: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const WEIGHT_NUMBER = /^\d+(?:\.\d+)?$/;
|
|
170
|
+
const WEIGHT_RANGE = /^(\d+(?:\.\d+)?)(?:\s*[-\u2013\u2014]\s*|\s+)(\d+(?:\.\d+)?)$/;
|
|
171
|
+
const clampWeight = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, Math.round(n)));
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Anything a file, a descriptor or an author may hold as a weight → its range, or null when it is
|
|
175
|
+
* not one: `700`, `'700'`, `'100 900'`, `'100-900'`, `'100–900'` (the tile's own spelling). A static
|
|
176
|
+
* weight clamps to 100–900 (as before); range ends clamp to CSS's 1–1000.
|
|
177
|
+
*/
|
|
178
|
+
export function parseFontWeight(value: unknown): FontWeightRange | null {
|
|
179
|
+
const text = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
|
|
180
|
+
if (WEIGHT_NUMBER.test(text)) {
|
|
181
|
+
const n = Number(text);
|
|
182
|
+
if (!(n > 0)) return null;
|
|
183
|
+
const w = clampWeight(n, MIN_WEIGHT, MAX_WEIGHT);
|
|
184
|
+
return { min: w, max: w };
|
|
185
|
+
}
|
|
186
|
+
const m = WEIGHT_RANGE.exec(text);
|
|
187
|
+
if (!m) return null;
|
|
188
|
+
const a = clampWeight(Number(m[1]), CSS_MIN_WEIGHT, CSS_MAX_WEIGHT);
|
|
189
|
+
const b = clampWeight(Number(m[2]), CSS_MIN_WEIGHT, CSS_MAX_WEIGHT);
|
|
190
|
+
if (!(a > 0) || !(b > 0)) return null;
|
|
191
|
+
return a === b ? { min: a, max: a } : { min: Math.min(a, b), max: Math.max(a, b) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A range in the stored / descriptor / CSS form: a number for one weight, `'<min> <max>'` for a span. */
|
|
195
|
+
export function fontWeightValue(range: FontWeightRange): FontWeight {
|
|
196
|
+
return range.min === range.max ? range.min : `${range.min} ${range.max}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Any weight → the stored form; a bad value → 400 (as `normalizeFontWeight`). */
|
|
200
|
+
export function normalizeFontWeightValue(value: unknown): FontWeight {
|
|
201
|
+
return fontWeightValue(parseFontWeight(value) ?? { min: DEFAULT_WEIGHT, max: DEFAULT_WEIGHT });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Any weight → its range; a bad value → 400–400. */
|
|
205
|
+
export function fontWeightRange(value: unknown): FontWeightRange {
|
|
206
|
+
return parseFontWeight(value) ?? { min: DEFAULT_WEIGHT, max: DEFAULT_WEIGHT };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True when the weight spans more than one value (a variable font's wght axis). */
|
|
210
|
+
export function isFontWeightRange(value: unknown): boolean {
|
|
211
|
+
const r = parseFontWeight(value);
|
|
212
|
+
return !!r && r.min !== r.max;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** How a weight reads on screen: `700`, or `100–900` for a range. */
|
|
216
|
+
export function formatFontWeight(value: unknown): string {
|
|
217
|
+
const r = fontWeightRange(value);
|
|
218
|
+
return r.min === r.max ? String(r.min) : `${r.min}\u2013${r.max}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A weight an author or a client SENT, held to what a face can be — or null: a whole number 100–900,
|
|
223
|
+
* or a range of whole numbers 1 ≤ min < max ≤ 1000. Nothing is clamped or rounded here; a value that
|
|
224
|
+
* would need it is refused (the PUT correction's rule).
|
|
225
|
+
*/
|
|
226
|
+
export function checkFontWeight(value: unknown): FontWeight | null {
|
|
227
|
+
const text = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
|
|
228
|
+
if (/^\d+$/.test(text)) {
|
|
229
|
+
const n = Number(text);
|
|
230
|
+
return n >= MIN_WEIGHT && n <= MAX_WEIGHT ? n : null;
|
|
231
|
+
}
|
|
232
|
+
const m = /^(\d+)(?:\s*[-\u2013\u2014]\s*|\s+)(\d+)$/.exec(text);
|
|
233
|
+
if (!m) return null;
|
|
234
|
+
const min = Number(m[1]);
|
|
235
|
+
const max = Number(m[2]);
|
|
236
|
+
return min >= CSS_MIN_WEIGHT && max <= CSS_MAX_WEIGHT && min < max ? `${min} ${max}` : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** True when a face of `weight` renders `wanted` from its own file (inside its range). */
|
|
240
|
+
export function fontWeightCovers(weight: unknown, wanted: number): boolean {
|
|
241
|
+
const r = fontWeightRange(weight);
|
|
242
|
+
return wanted >= r.min && wanted <= r.max;
|
|
243
|
+
}
|
|
244
|
+
|
|
48
245
|
/** The file extensions a studio may upload as a font, and the descriptor format each one IS (§5b). */
|
|
49
246
|
export const FONT_FILE_FORMATS: Readonly<Record<string, FontFormat>> = Object.freeze({
|
|
50
247
|
woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype',
|
|
@@ -78,7 +275,8 @@ export function fontFileStem(key: string | null | undefined): string {
|
|
|
78
275
|
return fontFileName(key).replace(/\.[^.]+$/, '');
|
|
79
276
|
}
|
|
80
277
|
|
|
81
|
-
/** A weight as CSS can use it: 100–900, a bad value → 400.
|
|
278
|
+
/** A single weight as CSS can use it: 100–900, a bad value → 400. A range is not a single weight —
|
|
279
|
+
* use `normalizeFontWeightValue` for a face. */
|
|
82
280
|
export function normalizeFontWeight(w: unknown): number {
|
|
83
281
|
const n = Math.round(Number(w));
|
|
84
282
|
return Number.isFinite(n) && n > 0 ? Math.min(MAX_WEIGHT, Math.max(MIN_WEIGHT, n)) : DEFAULT_WEIGHT;
|
|
@@ -107,10 +305,10 @@ const preference = (format: FontFormat) => {
|
|
|
107
305
|
* whose `src` lists them best format first, which is what the two retired forks did per file stem.
|
|
108
306
|
*/
|
|
109
307
|
export function buildFontFaceCss(descriptors: ReadonlyArray<FontDescriptor> | null | undefined): string {
|
|
110
|
-
const faces = new Map<string, { family: string; weight:
|
|
308
|
+
const faces = new Map<string, { family: string; weight: FontWeight; style: FontStyle; files: FontDescriptor[] }>();
|
|
111
309
|
for (const d of (descriptors ?? []).filter(usableDescriptor)) {
|
|
112
310
|
const family = d.family.trim();
|
|
113
|
-
const weight =
|
|
311
|
+
const weight = normalizeFontWeightValue(d.weight);
|
|
114
312
|
const style = normalizeFontStyle(d.style);
|
|
115
313
|
const id = `${fontFamilyKey(family)}|${weight}|${style}`;
|
|
116
314
|
const face = faces.get(id) ?? { family, weight, style, files: [] };
|
|
@@ -125,7 +323,7 @@ export function buildFontFaceCss(descriptors: ReadonlyArray<FontDescriptor> | nu
|
|
|
125
323
|
.join(', ');
|
|
126
324
|
return [
|
|
127
325
|
'@font-face {',
|
|
128
|
-
` font-family: ${
|
|
326
|
+
` font-family: ${formatFontFamily(face.family)};`,
|
|
129
327
|
` src: ${src};`,
|
|
130
328
|
` font-weight: ${face.weight};`,
|
|
131
329
|
` font-style: ${face.style};`,
|
|
@@ -139,13 +337,26 @@ export function buildFontFaceCss(descriptors: ReadonlyArray<FontDescriptor> | nu
|
|
|
139
337
|
/** An uploaded family: every file sharing a family name (case-insensitively), with its real cuts. */
|
|
140
338
|
export interface FontFamilyGroup {
|
|
141
339
|
family: string;
|
|
142
|
-
/** Distinct weights, ascending. */
|
|
143
|
-
weights:
|
|
340
|
+
/** Distinct weights, ascending by their lower end — a variable file's is its `'<min> <max>'` range. */
|
|
341
|
+
weights: FontWeight[];
|
|
144
342
|
styles: FontStyle[];
|
|
145
343
|
/** Every file of the family, lightest first. */
|
|
146
344
|
faces: FontDescriptor[];
|
|
147
345
|
}
|
|
148
346
|
|
|
347
|
+
/** Lighter first: by the range's lower end, then its upper end (a static 100 before a 100–900 file). */
|
|
348
|
+
export function compareFontWeights(a: unknown, b: unknown): number {
|
|
349
|
+
const ra = fontWeightRange(a);
|
|
350
|
+
const rb = fontWeightRange(b);
|
|
351
|
+
return ra.min - rb.min || ra.max - rb.max;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The weight a family's preview renders at: 400 when a face covers it, else its lightest. */
|
|
355
|
+
export function previewFontWeight(weights: ReadonlyArray<FontWeight>): number {
|
|
356
|
+
if (!weights?.length || weights.some(w => fontWeightCovers(w, DEFAULT_WEIGHT))) return DEFAULT_WEIGHT;
|
|
357
|
+
return fontWeightRange([...weights].sort(compareFontWeights)[0]).min;
|
|
358
|
+
}
|
|
359
|
+
|
|
149
360
|
/** Files → families (§5b "Group into families"): the Files tile, the typeface dropdown and Design ▸ Type. */
|
|
150
361
|
export function groupFontFamilies(descriptors: ReadonlyArray<FontDescriptor> | null | undefined): FontFamilyGroup[] {
|
|
151
362
|
const groups = new Map<string, FontFamilyGroup>();
|
|
@@ -157,11 +368,16 @@ export function groupFontFamilies(descriptors: ReadonlyArray<FontDescriptor> | n
|
|
|
157
368
|
}
|
|
158
369
|
return [...groups.values()]
|
|
159
370
|
.map(g => {
|
|
160
|
-
const faces = [...g.faces].sort((a, b) =>
|
|
371
|
+
const faces = [...g.faces].sort((a, b) => compareFontWeights(a.weight, b.weight)
|
|
161
372
|
|| normalizeFontStyle(a.style).localeCompare(normalizeFontStyle(b.style)));
|
|
373
|
+
const weights = new Map<string, FontWeight>();
|
|
374
|
+
for (const f of faces) {
|
|
375
|
+
const w = normalizeFontWeightValue(f.weight);
|
|
376
|
+
weights.set(String(w), w);
|
|
377
|
+
}
|
|
162
378
|
return {
|
|
163
379
|
family: g.family,
|
|
164
|
-
weights: [...
|
|
380
|
+
weights: [...weights.values()].sort(compareFontWeights),
|
|
165
381
|
styles: FONT_STYLES.filter(s => faces.some(f => normalizeFontStyle(f.style) === s)),
|
|
166
382
|
faces,
|
|
167
383
|
};
|
|
@@ -169,12 +385,14 @@ export function groupFontFamilies(descriptors: ReadonlyArray<FontDescriptor> | n
|
|
|
169
385
|
.sort((a, b) => a.family.localeCompare(b.family));
|
|
170
386
|
}
|
|
171
387
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
388
|
+
/**
|
|
389
|
+
* The family names in a CSS `font-family` stack, in order — the exact inverse of `formatFontFamily` /
|
|
390
|
+
* `formatFontFamilies`: quotes removed, CSS escapes decoded (`\'`, `\\`, `\3c `), a comma INSIDE
|
|
391
|
+
* quotes kept, an unquoted name's words joined by one space. Every reader of a font token goes
|
|
392
|
+
* through this; a naive split on `,` is how `O'Neil Sans` stopped matching its own token.
|
|
393
|
+
*/
|
|
394
|
+
export function parseFontFamilies(stack: string | null | undefined): string[] {
|
|
395
|
+
return splitFontStack(stack).map(e => e.family);
|
|
178
396
|
}
|
|
179
397
|
|
|
180
398
|
/** Where Google serves the stylesheets, and the origin the font FILES come from (preconnect). */
|
|
@@ -223,12 +441,13 @@ export function fontsForTokens(
|
|
|
223
441
|
const ids: string[] = [];
|
|
224
442
|
for (const role of roles) {
|
|
225
443
|
const row = rows.find(t => t.name === role);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
444
|
+
// The role's FIRST catalog family is the one that renders (or stands in for an uploaded
|
|
445
|
+
// family that is missing — `userFontStack` puts the look's font right after the author's);
|
|
446
|
+
// a catalog family further down is never reached, so it is never loaded.
|
|
447
|
+
const id = parseFontFamilies(row?.value ?? '').map(catalogFontId).find((x): x is string => !!x);
|
|
448
|
+
if (id && !seen.has(id)) {
|
|
449
|
+
seen.add(id);
|
|
450
|
+
ids.push(id);
|
|
232
451
|
}
|
|
233
452
|
}
|
|
234
453
|
return ids;
|
|
@@ -243,28 +462,38 @@ export function stackNamesFamily(stack: string | null | undefined, family: strin
|
|
|
243
462
|
}
|
|
244
463
|
|
|
245
464
|
/**
|
|
246
|
-
* The font tokens
|
|
247
|
-
* last file goes (§5b): deleting it would
|
|
465
|
+
* The font tokens that render in `family` — their stack LEADS with it — what the delete guard asks
|
|
466
|
+
* before an uploaded family's last file goes (§5b): deleting it would drop those roles to their
|
|
467
|
+
* fallback. A role naming it further down (the look's font behind the author's, `userFontStack`) does
|
|
468
|
+
* not render in it, and rewriting that role would throw the author's own choice away.
|
|
248
469
|
*/
|
|
249
470
|
export function fontRoleReferences(
|
|
250
471
|
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
251
472
|
family: string | null | undefined,
|
|
252
473
|
): FontFamilyToken[] {
|
|
474
|
+
const key = fontFamilyKey(family);
|
|
475
|
+
if (!key) return [];
|
|
253
476
|
const rows = (tokens ?? []).filter(t => !!t && typeof t.name === 'string');
|
|
254
|
-
return FONT_FAMILY_TOKENS.filter(role => rows.some(t => t.name === role
|
|
477
|
+
return FONT_FAMILY_TOKENS.filter(role => rows.some(t => t.name === role
|
|
478
|
+
&& fontFamilyKey(parseFontFamilies(t.value ?? '')[0]) === key));
|
|
255
479
|
}
|
|
256
480
|
|
|
257
481
|
/**
|
|
258
|
-
* The face CSS font matching picks for `weight` from `faces` (CSS Fonts §5.2):
|
|
259
|
-
*
|
|
260
|
-
*
|
|
482
|
+
* The face CSS font matching picks for `weight` from `faces` (CSS Fonts §5.2): a face whose range
|
|
483
|
+
* holds the weight is exact (a variable file covers every weight on its axis); otherwise 400 tries
|
|
484
|
+
* 500 next, 500 tries 400; lighter weights look down first, heavier ones up — each face measured at
|
|
485
|
+
* its nearest end. So an `h1` at 600 over a family uploaded at 400 + 700 renders the real 700 cut —
|
|
486
|
+
* the one worth preloading.
|
|
261
487
|
*/
|
|
262
|
-
export function matchFontWeight<T extends { weight:
|
|
488
|
+
export function matchFontWeight<T extends { weight: FontWeight }>(faces: ReadonlyArray<T>, weight: number): T | null {
|
|
263
489
|
if (!faces?.length) return null;
|
|
264
490
|
const want = normalizeFontWeight(weight);
|
|
265
|
-
const exact = faces.find(f =>
|
|
491
|
+
const exact = faces.find(f => fontWeightCovers(f.weight, want));
|
|
266
492
|
if (exact) return exact;
|
|
267
|
-
const w = (f: T) =>
|
|
493
|
+
const w = (f: T) => {
|
|
494
|
+
const r = fontWeightRange(f.weight);
|
|
495
|
+
return r.max < want ? r.max : r.min;
|
|
496
|
+
};
|
|
268
497
|
const below = (max: number) => [...faces].filter(f => w(f) < max).sort((a, b) => w(b) - w(a))[0] ?? null;
|
|
269
498
|
const above = (min: number) => [...faces].filter(f => w(f) > min).sort((a, b) => w(a) - w(b))[0] ?? null;
|
|
270
499
|
if (want >= 400 && want <= 500) {
|
|
@@ -306,3 +535,46 @@ export function fontPreloads(
|
|
|
306
535
|
}
|
|
307
536
|
return [...out.values()];
|
|
308
537
|
}
|
|
538
|
+
|
|
539
|
+
/** CSS generic families and the faces every visitor's system already has — never "missing". */
|
|
540
|
+
const ALWAYS_AVAILABLE_FAMILIES: ReadonlySet<string> = new Set([
|
|
541
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif',
|
|
542
|
+
'ui-monospace', 'ui-rounded', 'emoji', 'math', 'fangsong', '-apple-system', 'blinkmacsystemfont',
|
|
543
|
+
'arial', 'helvetica', 'helvetica neue', 'georgia', 'times new roman', 'times', 'courier new', 'courier',
|
|
544
|
+
'verdana', 'tahoma', 'trebuchet ms', 'segoe ui', 'roboto', 'menlo', 'monaco', 'consolas',
|
|
545
|
+
]);
|
|
546
|
+
|
|
547
|
+
/** A font role naming a family that is neither uploaded nor in the catalog, and what renders instead. */
|
|
548
|
+
export interface MissingFontRole {
|
|
549
|
+
role: FontFamilyToken;
|
|
550
|
+
/** The family the role names first, which no file and no catalog entry serves. */
|
|
551
|
+
family: string;
|
|
552
|
+
/** The next family in the role's stack that does render — the look's own font for a stack written
|
|
553
|
+
* by `userFontStack`, `system-ui` for an older one. */
|
|
554
|
+
fallback: string;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Board 8's "the same check runs when a font token points at a missing family": the font roles whose
|
|
559
|
+
* FIRST family is not uploaded (`uploaded` — the org's families), not in the catalog and not a system
|
|
560
|
+
* face. Only meaningful over a listing that SUCCEEDED — an empty bucket because storage is down would
|
|
561
|
+
* otherwise report every uploaded family missing.
|
|
562
|
+
*/
|
|
563
|
+
export function missingFontRoles(
|
|
564
|
+
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
565
|
+
uploaded: ReadonlyArray<string> | null | undefined,
|
|
566
|
+
): MissingFontRole[] {
|
|
567
|
+
const have = new Set((uploaded ?? []).map(fontFamilyKey));
|
|
568
|
+
const available = (family: string) => {
|
|
569
|
+
const key = fontFamilyKey(family);
|
|
570
|
+
return have.has(key) || ALWAYS_AVAILABLE_FAMILIES.has(key) || catalogFontId(family) !== null;
|
|
571
|
+
};
|
|
572
|
+
const resolved = resolveTokenValues(tokens);
|
|
573
|
+
const out: MissingFontRole[] = [];
|
|
574
|
+
for (const role of FONT_FAMILY_TOKENS) {
|
|
575
|
+
const families = parseFontFamilies(resolved[role] ?? '');
|
|
576
|
+
if (!families.length || /^var\(/i.test(families[0]) || available(families[0])) continue;
|
|
577
|
+
out.push({ role, family: families[0], fallback: families.slice(1).find(available) ?? '' });
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|