@caelo-cms/shared 0.10.52 → 0.10.55

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 (91) hide show
  1. package/dist/auto-update.d.ts +5 -2
  2. package/dist/auto-update.d.ts.map +1 -1
  3. package/dist/auto-update.js +13 -6
  4. package/dist/auto-update.js.map +1 -1
  5. package/dist/fonts.d.ts +12 -0
  6. package/dist/fonts.d.ts.map +1 -1
  7. package/dist/fonts.js +37 -1
  8. package/dist/fonts.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/link-refs.d.ts +250 -0
  14. package/dist/link-refs.d.ts.map +1 -0
  15. package/dist/link-refs.js +416 -0
  16. package/dist/link-refs.js.map +1 -0
  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/nested-module-render.d.ts +4 -1
  26. package/dist/nested-module-render.d.ts.map +1 -1
  27. package/dist/nested-module-render.js +14 -2
  28. package/dist/nested-module-render.js.map +1 -1
  29. package/dist/platform-updates.d.ts +7 -3
  30. package/dist/platform-updates.d.ts.map +1 -1
  31. package/dist/platform-updates.js +13 -2
  32. package/dist/platform-updates.js.map +1 -1
  33. package/dist/preview-compose.d.ts +14 -3
  34. package/dist/preview-compose.d.ts.map +1 -1
  35. package/dist/preview-compose.js +60 -21
  36. package/dist/preview-compose.js.map +1 -1
  37. package/dist/publish-live-parity.d.ts +18 -0
  38. package/dist/publish-live-parity.d.ts.map +1 -0
  39. package/dist/publish-live-parity.js +21 -0
  40. package/dist/publish-live-parity.js.map +1 -0
  41. package/dist/raw-text-escape.d.ts +18 -0
  42. package/dist/raw-text-escape.d.ts.map +1 -0
  43. package/dist/raw-text-escape.js +40 -0
  44. package/dist/raw-text-escape.js.map +1 -0
  45. package/dist/responsive-images.d.ts.map +1 -1
  46. package/dist/responsive-images.js +3 -3
  47. package/dist/responsive-images.js.map +1 -1
  48. package/dist/seo.js +1 -1
  49. package/dist/seo.js.map +1 -1
  50. package/dist/template-balance.d.ts +3 -0
  51. package/dist/template-balance.d.ts.map +1 -0
  52. package/dist/template-balance.js +214 -0
  53. package/dist/template-balance.js.map +1 -0
  54. package/dist/template-engine.d.ts +11 -0
  55. package/dist/template-engine.d.ts.map +1 -1
  56. package/dist/template-engine.js +12 -0
  57. package/dist/template-engine.js.map +1 -1
  58. package/dist/theme-render.d.ts +2 -2
  59. package/dist/theme-render.d.ts.map +1 -1
  60. package/dist/theme-render.js +29 -5
  61. package/dist/theme-render.js.map +1 -1
  62. package/dist/version.d.ts +2 -2
  63. package/dist/version.js +1 -1
  64. package/package.json +2 -2
  65. package/src/auto-update.test.ts +32 -1
  66. package/src/auto-update.ts +13 -6
  67. package/src/fonts.test.ts +37 -0
  68. package/src/fonts.ts +38 -1
  69. package/src/index.ts +4 -0
  70. package/src/link-refs.test.ts +552 -0
  71. package/src/link-refs.ts +569 -0
  72. package/src/markup-structure.test.ts +184 -0
  73. package/src/markup-structure.ts +304 -0
  74. package/src/media.ts +43 -0
  75. package/src/nested-module-render.ts +18 -1
  76. package/src/platform-updates.test.ts +5 -0
  77. package/src/platform-updates.ts +13 -2
  78. package/src/preview-compose-deferrals.test.ts +103 -0
  79. package/src/preview-compose.ts +79 -28
  80. package/src/publish-live-parity.ts +25 -0
  81. package/src/raw-text-escape.ts +44 -0
  82. package/src/responsive-images.test.ts +10 -0
  83. package/src/responsive-images.ts +6 -3
  84. package/src/seo.test.ts +7 -0
  85. package/src/seo.ts +1 -1
  86. package/src/template-balance.test.ts +97 -0
  87. package/src/template-balance.ts +216 -0
  88. package/src/template-engine.ts +23 -0
  89. package/src/theme-render-sanitize.test.ts +40 -0
  90. package/src/theme-render.ts +40 -8
  91. package/src/version.ts +1 -1
