@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.
Files changed (57) hide show
  1. package/dist/ai-tools.d.ts +9 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +6 -0
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/font-axis-policy.d.ts +174 -0
  6. package/dist/font-axis-policy.d.ts.map +1 -0
  7. package/dist/font-axis-policy.js +282 -0
  8. package/dist/font-axis-policy.js.map +1 -0
  9. package/dist/font-usage.d.ts +23 -0
  10. package/dist/font-usage.d.ts.map +1 -0
  11. package/dist/font-usage.js +381 -0
  12. package/dist/font-usage.js.map +1 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/markup-structure.d.ts +79 -0
  18. package/dist/markup-structure.d.ts.map +1 -0
  19. package/dist/markup-structure.js +274 -0
  20. package/dist/markup-structure.js.map +1 -0
  21. package/dist/media.d.ts +25 -1
  22. package/dist/media.d.ts.map +1 -1
  23. package/dist/media.js +35 -0
  24. package/dist/media.js.map +1 -1
  25. package/dist/preview-compose.d.ts +4 -3
  26. package/dist/preview-compose.d.ts.map +1 -1
  27. package/dist/preview-compose.js +2 -1
  28. package/dist/preview-compose.js.map +1 -1
  29. package/dist/publish-live-parity.d.ts +18 -0
  30. package/dist/publish-live-parity.d.ts.map +1 -0
  31. package/dist/publish-live-parity.js +21 -0
  32. package/dist/publish-live-parity.js.map +1 -0
  33. package/dist/static-cache-policy.d.ts +21 -7
  34. package/dist/static-cache-policy.d.ts.map +1 -1
  35. package/dist/static-cache-policy.js +24 -7
  36. package/dist/static-cache-policy.js.map +1 -1
  37. package/dist/themes.d.ts.map +1 -1
  38. package/dist/themes.js +6 -1
  39. package/dist/themes.js.map +1 -1
  40. package/dist/version.d.ts +2 -2
  41. package/dist/version.js +1 -1
  42. package/package.json +1 -1
  43. package/src/ai-tools.ts +8 -0
  44. package/src/font-axis-policy.test.ts +182 -0
  45. package/src/font-axis-policy.ts +372 -0
  46. package/src/font-usage.test.ts +100 -0
  47. package/src/font-usage.ts +395 -0
  48. package/src/index.ts +4 -0
  49. package/src/markup-structure.test.ts +184 -0
  50. package/src/markup-structure.ts +304 -0
  51. package/src/media.ts +43 -0
  52. package/src/preview-compose.ts +6 -4
  53. package/src/publish-live-parity.ts +25 -0
  54. package/src/static-cache-policy.test.ts +33 -3
  55. package/src/static-cache-policy.ts +28 -8
  56. package/src/themes.ts +6 -1
  57. package/src/version.ts +1 -1
