@caelo-cms/shared 0.10.53 → 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 (76) 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 +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -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/media.d.ts +1 -1
  18. package/dist/nested-module-render.d.ts +4 -1
  19. package/dist/nested-module-render.d.ts.map +1 -1
  20. package/dist/nested-module-render.js +14 -2
  21. package/dist/nested-module-render.js.map +1 -1
  22. package/dist/platform-updates.d.ts +7 -3
  23. package/dist/platform-updates.d.ts.map +1 -1
  24. package/dist/platform-updates.js +13 -2
  25. package/dist/platform-updates.js.map +1 -1
  26. package/dist/preview-compose.d.ts +10 -0
  27. package/dist/preview-compose.d.ts.map +1 -1
  28. package/dist/preview-compose.js +58 -20
  29. package/dist/preview-compose.js.map +1 -1
  30. package/dist/raw-text-escape.d.ts +18 -0
  31. package/dist/raw-text-escape.d.ts.map +1 -0
  32. package/dist/raw-text-escape.js +40 -0
  33. package/dist/raw-text-escape.js.map +1 -0
  34. package/dist/responsive-images.d.ts.map +1 -1
  35. package/dist/responsive-images.js +3 -3
  36. package/dist/responsive-images.js.map +1 -1
  37. package/dist/seo.js +1 -1
  38. package/dist/seo.js.map +1 -1
  39. package/dist/template-balance.d.ts +3 -0
  40. package/dist/template-balance.d.ts.map +1 -0
  41. package/dist/template-balance.js +214 -0
  42. package/dist/template-balance.js.map +1 -0
  43. package/dist/template-engine.d.ts +11 -0
  44. package/dist/template-engine.d.ts.map +1 -1
  45. package/dist/template-engine.js +12 -0
  46. package/dist/template-engine.js.map +1 -1
  47. package/dist/theme-render.d.ts +2 -2
  48. package/dist/theme-render.d.ts.map +1 -1
  49. package/dist/theme-render.js +29 -5
  50. package/dist/theme-render.js.map +1 -1
  51. package/dist/version.d.ts +2 -2
  52. package/dist/version.js +1 -1
  53. package/package.json +2 -2
  54. package/src/auto-update.test.ts +32 -1
  55. package/src/auto-update.ts +13 -6
  56. package/src/fonts.test.ts +37 -0
  57. package/src/fonts.ts +38 -1
  58. package/src/index.ts +2 -0
  59. package/src/link-refs.test.ts +552 -0
  60. package/src/link-refs.ts +569 -0
  61. package/src/nested-module-render.ts +18 -1
  62. package/src/platform-updates.test.ts +5 -0
  63. package/src/platform-updates.ts +13 -2
  64. package/src/preview-compose-deferrals.test.ts +103 -0
  65. package/src/preview-compose.ts +73 -24
  66. package/src/raw-text-escape.ts +44 -0
  67. package/src/responsive-images.test.ts +10 -0
  68. package/src/responsive-images.ts +6 -3
  69. package/src/seo.test.ts +7 -0
  70. package/src/seo.ts +1 -1
  71. package/src/template-balance.test.ts +97 -0
  72. package/src/template-balance.ts +216 -0
  73. package/src/template-engine.ts +23 -0
  74. package/src/theme-render-sanitize.test.ts +40 -0
  75. package/src/theme-render.ts +40 -8
  76. package/src/version.ts +1 -1
