@caelo-cms/shared 0.10.51 → 0.10.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Which font weights a site can render — the input to the default `wght`
5
+ * clamp of pinned faces (see `font-axis-policy.ts`).
6
+ *
7
+ * Clamping `wght` is only safe when the result is a superset of every
8
+ * computed `font-weight` any element can end up with. A weight outside
9
+ * the delivered range does not fail visibly: the browser silently
10
+ * renders the nearest end of the range (800 → 700). So this module is
11
+ * deliberately conservative, and when it cannot be CERTAIN it reports
12
+ * `weights: null`, which keeps the full axis. Full is the safe outcome,
13
+ * not a degraded one — it is exactly what shipped before clamping.
14
+ *
15
+ * How the set is built:
16
+ *
17
+ * - It is site-wide, not per family. `font-weight` inherits separately
18
+ * from `font-family`, so a weight declared under another family still
19
+ * reaches a pinned face through inheritance. Treating every declared
20
+ * weight as applicable to every family over-approximates; it never
21
+ * under-approximates.
22
+ * - 400 (the initial value) and 700 (UA `bold` on h1-h6, th, ...) are
23
+ * always in. UA `b, strong { font-weight: bolder }` applies wherever
24
+ * rich text puts a `<strong>`, so the set is always closed under
25
+ * `bolder` (400 → 700 → 900); under `lighter` too when any source uses
26
+ * it. CSS Fonts 4 maps both deterministically from the parent weight,
27
+ * and every parent weight is itself in the set, so the closure is
28
+ * complete rather than a guess.
29
+ * - Every `font-weight` declaration (CSS, `style=""`, JSON-escaped
30
+ * content, script text), every `font:` shorthand and every camel-case
31
+ * `fontWeight` assignment is parsed. `var(--x)` is resolved against
32
+ * every declaration of `--x` in any source plus its fallback; a
33
+ * custom property that is also touched any other way (`setProperty`,
34
+ * `@property`) is not knowable and makes the result uncertain.
35
+ * - Anything else that could set a weight — `calc()`, a mustache
36
+ * placeholder, a system-font `font:` keyword, a `font-weight` mention
37
+ * that is not a plain declaration — makes the result uncertain.
38
+ * - `font-variation-settings` addresses axes directly; the axes it names
39
+ * are reported so the default keeps them full (unparsable → `"all"`).
40
+ *
41
+ * The caller passes every text a page can be built from (modules incl.
42
+ * their JS and field defaults, templates, layouts, content values, plugin
43
+ * source) plus the theme, so the editor preview and the deployed site
44
+ * derive the same set from the same data.
45
+ */
46
+
47
+ import { BASE_TECHNICAL_CSS } from "./base-css.js";
48
+ import type { WeightUsage } from "./font-axis-policy.js";
49
+ import { renderThemeCss } from "./theme-render.js";
50
+ import type { ThemeDocument } from "./themes.js";
51
+
52
+ /** One text the site is built from; `label` names it in uncertainty reasons. */
53
+ export interface FontUsageSource {
54
+ readonly label: string;
55
+ readonly text: string;
56
+ }
57
+
58
+ /** Result of {@link scanSiteFontUsage}. */
59
+ export interface SiteFontUsage extends WeightUsage {
60
+ /** Why `weights` is null (first few causes, `label: excerpt`); empty when certain. */
61
+ readonly uncertain: readonly string[];
62
+ }
63
+
64
+ const MAX_REASONS = 5;
65
+ const MAX_VAR_DEPTH = 8;
66
+ const MAX_EXPANSIONS = 256;
67
+
68
+ /** Characters that end a declaration value in CSS, attributes, JSON or JS strings. */
69
+ const VALUE_END = /[;{}"'`<>\n\\!]/;
70
+
71
+ /** CSS Fonts 4 §2.2 relative weights. */
72
+ function bolder(w: number): number {
73
+ return w < 350 ? 400 : w < 550 ? 700 : w < 900 ? 900 : w;
74
+ }
75
+ function lighter(w: number): number {
76
+ return w < 100 ? w : w < 550 ? 100 : w < 750 ? 400 : 700;
77
+ }
78
+
79
+ const GLOBAL_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
80
+ const SYSTEM_FONTS = new Set([
81
+ "caption",
82
+ "icon",
83
+ "menu",
84
+ "message-box",
85
+ "small-caption",
86
+ "status-bar",
87
+ ]);
88
+ const SIZE_KEYWORDS = new Set([
89
+ "xx-small",
90
+ "x-small",
91
+ "small",
92
+ "medium",
93
+ "large",
94
+ "x-large",
95
+ "xx-large",
96
+ "xxx-large",
97
+ "larger",
98
+ "smaller",
99
+ ]);
100
+ const SIZE_TOKEN = /^(?:\d*\.?\d+(?:[a-z]+|%)|0)(?:\/.*)?$|^(?:calc|clamp|min|max)\(/;
101
+
102
+ interface Scan {
103
+ readonly weights: Set<number>;
104
+ useLighter: boolean;
105
+ readonly reasons: string[];
106
+ axes: Set<string> | "all";
107
+ }
108
+
109
+ function excerpt(text: string, at: number): string {
110
+ return text
111
+ .slice(Math.max(0, at), at + 60)
112
+ .replace(/\s+/g, " ")
113
+ .trim();
114
+ }
115
+
116
+ function uncertain(scan: Scan, label: string, why: string): void {
117
+ if (scan.reasons.length < MAX_REASONS) scan.reasons.push(`${label}: ${why}`);
118
+ else if (scan.reasons.length === MAX_REASONS) scan.reasons.push("…");
119
+ }
120
+
121
+ function valueAfterColon(text: string, from: number): string | null {
122
+ const m = /^\s*:/.exec(text.slice(from, from + 64));
123
+ if (m === null) return null;
124
+ const start = from + m[0].length;
125
+ const rest = text.slice(start, start + 400);
126
+ const end = rest.search(VALUE_END);
127
+ return (end === -1 ? rest : rest.slice(0, end)).trim();
128
+ }
129
+
130
+ /** Index of the `)` closing the `(` at `open`, or -1. */
131
+ function closingParen(value: string, open: number): number {
132
+ let depth = 0;
133
+ for (let i = open; i < value.length; i++) {
134
+ if (value[i] === "(") depth++;
135
+ else if (value[i] === ")" && --depth === 0) return i;
136
+ }
137
+ return -1;
138
+ }
139
+
140
+ /**
141
+ * Custom-property knowledge collected from every source: all declared
142
+ * values per name, and names that are also written in a way we cannot
143
+ * follow (script, `@property`).
144
+ */
145
+ interface CustomProps {
146
+ readonly values: Map<string, string[]>;
147
+ readonly opaque: Set<string>;
148
+ }
149
+
150
+ function collectCustomProps(sources: readonly FontUsageSource[]): CustomProps {
151
+ const values = new Map<string, string[]>();
152
+ const opaque = new Set<string>();
153
+ const name = /(?<![\w-])(--[A-Za-z0-9_-]+)/g;
154
+ for (const { text } of sources) {
155
+ for (const m of text.matchAll(name)) {
156
+ const prop = m[1] as string;
157
+ const at = (m.index ?? 0) + prop.length;
158
+ const value = valueAfterColon(text, at);
159
+ if (value !== null) {
160
+ const list = values.get(prop) ?? [];
161
+ list.push(value);
162
+ values.set(prop, list);
163
+ continue;
164
+ }
165
+ const before = text.slice(Math.max(0, (m.index ?? 0) - 8), m.index ?? 0);
166
+ if (!/var\(\s*$/.test(before)) opaque.add(prop);
167
+ }
168
+ }
169
+ return { values, opaque };
170
+ }
171
+
172
+ /**
173
+ * Every value `value` can take once its `var()` references are
174
+ * substituted. Returns a reason string when that is not knowable.
175
+ */
176
+ function expandVars(value: string, props: CustomProps, depth = 0): string[] | string {
177
+ const at = value.indexOf("var(");
178
+ if (at === -1) return [value];
179
+ if (depth >= MAX_VAR_DEPTH) return "var() nesting too deep";
180
+ const close = closingParen(value, at + 3);
181
+ if (close === -1) return "unbalanced var()";
182
+ const inner = value.slice(at + 4, close);
183
+ const comma = inner.indexOf(",");
184
+ const prop = (comma === -1 ? inner : inner.slice(0, comma)).trim();
185
+ if (props.opaque.has(prop)) return `${prop} is set outside CSS declarations`;
186
+ const candidates = [...(props.values.get(prop) ?? [])];
187
+ if (comma !== -1) candidates.push(inner.slice(comma + 1).trim());
188
+ // No declaration and no fallback: invalid at computed-value time, so the
189
+ // property behaves as `unset` and inherits — it adds no weight.
190
+ const out: string[] = [];
191
+ for (const candidate of candidates) {
192
+ const substituted = `${value.slice(0, at)}${candidate}${value.slice(close + 1)}`;
193
+ const expanded = expandVars(substituted, props, depth + 1);
194
+ if (typeof expanded === "string") return expanded;
195
+ out.push(...expanded);
196
+ if (out.length > MAX_EXPANSIONS) return "too many var() combinations";
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /** Parse a resolved `font-weight` value; returns a reason when it is not plain. */
202
+ function addWeightTokens(scan: Scan, value: string): string | null {
203
+ const tokens = value
204
+ .toLowerCase()
205
+ .split(/[\s,]+/)
206
+ .filter(Boolean);
207
+ // Empty usually means the value continues past what we can read (a
208
+ // `{{placeholder}}`, a template literal) — not knowable.
209
+ if (tokens.length === 0) return "no readable value";
210
+ for (const token of tokens) {
211
+ if (/^\d+(\.\d+)?$/.test(token)) {
212
+ const n = Number(token);
213
+ if (n < 1 || n > 1000) return `weight ${token} out of range`;
214
+ scan.weights.add(n);
215
+ } else if (token === "normal") scan.weights.add(400);
216
+ else if (token === "bold") scan.weights.add(700);
217
+ else if (token === "lighter") scan.useLighter = true;
218
+ else if (token === "bolder" || GLOBAL_KEYWORDS.has(token)) {
219
+ // bolder: covered by the closure; globals: initial = 400, rest inherit.
220
+ } else return `unrecognised weight '${token}'`;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /** Tokens of a `font:` shorthand before its size (where the weight lives). */
226
+ function addShorthand(scan: Scan, value: string): string | null {
227
+ const tokens: string[] = [];
228
+ let depth = 0;
229
+ let current = "";
230
+ for (const ch of value.toLowerCase()) {
231
+ if (ch === "(") depth++;
232
+ if (ch === ")") depth--;
233
+ if (/\s/.test(ch) && depth === 0) {
234
+ if (current) tokens.push(current);
235
+ current = "";
236
+ } else current += ch;
237
+ }
238
+ if (current) tokens.push(current);
239
+ if (tokens.length === 1 && GLOBAL_KEYWORDS.has(tokens[0] as string)) return null;
240
+ if (tokens.length === 1 && SYSTEM_FONTS.has(tokens[0] as string)) {
241
+ return `system font '${tokens[0]}' (weight chosen by the OS)`;
242
+ }
243
+ for (const token of tokens) {
244
+ if (SIZE_TOKEN.test(token) || SIZE_KEYWORDS.has(token)) return null;
245
+ if (/^\d+(\.\d+)?$/.test(token)) {
246
+ const reason = addWeightTokens(scan, token);
247
+ if (reason !== null) return reason;
248
+ } else if (token === "bold" || token === "lighter" || token === "bolder") {
249
+ addWeightTokens(scan, token);
250
+ }
251
+ // style/variant/stretch keywords and `normal` (= 400, already in) need nothing.
252
+ }
253
+ // A shorthand without a size is invalid CSS and is dropped by the browser.
254
+ return null;
255
+ }
256
+
257
+ function parseVariationSettings(scan: Scan, raw: string): void {
258
+ const value = raw.replace(/["'\\\s]+$/, "").trim();
259
+ if (value === "" || GLOBAL_KEYWORDS.has(value) || value === "normal") return;
260
+ const pair = /^\s*(\\?["'])([A-Za-z0-9 ]{4})\1\s+-?\d*\.?\d+\s*$/;
261
+ const tags: string[] = [];
262
+ for (const part of value.split(",")) {
263
+ const m = pair.exec(part);
264
+ if (m === null) {
265
+ scan.axes = "all";
266
+ return;
267
+ }
268
+ tags.push(m[2] as string);
269
+ }
270
+ if (scan.axes !== "all") for (const t of tags) scan.axes.add(t);
271
+ }
272
+
273
+ function scanSource(scan: Scan, source: FontUsageSource, props: CustomProps): void {
274
+ const { label, text } = source;
275
+
276
+ for (const m of text.matchAll(/(?<![\w-])font-weight(?![\w-])/gi)) {
277
+ const at = m.index ?? 0;
278
+ const value = valueAfterColon(text, at + m[0].length);
279
+ if (value === null) {
280
+ uncertain(scan, label, `font-weight used outside a declaration (${excerpt(text, at)})`);
281
+ continue;
282
+ }
283
+ const expanded = expandVars(value, props);
284
+ if (typeof expanded === "string") {
285
+ uncertain(scan, label, `${expanded} (${excerpt(text, at)})`);
286
+ continue;
287
+ }
288
+ for (const v of expanded) {
289
+ const reason = addWeightTokens(scan, v);
290
+ if (reason !== null) uncertain(scan, label, `${reason} (${excerpt(text, at)})`);
291
+ }
292
+ }
293
+
294
+ for (const m of text.matchAll(/(?<![\w-])font\s*:/gi)) {
295
+ const at = m.index ?? 0;
296
+ // Start at the colon itself: valueAfterColon consumes it.
297
+ const value = valueAfterColon(text, at + m[0].length - 1);
298
+ if (value === null) continue;
299
+ const expanded = expandVars(value, props);
300
+ if (typeof expanded === "string") {
301
+ uncertain(scan, label, `${expanded} (${excerpt(text, at)})`);
302
+ continue;
303
+ }
304
+ for (const v of expanded) {
305
+ const reason = addShorthand(scan, v);
306
+ if (reason !== null) uncertain(scan, label, `${reason} (${excerpt(text, at)})`);
307
+ }
308
+ }
309
+
310
+ for (const m of text.matchAll(/\bfontWeight\b/g)) {
311
+ const at = m.index ?? 0;
312
+ const literal = /^\s*[:=]\s*(?:(["'`])([^"'`\n]{0,20})\1|(\d+))/.exec(text.slice(at + 10));
313
+ const value = literal === null ? null : (literal[2] ?? literal[3] ?? null);
314
+ const reason =
315
+ value === null ? "fontWeight set from script" : addWeightTokens(scan, value.toString());
316
+ if (reason !== null) uncertain(scan, label, `${reason} (${excerpt(text, at)})`);
317
+ }
318
+ if (/\.font\s*=[^=]/.test(text)) {
319
+ uncertain(scan, label, "font shorthand assigned from script");
320
+ }
321
+
322
+ for (const m of text.matchAll(/(?<![\w-])font-variation-settings(?![\w-])/gi)) {
323
+ const at = (m.index ?? 0) + m[0].length;
324
+ const colon = /^\s*:/.exec(text.slice(at, at + 64));
325
+ if (colon === null) {
326
+ scan.axes = "all";
327
+ continue;
328
+ }
329
+ const rest = text.slice(at + colon[0].length, at + colon[0].length + 400);
330
+ const end = rest.search(/[;{}<>\n!]/);
331
+ parseVariationSettings(scan, end === -1 ? rest : rest.slice(0, end));
332
+ }
333
+ if (/\bfontVariationSettings\b/.test(text)) scan.axes = "all";
334
+ }
335
+
336
+ /**
337
+ * Derive {@link SiteFontUsage} from the theme and every site text.
338
+ *
339
+ * @param input.sources every module/template/layout/content/plugin text;
340
+ * the theme's own CSS and the base CSS are added here
341
+ */
342
+ export function scanSiteFontUsage(input: {
343
+ readonly tokens: ThemeDocument;
344
+ readonly sources: readonly FontUsageSource[];
345
+ }): SiteFontUsage {
346
+ const all: FontUsageSource[] = [
347
+ { label: "theme", text: renderThemeCss(input.tokens) },
348
+ { label: "base-css", text: BASE_TECHNICAL_CSS },
349
+ ...input.sources,
350
+ ];
351
+ const scan: Scan = {
352
+ weights: new Set([400, 700]),
353
+ useLighter: false,
354
+ reasons: [],
355
+ axes: new Set(),
356
+ };
357
+ // Theme typography weights apply to their roles whether or not any CSS
358
+ // references `--font-weight-<role>`; the theme emits them as such vars.
359
+ const themeCss = all[0]?.text ?? "";
360
+ // The name match has no suffix and starts only at a name boundary, so it
361
+ // never backtracks; the value is read with the same bounded reader as
362
+ // every other declaration (a `[name]+:` pattern here is polynomial on
363
+ // runs like `--font-weight---font-weight-...`).
364
+ for (const m of themeCss.matchAll(/(?<![\w-])--font-weight-[A-Za-z0-9_-]+/g)) {
365
+ const value = valueAfterColon(themeCss, (m.index ?? 0) + m[0].length);
366
+ if (value === null) continue;
367
+ const reason = addWeightTokens(scan, value);
368
+ if (reason !== null) uncertain(scan, "theme", reason);
369
+ }
370
+ const props = collectCustomProps(all);
371
+ for (const source of all) scanSource(scan, source, props);
372
+
373
+ const axes = scan.axes === "all" ? ("all" as const) : [...scan.axes].sort();
374
+ if (scan.reasons.length > 0) {
375
+ return { weights: null, uncertain: scan.reasons, variationSettingsAxes: axes };
376
+ }
377
+ // Fixed point of the relative-weight closure.
378
+ const closed = new Set(scan.weights);
379
+ for (let grew = true; grew; ) {
380
+ grew = false;
381
+ for (const w of [...closed]) {
382
+ for (const next of scan.useLighter ? [bolder(w), lighter(w)] : [bolder(w)]) {
383
+ if (!closed.has(next)) {
384
+ closed.add(next);
385
+ grew = true;
386
+ }
387
+ }
388
+ }
389
+ }
390
+ return {
391
+ weights: [...closed].sort((a, z) => a - z),
392
+ uncertain: [],
393
+ variationSettingsAxes: axes,
394
+ };
395
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export * from "./database-url.js";
17
17
  export * from "./design-draft-shell.js";
18
18
  export * from "./document-language.js";
19
19
  export * from "./font-assets.js";
20
+ export * from "./font-axis-policy.js";
21
+ export * from "./font-usage.js";
20
22
  export * from "./fonts.js";
21
23
  export * from "./genesis.js";
22
24
  export * from "./genesis-inventory.js";
@@ -1,15 +1,20 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import { CONTENT_HASHED_PATH_PATTERN, isContentHashedPath } from "./static-cache-policy.js";
4
+ import {
5
+ CONTENT_HASHED_PATH_PATTERN,
6
+ isContentHashedPath,
7
+ isPinnedFontSliceFile,
8
+ } from "./static-cache-policy.js";
5
9
 
6
10
  /** Build outputs whose URL changes whenever their bytes change. */
7
11
  const HASHED_PATHS = [
8
12
  // Lighthouse report on gcp-firebase staging (v0.10.28).
9
13
  "/_assets/fonts/inter/29ede7bd4be32ab0.woff2",
10
14
  "/_assets/fonts/manrope/f3a06e9b32049b82.woff2",
11
- `/_assets/fonts/pinned/${"a".repeat(64)}.latin.0123456789ab.woff2`,
12
- `/_assets/fonts/pinned/${"0".repeat(64)}.cyrillic-ext.0123456789ab.woff2`,
15
+ // #793 — `<source sha256>.<axis-spec hash>.<slice>.<toolchain tag>.woff2`.
16
+ `/_assets/fonts/pinned/${"a".repeat(64)}.de5786ef1b56.latin.0123456789ab.woff2`,
17
+ `/_assets/fonts/pinned/${"0".repeat(64)}.1f23e5fa3b6d.cyrillic-ext.0123456789ab.woff2`,
13
18
  "/_caelo/plugin/consent-manager/runtime.0123456789ab.js",
14
19
  "/_caelo/plugin/consent-manager/banner.abcdef012345.css",
15
20
  "/_app/immutable/chunks/abc.js",
@@ -44,6 +49,8 @@ const STABLE_PATHS = [
44
49
  // appear, it must not be pinned in caches for a year.
45
50
  `/_assets/fonts/pinned/${"0".repeat(64)}.ttf`,
46
51
  `/_assets/fonts/pinned/${"a".repeat(64)}.latin.woff2`,
52
+ // Pre-#793 slice name (no axis-spec hash) — no longer emitted.
53
+ `/_assets/fonts/pinned/${"a".repeat(64)}.latin.0123456789ab.woff2`,
47
54
  "/_assets/fonts/inter/not-a-hash.woff2",
48
55
  "/_assets/fonts/inter/29ede7bd4be32ab0.woff2.html",
49
56
  "/_caelo/plugin/consent-manager/runtime.js",
@@ -77,3 +84,26 @@ describe("isContentHashedPath", () => {
77
84
  expect(CONTENT_HASHED_PATH_PATTERN.endsWith("$")).toBe(true);
78
85
  });
79
86
  });
87
+
88
+ describe("isPinnedFontSliceFile", () => {
89
+ it("accepts the slice name the delivery emits (e2e-livedit run 38915969785)", () => {
90
+ expect(
91
+ isPinnedFontSliceFile(
92
+ "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031.de5786ef1b56.latin.3075c73f4eb5.woff2",
93
+ ),
94
+ ).toBe(true);
95
+ expect(
96
+ isPinnedFontSliceFile(`${"b".repeat(64)}.0123456789ab.latin-ext.0123456789ab.woff2`),
97
+ ).toBe(true);
98
+ });
99
+
100
+ it("rejects the pre-#793 shape, other extensions and path segments", () => {
101
+ expect(isPinnedFontSliceFile(`${"b".repeat(64)}.latin.0123456789ab.woff2`)).toBe(false);
102
+ expect(isPinnedFontSliceFile(`${"b".repeat(64)}.0123456789ab.latin.0123456789ab.ttf`)).toBe(
103
+ false,
104
+ );
105
+ expect(
106
+ isPinnedFontSliceFile(`../${"b".repeat(64)}.0123456789ab.latin.0123456789ab.woff2`),
107
+ ).toBe(false);
108
+ });
109
+ });
@@ -17,12 +17,13 @@
17
17
  * Google Fonts faces; the name is a hash of the upstream face URL,
18
18
  * which Google versions per file (a changed face gets a new URL,
19
19
  * hence a new name).
20
- * - `_assets/fonts/pinned/<sha256>.<slice>.<12 hex>.woff2` — WOFF2
21
- * unicode-range slices derived from a registry font: the sha256 of
22
- * the pinned source plus the delivery-toolchain tag
23
- * (`font-service/src/delivery.ts`), which together determine the
24
- * bytes. (The sibling `pinned/<font-id>.license.txt` is id-named,
25
- * NOT hashed.)
20
+ * - `_assets/fonts/pinned/<sha256>.<12 hex>.<slice>.<12 hex>.woff2` —
21
+ * WOFF2 unicode-range slices derived from a registry font: the sha256
22
+ * of the pinned source, the hash of the effective axis spec (#793)
23
+ * and the delivery-toolchain tag (`font-service/src/delivery.ts`),
24
+ * which together determine the bytes. The file-name shape is
25
+ * {@link PINNED_FONT_SLICE_FILE_PATTERN}. (The sibling
26
+ * `pinned/<font-id>.license.txt` is id-named, NOT hashed.)
26
27
  * - `_caelo/plugin/<slug>/<stem>.<12 hex>.<js|css>` — plugin client
27
28
  * assets, sha256 of the content in the name
28
29
  * (`plugin-host/src/client-assets.ts`).
@@ -41,6 +42,26 @@
41
42
  * by font id) and media under the pre-#667 bare-slug names.
42
43
  */
43
44
 
45
+ /**
46
+ * Regex (source string, unanchored) for the file name of a pinned-font
47
+ * slice: `<source sha256>.<axis-spec hash 12>.<slice>.<toolchain tag 12>.woff2`
48
+ * (`apps/static-generator/src/pinned-font-delivery.ts` builds it). The
49
+ * ONE definition every consumer derives from — the cache policy below
50
+ * and the admin's preview font route. #793 added the axis-spec hash to
51
+ * the name; the route and this policy each kept their own copy of the
52
+ * old shape, so the editor preview 404'd every pinned font and the
53
+ * published slices lost their immutable cache lifetime. RE2-compatible.
54
+ */
55
+ export const PINNED_FONT_SLICE_FILE_PATTERN =
56
+ "[0-9a-f]{64}\\.[0-9a-f]{12}\\.[a-z]+(?:-[a-z]+)?\\.[0-9a-f]{12}\\.woff2";
57
+
58
+ const PINNED_FONT_SLICE_FILE_RE = new RegExp(`^${PINNED_FONT_SLICE_FILE_PATTERN}$`);
59
+
60
+ /** True when `fileName` (no directory) is a pinned-font slice name. */
61
+ export function isPinnedFontSliceFile(fileName: string): boolean {
62
+ return PINNED_FONT_SLICE_FILE_RE.test(fileName);
63
+ }
64
+
44
65
  /** Long-lived policy for content-addressed files. */
45
66
  export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
46
67
 
@@ -54,8 +75,7 @@ export const HTML_CACHE_CONTROL = "public, max-age=60, stale-while-revalidate=86
54
75
  * the same string can be handed to Firebase Hosting's `regex` header
55
76
  * matcher and Caddy's `path_regexp` (both RE2) and to `RegExp` here.
56
77
  */
57
- export const CONTENT_HASHED_PATH_PATTERN =
58
- "^/(?:_app/immutable/.+|_assets/fonts/[^/]+/[0-9a-f]{16}\\.woff2|_assets/fonts/pinned/[0-9a-f]{64}\\.[a-z]+(?:-[a-z]+)?\\.[0-9a-f]{12}\\.woff2|_assets/[a-z0-9][a-z0-9-]*(?:/[a-z][a-z0-9-]*)?\\.[0-9a-f]{16}\\.[a-z0-9]{2,5}|_caelo/plugin/[^/]+/[^/]+\\.[0-9a-f]{12}\\.(?:js|css))$";
78
+ export const CONTENT_HASHED_PATH_PATTERN = `^/(?:_app/immutable/.+|_assets/fonts/[^/]+/[0-9a-f]{16}\\.woff2|_assets/fonts/pinned/${PINNED_FONT_SLICE_FILE_PATTERN}|_assets/[a-z0-9][a-z0-9-]*(?:/[a-z][a-z0-9-]*)?\\.[0-9a-f]{16}\\.[a-z0-9]{2,5}|_caelo/plugin/[^/]+/[^/]+\\.[0-9a-f]{12}\\.(?:js|css))$`;
59
79
 
60
80
  /** Hex length of the content hash in published media names (#667). */
61
81
  export const MEDIA_CONTENT_HASH_HEX_LENGTH = 16;
package/src/themes.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { z } from "zod";
26
+ import { FONT_AXES_EXTENSION } from "./font-axis-policy.js";
26
27
  import { isUnsafeKey } from "./safe-keys.js";
27
28
  import { UnknownTokenName } from "./themes-errors.js";
28
29
 
@@ -788,8 +789,12 @@ export function applyDtcgWrites(
788
789
  nextValue &&
789
790
  typeof nextValue === "object" &&
790
791
  (nextValue as { fontFamily?: string }).fontFamily !== oldValue?.fontFamily
791
- )
792
+ ) {
793
+ // A new family unbinds the pinned face, and with it the face's axis
794
+ // override: it was validated against the old face's axes.
792
795
  delete extensions["caelo.font"];
796
+ delete extensions[FONT_AXES_EXTENSION];
797
+ }
793
798
  setLeafAtPath(out, path, {
794
799
  ...(Object.keys(extensions).length ? { $extensions: extensions } : {}),
795
800
  $value: nextValue,
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.51";
25
+ export const CAELO_VERSION = "0.10.52";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this