@@ -0,0 +1,304 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #820 — structural fingerprints of HTML, for telling "this markup is
5
+ * a reusable pattern that already exists elsewhere" from one-off content.
6
+ *
7
+ * Free HTML (a step chain, a comparison table, a timeline pasted into a
8
+ * richtext value, or minted as a one-off module per page) is cheap to
9
+ * write and expensive to keep: every copy is edited separately, the copies
10
+ * drift, and the pattern's CSS has to live in the layout where it ships on
11
+ * every page. The fix is to make the second copy a module. This file gives
12
+ * the write-time guard and the core `structure` quality check one shared
13
+ * notion of "the same markup":
14
+ *
15
+ * - A node's SIGNATURE is its tag plus sorted class tokens plus the
16
+ * signatures of its distinct children (first-occurrence order). Text,
17
+ * attribute values and repetition are ignored, so a 5-step chain and a
18
+ * 7-step chain with different copy are the same pattern.
19
+ * - A subtree QUALIFIES as a pattern when it is big enough to be a
20
+ * component (MIN_PATTERN_NODES distinct nodes over MIN_PATTERN_DEPTH
21
+ * levels) and carries at least one class. Prose converted from Markdown
22
+ * (`<ul><li><a>`, plain tables) has no classes and never qualifies; an
23
+ * authored component always has them.
24
+ * - SIMILARITY is the Jaccard index over parent→child edges, for near
25
+ * matches that are not exact (a pattern with one extra element).
26
+ *
27
+ * The site importer's boilerplate walker (site-importer/boilerplate.ts)
28
+ * stays separate on purpose: it streams byte ranges, hashes text into a
29
+ * content signature, and its signatures are persisted and replayed
30
+ * (#424), so its output must stay byte-stable.
31
+ *
32
+ * Every walk is bounded (MAX_NODES) so an adversarial body cannot blow it up.
33
+ */
34
+
35
+ import { Parser } from "htmlparser2";
36
+
37
+ /** One element of parsed markup. Text is dropped; it never matters here. */
38
+ export interface MarkupNode {
39
+ readonly tag: string;
40
+ readonly classes: readonly string[];
41
+ /** The `data-caelo-module-id` the composer stamps on every module root. */
42
+ readonly moduleId: string | null;
43
+ readonly children: MarkupNode[];
44
+ readonly parent: MarkupNode | null;
45
+ }
46
+
47
+ /** Most elements one parse keeps; the rest of the document is ignored. */
48
+ export const MAX_NODES = 50_000;
49
+ /** A pattern has at least this many distinct nodes… */
50
+ export const MIN_PATTERN_NODES = 4;
51
+ /** …spread over at least this many levels (root, child, grandchild). */
52
+ export const MIN_PATTERN_DEPTH = 3;
53
+ /** Jaccard similarity at or above which two patterns are "near" matches. */
54
+ export const NEAR_MATCH_THRESHOLD = 0.8;
55
+
56
+ /** Elements whose inside is not markup structure (icons, raw text). */
57
+ const LEAF_TAGS = new Set(["svg", "math", "script", "style", "noscript", "iframe"]);
58
+
59
+ /** Parse markup into a forest of element nodes. */
60
+ export function parseMarkup(html: string): MarkupNode[] {
61
+ const roots: MarkupNode[] = [];
62
+ const stack: (MarkupNode | null)[] = [];
63
+ let count = 0;
64
+ let leafDepth = 0;
65
+ const parser = new Parser(
66
+ {
67
+ onopentag(name, attribs) {
68
+ if (leafDepth > 0 || count >= MAX_NODES) {
69
+ stack.push(null);
70
+ if (leafDepth > 0) leafDepth++;
71
+ return;
72
+ }
73
+ const tag = name.toLowerCase();
74
+ const parent = findOpen(stack);
75
+ const node: MarkupNode = {
76
+ tag,
77
+ classes: splitClasses(attribs.class),
78
+ moduleId: attribs["data-caelo-module-id"] ?? null,
79
+ children: [],
80
+ parent,
81
+ };
82
+ count++;
83
+ if (parent) parent.children.push(node);
84
+ else roots.push(node);
85
+ stack.push(node);
86
+ if (LEAF_TAGS.has(tag)) leafDepth = 1;
87
+ },
88
+ onclosetag() {
89
+ stack.pop();
90
+ if (leafDepth > 0) leafDepth--;
91
+ },
92
+ },
93
+ { decodeEntities: true, lowerCaseTags: true },
94
+ );
95
+ parser.write(html);
96
+ parser.end();
97
+ return roots;
98
+ }
99
+
100
+ function findOpen(stack: readonly (MarkupNode | null)[]): MarkupNode | null {
101
+ for (let i = stack.length - 1; i >= 0; i--) {
102
+ const n = stack[i];
103
+ if (n) return n;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function splitClasses(raw: string | undefined): string[] {
109
+ if (!raw) return [];
110
+ return [...new Set(raw.split(/\s+/).filter((c) => c.length > 0))].sort();
111
+ }
112
+
113
+ /**
114
+ * Remove Mustache tags (`{{x}}`, `{{{x}}}`, `{{#s}}`, `{{/s}}`, `{{>p}}`)
115
+ * from a module template so it parses as the markup it renders: a section
116
+ * body stays once, a placeholder becomes empty.
117
+ */
118
+ export function stripMustache(template: string): string {
119
+ return stripDelimited(stripDelimited(template, 3), 2);
120
+ }
121
+
122
+ /**
123
+ * Removes every `{`×n + non-`}` run + `}`×n span, leftmost first — the
124
+ * same result as `replace(/\{{n}[^}]*\}{n}/g, "")`, in linear time. The
125
+ * regex form backtracks quadratically on long `{{{{…` runs in module HTML
126
+ * (CodeQL js/polynomial-redos); here, when the first `}` after an opener
127
+ * does not start the closer, no opener before that `}` can match either
128
+ * (they all hit the same `}`), so the scan resumes there.
129
+ */
130
+ function stripDelimited(text: string, n: number): string {
131
+ const opener = "{".repeat(n);
132
+ const closer = "}".repeat(n);
133
+ let out = "";
134
+ let i = 0;
135
+ while (i < text.length) {
136
+ const open = text.indexOf(opener, i);
137
+ if (open < 0) break;
138
+ const brace = text.indexOf("}", open + n);
139
+ if (brace < 0) break;
140
+ if (text.startsWith(closer, brace)) {
141
+ out += text.slice(i, open);
142
+ i = brace + n;
143
+ } else {
144
+ out += text.slice(i, brace);
145
+ i = brace;
146
+ }
147
+ }
148
+ return out + text.slice(i);
149
+ }
150
+
151
+ /** The node's key without its children: `div.card.card--wide`. */
152
+ export function nodeKey(n: Pick<MarkupNode, "tag" | "classes">): string {
153
+ return n.classes.length === 0 ? n.tag : `${n.tag}.${n.classes.join(".")}`;
154
+ }
155
+
156
+ /** Structural facts about one subtree. */
157
+ export interface SubtreeShape {
158
+ /** Repetition- and text-independent structural signature. */
159
+ readonly signature: string;
160
+ /** Distinct nodes after repetition collapse. */
161
+ readonly nodeCount: number;
162
+ /** Levels, the subtree root counting as 1. */
163
+ readonly depth: number;
164
+ /** Whether any node in it carries a class. */
165
+ readonly hasClass: boolean;
166
+ /** Parent→child edges, for near-match similarity. */
167
+ readonly edges: ReadonlySet<string>;
168
+ }
169
+
170
+ /**
171
+ * Compute the shapes of a subtree and all its descendants in one pass.
172
+ * Nested module roots are opaque: their own module owns them.
173
+ */
174
+ export function shapeTree(root: MarkupNode, out = new Map<MarkupNode, SubtreeShape>()) {
175
+ const visit = (n: MarkupNode): SubtreeShape => {
176
+ const seen = new Set<string>();
177
+ const parts: string[] = [];
178
+ let nodeCount = 1;
179
+ let depth = 1;
180
+ let hasClass = n.classes.length > 0;
181
+ const edges = new Set<string>();
182
+ const key = nodeKey(n);
183
+ for (const c of n.children) {
184
+ if (c.moduleId !== null) continue;
185
+ const s = visit(c);
186
+ if (seen.has(s.signature)) continue;
187
+ seen.add(s.signature);
188
+ parts.push(s.signature);
189
+ nodeCount += s.nodeCount;
190
+ depth = Math.max(depth, s.depth + 1);
191
+ hasClass ||= s.hasClass;
192
+ edges.add(`${key}>${nodeKey(c)}`);
193
+ for (const e of s.edges) edges.add(e);
194
+ }
195
+ const shape: SubtreeShape = {
196
+ signature: parts.length === 0 ? key : `${key}(${parts.join(",")})`,
197
+ nodeCount,
198
+ depth,
199
+ hasClass,
200
+ edges,
201
+ };
202
+ out.set(n, shape);
203
+ return shape;
204
+ };
205
+ visit(root);
206
+ return out;
207
+ }
208
+
209
+ /** Whether a subtree is big and specific enough to count as a pattern. */
210
+ export function isPattern(shape: SubtreeShape): boolean {
211
+ return shape.hasClass && shape.nodeCount >= MIN_PATTERN_NODES && shape.depth >= MIN_PATTERN_DEPTH;
212
+ }
213
+
214
+ /** Jaccard similarity of two shapes' edge sets, 0…1. */
215
+ export function shapeSimilarity(a: SubtreeShape, b: SubtreeShape): number {
216
+ if (a.signature === b.signature) return 1;
217
+ let shared = 0;
218
+ for (const e of a.edges) if (b.edges.has(e)) shared++;
219
+ const union = a.edges.size + b.edges.size - shared;
220
+ return union === 0 ? 0 : shared / union;
221
+ }
222
+
223
+ /** A qualifying pattern found in a piece of markup. */
224
+ export interface FoundPattern {
225
+ readonly node: MarkupNode;
226
+ readonly shape: SubtreeShape;
227
+ }
228
+
229
+ /**
230
+ * The outermost qualifying patterns in a forest: a pattern inside a bigger
231
+ * pattern is part of it and not reported on its own.
232
+ */
233
+ export function outermostPatterns(roots: readonly MarkupNode[]): FoundPattern[] {
234
+ const found: FoundPattern[] = [];
235
+ const walk = (n: MarkupNode, shapes: Map<MarkupNode, SubtreeShape>) => {
236
+ const shape = shapes.get(n);
237
+ if (shape && isPattern(shape)) {
238
+ found.push({ node: n, shape });
239
+ return;
240
+ }
241
+ for (const c of n.children) if (c.moduleId === null) walk(c, shapes);
242
+ };
243
+ for (const r of roots) walk(r, shapeTree(r));
244
+ return found;
245
+ }
246
+
247
+ /** The shape of a whole module template (its first element), or null. */
248
+ export function templateShape(templateHtml: string): SubtreeShape | null {
249
+ const root = parseMarkup(stripMustache(templateHtml))[0];
250
+ return root ? (shapeTree(root).get(root) ?? null) : null;
251
+ }
252
+
253
+ /**
254
+ * The parts of a rendered module that did NOT come from its template: the
255
+ * markup field values injected (richtext, text). The rendered tree is
256
+ * walked in step with the template tree; a rendered child that no template
257
+ * child can account for (same tag, template's classes all present) starts
258
+ * a free region. List sections still match, because every iteration of a
259
+ * section renders the same template child.
260
+ *
261
+ * @returns the free region roots, or null when the rendered root does not
262
+ * match the template root at all (a module the composer renders from
263
+ * other data, such as a navigation menu) — nothing can be attributed then.
264
+ */
265
+ export function freeRegions(
266
+ rendered: MarkupNode,
267
+ templateRoots: readonly MarkupNode[],
268
+ ): MarkupNode[] | null {
269
+ const tRoot = templateRoots[0];
270
+ if (!tRoot || !accounts(tRoot, rendered)) return null;
271
+ const free: MarkupNode[] = [];
272
+ const walk = (r: MarkupNode, t: MarkupNode) => {
273
+ for (const c of r.children) {
274
+ if (c.moduleId !== null) continue;
275
+ const match = t.children.find((tc) => accounts(tc, c));
276
+ if (match) walk(c, match);
277
+ else free.push(c);
278
+ }
279
+ };
280
+ walk(rendered, tRoot);
281
+ return free;
282
+ }
283
+
284
+ function accounts(template: MarkupNode, rendered: MarkupNode): boolean {
285
+ return (
286
+ template.tag === rendered.tag && template.classes.every((k) => rendered.classes.includes(k))
287
+ );
288
+ }
289
+
290
+ /** Every module root (`data-caelo-module-id`) in a forest, nested ones included. */
291
+ export function moduleRoots(roots: readonly MarkupNode[]): MarkupNode[] {
292
+ const out: MarkupNode[] = [];
293
+ const walk = (n: MarkupNode) => {
294
+ if (n.moduleId !== null) out.push(n);
295
+ for (const c of n.children) walk(c);
296
+ };
297
+ for (const r of roots) walk(r);
298
+ return out;
299
+ }
300
+
301
+ /** A short operator-readable name for a pattern: its root key, e.g. `div.chain`. */
302
+ export function patternLabel(p: Pick<MarkupNode, "tag" | "classes">): string {
303
+ return nodeKey(p);
304
+ }
package/src/media.ts CHANGED
@@ -123,6 +123,49 @@ export function buildMediaUrl(slug: string, variant: MediaVariantTag | string):
123
123
  : `${MEDIA_URL_PREFIX}/${slug}/${variant}`;
124
124
  }
125
125
 
126
+ /**
127
+ * The favicon rendition of a raster image bound to the theme's favicon
128
+ * slot: a square PNG, `FAVICON_VARIANT_PX` on each side. Browsers show a
129
+ * favicon at 16-32 CSS px (64 covers 2x screens). The `orig` of a raster
130
+ * favicon is often an AI-generated 1024 px PNG of 1-2 MB, and the browser
131
+ * fetches the `<link rel="icon">` while the page loads: under Lighthouse's
132
+ * throttled network that download pushed LCP from 0.9 s to 4.7 s on an
133
+ * otherwise perfect page (real-AI run 39038927234). Created when the
134
+ * favicon is bound (`themes.set_asset`), not on every upload.
135
+ */
136
+ export const FAVICON_VARIANT = "icon-64";
137
+
138
+ /** Edge length in px of {@link FAVICON_VARIANT}. */
139
+ export const FAVICON_VARIANT_PX = 64;
140
+
141
+ /** Raster mimes: the ones the image pipeline decodes (sharp) and resizes. */
142
+ const RASTER_IMAGE_MIMES: ReadonlySet<string> = new Set([
143
+ "image/jpeg",
144
+ "image/png",
145
+ "image/webp",
146
+ "image/avif",
147
+ "image/gif",
148
+ ]);
149
+
150
+ /** Whether `mime` is a raster image the pipeline can decode and resize. */
151
+ export function isRasterImageMime(mime: string): boolean {
152
+ return RASTER_IMAGE_MIMES.has(mime);
153
+ }
154
+
155
+ /**
156
+ * The URL + content type the `<link rel="icon">` of a bound favicon uses.
157
+ * A raster favicon ships its {@link FAVICON_VARIANT} PNG; ICO and SVG are
158
+ * favicon formats already and ship as uploaded (`orig`).
159
+ */
160
+ export function themeFaviconRendition(
161
+ slug: string,
162
+ mime: string,
163
+ ): { readonly url: string; readonly mime: string } {
164
+ return isRasterImageMime(mime)
165
+ ? { url: buildMediaUrl(slug, FAVICON_VARIANT), mime: "image/png" }
166
+ : { url: buildMediaUrl(slug, "orig"), mime };
167
+ }
168
+
126
169
  /**
127
170
  * Turn a human/asset label into a URL-safe media slug: lowercase ascii,
128
171
  * kebab-case, extension + junk stripped, capped at 60 chars. NOT
@@ -108,9 +108,10 @@ export interface ComposeThemeAsset {
108
108
  readonly mediaId: string;
109
109
  readonly url: string;
110
110
  /**
111
- * `media_assets.mime` of the bound asset — the content type of the
112
- * `orig` bytes the URL serves. Carried so `<head>` metadata that
113
- * declares a type (`<link rel="icon" type=…>`) states the real one.
111
+ * Content type of the bytes the URL serves: `media_assets.mime` for the
112
+ * `orig` URL, `image/png` for a raster favicon's rendition (see
113
+ * `themeFaviconRendition`). Carried so `<head>` metadata that declares
114
+ * a type (`<link rel="icon" type=…>`) states the real one.
114
115
  */
115
116
  readonly mime: string;
116
117
  }
@@ -239,7 +240,8 @@ export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | nul
239
240
  * regardless of which layout or chrome modules the page uses, so the
240
241
  * composer emits it from the theme binding instead of relying on a
241
242
  * module to carry the tag. The href is the media URL as composed
242
- * (`/_caelo/media/<slug>`); the static generator's media pass rewrites
243
+ * (`/_caelo/media/<slug>/icon-64` for a raster favicon, the `orig` URL
244
+ * for ICO/SVG — see `themeFaviconRendition`); the static generator's media pass rewrites
243
245
  * it to the published `/_assets/<slug>.<hash>.<ext>` and copies the bytes, the
244
246
  * same as any other media reference. Returns null when no theme is
245
247
  * threaded or no favicon is bound (nothing to declare; browsers fall
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #834 / #835 — the wording Publish live's parity refusal shares between
5
+ * the server that writes it (admin-core `build-parity.ts`) and the Live
6
+ * edit toolbar that renders it. The toolbar truncates long errors, so it
7
+ * recognises a parity refusal by its prefix and shows the next step
8
+ * ("Stage again") as its own, untruncated hint next to the button.
9
+ */
10
+
11
+ /** Every parity refusal starts with this. */
12
+ export const PARITY_REFUSAL_PREFIX = "Publish live stopped:";
13
+
14
+ /**
15
+ * The next step every parity refusal ends with. Operators use the
16
+ * toolbar's "Stage again" button next to Publish live; the AI re-stages
17
+ * with its tools.
18
+ */
19
+ export const RESTAGE_NEXT_STEP =
20
+ 'Next step: Stage again (the "Stage again" button next to Publish live, or stage_changes / deploy_staging in chat), check staging, then Publish live.';
21
+
22
+ /** True when `message` is a Publish-live parity refusal. */
23
+ export function isParityRefusal(message: string): boolean {
24
+ return message.startsWith(PARITY_REFUSAL_PREFIX);
25
+ }
@@ -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.53";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this