@@ -0,0 +1,216 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Keep an HTML fragment from closing a `<template>` it is placed inside.
5
+ *
6
+ * The composer parks a consent-withheld module inside an inert
7
+ * `<template data-caelo-deferred-content>` (#450). In the HTML parser the
8
+ * only thing that ends a template from within is a `</template` end tag
9
+ * (an ASCII-case-insensitive literal; tag names never decode character
10
+ * references), so a fragment with an unmatched one would put the rest of
11
+ * the module live on the page before the visitor consents. Balanced nested
12
+ * templates are legitimate markup and must survive, which rules out
13
+ * escaping every `</template`.
14
+ *
15
+ * The walk is FAIL-CLOSED: it does not have to agree with the browser's
16
+ * tokenizer and tree builder everywhere, only in one direction. Every
17
+ * `</template` it does not keep as the close of a template it counted is
18
+ * rewritten — inside tags, attribute values, comments and raw text too,
19
+ * because those are exactly the places where a hand-written walk and the
20
+ * HTML parser can disagree (abrupt comments like `<!-->`, raw-text
21
+ * elements that are ordinary elements in SVG/MathML, ...). A template is
22
+ * only counted where it is certainly an HTML `<template>`: never inside
23
+ * `<svg>` / `<math>`, where the tree builder can pop a foreign `template`
24
+ * element implicitly (e.g. by `</svg>`) and a following `</template>`
25
+ * would then close the wrapper. When the walk and the browser disagree,
26
+ * the worst outcome is a template that stays open and keeps the rest of
27
+ * the page inert — never withheld content running before consent.
28
+ *
29
+ * Rewrites: `&lt;/template` in markup, attribute values and comments (the
30
+ * same characters once decoded or shown); `<\/template` in `<script>` /
31
+ * `<style>` text (the same string in JS and CSS). Templates the fragment
32
+ * leaves open are closed at its end so the wrapper's own `</template>`
33
+ * closes the wrapper.
34
+ */
35
+
36
+ /** Elements whose content the HTML tokenizer reads as text until their end tag. */
37
+ const RAW_TEXT_ELEMENTS = new Set([
38
+ "script",
39
+ "style",
40
+ "textarea",
41
+ "title",
42
+ "xmp",
43
+ "iframe",
44
+ "noembed",
45
+ "noframes",
46
+ "noscript",
47
+ ]);
48
+
49
+ /** Raw-text elements whose content is JS or CSS, where `<\/` keeps the meaning. */
50
+ const CODE_ELEMENTS = new Set(["script", "style"]);
51
+
52
+ /** Roots of foreign (SVG / MathML) content. */
53
+ const FOREIGN_ROOTS = new Set(["svg", "math"]);
54
+
55
+ /** The HTML tokenizer's whitespace (not JS `\s`, which also matches U+00A0 etc.). */
56
+ const HTML_WS = /[\t\n\f\r ]/;
57
+
58
+ /** A tag name ends at HTML whitespace, `/` or `>`. */
59
+ const TAG_NAME_END = /[\t\n\f\r />]/;
60
+
61
+ const TEMPLATE_CLOSE_RE = /<\/template/gi;
62
+
63
+ /** Neutralize every `</template` in markup / attribute / comment text. */
64
+ function neutralizeMarkup(s: string): string {
65
+ return s.replace(TEMPLATE_CLOSE_RE, (m) => `&lt;${m.slice(1)}`);
66
+ }
67
+
68
+ /** Neutralize every `</template` in JS / CSS text. */
69
+ function neutralizeCode(s: string): string {
70
+ return s.replace(TEMPLATE_CLOSE_RE, (m) => `<\\${m.slice(1)}`);
71
+ }
72
+
73
+ /**
74
+ * Rewrite `<!--` in script text to `<\!--`. A `<!--` puts the HTML
75
+ * tokenizer's script data into its escaped states, where a nested
76
+ * `<script` makes the next `</script>` NOT end the element — the browser's
77
+ * script would then run past the end this walk found. Without `<!--` both
78
+ * agree. `\!` is `!` in JS strings and non-unicode regexes.
79
+ */
80
+ function neutralizeScriptEscapes(s: string): string {
81
+ return s.replaceAll("<!--", () => "<\\!--");
82
+ }
83
+
84
+ /** ASCII-only lowercase: same length as the input, like the HTML tokenizer. */
85
+ function asciiLower(s: string): string {
86
+ return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
87
+ }
88
+
89
+ /** Neutralize unmatched `</template>` closes and close templates left open. */
90
+ export function balanceTemplateCloses(html: string): string {
91
+ let out = "";
92
+ let depth = 0;
93
+ let foreignDepth = 0;
94
+ let i = 0;
95
+ while (i < html.length) {
96
+ const lt = html.indexOf("<", i);
97
+ if (lt === -1) {
98
+ out += html.slice(i);
99
+ break;
100
+ }
101
+ out += html.slice(i, lt);
102
+ if (html.startsWith("<!--", lt)) {
103
+ const end = commentEnd(html, lt + 4);
104
+ out += neutralizeMarkup(html.slice(lt, end));
105
+ i = end;
106
+ continue;
107
+ }
108
+ if (html[lt + 1] === "!" || html[lt + 1] === "?") {
109
+ // Doctype / CDATA / bogus comment: text up to the next `>`.
110
+ const end = html.indexOf(">", lt);
111
+ const stop = end === -1 ? html.length : end + 1;
112
+ out += neutralizeMarkup(html.slice(lt, stop));
113
+ i = stop;
114
+ continue;
115
+ }
116
+ const isEnd = html[lt + 1] === "/";
117
+ const nameStart = lt + (isEnd ? 2 : 1);
118
+ if (!/[A-Za-z]/.test(html[nameStart] ?? "")) {
119
+ // Not a tag (`a < b`, `</ >`): the `<` is text.
120
+ out += "<";
121
+ i = lt + 1;
122
+ continue;
123
+ }
124
+ let nameEnd = nameStart;
125
+ while (nameEnd < html.length && !TAG_NAME_END.test(html[nameEnd] ?? "")) nameEnd++;
126
+ const name = asciiLower(html.slice(nameStart, nameEnd));
127
+ const tagEnd = tagClose(html, nameEnd);
128
+ const tag = html.slice(lt, tagEnd);
129
+ const selfClosing = tag.endsWith("/>");
130
+ i = tagEnd;
131
+ if (isEnd) {
132
+ if (name === "template" && depth > 0 && foreignDepth === 0) {
133
+ depth--;
134
+ out += `</template${neutralizeMarkup(tag.slice("</template".length))}`;
135
+ continue;
136
+ }
137
+ if (FOREIGN_ROOTS.has(name) && foreignDepth > 0) foreignDepth--;
138
+ out += neutralizeMarkup(tag);
139
+ continue;
140
+ }
141
+ out += neutralizeMarkup(tag);
142
+ if (foreignDepth > 0) {
143
+ // Foreign content: no raw-text elements, no HTML templates counted.
144
+ if (FOREIGN_ROOTS.has(name) && !selfClosing) foreignDepth++;
145
+ continue;
146
+ }
147
+ if (name === "template") {
148
+ depth++;
149
+ } else if (FOREIGN_ROOTS.has(name)) {
150
+ if (!selfClosing) foreignDepth++;
151
+ } else if (RAW_TEXT_ELEMENTS.has(name)) {
152
+ const close = rawTextEnd(html, tagEnd, name);
153
+ const body = html.slice(tagEnd, close);
154
+ out +=
155
+ name === "script"
156
+ ? neutralizeScriptEscapes(neutralizeCode(body))
157
+ : CODE_ELEMENTS.has(name)
158
+ ? neutralizeCode(body)
159
+ : neutralizeMarkup(body);
160
+ i = close;
161
+ }
162
+ }
163
+ return out + "</template>".repeat(depth);
164
+ }
165
+
166
+ /**
167
+ * Index just past the comment whose `<!--` ends at `from`. Per the HTML
168
+ * tokenizer `<!-->` and `<!--->` are complete (empty) comments; otherwise
169
+ * the comment ends at `-->` or `--!>`, or at the end of input.
170
+ */
171
+ function commentEnd(html: string, from: number): number {
172
+ if (html[from] === ">") return from + 1;
173
+ if (html.startsWith("->", from)) return from + 2;
174
+ const m = /--!?>/g;
175
+ m.lastIndex = from;
176
+ const hit = m.exec(html);
177
+ return hit === null ? html.length : hit.index + hit[0].length;
178
+ }
179
+
180
+ /**
181
+ * Index just past the `>` that ends the tag whose name ends at `from`.
182
+ * Quoted attribute values may hold `>`; they are only quotes after `=`.
183
+ */
184
+ function tagClose(html: string, from: number): number {
185
+ let i = from;
186
+ while (i < html.length) {
187
+ const ch = html[i];
188
+ if (ch === ">") return i + 1;
189
+ if (ch === "=") {
190
+ i++;
191
+ while (i < html.length && HTML_WS.test(html[i] ?? "")) i++;
192
+ const quote = html[i];
193
+ if (quote === '"' || quote === "'") {
194
+ const close = html.indexOf(quote, i + 1);
195
+ if (close === -1) return html.length;
196
+ i = close + 1;
197
+ continue;
198
+ }
199
+ continue;
200
+ }
201
+ i++;
202
+ }
203
+ return html.length;
204
+ }
205
+
206
+ /** Index where the raw-text element `name`'s end tag starts, or the end. */
207
+ function rawTextEnd(html: string, from: number, name: string): number {
208
+ const lower = asciiLower(html);
209
+ let at = lower.indexOf(`</${name}`, from);
210
+ while (at !== -1) {
211
+ const next = html[at + 2 + name.length];
212
+ if (next === undefined || TAG_NAME_END.test(next)) return at;
213
+ at = lower.indexOf(`</${name}`, at + 1);
214
+ }
215
+ return html.length;
216
+ }
@@ -41,6 +41,12 @@
41
41
  * v0.11.1 (issue #76): `theme-asset-unbound:<slot>` for the four
42
42
  * `{{theme_<slot>_url}}` placeholders when the active theme's asset
43
43
  * slot isn't bound.
44
+ * Epic #724: `link-target-missing:page/<id>` / `…:media/<id>` for an
45
+ * internal reference (`caelo:page/<id>`, `caelo:media/<id>`) whose
46
+ * target does not exist, `link-resolver-unavailable:<kind>/<id>` for
47
+ * one rendered without a resolver, and `link-ref-malformed:<text>` for
48
+ * text that starts like a reference but breaks the grammar (see
49
+ * `link-refs.ts`).
44
50
  * The chat-runner diag pass + editor missing-content surface read
45
51
  * these literal strings; any rename is a silent regression for them.
46
52
  *
@@ -63,6 +69,7 @@
63
69
 
64
70
  import Mustache from "mustache";
65
71
  import { MODULE_FIELD_SECTION_KINDS, type ModuleFieldKind } from "./content.js";
72
+ import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
66
73
  import { stripCdataGuards } from "./strip-cdata.js";
67
74
 
68
75
  // Override Mustache's default HTML escape — module HTML substitutes
@@ -139,6 +146,16 @@ export interface RenderTemplateInput {
139
146
  readonly favicon: string | null;
140
147
  readonly socialShare: string | null;
141
148
  };
149
+ /**
150
+ * Epic #724 — turns internal references (`caelo:page/<id>`,
151
+ * `caelo:media/<id>`) into URLs for the page being rendered. Applied
152
+ * to the fully substituted output, so a reference resolves wherever it
153
+ * came from: module HTML attributes, a field value, a field default, a
154
+ * link-list item, rich text. A missing target stays raw and pushes
155
+ * `link-target-missing:<kind>/<id>`; with no resolver every reference
156
+ * pushes `link-resolver-unavailable:<kind>/<id>` (loud, never empty).
157
+ */
158
+ readonly linkResolver?: LinkResolver;
142
159
  }
143
160
 
144
161
  export interface RenderTemplateOutput {
@@ -373,6 +390,12 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
373
390
  final = final.split(sentinel).join(original);
374
391
  }
375
392
 
393
+ // 6. Resolve internal references (epic #724). Last, so values,
394
+ // defaults and list items substituted above are covered; nested
395
+ // partials arrive already resolved by their own render, and a
396
+ // resolved URL never matches the reference grammar again.
397
+ final = resolveLinkRefsInHtml(final, input.linkResolver, missing);
398
+
376
399
  return { html: final, missingSlots: missing };
377
400
  }
