@caelo-cms/shared 0.10.67 → 0.10.69

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 (73) hide show
  1. package/dist/ai-tools.d.ts +30 -14
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +33 -14
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +7 -6
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +17 -13
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/html-blank.d.ts +11 -0
  10. package/dist/html-blank.d.ts.map +1 -0
  11. package/dist/html-blank.js +17 -0
  12. package/dist/html-blank.js.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/link-canonicalise.d.ts +29 -4
  18. package/dist/link-canonicalise.d.ts.map +1 -1
  19. package/dist/link-canonicalise.js +96 -8
  20. package/dist/link-canonicalise.js.map +1 -1
  21. package/dist/markup-structure.d.ts +24 -0
  22. package/dist/markup-structure.d.ts.map +1 -1
  23. package/dist/markup-structure.js +52 -0
  24. package/dist/markup-structure.js.map +1 -1
  25. package/dist/media.d.ts +2 -2
  26. package/dist/phase-timer.d.ts +36 -0
  27. package/dist/phase-timer.d.ts.map +1 -0
  28. package/dist/phase-timer.js +54 -0
  29. package/dist/phase-timer.js.map +1 -0
  30. package/dist/run-batched.d.ts +12 -0
  31. package/dist/run-batched.d.ts.map +1 -0
  32. package/dist/run-batched.js +37 -0
  33. package/dist/run-batched.js.map +1 -0
  34. package/dist/seo.d.ts +12 -5
  35. package/dist/seo.d.ts.map +1 -1
  36. package/dist/seo.js +40 -8
  37. package/dist/seo.js.map +1 -1
  38. package/dist/strip-cdata.d.ts +23 -0
  39. package/dist/strip-cdata.d.ts.map +1 -1
  40. package/dist/strip-cdata.js +31 -0
  41. package/dist/strip-cdata.js.map +1 -1
  42. package/dist/template-engine.d.ts.map +1 -1
  43. package/dist/template-engine.js +54 -22
  44. package/dist/template-engine.js.map +1 -1
  45. package/dist/themes.d.ts.map +1 -1
  46. package/dist/themes.js +29 -4
  47. package/dist/themes.js.map +1 -1
  48. package/dist/version.d.ts +2 -2
  49. package/dist/version.js +1 -1
  50. package/package.json +1 -1
  51. package/src/ai-tools.ts +34 -14
  52. package/src/build-page.test.ts +5 -4
  53. package/src/build-page.ts +17 -14
  54. package/src/html-blank.ts +21 -0
  55. package/src/index.ts +2 -0
  56. package/src/link-canonicalise.test.ts +27 -0
  57. package/src/link-canonicalise.ts +100 -5
  58. package/src/markup-structure.test.ts +21 -0
  59. package/src/markup-structure.ts +54 -0
  60. package/src/nested-module-render.test.ts +77 -0
  61. package/src/phase-timer.test.ts +48 -0
  62. package/src/phase-timer.ts +62 -0
  63. package/src/run-batched.test.ts +40 -0
  64. package/src/run-batched.ts +39 -0
  65. package/src/seo.test.ts +50 -0
  66. package/src/seo.ts +40 -8
  67. package/src/strip-cdata.test.ts +30 -1
  68. package/src/strip-cdata.ts +31 -0
  69. package/src/template-engine.test.ts +84 -0
  70. package/src/template-engine.ts +62 -17
  71. package/src/theme-dimension.test.ts +61 -0
  72. package/src/themes.ts +30 -5
  73. package/src/version.ts +1 -1
