@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,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";
@@ -34,6 +36,7 @@ export {
34
36
  type ServiceName,
35
37
  type StructuredLogEntry,
36
38
  } from "./logger.js";
39
+ export * from "./markup-structure.js";
37
40
  export * from "./media.js";
38
41
  export * from "./nested-module-render.js";
39
42
  export * from "./page-log.js";
@@ -43,6 +46,7 @@ export * from "./preview-compose.js";
43
46
  export * from "./preview-scanner.js";
44
47
  export * from "./proposal-status.js";
45
48
  export * from "./provisioning-command.js";
49
+ export * from "./publish-live-parity.js";
46
50
  export * from "./redirect-emit-caddy.js";
47
51
  export * from "./redirect-emit-cloudfront.js";
48
52
  export * from "./redirect-emit-front-door.js";
@@ -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
+ });