378
401
 
@@ -42,4 +42,44 @@ describe("renderThemeCss CSS-token sanitization (S6)", () => {
42
42
  const css = renderThemeCss(fontDoc("Inter, sans-serif"));
43
43
  expect(css).toContain("Inter, sans-serif");
44
44
  });
45
+
46
+ it("scrubs </style> from string shadow tokens", () => {
47
+ const doc = {
48
+ shadow: {
49
+ card: { $value: "0 2px 4px #000</style><script>alert(1)</script>", $type: "shadow" },
50
+ },
51
+ } as unknown as ThemeDocument;
52
+ const css = renderThemeCss(doc);
53
+ expect(css.toLowerCase()).not.toContain("</style");
54
+ });
55
+
56
+ it("rejects selectors containing CSS block delimiters or </style>", () => {
57
+ expect(() => renderThemeCss(fontDoc("Inter"), { selector: ":root{color:red}body" })).toThrow(
58
+ /Invalid CSS selector/,
59
+ );
60
+ expect(() => renderThemeCss(fontDoc("Inter"), { selector: "</style><script>" })).toThrow(
61
+ /Invalid CSS selector/,
62
+ );
63
+ });
64
+
65
+ it("escapes token keys outside CSS identifier characters instead of letting them end the rule", () => {
66
+ const doc = {
67
+ color: {
68
+ "primary;--x:1}</style><script>": { $type: "color", $value: "#000" },
69
+ valid: { $type: "color", $value: "#fff" },
70
+ },
71
+ } as unknown as ThemeDocument;
72
+ const css = renderThemeCss(doc);
73
+ expect(css.toLowerCase()).not.toContain("</style");
74
+ expect(css).not.toContain("}</");
75
+ expect(css).toContain("--color-primary\\3b --x\\3a 1\\7d \\3c \\2f style\\3e ");
76
+ expect(css).toContain("--color-valid:#fff;");
77
+ });
78
+
79
+ it("keeps non-ASCII token names as they are (valid CSS identifiers)", () => {
80
+ const doc = {
81
+ color: { grün: { $type: "color", $value: "#0a0" } },
82
+ } as unknown as ThemeDocument;
83
+ expect(renderThemeCss(doc)).toContain("--color-grün:#0a0;");
84
+ });
45
85
  });