@@ -187,6 +187,12 @@ const SECTION_RE =
187
187
  /\{\{\s*#\s*([a-z][a-z0-9_]*)\s*\}\}((?:(?!\{\{\s*\/\s*\1\s*\}\})[\s\S])*)\{\{\s*\/\s*\1\s*\}\}/g;
188
188
  const PARTIAL_RE = /\{\{\s*>\s*([a-z][a-z0-9_]*)\s*\}\}/g;
189
189
  const PRIMITIVE_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
190
+ // A plain tag (no Mustache sigil) of any name, and the identifier test
191
+ // that tells a field placeholder from literal text such as `{{Page URL}}`.
192
+ // No `\s*` before the close: it would overlap `[^{}]*` (polynomial
193
+ // backtracking); the callback trims instead.
194
+ const NON_IDENTIFIER_TAG_RE = /\{\{\s*([^#^/>!&={}\s][^{}]*)\}\}/g;
195
+ const IDENTIFIER_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
190
196
 
191
197
  // Section-dispatch kinds (the engine's `{{#name}}` operand). Imported
192
198
  // from content.ts so new list-shaped kinds wire through automatically
@@ -284,6 +290,14 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
284
290
  // pattern into their HTML can't break loud-raw restore by
285
291
  // intercepting the sentinel for a known-key sentinel — they would
286
292
  // have to guess the UUID minted at render time.
293
+ //
294
+ // The same sentinels also carry DATA through the render (#1053): every
295
+ // value the engine pastes into the template before Mustache runs — a
296
+ // list item, a link label/href, a conditional's `{{.}}`, a plugin list
297
+ // value, a rendered nested module — is wrapped, so literal `{{…}}` in
298
+ // content (a GTM `{{Page URL}}` in a code example) is never parsed as a
299
+ // tag a second time. Values Mustache substitutes itself are never
300
+ // re-parsed, so only these pre-pasted ones need it.
287
301
  const sentinels = new Map<string, string>();
288
302
  const sentinelPrefix = `__CAELO_TPL_${globalThis.crypto.randomUUID()}_`;
289
303
  const mkSentinel = (original: string): string => {
@@ -380,15 +394,28 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
380
394
  return mkSentinel(match);
381
395
  });
382
396
 
397
+ // 3b. A tag whose name is not a field identifier (`{{Page URL}}`, a
398
+ // Google Tag Manager variable in a snippet) can never be a
399
+ // placeholder — field names are snake_case. Mustache would look it
400
+ // up and silently render "", so it stays literal (#1053).
401
+ html = html.replace(NON_IDENTIFIER_TAG_RE, (match, name: string) =>
402
+ IDENTIFIER_RE.test(name.trim()) ? match : mkSentinel(match),
403
+ );
404
+
383
405
  // 4. Render. The view holds only lowercase keys; sentinels survive
384
406
  // untouched; sections + partials are already substituted.
385
407
  const rendered = Mustache.render(html, view);
386
408
 
387
- // 5. Restore loud-raw sentinels.
388
- let final = rendered;
389
- for (const [sentinel, original] of sentinels) {
390
- final = final.split(sentinel).join(original);
391
- }
409
+ // 5. Restore sentinels in one pass. A single replace never re-scans
410
+ // the text it inserts, so restored content cannot be mistaken for a
411
+ // sentinel (and a list of N items costs one scan, not N).
412
+ let final =
413
+ sentinels.size === 0
414
+ ? rendered
415
+ : rendered.replace(
416
+ new RegExp(`${sentinelPrefix}\\d+__`, "g"),
417
+ (key) => sentinels.get(key) ?? key,
418
+ );
392
419
 
393
420
  // 6. Resolve internal references (epic #724). Last, so values,
394
421
  // defaults and list items substituted above are covered; nested
@@ -417,7 +444,7 @@ function renderSection(
417
444
  // fields are looked up FIRST by construction, so a plugin can never
418
445
  // shadow something the module's author declared.
419
446
  const items = dataLists[name];
420
- if (items) return renderDataList(inner, items);
447
+ if (items) return renderDataList(inner, items, mkSentinel);
421
448
  // Declared by an installed plugin that is NOT running. Naming the
422
449
  // plugin turns "unknown field" (hunt for a typo) into "switch that
423
450
  // plugin back on", which is the actual fix.
@@ -430,13 +457,13 @@ function renderSection(
430
457
  return mkSentinel(match);
431
458
  }
432
459
  if (field.kind === "module-list") {
433
- return renderModuleList(name, field, cvs, partials, missing);
460
+ return renderModuleList(name, field, cvs, partials, missing, mkSentinel);
434
461
  }
435
462
  if (field.kind === "text-list") {
436
- return renderTextList(name, inner, field, cvs, missing);
463
+ return renderTextList(name, inner, field, cvs, missing, mkSentinel);
437
464
  }
438
465
  if (field.kind === "link-list") {
439
- return renderLinkList(name, inner, field, cvs, missing);
466
+ return renderLinkList(name, inner, field, cvs, missing, mkSentinel);
440
467
  }
441
468
  // Any NON-list field used as a `{{#name}}…{{/name}}` section is a
442
469
  // CONDITIONAL (Mustache semantics): render the inner block once when the
@@ -449,7 +476,7 @@ function renderSection(
449
476
  // partial pass); `{{.}}`/`{{item}}` are substituted here for parity with
450
477
  // the *-list renderers. An empty conditional is intentional, not
451
478
  // "missing" — nothing is pushed to `missing`.
452
- return renderConditionalSection(name, inner, field, cvs);
479
+ return renderConditionalSection(name, inner, field, cvs, mkSentinel);
453
480
  }
454
481
 
455
482
  /**
@@ -461,11 +488,14 @@ function renderConditionalSection(
461
488
  inner: string,
462
489
  field: TemplateField,
463
490
  cvs: Readonly<Record<string, unknown>>,
491
+ mkSentinel: (original: string) => string,
464
492
  ): string {
465
493
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
466
494
  if (!isPresentSectionValue(raw)) return "";
467
495
  const value = scalarSectionString(raw);
468
- return value.length > 0 ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value) : inner;
496
+ return value.length > 0
497
+ ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => mkSentinel(value))
498
+ : inner;
469
499
  }
470
500
 
471
501
  /**
@@ -498,6 +528,7 @@ function renderTextList(
498
528
  field: TemplateField,
499
529
  cvs: Readonly<Record<string, unknown>>,
500
530
  missing: string[],
531
+ mkSentinel: (original: string) => string,
501
532
  ): string {
502
533
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
503
534
  if (!Array.isArray(raw)) return "";
@@ -510,7 +541,7 @@ function renderTextList(
510
541
  continue;
511
542
  }
512
543
  const value = String(el);
513
- parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value));
544
+ parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => mkSentinel(value)));
514
545
  }
515
546
  return parts.join("");
516
547
  }
@@ -529,13 +560,16 @@ function renderTextList(
529
560
  function renderDataList(
530
561
  inner: string,
531
562
  items: ReadonlyArray<Readonly<Record<string, string>>>,
563
+ mkSentinel: (original: string) => string,
532
564
  ): string {
533
565
  const parts: string[] = [];
534
566
  for (const item of items) {
535
567
  let rendered = inner;
536
568
  for (const [key, value] of Object.entries(item)) {
537
569
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
538
- rendered = rendered.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), () => value);
570
+ rendered = rendered.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), () =>
571
+ mkSentinel(value),
572
+ );
539
573
  }
540
574
  parts.push(rendered);
541
575
  }
@@ -548,6 +582,7 @@ function renderLinkList(
548
582
  field: TemplateField,
549
583
  cvs: Readonly<Record<string, unknown>>,
550
584
  missing: string[],
585
+ mkSentinel: (original: string) => string,
551
586
  ): string {
552
587
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
553
588
  if (!Array.isArray(raw)) return "";
@@ -566,7 +601,9 @@ function renderLinkList(
566
601
  }
567
602
  const { label, href } = el as { label: string; href: string };
568
603
  parts.push(
569
- inner.replace(/\{\{\s*label\s*\}\}/g, () => label).replace(/\{\{\s*href\s*\}\}/g, () => href),
604
+ inner
605
+ .replace(/\{\{\s*label\s*\}\}/g, () => mkSentinel(label))
606
+ .replace(/\{\{\s*href\s*\}\}/g, () => mkSentinel(href)),
570
607
  );
571
608
  }
572
609
  return parts.join("");
@@ -578,6 +615,7 @@ function renderModuleList(
578
615
  cvs: Readonly<Record<string, unknown>>,
579
616
  partials: PartialResolver,
580
617
  missing: string[],
618
+ mkSentinel: (original: string) => string,
581
619
  ): string {
582
620
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
583
621
  if (!Array.isArray(raw)) return "";
@@ -601,7 +639,7 @@ function renderModuleList(
601
639
  parts.push(`<!-- caelo:module-list ${name} needs recursive renderer (compose path) -->`);
602
640
  continue;
603
641
  }
604
- parts.push(partialHtml);
642
+ parts.push(mkSentinel(partialHtml));
605
643
  }
606
644
  return parts.join("");
607
645
  }
@@ -625,7 +663,14 @@ function renderPartialRef(
625
663
  missing.push(reason);
626
664
  return comment(reason);
627
665
  }
628
- const ref = cvs[name];
666
+ const ref = Object.hasOwn(cvs, name) ? cvs[name] : undefined;
667
+ // An unset or explicitly null single `module` field is an EMPTY slot,
668
+ // the same as an empty module-list / text-list: it renders nothing and
669
+ // is not a failure (#1041). The static generator refuses a Stage on any
670
+ // marker, so flagging "empty" here made an optional nested slot
671
+ // impossible to leave blank. A present value that is not a ref is still
672
+ // malformed and stays loud.
673
+ if (ref === undefined || ref === null) return "";
629
674
  if (!isNestedRef(ref)) {
630
675
  missing.push(`module-ref-malformed:${name}`);
631
676
  return comment(`module-ref-malformed ${name}`);
@@ -636,5 +681,5 @@ function renderPartialRef(
636
681
  missing.push(`${NESTED_RENDERER_UNAVAILABLE}:${name}`);
637
682
  return `<!-- caelo:module ${name} needs recursive renderer (compose path) -->`;
638
683
  }
639
- return partialHtml;
684
+ return mkSentinel(partialHtml);
640
685
  }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Dimension values accept CSS math functions (e2e-livedit run 40018361466:
5
+ * set_theme_tokens refused `typography.display` with a fluid
6
+ * `clamp(2.5rem, 2rem + 2.5vw, 4rem)` font size, and the error printed the
7
+ * truncated composite value, which hid the field it was about).
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { themeDocument } from "./themes.js";
12
+
13
+ const display = (fontSize: string) => ({
14
+ typography: {
15
+ display: {
16
+ $type: "typography",
17
+ $value: {
18
+ fontFamily: "Inter, system-ui, -apple-system, 'Segoe UI', sans-serif",
19
+ fontSize,
20
+ fontWeight: 800,
21
+ lineHeight: 1.05,
22
+ },
23
+ },
24
+ },
25
+ });
26
+
27
+ describe("theme dimension values", () => {
28
+ it("accept clamp() / calc() / min() / max() font sizes and spacing", () => {
29
+ for (const v of [
30
+ "clamp(2.5rem, 2rem + 2.5vw, 4rem)",
31
+ "calc(1rem + 0.5vw)",
32
+ "min(4rem, 10vw)",
33
+ "max(1rem, calc(0.75rem + 1vw))",
34
+ ]) {
35
+ expect(themeDocument.safeParse(display(v)).success).toBe(true);
36
+ }
37
+ const spacing = {
38
+ spacing: { section: { $type: "dimension", $value: "clamp(2rem, 5vw, 5rem)" } },
39
+ };
40
+ expect(themeDocument.safeParse(spacing).success).toBe(true);
41
+ });
42
+
43
+ it("still refuse values that are not a length, and math functions that could close the declaration", () => {
44
+ for (const v of [
45
+ "large",
46
+ "clamp(1rem; } body { display:none }",
47
+ "calc(1rem)</style>",
48
+ "url(x)",
49
+ ]) {
50
+ expect(themeDocument.safeParse(display(v)).success).toBe(false);
51
+ }
52
+ });
53
+
54
+ it("name the offending field's value, not the truncated composite", () => {
55
+ const r = themeDocument.safeParse(display("large"));
56
+ expect(r.success).toBe(false);
57
+ if (r.success) return;
58
+ const message = r.error.issues.map((i) => i.message).join(" ");
59
+ expect(message).toContain('(got "large")');
60
+ });
61
+ });
package/src/themes.ts CHANGED
@@ -51,14 +51,26 @@ const colorValueString = z
51
51
  "color value must be #hex, oklch(...), rgb(...), or a CSS named color",
52
52
  );
53
53
 
54
- /** Dimension: a CSS length / percentage / numeric value. */
54
+ /**
55
+ * Dimension: a CSS length / percentage / numeric value, or a CSS math
56
+ * function over them (`clamp()`, `calc()`, `min()`, `max()`).
57
+ *
58
+ * The math functions are accepted because fluid type is the idiomatic way to
59
+ * size a display heading — `clamp(2.5rem, 2rem + 2.5vw, 4rem)` — and token
60
+ * values are emitted verbatim as CSS custom properties, where a browser
61
+ * resolves them like any length. Refusing them (e2e-livedit run 40018361466,
62
+ * set_theme_tokens `typography.display`) cost a retry and the AI downgraded to
63
+ * a fixed size it had not chosen. The arguments are limited to numbers,
64
+ * units, operators, commas and nested parentheses, so a value cannot close
65
+ * the declaration (`;`, `{`, `}`) or carry markup.
66
+ */
55
67
  const dimensionValueString = z
56
68
  .string()
57
69
  .min(1)
58
- .max(80)
70
+ .max(120)
59
71
  .regex(
60
- /^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex|fr|deg|rad|turn|s|ms)?$|^auto$|^0$/,
61
- "dimension must be a CSS length / number / percentage",
72
+ /^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex|fr|deg|rad|turn|s|ms)?$|^auto$|^0$|^(calc|clamp|min|max)\([0-9a-zA-Z.%+\-*/,\s()]+\)$/,
73
+ "dimension must be a CSS length / number / percentage, or clamp()/calc()/min()/max() over them",
62
74
  );
63
75
 
64
76
  /** Font-weight: keyword or 1-1000 number. */
@@ -422,6 +434,16 @@ const CATEGORY_EXPECTATIONS: Record<string, string> = {
422
434
  };
423
435
 
424
436
  /** Compact, human-readable render of a rejected `$value` for the error tail. */
437
+ /** The value `path` points at inside `root`; `root` itself for an empty path. */
438
+ function valueAtPath(root: unknown, path: readonly PropertyKey[]): unknown {
439
+ let node = root;
440
+ for (const key of path) {
441
+ if (node === null || typeof node !== "object") return node;
442
+ node = (node as Record<PropertyKey, unknown>)[key];
443
+ }
444
+ return node;
445
+ }
446
+
425
447
  function describeValue(value: unknown): string {
426
448
  if (typeof value === "string") return JSON.stringify(value);
427
449
  if (value === undefined) return "undefined";
@@ -488,10 +510,13 @@ function refineKnownCategoryLeaves(
488
510
  });
489
511
  } else {
490
512
  for (const issue of sub.error.issues) {
513
+ // Name the offending LEAF, not the whole composite `$value`: a
514
+ // typography value prints `fontFamily` first, so the truncated
515
+ // object hid the `fontSize` the issue was about.
491
516
  ctx.addIssue({
492
517
  code: "custom",
493
518
  path: [...path, ...issue.path],
494
- message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(obj.$value)})`,
519
+ message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(valueAtPath(obj, issue.path))})`,
495
520
  });
496
521
  }
497
522
  }
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.67";
25
+ export const CAELO_VERSION = "0.10.69";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this