@caelo-cms/shared 0.10.51 → 0.10.53
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/dist/ai-tools.d.ts +9 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +6 -0
- package/dist/ai-tools.js.map +1 -1
- package/dist/font-axis-policy.d.ts +174 -0
- package/dist/font-axis-policy.d.ts.map +1 -0
- package/dist/font-axis-policy.js +282 -0
- package/dist/font-axis-policy.js.map +1 -0
- package/dist/font-usage.d.ts +23 -0
- package/dist/font-usage.d.ts.map +1 -0
- package/dist/font-usage.js +381 -0
- package/dist/font-usage.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/markup-structure.d.ts +79 -0
- package/dist/markup-structure.d.ts.map +1 -0
- package/dist/markup-structure.js +274 -0
- package/dist/markup-structure.js.map +1 -0
- package/dist/media.d.ts +25 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +35 -0
- package/dist/media.js.map +1 -1
- package/dist/preview-compose.d.ts +4 -3
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +2 -1
- package/dist/preview-compose.js.map +1 -1
- package/dist/publish-live-parity.d.ts +18 -0
- package/dist/publish-live-parity.d.ts.map +1 -0
- package/dist/publish-live-parity.js +21 -0
- package/dist/publish-live-parity.js.map +1 -0
- package/dist/static-cache-policy.d.ts +21 -7
- package/dist/static-cache-policy.d.ts.map +1 -1
- package/dist/static-cache-policy.js +24 -7
- package/dist/static-cache-policy.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +6 -1
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +8 -0
- package/src/font-axis-policy.test.ts +182 -0
- package/src/font-axis-policy.ts +372 -0
- package/src/font-usage.test.ts +100 -0
- package/src/font-usage.ts +395 -0
- package/src/index.ts +4 -0
- package/src/markup-structure.test.ts +184 -0
- package/src/markup-structure.ts +304 -0
- package/src/media.ts +43 -0
- package/src/preview-compose.ts +6 -4
- package/src/publish-live-parity.ts +25 -0
- package/src/static-cache-policy.test.ts +33 -3
- package/src/static-cache-policy.ts +28 -8
- package/src/themes.ts +6 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Variation-axis policy for pinned registry faces: which part of a
|
|
5
|
+
* variable font's design space visitors download.
|
|
6
|
+
*
|
|
7
|
+
* A variable face carries its whole design space (Inter: `wght` 100-900
|
|
8
|
+
* and `opsz` 14-32). Most sites use a fraction of it, and every axis kept
|
|
9
|
+
* costs bytes in every slice: Inter's latin slice is 103 KB with all
|
|
10
|
+
* axes, ~50 KB with `wght` clamped to the weights in use and `opsz`
|
|
11
|
+
* pinned. So delivery instantiates the face (harfbuzz) to an
|
|
12
|
+
* {@link EffectiveAxisSpec} per axis:
|
|
13
|
+
*
|
|
14
|
+
* - `full` — keep the source range;
|
|
15
|
+
* - `range` — clamp to `[min, max]` (a sub-range of the source);
|
|
16
|
+
* - `pin` — fix the axis at one value; the axis disappears from the
|
|
17
|
+
* delivered file.
|
|
18
|
+
*
|
|
19
|
+
* Where it comes from: the operator may store an override per face (the
|
|
20
|
+
* `caelo.fontAxes` extension next to `caelo.font` on each typography role
|
|
21
|
+
* bound to the face — see {@link FONT_AXES_EXTENSION}). Axes the override
|
|
22
|
+
* does not name follow the derived default ({@link deriveDefaultAxisSpec}):
|
|
23
|
+
* `wght` clamped to the weights the site can actually render (see
|
|
24
|
+
* `font-usage.ts` for how that set is derived and when it is unknown),
|
|
25
|
+
* every other axis pinned to the font's default — the same choice Google
|
|
26
|
+
* Fonts makes. The one visible consequence of that default is `opsz`:
|
|
27
|
+
* with `font-optical-sizing: auto` the browser would pick the optical
|
|
28
|
+
* size from the font size; pinned, large headings render with the text
|
|
29
|
+
* optical size. That is the trade-off the operator can reverse per face
|
|
30
|
+
* with `{ opsz: "full" }`.
|
|
31
|
+
*
|
|
32
|
+
* Validation is strict (CLAUDE.md §2): an override naming an axis the
|
|
33
|
+
* face does not have, or a value outside the face's source range, is a
|
|
34
|
+
* {@link FontAxisPolicyError} — never silently clamped.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
import { type FontRef, fontRef } from "./font-assets.js";
|
|
39
|
+
|
|
40
|
+
/** Theme-token extension key holding a role's axis override (beside `caelo.font`). */
|
|
41
|
+
export const FONT_AXES_EXTENSION = "caelo.fontAxes";
|
|
42
|
+
|
|
43
|
+
/** OpenType axis tag: four printable characters (`wght`, `opsz`, `GRAD`). */
|
|
44
|
+
export const fontAxisTag = z.string().regex(/^[A-Za-z0-9]{4}$/, "axis tag must be 4 characters");
|
|
45
|
+
|
|
46
|
+
/** One axis in an operator override. */
|
|
47
|
+
export const fontAxisSetting = z.union([
|
|
48
|
+
z.literal("full"),
|
|
49
|
+
z.object({ min: z.number().finite(), max: z.number().finite() }).strict(),
|
|
50
|
+
z.number().finite(),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Operator override for one face: axis tag → setting. Unnamed axes use the default. */
|
|
54
|
+
export const fontAxisPolicy = z
|
|
55
|
+
.record(fontAxisTag, fontAxisSetting)
|
|
56
|
+
.refine(
|
|
57
|
+
(p) => Object.keys(p).length > 0 && Object.keys(p).length <= 16,
|
|
58
|
+
"name between 1 and 16 axes; pass null to reset the face to the default policy",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export type FontAxisSetting = z.infer<typeof fontAxisSetting>;
|
|
62
|
+
export type FontAxisPolicy = z.infer<typeof fontAxisPolicy>;
|
|
63
|
+
|
|
64
|
+
/** The source design space of one axis, as the registry metadata records it. */
|
|
65
|
+
export interface SourceAxis {
|
|
66
|
+
readonly min: number;
|
|
67
|
+
readonly max: number;
|
|
68
|
+
readonly default: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What delivery does with one axis. */
|
|
72
|
+
export type EffectiveAxis =
|
|
73
|
+
| { readonly mode: "full"; readonly min: number; readonly max: number }
|
|
74
|
+
| { readonly mode: "range"; readonly min: number; readonly max: number }
|
|
75
|
+
| { readonly mode: "pin"; readonly value: number };
|
|
76
|
+
|
|
77
|
+
/** Axis tag → effective setting, one entry per source axis, keys sorted. */
|
|
78
|
+
export type EffectiveAxisSpec = Readonly<Record<string, EffectiveAxis>>;
|
|
79
|
+
|
|
80
|
+
/** Where an axis' effective setting came from (for the AI read surface). */
|
|
81
|
+
export type AxisOrigin = "override" | "default";
|
|
82
|
+
|
|
83
|
+
/** Structured, AI-actionable policy failure. */
|
|
84
|
+
export class FontAxisPolicyError extends Error {
|
|
85
|
+
readonly code = "FontAxisPolicyInvalid";
|
|
86
|
+
constructor(message: string) {
|
|
87
|
+
super(`FontAxisPolicyInvalid: ${message}`);
|
|
88
|
+
this.name = "FontAxisPolicyError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Weight usage as far as the default needs it; `null` = not determinable. */
|
|
93
|
+
export interface WeightUsage {
|
|
94
|
+
readonly weights: readonly number[] | null;
|
|
95
|
+
/** Axis tags named in `font-variation-settings` anywhere; `"all"` when unparsable. */
|
|
96
|
+
readonly variationSettingsAxes: readonly string[] | "all";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalize(axis: SourceAxis, min: number, max: number): EffectiveAxis {
|
|
100
|
+
if (min === max) return { mode: "pin", value: min };
|
|
101
|
+
if (min === axis.min && max === axis.max) return { mode: "full", min, max };
|
|
102
|
+
return { mode: "range", min, max };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sortedSpec(entries: [string, EffectiveAxis][]): EffectiveAxisSpec {
|
|
106
|
+
return Object.fromEntries(entries.sort(([a], [z]) => (a < z ? -1 : a > z ? 1 : 0)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check an override against the face's real axes.
|
|
111
|
+
*
|
|
112
|
+
* @param subsettable false when OS/2 fsType forbids subsetting: the face
|
|
113
|
+
* ships byte-complete, so no axis can be instanced either
|
|
114
|
+
* @throws FontAxisPolicyError naming the offending axis and the valid range
|
|
115
|
+
*/
|
|
116
|
+
export function validateAxisPolicy(
|
|
117
|
+
policy: FontAxisPolicy,
|
|
118
|
+
sourceAxes: Readonly<Record<string, SourceAxis>>,
|
|
119
|
+
subsettable: boolean,
|
|
120
|
+
): void {
|
|
121
|
+
const known = Object.keys(sourceAxes).sort();
|
|
122
|
+
for (const [tag, setting] of Object.entries(policy)) {
|
|
123
|
+
const axis = sourceAxes[tag];
|
|
124
|
+
if (axis === undefined) {
|
|
125
|
+
throw new FontAxisPolicyError(
|
|
126
|
+
known.length === 0
|
|
127
|
+
? `this face is not a variable font (no variation axes), so '${tag}' cannot be set — remove the override`
|
|
128
|
+
: `this face has no '${tag}' axis; its axes are ${known.map((t) => `${t} ${sourceAxes[t]?.min}-${sourceAxes[t]?.max}`).join(", ")}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (setting === "full") continue;
|
|
132
|
+
if (!subsettable) {
|
|
133
|
+
throw new FontAxisPolicyError(
|
|
134
|
+
`the foundry forbids modifying this face (OS/2 fsType: no subsetting), so it always ships with its full design space — use "full" or reset the override`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const [min, max] =
|
|
138
|
+
typeof setting === "number" ? [setting, setting] : [setting.min, setting.max];
|
|
139
|
+
if (min > max) {
|
|
140
|
+
throw new FontAxisPolicyError(`'${tag}' range min ${min} is greater than max ${max}`);
|
|
141
|
+
}
|
|
142
|
+
if (min < axis.min || max > axis.max) {
|
|
143
|
+
throw new FontAxisPolicyError(
|
|
144
|
+
`'${tag}' ${typeof setting === "number" ? `value ${min}` : `range ${min}-${max}`} is outside the face's ${tag} range ${axis.min}-${axis.max}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The policy used when the operator set nothing for an axis.
|
|
152
|
+
*
|
|
153
|
+
* - `wght`: clamped to `[min, max]` of the weights the site can render,
|
|
154
|
+
* intersected with the source range — or kept full when that set is
|
|
155
|
+
* not determinable (keeping the full range is the safe outcome, never a
|
|
156
|
+
* degraded one: the browser simply has every weight available).
|
|
157
|
+
* - every other axis: pinned to the font's default, as Google Fonts
|
|
158
|
+
* does. Registered axes other than `opsz` are already held at one value
|
|
159
|
+
* by the single-valued `font-style`/`font-stretch` descriptors the
|
|
160
|
+
* resolver emits, so pinning them changes nothing visible; `opsz` is
|
|
161
|
+
* the accepted trade-off described in the file header.
|
|
162
|
+
* - any axis named in `font-variation-settings` somewhere on the site is
|
|
163
|
+
* kept full: that declaration addresses the axis directly and would
|
|
164
|
+
* silently stop working on an instanced file.
|
|
165
|
+
* - a face whose fsType forbids subsetting keeps every axis full.
|
|
166
|
+
*/
|
|
167
|
+
export function deriveDefaultAxisSpec(
|
|
168
|
+
sourceAxes: Readonly<Record<string, SourceAxis>>,
|
|
169
|
+
usage: WeightUsage,
|
|
170
|
+
subsettable: boolean,
|
|
171
|
+
): EffectiveAxisSpec {
|
|
172
|
+
const entries: [string, EffectiveAxis][] = [];
|
|
173
|
+
for (const [tag, axis] of Object.entries(sourceAxes)) {
|
|
174
|
+
const addressedDirectly =
|
|
175
|
+
usage.variationSettingsAxes === "all" || usage.variationSettingsAxes.includes(tag);
|
|
176
|
+
if (!subsettable || addressedDirectly) {
|
|
177
|
+
entries.push([tag, { mode: "full", min: axis.min, max: axis.max }]);
|
|
178
|
+
} else if (tag === "wght") {
|
|
179
|
+
if (usage.weights === null || usage.weights.length === 0) {
|
|
180
|
+
entries.push([tag, { mode: "full", min: axis.min, max: axis.max }]);
|
|
181
|
+
} else {
|
|
182
|
+
const lo = Math.min(Math.max(Math.min(...usage.weights), axis.min), axis.max);
|
|
183
|
+
const hi = Math.max(Math.min(Math.max(...usage.weights), axis.max), axis.min);
|
|
184
|
+
entries.push([tag, normalize(axis, lo, hi)]);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
entries.push([tag, { mode: "pin", value: axis.default }]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return sortedSpec(entries);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Effective spec for a face: the override's axes win, every other axis
|
|
195
|
+
* follows {@link deriveDefaultAxisSpec}. Also reports where each axis
|
|
196
|
+
* came from.
|
|
197
|
+
*
|
|
198
|
+
* @throws FontAxisPolicyError when the override does not fit the face
|
|
199
|
+
*/
|
|
200
|
+
export function effectiveAxisSpec(
|
|
201
|
+
sourceAxes: Readonly<Record<string, SourceAxis>>,
|
|
202
|
+
override: FontAxisPolicy | null,
|
|
203
|
+
usage: WeightUsage,
|
|
204
|
+
subsettable: boolean,
|
|
205
|
+
): { readonly spec: EffectiveAxisSpec; readonly origin: Readonly<Record<string, AxisOrigin>> } {
|
|
206
|
+
if (override !== null) validateAxisPolicy(override, sourceAxes, subsettable);
|
|
207
|
+
const defaults = deriveDefaultAxisSpec(sourceAxes, usage, subsettable);
|
|
208
|
+
const entries: [string, EffectiveAxis][] = [];
|
|
209
|
+
const origin: Record<string, AxisOrigin> = {};
|
|
210
|
+
for (const [tag, axis] of Object.entries(sourceAxes)) {
|
|
211
|
+
const setting = override?.[tag];
|
|
212
|
+
if (setting === undefined) {
|
|
213
|
+
const derived = defaults[tag];
|
|
214
|
+
if (derived === undefined) throw new Error(`default spec lacks source axis ${tag}`);
|
|
215
|
+
entries.push([tag, derived]);
|
|
216
|
+
origin[tag] = "default";
|
|
217
|
+
} else {
|
|
218
|
+
const [min, max] =
|
|
219
|
+
setting === "full"
|
|
220
|
+
? [axis.min, axis.max]
|
|
221
|
+
: typeof setting === "number"
|
|
222
|
+
? [setting, setting]
|
|
223
|
+
: [setting.min, setting.max];
|
|
224
|
+
entries.push([tag, normalize(axis, min, max)]);
|
|
225
|
+
origin[tag] = "override";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { spec: sortedSpec(entries), origin };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** True when delivery would ship the face's design space untouched. */
|
|
232
|
+
export function isFullAxisSpec(spec: EffectiveAxisSpec): boolean {
|
|
233
|
+
return Object.values(spec).every((a) => a.mode === "full");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Canonical text form of a spec (`opsz=14;wght=400..900`), stable across
|
|
238
|
+
* key order. Delivery hashes it into cache keys and file names, so a
|
|
239
|
+
* policy change can never be answered with slices cut for another one.
|
|
240
|
+
*/
|
|
241
|
+
export function axisSpecKey(spec: EffectiveAxisSpec): string {
|
|
242
|
+
return Object.keys(spec)
|
|
243
|
+
.sort()
|
|
244
|
+
.map((tag) => {
|
|
245
|
+
const a = spec[tag] as EffectiveAxis;
|
|
246
|
+
return a.mode === "pin" ? `${tag}=${a.value}` : `${tag}=${a.min}..${a.max}`;
|
|
247
|
+
})
|
|
248
|
+
.join(";");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Axes a delivered file must report after instancing: pinned axes are
|
|
253
|
+
* gone, the rest carry their (possibly clamped) range.
|
|
254
|
+
*/
|
|
255
|
+
export function expectedOutputAxes(
|
|
256
|
+
spec: EffectiveAxisSpec,
|
|
257
|
+
): Record<string, { min: number; max: number }> {
|
|
258
|
+
const out: Record<string, { min: number; max: number }> = {};
|
|
259
|
+
for (const tag of Object.keys(spec).sort()) {
|
|
260
|
+
const a = spec[tag] as EffectiveAxis;
|
|
261
|
+
if (a.mode !== "pin") out[tag] = { min: a.min, max: a.max };
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The `@font-face` `font-weight` descriptor matching the delivered file,
|
|
268
|
+
* so the browser neither requests nor synthesizes outside it.
|
|
269
|
+
*
|
|
270
|
+
* @param staticWeight the face's OS/2 weight, used when it has no `wght` axis
|
|
271
|
+
*/
|
|
272
|
+
export function fontWeightDescriptor(spec: EffectiveAxisSpec, staticWeight: number): string {
|
|
273
|
+
const wght = spec.wght;
|
|
274
|
+
if (wght === undefined) return String(staticWeight);
|
|
275
|
+
return wght.mode === "pin" ? String(wght.value) : `${wght.min} ${wght.max}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The weights a face actually serves, as `[min, max]`. */
|
|
279
|
+
export function servedWeightRange(
|
|
280
|
+
spec: EffectiveAxisSpec,
|
|
281
|
+
staticWeight: number,
|
|
282
|
+
): readonly [number, number] {
|
|
283
|
+
const wght = spec.wght;
|
|
284
|
+
if (wght === undefined) return [staticWeight, staticWeight];
|
|
285
|
+
return wght.mode === "pin" ? [wght.value, wght.value] : [wght.min, wght.max];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** One typography role bound to a pinned face. */
|
|
289
|
+
export interface PinnedRole {
|
|
290
|
+
readonly role: string;
|
|
291
|
+
readonly fontFamily: string | undefined;
|
|
292
|
+
readonly fontWeight: number | string | undefined;
|
|
293
|
+
readonly fontStyle: string | undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A pinned face with every role that binds it and its stored override. */
|
|
297
|
+
export interface PinnedFaceBinding {
|
|
298
|
+
readonly ref: FontRef;
|
|
299
|
+
readonly roles: readonly PinnedRole[];
|
|
300
|
+
/** The `caelo.fontAxes` override the face's roles carry; null = default policy. */
|
|
301
|
+
readonly override: FontAxisPolicy | null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function canonicalPolicy(policy: FontAxisPolicy | null): string {
|
|
305
|
+
if (policy === null) return "null";
|
|
306
|
+
return JSON.stringify(
|
|
307
|
+
Object.keys(policy)
|
|
308
|
+
.sort()
|
|
309
|
+
.map((tag) => [tag, policy[tag]]),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Group the theme's `caelo.font` bindings by face (sha256), in role
|
|
315
|
+
* order. The override belongs to the FACE — one file, one `@font-face`
|
|
316
|
+
* set — so every role bound to the same face must carry the same
|
|
317
|
+
* `caelo.fontAxes` (themes.update_tokens keeps them in step). Roles
|
|
318
|
+
* whose binding does not parse are skipped here; the resolver reports
|
|
319
|
+
* those itself.
|
|
320
|
+
*
|
|
321
|
+
* @throws FontAxisPolicyError when a stored override is malformed or two
|
|
322
|
+
* roles of one face disagree
|
|
323
|
+
*/
|
|
324
|
+
export function collectPinnedFaces(tokens: unknown): PinnedFaceBinding[] {
|
|
325
|
+
const typography = (tokens as { typography?: unknown } | null)?.typography;
|
|
326
|
+
if (typography === null || typeof typography !== "object") return [];
|
|
327
|
+
const byFace = new Map<
|
|
328
|
+
string,
|
|
329
|
+
{ ref: FontRef; roles: PinnedRole[]; policies: Set<string>; override: FontAxisPolicy | null }
|
|
330
|
+
>();
|
|
331
|
+
for (const [role, raw] of Object.entries(typography as Record<string, unknown>)) {
|
|
332
|
+
const entry = raw as {
|
|
333
|
+
$extensions?: Record<string, unknown>;
|
|
334
|
+
$value?: { fontFamily?: string; fontWeight?: number | string; fontStyle?: string };
|
|
335
|
+
};
|
|
336
|
+
const ref = fontRef.safeParse(entry?.$extensions?.["caelo.font"]);
|
|
337
|
+
if (!ref.success) continue;
|
|
338
|
+
const stored = entry.$extensions?.[FONT_AXES_EXTENSION];
|
|
339
|
+
let override: FontAxisPolicy | null = null;
|
|
340
|
+
if (stored !== undefined) {
|
|
341
|
+
const parsed = fontAxisPolicy.safeParse(stored);
|
|
342
|
+
if (!parsed.success) {
|
|
343
|
+
throw new FontAxisPolicyError(
|
|
344
|
+
`typography.${role} carries a malformed ${FONT_AXES_EXTENSION} (${parsed.error.issues[0]?.message ?? "invalid"}) — reset it with set_theme_tokens fontAxes: { ${role}: null }`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
override = parsed.data;
|
|
348
|
+
}
|
|
349
|
+
const face = byFace.get(ref.data.sha256) ?? {
|
|
350
|
+
ref: ref.data,
|
|
351
|
+
roles: [],
|
|
352
|
+
policies: new Set<string>(),
|
|
353
|
+
override,
|
|
354
|
+
};
|
|
355
|
+
face.roles.push({
|
|
356
|
+
role,
|
|
357
|
+
fontFamily: entry.$value?.fontFamily,
|
|
358
|
+
fontWeight: entry.$value?.fontWeight,
|
|
359
|
+
fontStyle: entry.$value?.fontStyle,
|
|
360
|
+
});
|
|
361
|
+
face.policies.add(canonicalPolicy(override));
|
|
362
|
+
byFace.set(ref.data.sha256, face);
|
|
363
|
+
}
|
|
364
|
+
return [...byFace.values()].map((face) => {
|
|
365
|
+
if (face.policies.size > 1) {
|
|
366
|
+
throw new FontAxisPolicyError(
|
|
367
|
+
`roles ${face.roles.map((r) => r.role).join(", ")} bind the same font file but carry different axis overrides — set one override for the face with set_theme_tokens fontAxes (it applies to every role using that file)`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return { ref: face.ref, roles: face.roles, override: face.override };
|
|
371
|
+
});
|
|
372
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { scanSiteFontUsage } from "./font-usage.js";
|
|
4
|
+
import type { ThemeDocument } from "./themes.js";
|
|
5
|
+
|
|
6
|
+
const THEME = {
|
|
7
|
+
typography: {
|
|
8
|
+
body: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: 400 } },
|
|
9
|
+
heading: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: 600 } },
|
|
10
|
+
},
|
|
11
|
+
} as unknown as ThemeDocument;
|
|
12
|
+
|
|
13
|
+
function scan(...texts: string[]) {
|
|
14
|
+
return scanSiteFontUsage({
|
|
15
|
+
tokens: THEME,
|
|
16
|
+
sources: texts.map((text, i) => ({ label: `module:m${i}`, text })),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("scanSiteFontUsage", () => {
|
|
21
|
+
it("always includes the initial 400, UA bold 700 and the bolder closure", () => {
|
|
22
|
+
// Theme 600 → bolder 900; UA 700 → 900.
|
|
23
|
+
expect(scan().weights).toEqual([400, 600, 700, 900]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("collects weights from CSS, inline styles, JSON-escaped content and the font shorthand", () => {
|
|
27
|
+
const usage = scan(
|
|
28
|
+
".a{font-weight:300}.b{font-weight: bold !important}",
|
|
29
|
+
'<p style="font-weight:500">x</p>',
|
|
30
|
+
'{"body":"<span style=\\"font-weight:800\\">x</span>"}',
|
|
31
|
+
".c{font:italic 200 1rem/1.4 var(--font-body)}",
|
|
32
|
+
);
|
|
33
|
+
expect(usage.uncertain).toEqual([]);
|
|
34
|
+
expect(usage.weights).toEqual([200, 300, 400, 500, 600, 700, 800, 900]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("resolves var() against every declaration of the property, including theme vars", () => {
|
|
38
|
+
const usage = scan(
|
|
39
|
+
".h{font-weight:var(--font-weight-heading)}",
|
|
40
|
+
".x{--w:350}.y{font-weight:var(--w, 450)}",
|
|
41
|
+
);
|
|
42
|
+
expect(usage.uncertain).toEqual([]);
|
|
43
|
+
expect(usage.weights).toEqual([350, 400, 450, 600, 700, 900]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("applies the lighter closure only when lighter is used", () => {
|
|
47
|
+
expect(scan(".a{font-weight:lighter}").weights).toEqual([100, 400, 600, 700, 900]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("reports uncertainty instead of guessing", () => {
|
|
51
|
+
const cases = [
|
|
52
|
+
".a{font-weight:calc(400 + 100)}",
|
|
53
|
+
".a{font-weight:{{weight}}}",
|
|
54
|
+
".a{font-weight:var(--w)} el.style.setProperty('--w', 800)",
|
|
55
|
+
"el.style.fontWeight = w;",
|
|
56
|
+
".a{font:menu}",
|
|
57
|
+
".a{transition:font-weight .2s}",
|
|
58
|
+
"ctx.font = size + 'px Inter';",
|
|
59
|
+
];
|
|
60
|
+
for (const text of cases) {
|
|
61
|
+
const usage = scan(text);
|
|
62
|
+
expect({ text, weights: usage.weights }).toEqual({ text, weights: null });
|
|
63
|
+
expect(usage.uncertain[0]).toStartWith("module:m0: ");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("accepts literal camelCase weights in script", () => {
|
|
68
|
+
expect(scan("const s = { fontWeight: 650 };").weights).toEqual([400, 600, 650, 700, 900]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reports axes named by font-variation-settings, all of them when unparsable", () => {
|
|
72
|
+
expect(scan(`.a{font-variation-settings:"opsz" 32, 'GRAD' -50}`).variationSettingsAxes).toEqual(
|
|
73
|
+
["GRAD", "opsz"],
|
|
74
|
+
);
|
|
75
|
+
expect(scan(".a{font-variation-settings:normal}").variationSettingsAxes).toEqual([]);
|
|
76
|
+
expect(scan(".a{font-variation-settings:var(--v)}").variationSettingsAxes).toBe("all");
|
|
77
|
+
expect(scan("el.style.fontVariationSettings = v").variationSettingsAxes).toBe("all");
|
|
78
|
+
expect(scan().variationSettingsAxes).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reads theme weights in linear time even for role names repeating --font-weight-", () => {
|
|
82
|
+
// Regression (CodeQL js/polynomial-redos): the theme-weight pattern used
|
|
83
|
+
// to backtrack quadratically on runs of `--font-weight-`.
|
|
84
|
+
const role = `x${"--font-weight-".repeat(20_000)}`;
|
|
85
|
+
const tokens = {
|
|
86
|
+
typography: {
|
|
87
|
+
[role]: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: 550 } },
|
|
88
|
+
},
|
|
89
|
+
} as unknown as ThemeDocument;
|
|
90
|
+
const started = performance.now();
|
|
91
|
+
const usage = scanSiteFontUsage({ tokens, sources: [] });
|
|
92
|
+
expect(performance.now() - started).toBeLessThan(2_000);
|
|
93
|
+
expect(usage.uncertain).toEqual([]);
|
|
94
|
+
expect(usage.weights).toContain(550);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not mistake custom property names for font-weight declarations", () => {
|
|
98
|
+
expect(scan(".a{--font-weight-x:500}").weights).toEqual([400, 600, 700, 900]);
|
|
99
|
+
});
|
|
100
|
+
});
|