@@ -34,9 +34,13 @@ import { type AnyThemeToken, flattenTokens, type ThemeDocument } from "./themes.
34
34
 
35
35
  /** Renderer-level error for cyclic / unresolved aliases. */
36
36
  export class ThemeRenderError extends Error {
37
- readonly kind: "cyclic-alias" | "missing-alias";
37
+ readonly kind: "cyclic-alias" | "missing-alias" | "invalid-selector";
38
38
  readonly path: string;
39
- constructor(kind: "cyclic-alias" | "missing-alias", path: string, message: string) {
39
+ constructor(
40
+ kind: "cyclic-alias" | "missing-alias" | "invalid-selector",
41
+ path: string,
42
+ message: string,
43
+ ) {
40
44
  super(message);
41
45
  this.name = "ThemeRenderError";
42
46
  this.kind = kind;
@@ -74,6 +78,13 @@ export interface RenderThemeCssOptions {
74
78
  */
75
79
  export function renderThemeCss(tokens: ThemeDocument, options: RenderThemeCssOptions = {}): string {
76
80
  const selector = options.selector ?? ":root";
81
+ if (/[{};]|<\/style|<!--|--!?>/i.test(selector)) {
82
+ throw new ThemeRenderError(
83
+ "invalid-selector",
84
+ selector,
85
+ `Invalid CSS selector for theme rendering: '${selector}'`,
86
+ );
87
+ }
77
88
  const flat = flattenTokens(tokens);
78
89
  const resolveCache = new Map<string, unknown>();
79
90
  const lightLines: string[] = [];
@@ -337,6 +348,25 @@ function lookupPath(doc: ThemeDocument, path: string): AnyThemeToken | null {
337
348
  // Per-category CSS emission
338
349
  // ────────────────────────────────────────────────────────────────────
339
350
 
351
+ /**
352
+ * Serialize a token-path segment as part of a CSS custom-property name, the
353
+ * way `CSS.escape` does: ASCII letters, digits, `-`, `_` and every
354
+ * non-ASCII character stay as they are (so `color.grün` is still
355
+ * `--color-grün`), anything else becomes a hex escape. A theme key can
356
+ * therefore never end the declaration, the rule, or the surrounding
357
+ * `<style>` element.
358
+ */
359
+ function escapeCssIdentPart(part: string): string {
360
+ let out = "";
361
+ for (const ch of part) {
362
+ const cp = ch.codePointAt(0) ?? 0;
363
+ if (cp === 0) out += "\\fffd ";
364
+ else if (/[A-Za-z0-9_-]/.test(ch) || cp >= 0x80) out += ch;
365
+ else out += `\\${cp.toString(16)} `;
366
+ }
367
+ return out;
368
+ }
369
+
340
370
  function emitTokenLines(
341
371
  category: string,
342
372
  path: string,
@@ -358,10 +388,12 @@ function emitTokenLines(
358
388
  // every category (color/spacing/radius/etc.) so any future
359
389
  // DTCG-group-with-DEFAULT-alias works the same way.
360
390
  const restRaw = path.slice(category.length + 1);
361
- const rest = restRaw.endsWith(".DEFAULT")
362
- ? restRaw.slice(0, -".DEFAULT".length).replace(/\./g, "-")
363
- : restRaw.replace(/\./g, "-");
364
- const baseName = `--${category === "ease" ? "ease" : category}-${rest}`;
391
+ const rest = escapeCssIdentPart(
392
+ restRaw.endsWith(".DEFAULT")
393
+ ? restRaw.slice(0, -".DEFAULT".length).replace(/\./g, "-")
394
+ : restRaw.replace(/\./g, "-"),
395
+ );
396
+ const baseName = `--${category === "ease" ? "ease" : escapeCssIdentPart(category)}-${rest}`;
365
397
 
366
398
  switch (category) {
367
399
  case "color":
@@ -430,9 +462,9 @@ function emitTypography(rest: string, value: unknown, out: string[]): void {
430
462
 
431
463
  function emitShadow(baseName: string, value: unknown, out: string[]): void {
432
464
  // Literal CSS box-shadow string (the tolerant `shadowValueString` form).
433
- // Emit verbatim — the DTCG object form is expanded below.
465
+ // Scrubbed through sanitizeCssTokenValue like every other token value.
434
466
  if (typeof value === "string") {
435
- const s = value.trim();
467
+ const s = sanitizeCssTokenValue(value.trim());
436
468
  if (s.length > 0) out.push(`${baseName}:${s};`);
437
469
  return;
438
470
  }
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.53";
25
+ export const CAELO_VERSION = "0.10.55";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this