@@ -0,0 +1,184 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ freeRegions,
6
+ isPattern,
7
+ MAX_NODES,
8
+ moduleRoots,
9
+ NEAR_MATCH_THRESHOLD,
10
+ outermostPatterns,
11
+ parseMarkup,
12
+ shapeSimilarity,
13
+ shapeTree,
14
+ stripMustache,
15
+ templateShape,
16
+ } from "./markup-structure.js";
17
+
18
+ const chain = (steps: string[]) =>
19
+ `<div class="chain">${steps
20
+ .map((s, i) => `<div class="step"><span class="n">${i + 1}</span><p>${s}</p></div>`)
21
+ .join('<span class="arrow"></span>')}</div>`;
22
+
23
+ function shapeOf(html: string) {
24
+ const root = parseMarkup(html)[0];
25
+ if (!root) throw new Error("no element");
26
+ const shape = shapeTree(root).get(root);
27
+ if (!shape) throw new Error("no shape");
28
+ return shape;
29
+ }
30
+
31
+ describe("signatures", () => {
32
+ it("ignore text and repetition: a 3-step and a 5-step chain are the same pattern", () => {
33
+ const a = shapeOf(chain(["Crawl", "Diff", "Alert"]));
34
+ const b = shapeOf(chain(["One", "Two", "Three", "Four", "Five"]));
35
+ expect(a.signature).toBe(b.signature);
36
+ expect(isPattern(a)).toBe(true);
37
+ });
38
+
39
+ it("class order and duplicate class tokens do not matter", () => {
40
+ expect(shapeOf('<div class="b a a"><p></p></div>').signature).toBe(
41
+ shapeOf('<div class="a b"><p></p></div>').signature,
42
+ );
43
+ });
44
+
45
+ it("a different class makes a different signature", () => {
46
+ expect(shapeOf(chain(["x"])).signature).not.toBe(
47
+ shapeOf(chain(["x"]).replace('class="chain"', 'class="flow"')).signature,
48
+ );
49
+ });
50
+
51
+ it("near matches score above the threshold, unrelated markup below", () => {
52
+ const a = shapeOf(chain(["x", "y"]));
53
+ const b = shapeOf(chain(["x", "y"]).replace("</p>", "</p><small>note</small>"));
54
+ expect(a.signature).not.toBe(b.signature);
55
+ expect(shapeSimilarity(a, b)).toBeGreaterThanOrEqual(NEAR_MATCH_THRESHOLD);
56
+ const c = shapeOf('<table class="pm"><tr><th>a</th><td>b</td></tr></table>');
57
+ expect(shapeSimilarity(a, c)).toBeLessThan(0.2);
58
+ });
59
+ });
60
+
61
+ describe("isPattern", () => {
62
+ it("class-less prose never qualifies, however deep", () => {
63
+ expect(isPattern(shapeOf("<ul><li><a>x</a><strong>y</strong></li></ul>"))).toBe(false);
64
+ expect(isPattern(shapeOf("<table><thead><tr><th>a</th></tr></thead></table>"))).toBe(false);
65
+ });
66
+
67
+ it("small classed markup is not a component", () => {
68
+ expect(isPattern(shapeOf('<p class="lead"><a>x</a></p>'))).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("outermostPatterns", () => {
73
+ it("reports the outer pattern, not the patterns inside it", () => {
74
+ const html = `<section class="wrap"><h2>t</h2>${chain(["a", "b"])}</section>`;
75
+ const found = outermostPatterns(parseMarkup(html));
76
+ expect(found.map((f) => f.node.tag + f.node.classes.join("."))).toEqual(["sectionwrap"]);
77
+ });
78
+
79
+ it("finds several patterns side by side in prose", () => {
80
+ const html = `<p>intro</p>${chain(["a"])}<p>mid</p>${chain(["b"]).replace("chain", "pair")}`;
81
+ expect(outermostPatterns(parseMarkup(html)).map((f) => f.node.classes[0])).toEqual([
82
+ "chain",
83
+ "pair",
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("templates and free regions", () => {
89
+ const template =
90
+ '<section class="prose"><h2>{{title}}</h2><div class="body">{{body}}</div>' +
91
+ '<ul class="tags">{{#tags}}<li class="tag">{{.}}</li>{{/tags}}</ul></section>';
92
+
93
+ it("stripMustache matches the two-pass regex on edge cases and stays linear on long brace runs", () => {
94
+ const regex = (t: string): string =>
95
+ t.replace(/\{\{\{[^}]*\}\}\}/g, "").replace(/\{\{[^}]*\}\}/g, "");
96
+ for (const t of [
97
+ "{{x{{{y}}}",
98
+ "{{a}}}",
99
+ "{{{a}}",
100
+ "{{{{a}}}}",
101
+ "a{b}c{{",
102
+ "{{}}{{{}}}",
103
+ "}}{{a}b}}",
104
+ "<p>{{#s}}{{{h}}}{{/s}}</p>",
105
+ ]) {
106
+ expect(stripMustache(t)).toBe(regex(t));
107
+ }
108
+ const started = performance.now();
109
+ stripMustache("{{{{{{".repeat(50_000));
110
+ stripMustache(`${"{{".repeat(50_000)}}`);
111
+ expect(performance.now() - started).toBeLessThan(500);
112
+ });
113
+
114
+ it("stripMustache keeps the markup a template renders", () => {
115
+ expect(stripMustache("<p>{{a}}{{{b}}}</p>{{#s}}<i></i>{{/s}}{{>n}}")).toBe("<p></p><i></i>");
116
+ });
117
+
118
+ it("everything a template renders, list items included, is not free", () => {
119
+ const rendered = parseMarkup(
120
+ '<section class="prose" data-caelo-module-id="m1"><h2>Hi</h2><div class="body"><p>text</p></div>' +
121
+ '<ul class="tags"><li class="tag">a</li><li class="tag">b</li></ul></section>',
122
+ );
123
+ const root = moduleRoots(rendered)[0];
124
+ if (!root) throw new Error("no module root");
125
+ const free = freeRegions(root, parseMarkup(stripMustache(template)));
126
+ expect(free?.map((n) => n.tag)).toEqual(["p"]);
127
+ });
128
+
129
+ it("markup pasted into a richtext value is a free region", () => {
130
+ const rendered = parseMarkup(
131
+ `<section class="prose" data-caelo-module-id="m1"><h2>Hi</h2><div class="body">${chain(["a", "b"])}</div></section>`,
132
+ );
133
+ const root = moduleRoots(rendered)[0];
134
+ if (!root) throw new Error("no module root");
135
+ const free = freeRegions(root, parseMarkup(stripMustache(template))) ?? [];
136
+ expect(outermostPatterns(free).map((f) => f.node.classes[0])).toEqual(["chain"]);
137
+ });
138
+
139
+ it("a nested module is opaque to its parent and found on its own", () => {
140
+ const rendered = parseMarkup(
141
+ `<section class="prose" data-caelo-module-id="m1"><div class="body">${chain(["a"]).replace(
142
+ '<div class="chain"',
143
+ '<div class="chain" data-caelo-module-id="m2"',
144
+ )}</div></section>`,
145
+ );
146
+ expect(moduleRoots(rendered).map((r) => r.moduleId)).toEqual(["m1", "m2"]);
147
+ const root = moduleRoots(rendered)[0];
148
+ if (!root) throw new Error("no module root");
149
+ expect(freeRegions(root, parseMarkup(stripMustache(template)))).toEqual([]);
150
+ });
151
+
152
+ it("returns null when the rendered root is not the template's root", () => {
153
+ const rendered = parseMarkup('<nav data-caelo-module-id="m3"><a>x</a></nav>');
154
+ const root = moduleRoots(rendered)[0];
155
+ if (!root) throw new Error("no module root");
156
+ expect(freeRegions(root, parseMarkup(stripMustache(template)))).toBeNull();
157
+ });
158
+
159
+ it("templateShape fingerprints the template's root element", () => {
160
+ const t = templateShape(
161
+ '<div class="chain">{{#steps}}<div class="step"><span class="n">{{n}}</span><p>{{t}}</p></div><span class="arrow"></span>{{/steps}}</div>',
162
+ );
163
+ expect(t?.signature).toBe(shapeOf(chain(["a", "b", "c"])).signature);
164
+ });
165
+ });
166
+
167
+ describe("bounds", () => {
168
+ it("stops collecting elements at MAX_NODES", () => {
169
+ const html = "<div>".repeat(10) + "<i></i>".repeat(MAX_NODES + 100) + "</div>".repeat(10);
170
+ const roots = parseMarkup(html);
171
+ let count = 0;
172
+ const walk = (n: (typeof roots)[number]) => {
173
+ count++;
174
+ for (const c of n.children) walk(c);
175
+ };
176
+ for (const r of roots) walk(r);
177
+ expect(count).toBe(MAX_NODES);
178
+ });
179
+
180
+ it("does not descend into svg icons", () => {
181
+ const root = parseMarkup('<span class="i"><svg><g><path/><path/></g></svg></span>')[0];
182
+ expect(root?.children[0]?.children).toEqual([]);
183
+ });
184
+ });
@@ -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
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  import type { ModuleFieldKind } from "./content.js";
60
+ import { isLinkFailureMarker, type LinkResolver } from "./link-refs.js";
60
61
  import { caeloMissingComment, renderTemplate, type TemplateField } from "./template-engine.js";
61
62
 
62
63
  const MAX_RECURSION_DEPTH = 8;
@@ -158,6 +159,8 @@ interface RenderContext {
158
159
  * module: a switcher can sit inside a nested chrome module. */
159
160
  readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
160
161
  readonly dormantDataLists: Readonly<Record<string, string>>;
162
+ /** Epic #724 — resolves internal references in every nested module. */
163
+ readonly linkResolver: LinkResolver | undefined;
161
164
  }
162
165
 
163
166
  function isNestedRef(v: unknown): v is NestedRefValue {
@@ -193,6 +196,7 @@ export function renderModuleWithContent(
193
196
  readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
194
197
  readonly dormantDataLists: Readonly<Record<string, string>>;
195
198
  },
199
+ linkResolver?: LinkResolver,
196
200
  ): NestedRenderResult {
197
201
  const touched = new Set<string>();
198
202
  const missing: string[] = [];
@@ -208,6 +212,7 @@ export function renderModuleWithContent(
208
212
  themeAssets,
209
213
  dataLists: pluginLists?.dataLists ?? {},
210
214
  dormantDataLists: pluginLists?.dormantDataLists ?? {},
215
+ linkResolver,
211
216
  });
212
217
  return { html, touchedModuleIds: touched, missingSlots: missing, failures };
213
218
  }
@@ -255,6 +260,7 @@ function renderInner(moduleId: string, contentInstanceId: string, ctx: RenderCon
255
260
  themeAssets: ctx.themeAssets,
256
261
  dataLists: ctx.dataLists,
257
262
  dormantDataLists: ctx.dormantDataLists,
263
+ linkResolver: ctx.linkResolver,
258
264
  };
259
265
 
260
266
  return substituteWithRecursion(mod.html, mod.fields, ci.values, childCtx);
@@ -310,8 +316,16 @@ function substituteWithRecursion(
310
316
  themeAssets: ctx.themeAssets,
311
317
  dataLists: ctx.dataLists,
312
318
  dormantDataLists: ctx.dormantDataLists,
319
+ linkResolver: ctx.linkResolver,
313
320
  });
314
- for (const m of result.missingSlots) fail(ctx, m);
321
+ for (const m of result.missingSlots) {
322
+ // A broken reference inside a nested module stays raw in the partial
323
+ // HTML (loud-raw), so the enclosing module's render meets it again.
324
+ // The nested render already reported it with the precise field
325
+ // trail; drop the echo instead of reporting one broken link twice.
326
+ if (isLinkFailureMarker(m) && ctx.missing.includes(m)) continue;
327
+ fail(ctx, m);
328
+ }
315
329
  return result.html;
316
330
  }
317
331
 
@@ -381,6 +395,8 @@ export function renderPlacedModule(
381
395
  readonly themeAssets?: NestedRenderThemeAssets;
382
396
  readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
383
397
  readonly dormantDataLists?: Readonly<Record<string, string>>;
398
+ /** Epic #724 — resolves internal references (see `link-refs.ts`). */
399
+ readonly linkResolver?: LinkResolver;
384
400
  },
385
401
  ): NestedRenderResult {
386
402
  const touched = new Set<string>();
@@ -400,6 +416,7 @@ export function renderPlacedModule(
400
416
  themeAssets: options.themeAssets,
401
417
  dataLists: options.dataLists ?? {},
402
418
  dormantDataLists: options.dormantDataLists ?? {},
419
+ linkResolver: options.linkResolver,
403
420
  });
404
421
  return { html, touchedModuleIds: touched, missingSlots: missing, failures };
405
422
  }
@@ -61,6 +61,7 @@ describe("autoInstallRefusal", () => {
61
61
  autoSecurity: true,
62
62
  autoPatch: false,
63
63
  autoMinor: false,
64
+ autoConverge: false,
64
65
  channel: "stable",
65
66
  windowStart: null,
66
67
  windowEnd: null,
@@ -123,6 +124,7 @@ describe("updatePolicySchema", () => {
123
124
  autoSecurity: true,
124
125
  autoPatch: false,
125
126
  autoMinor: false,
127
+ autoConverge: false,
126
128
  channel: "stable",
127
129
  windowStart: "02:00",
128
130
  windowEnd: "04:00",
@@ -140,6 +142,9 @@ describe("updatePolicySchema", () => {
140
142
  { ...base, soakHours: 1000 },
141
143
  { ...base, securitySoakHours: 48 },
142
144
  { ...base, extra: true },
145
+ // "Don't tell me" is gone (#862): security releases are always announced.
146
+ { ...base, mode: "off" },
147
+ { ...base, autoConverge: undefined },
143
148
  ]) {
144
149
  expect(updatePolicySchema.safeParse(bad).success).toBe(false);
145
150
  }
@@ -128,8 +128,12 @@ function comparePrerelease(x: string, y: string): number {
128
128
  return 0;
129
129
  }
130
130
 
131
- /** The automatic-update modes. */
132
- export const UPDATE_POLICY_MODES = ["off", "notify", "auto"] as const;
131
+ /**
132
+ * The automatic-update modes. There is no "off": security releases are
133
+ * always announced (maintainer decision on #862; migration 0301 moved
134
+ * stored `off` rows to `notify`).
135
+ */
136
+ export const UPDATE_POLICY_MODES = ["notify", "auto"] as const;
133
137
  export type UpdatePolicyMode = (typeof UPDATE_POLICY_MODES)[number];
134
138
 
135
139
  /** Release channels a policy may follow (only `stable` installs automatically). */
@@ -158,6 +162,13 @@ export const updatePolicySchema = z
158
162
  autoSecurity: z.boolean(),
159
163
  autoPatch: z.boolean(),
160
164
  autoMinor: z.boolean(),
165
+ /**
166
+ * Releases that change the install's Google Cloud project (an
167
+ * infrastructure-contract bump) may install on their own. Stored and
168
+ * shown, but inert until those releases can run from Caelo (#819): the
169
+ * scheduler treats them as notify-only regardless (auto-update.ts).
170
+ */
171
+ autoConverge: z.boolean(),
161
172
  channel: z.enum(UPDATE_POLICY_CHANNELS),
162
173
  /** `HH:MM` local time in `windowTimezone`, or null with windowEnd null = any time. */
163
174
  windowStart: z.string().regex(WALL_CLOCK, "expected HH:MM").nullable(),