@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
@@ -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(),
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { describe, expect, it } from "bun:test";
17
+ import { Parser } from "htmlparser2";
17
18
  import { type ComposeDeferral, composePageWithLayout } from "./preview-compose.js";
18
19
 
19
20
  const LAYOUT = '<html><head></head><body><caelo-slot name="content">_</caelo-slot></body></html>';
@@ -141,4 +142,106 @@ describe("composer — withheld modules", () => {
141
142
  expect(html).not.toContain('onload="evil()"');
142
143
  expect(html).toContain("&quot;");
143
144
  });
145
+
146
+ it("prevents </template>, </style>, and </script> inside withheld modules from breaking out of the inert template", () => {
147
+ const breakout = {
148
+ ...VIDEO,
149
+ html: "<div></template><script>alert(1)</script></div>",
150
+ css: ".v{content:'</style></template><script>alert(2)</script>'}",
151
+ js: 'const x = "</script></template><script>alert(3)</script>";',
152
+ };
153
+ const { html } = composePageWithLayout({
154
+ templateHtml: TEMPLATE,
155
+ templateCss: "",
156
+ blocks: [{ blockName: "main", modules: [breakout] }],
157
+ layoutHtml: LAYOUT,
158
+ layoutCss: "",
159
+ layoutBlocks: [],
160
+ deferredModules: { [VIDEO.moduleId]: DEFERRAL },
161
+ });
162
+ // None of the module's scripts may end up outside the inert template.
163
+ expect(liveScripts(html).filter((s) => s.includes("alert"))).toEqual([]);
164
+ expect(html).toContain("&lt;/template><script>alert(1)</script></div>");
165
+ });
166
+
167
+ it("parks JS that could end its <script> as base64 carrying the exact bytes", () => {
168
+ // `0</script/.test(v)` is valid JS that any `<\/script` rewrite breaks,
169
+ // and `<!--` + `<script` would keep the browser from ending the element.
170
+ const js = "window.r = [0</script/.test('x'), '<!--<script>'];";
171
+ const { html } = composePageWithLayout({
172
+ templateHtml: TEMPLATE,
173
+ templateCss: "",
174
+ blocks: [{ blockName: "main", modules: [{ ...VIDEO, js }] }],
175
+ layoutHtml: LAYOUT,
176
+ layoutCss: "",
177
+ layoutBlocks: [],
178
+ deferredModules: { [VIDEO.moduleId]: DEFERRAL },
179
+ });
180
+ const m =
181
+ /<script type="text\/plain" data-caelo-deferred-script="[^"]+" data-caelo-encoding="base64">([^<]*)<\/script>/.exec(
182
+ html,
183
+ );
184
+ expect(m).not.toBeNull();
185
+ expect(Buffer.from(m?.[1] ?? "", "base64").toString("utf8")).toBe(js);
186
+ });
187
+
188
+ it("escapes </style in live module CSS so it cannot add markup to <head>", () => {
189
+ const { html } = composePageWithLayout({
190
+ templateHtml: TEMPLATE,
191
+ templateCss: "",
192
+ blocks: [
193
+ { blockName: "main", modules: [{ ...VIDEO, css: ".v{content:'</STYLE><meta x>'}" }] },
194
+ ],
195
+ layoutHtml: LAYOUT,
196
+ layoutCss: "",
197
+ layoutBlocks: [],
198
+ });
199
+ const head = html.slice(0, html.indexOf("</head>"));
200
+ expect(head).toContain(".v{content:'<\\/STYLE><meta x>'}");
201
+ expect(head.toLowerCase()).not.toContain("</style><meta");
202
+ });
203
+
204
+ it("keeps a balanced nested <template> inside a withheld module intact", () => {
205
+ const nested = { ...VIDEO, html: "<ul><template id=row><li>x</li></template></ul>" };
206
+ const { html } = composePageWithLayout({
207
+ templateHtml: TEMPLATE,
208
+ templateCss: "",
209
+ blocks: [{ blockName: "main", modules: [nested] }],
210
+ layoutHtml: LAYOUT,
211
+ layoutCss: "",
212
+ layoutBlocks: [],
213
+ deferredModules: { [VIDEO.moduleId]: DEFERRAL },
214
+ });
215
+ expect(html).toContain("<template id=row><li>x</li></template></ul></template></div>");
216
+ });
144
217
  });
218
+
219
+ /**
220
+ * The text of every `<script>` a browser would run because it sits outside
221
+ * any `<template>` — tokenized like a browser (style / script bodies are
222
+ * raw text, so a `</template` inside them is not a tag).
223
+ */
224
+ function liveScripts(html: string): string[] {
225
+ const scripts: string[] = [];
226
+ let templateDepth = 0;
227
+ let current: string | null = null;
228
+ const parser = new Parser({
229
+ onopentag(name) {
230
+ if (name === "template") templateDepth++;
231
+ if (name === "script" && templateDepth === 0) current = "";
232
+ },
233
+ ontext(text) {
234
+ if (current !== null) current += text;
235
+ },
236
+ onclosetag(name) {
237
+ if (name === "template") templateDepth--;
238
+ if (name === "script" && current !== null) {
239
+ scripts.push(current);
240
+ current = null;
241
+ }
242
+ },
243
+ });
244
+ parser.write(html);
245
+ parser.end();
246
+ return scripts;
247
+ }
@@ -28,6 +28,7 @@
28
28
  import { BASE_TECHNICAL_CSS } from "./base-css.js";
29
29
  import type { ModuleFieldKind } from "./content.js";
30
30
  import { NAV_FUNCTIONAL_CSS, NAV_TOGGLE_JS } from "./interactions.js";
31
+ import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
31
32
  import {
32
33
  type NestedModuleResource,
33
34
  type NestedRenderFailure,
@@ -39,7 +40,9 @@ import {
39
40
  extractInnerOfTopLevelContentSlot,
40
41
  listSlotNames,
41
42
  } from "./preview-scanner.js";
43
+ import { escapeStyleText, scriptNeedsEncoding, utf8ToBase64 } from "./raw-text-escape.js";
42
44
  import { stripCdataGuards } from "./strip-cdata.js";
45
+ import { balanceTemplateCloses } from "./template-balance.js";
43
46
  import { renderTemplate, type TemplateField } from "./template-engine.js";
44
47
  import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
45
48
  import type { ThemeDocument } from "./themes.js";
@@ -176,6 +179,15 @@ export interface ComposeInput {
176
179
  * `nested-renderer-unavailable` failure.
177
180
  */
178
181
  readonly nestedModules?: NestedRenderResolver;
182
+ /**
183
+ * Epic #724 — resolves internal references (`caelo:page/<id>`,
184
+ * `caelo:media/<id>`) for THIS page: in every placed and nested
185
+ * module and in nav-menu items. A broken reference becomes a
186
+ * `link-target-missing:<kind>/<id>` module failure; without a resolver
187
+ * every reference fails with `link-resolver-unavailable` (loud — a
188
+ * deploy refuses it).
189
+ */
190
+ readonly linkResolver?: LinkResolver;
179
191
  }
180
192
 
181
193
  /**
@@ -224,11 +236,11 @@ export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | nul
224
236
  const links = fonts.preloads
225
237
  .map((href) => {
226
238
  const format = /\.(woff2?|ttf|otf)(?:[?#]|$)/.exec(href)?.[1];
227
- return `<link rel="preload" as="font"${format ? ` type="font/${format}"` : ""} crossorigin href="${href}">`;
239
+ return `<link rel="preload" as="font"${format ? ` type="font/${format}"` : ""} crossorigin href="${escapeAttr(href)}">`;
228
240
  })
229
241
  .join("");
230
- const style =
231
- fonts.css.trim().length > 0 ? `<style data-source="fonts">${fonts.css}</style>` : "";
242
+ const safeCss = escapeStyleText(fonts.css);
243
+ const style = safeCss.trim().length > 0 ? `<style data-source="fonts">${safeCss}</style>` : "";
232
244
  const fragment = links + style;
233
245
  return fragment.length > 0 ? fragment : null;
234
246
  }
@@ -287,7 +299,9 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
287
299
  let baseHtml: string;
288
300
  if (navMenuItems !== null) {
289
301
  navRendered = true;
290
- baseHtml = renderNavMenuHtml(navMenuItems);
302
+ const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
303
+ baseHtml = nav.html;
304
+ recordFailures(moduleFailures, block.blockName, m, nav.failures);
291
305
  } else {
292
306
  const rendered = renderModuleFields(m, input);
293
307
  baseHtml = rendered.html;
@@ -355,7 +369,7 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
355
369
  );
356
370
 
357
371
  if (allCss.length > 0) {
358
- const styleTag = `<style data-source="modules">\n${allCss.join("\n")}\n</style>`;
372
+ const styleTag = `<style data-source="modules">\n${escapeStyleText(allCss.join("\n"))}\n</style>`;
359
373
  html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
360
374
  }
361
375
  if (allJs.length > 0) {
@@ -423,8 +437,25 @@ function escapeText(s: string): string {
423
437
  * Render a nav-menu's typed items into HTML. Recursively handles
424
438
  * children for submenus. Plain `<nav><ul><li>` so site CSS can theme
425
439
  * it via the `caelo-nav-menu` class.
440
+ *
441
+ * Epic #724 — item hrefs may be internal references; they resolve
442
+ * through `resolver` like module content, and a broken one is returned
443
+ * as a failure attributed to the nav-menu module.
426
444
  */
427
- function renderNavMenuHtml(items: readonly unknown[]): string {
445
+ function renderNavMenuHtml(
446
+ items: readonly unknown[],
447
+ resolver: LinkResolver | undefined,
448
+ ): { html: string; failures: NestedRenderFailure[] } {
449
+ const missing: string[] = [];
450
+ const renderNavItem = (item: NavMenuItem): string => {
451
+ const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
452
+ const inner =
453
+ item.children && item.children.length > 0
454
+ ? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
455
+ : "";
456
+ const href = resolveLinkRefsInHtml(item.href, resolver, missing);
457
+ return `<li><a href="${escapeAttr(href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
458
+ };
428
459
  const safeItems = items.filter((it): it is NavMenuItem => {
429
460
  if (!it || typeof it !== "object") return false;
430
461
  const o = it as { label?: unknown; href?: unknown };
@@ -437,15 +468,8 @@ function renderNavMenuHtml(items: readonly unknown[]): string {
437
468
  '<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
438
469
  '<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
439
470
  "</button>";
440
- return `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
441
- }
442
- function renderNavItem(item: NavMenuItem): string {
443
- const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
444
- const inner =
445
- item.children && item.children.length > 0
446
- ? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
447
- : "";
448
- return `<li><a href="${escapeAttr(item.href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
471
+ const html = `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
472
+ return { html, failures: missing.map((reason) => ({ field: "", reason })) };
449
473
  }
450
474
 
451
475
  /**
@@ -502,6 +526,7 @@ function renderModuleFields(
502
526
  readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
503
527
  readonly dormantDataLists?: Readonly<Record<string, string>>;
504
528
  readonly nestedModules?: NestedRenderResolver;
529
+ readonly linkResolver?: LinkResolver;
505
530
  },
506
531
  ): RenderedModuleFields {
507
532
  // The substitution engine (renderTemplate) already unwraps CDATA
@@ -511,7 +536,15 @@ function renderModuleFields(
511
536
  Object.keys(input.dataLists ?? {}).length > 0 ||
512
537
  Object.keys(input.dormantDataLists ?? {}).length > 0;
513
538
  if (!m.fields && !m.contentValues && !input.theme && !hasLists) {
514
- return { html: stripCdataGuards(m.html), failures: [], nestedModuleIds: [] };
539
+ // Still resolve references: pre-rendered (editor preview) and
540
+ // field-less chrome modules carry links in their raw HTML too.
541
+ const missing: string[] = [];
542
+ const html = resolveLinkRefsInHtml(stripCdataGuards(m.html), input.linkResolver, missing);
543
+ return {
544
+ html,
545
+ failures: missing.map((reason) => ({ field: "", reason })),
546
+ nestedModuleIds: [],
547
+ };
515
548
  }
516
549
  const fields: TemplateField[] = (m.fields ?? []).map((f) => ({
517
550
  name: f.name,
@@ -537,7 +570,12 @@ function renderModuleFields(
537
570
  values: m.contentValues ?? {},
538
571
  },
539
572
  input.nestedModules,
540
- { themeAssets, dataLists: input.dataLists, dormantDataLists: input.dormantDataLists },
573
+ {
574
+ themeAssets,
575
+ dataLists: input.dataLists,
576
+ dormantDataLists: input.dormantDataLists,
577
+ linkResolver: input.linkResolver,
578
+ },
541
579
  );
542
580
  return { html: r.html, failures: r.failures, nestedModuleIds: [...r.touchedModuleIds] };
543
581
  }
@@ -548,6 +586,7 @@ function renderModuleFields(
548
586
  dataLists: input.dataLists,
549
587
  dormantDataLists: input.dormantDataLists,
550
588
  themeAssets,
589
+ linkResolver: input.linkResolver,
551
590
  });
552
591
  return {
553
592
  html: r.html,
@@ -637,22 +676,30 @@ function wrapDeferredModule(
637
676
  ): string {
638
677
  const attr = (v: string): string =>
639
678
  v.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
679
+ // Inside the inert <template>, only an element's own end tag ends a
680
+ // <style> / <script>; a `</template` there is text. So CSS needs just
681
+ // its own close escaped, JS that could end its <script> ships base64
682
+ // (the runtime decodes it, see consent-manager runtime.ts), and the
683
+ // markup needs its unmatched `</template>` closes neutralized
684
+ // (balanced nested templates stay).
685
+ const safeHtml = balanceTemplateCloses(moduleHtml);
640
686
  const gated = [module, ...nestedModules];
641
687
  const css = gated
642
688
  .filter((g) => g.css.trim().length > 0)
643
- .map((g) => `<style data-source="module">${g.css}</style>`)
689
+ .map((g) => `<style data-source="module">${escapeStyleText(g.css)}</style>`)
644
690
  .join("");
645
691
  const js = gated
646
692
  .filter((g) => g.js.trim().length > 0)
647
- .map(
648
- (g) =>
649
- `<script type="text/plain" data-caelo-deferred-script="${attr(g.moduleId)}">${g.js}</script>`,
693
+ .map((g) =>
694
+ scriptNeedsEncoding(g.js)
695
+ ? `<script type="text/plain" data-caelo-deferred-script="${attr(g.moduleId)}" data-caelo-encoding="base64">${utf8ToBase64(g.js)}</script>`
696
+ : `<script type="text/plain" data-caelo-deferred-script="${attr(g.moduleId)}">${g.js}</script>`,
650
697
  )
651
698
  .join("");
652
699
  return [
653
700
  `<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}">`,
654
701
  `<div data-caelo-deferred-placeholder>${deferral.placeholderHtml}</div>`,
655
- `<template data-caelo-deferred-content>${css}${moduleHtml}${js}</template>`,
702
+ `<template data-caelo-deferred-content>${css}${safeHtml}${js}</template>`,
656
703
  `</div>`,
657
704
  ].join("");
658
705
  }
@@ -775,7 +822,9 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
775
822
  let baseHtml: string;
776
823
  if (navMenuItems !== null) {
777
824
  navRendered = true;
778
- baseHtml = renderNavMenuHtml(navMenuItems);
825
+ const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
826
+ baseHtml = nav.html;
827
+ recordFailures(moduleFailures, blockName, m, nav.failures);
779
828
  } else {
780
829
  const rendered = renderModuleFields(m, input);
781
830
  baseHtml = rendered.html;
@@ -902,7 +951,7 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
902
951
  html = injectBefore(
903
952
  html,
904
953
  HEAD_CLOSE_RE,
905
- `<style data-source="modules">\n${cssParts.join("\n")}\n</style>`,
954
+ `<style data-source="modules">\n${escapeStyleText(cssParts.join("\n"))}\n</style>`,
906
955
  );
907
956
  }
908
957
  if (jsParts.length > 0) {
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Escapes for CSS and JS that the composer places inside `<style>` /
5
+ * `<script>` elements. The HTML tokenizer ends those elements at the first
6
+ * `</style` / `</script` (ASCII case-insensitive), wherever it sits in the
7
+ * CSS or JS, so unescaped text there would end the element and put the
8
+ * rest into the document as markup — into `<head>` for CSS, which the
9
+ * structured-head invariant forbids.
10
+ */
11
+
12
+ const STYLE_CLOSE_RE = /<\/style/gi;
13
+
14
+ /**
15
+ * CSS safe to place between `<style>` and `</style>`. `<\/style` means
16
+ * what `</style` means wherever CSS can hold that sequence (strings,
17
+ * `url()`, comments — `\/` is an escaped `/`), so the CSS is unchanged.
18
+ */
19
+ export function escapeStyleText(css: string): string {
20
+ return css.replace(STYLE_CLOSE_RE, (m) => `<\\${m.slice(1)}`);
21
+ }
22
+
23
+ /**
24
+ * Whether `js` cannot be placed verbatim inside a `<script>` element:
25
+ * a `</script` would end it, and a `<!--` switches the tokenizer into
26
+ * script-data escaping where a nested `<script` keeps the real `</script>`
27
+ * from ending it. No textual rewrite keeps every JavaScript program's
28
+ * meaning (regex literals and `String.raw` observe an inserted
29
+ * backslash), so callers that hit this ship the bytes encoded instead.
30
+ */
31
+ export function scriptNeedsEncoding(js: string): boolean {
32
+ return /<\/script|<!--/i.test(js);
33
+ }
34
+
35
+ /** Standard base64 of `text`'s UTF-8 bytes (browser- and Bun-safe, no Buffer). */
36
+ export function utf8ToBase64(text: string): string {
37
+ const bytes = new TextEncoder().encode(text);
38
+ let bin = "";
39
+ const CHUNK = 0x8000;
40
+ for (let i = 0; i < bytes.length; i += CHUNK) {
41
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
42
+ }
43
+ return btoa(bin);
44
+ }
@@ -58,6 +58,16 @@ describe("enrichResponsiveImages (issue #162)", () => {
58
58
  expect(out.match(/srcset=/g)).toHaveLength(1);
59
59
  });
60
60
 
61
+ it("inserts URLs verbatim even when they spell $-replacement patterns", () => {
62
+ // A string replacement would expand `$&` into the matched text.
63
+ const out = enrichResponsiveImages(IMG, byAsset, {
64
+ rewriteSrc: true,
65
+ urlFor: (id, v) => `/cdn/$&/${id}/${v}`,
66
+ });
67
+ expect(out).toContain(`src="/cdn/$&/${ASSET}/webp-800"`);
68
+ expect(out).toContain(`/cdn/$&/${ASSET}/webp-400 400w`);
69
+ });
70
+
61
71
  it("skips srcset for sub-two-entry ladders and non-media images", () => {
62
72
  const single = new Map([[ASSET, [{ variant: "webp-800", format: "webp" }]]]);
63
73
  const out = enrichResponsiveImages(IMG, single, {
@@ -137,7 +137,10 @@ export function enrichResponsiveImages(
137
137
 
138
138
  let out = tag;
139
139
  if (options.rewriteSrc) {
140
- out = out.replace(srcMatch[0], `src="${options.urlFor(assetId, variant, requestedFormat)}"`);
140
+ out = out.replace(
141
+ srcMatch[0],
142
+ () => `src="${options.urlFor(assetId, variant, requestedFormat)}"`,
143
+ );
141
144
  }
142
145
 
143
146
  // #667 — an `orig` embed (the default `/_caelo/media/<slug>` form,
@@ -155,7 +158,7 @@ export function enrichResponsiveImages(
155
158
  ? origSrcset(variants, assetId, options)
156
159
  : null;
157
160
  if (origLadder) {
158
- out = out.replace(/<img\b/, `<img srcset="${origLadder}"`);
161
+ out = out.replace(/<img\b/, () => `<img srcset="${origLadder}"`);
159
162
  }
160
163
 
161
164
  if (familyVariants.length >= 2 && !/\bsrcset=/i.test(out)) {
@@ -168,7 +171,7 @@ export function enrichResponsiveImages(
168
171
  : "100vw";
169
172
  out = out.replace(
170
173
  /<img\b/,
171
- `<img srcset="${srcset}" sizes="${/\bsizes=/i.test(out) ? "" : sizes}"`,
174
+ () => `<img srcset="${srcset}" sizes="${/\bsizes=/i.test(out) ? "" : sizes}"`,
172
175
  );
173
176
  out = out.replace(/\bsizes=""\s*/g, "");
174
177
  }
package/src/seo.test.ts CHANGED
@@ -192,6 +192,13 @@ describe("injectSeoIntoHead", () => {
192
192
  it("falls back to prepending when the document has no </head>", () => {
193
193
  expect(injectSeoIntoHead("<body>x</body>", "<title>X</title>")).toContain("<title>X</title>");
194
194
  });
195
+
196
+ it("preserves literal $-replacement patterns in headBlock", () => {
197
+ const html = "<html><head></head><body>x</body></html>";
198
+ const headBlock = "<title>Price $& and $' and $`</title>";
199
+ const out = injectSeoIntoHead(html, headBlock);
200
+ expect(out).toContain(headBlock);
201
+ });
195
202
  });
196
203
 
197
204
  describe("schemas", () => {
package/src/seo.ts CHANGED
@@ -344,7 +344,7 @@ export function injectSeoIntoHead(html: string, headBlock: string): string {
344
344
  // the headBlock at the right position.
345
345
  const titleStripped = html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
346
346
  if (titleStripped.includes("</head>")) {
347
- return titleStripped.replace("</head>", `${headBlock}\n</head>`);
347
+ return titleStripped.replace("</head>", () => `${headBlock}\n</head>`);
348
348
  }
349
349
  // No closing head tag — prepend the block to the document.
350
350
  return `${headBlock}\n${titleStripped}`;
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { balanceTemplateCloses } from "./template-balance.js";
5
+
6
+ describe("balanceTemplateCloses", () => {
7
+ it("leaves balanced nested templates untouched", () => {
8
+ const html = "<div><template id=row><li>x</li></template></div>";
9
+ expect(balanceTemplateCloses(html)).toBe(html);
10
+ });
11
+
12
+ it("neutralizes an unmatched </template> close", () => {
13
+ expect(balanceTemplateCloses("<div></template><script>alert(1)</script></div>")).toBe(
14
+ "<div>&lt;/template><script>alert(1)</script></div>",
15
+ );
16
+ expect(balanceTemplateCloses("<template></template></TEMPLATE >")).toBe(
17
+ "<template></template>&lt;/TEMPLATE >",
18
+ );
19
+ });
20
+
21
+ it("closes templates the fragment leaves open", () => {
22
+ expect(balanceTemplateCloses("<template><p>x")).toBe("<template><p>x</template>");
23
+ });
24
+
25
+ it("does not count a <template spelled inside an attribute value", () => {
26
+ expect(balanceTemplateCloses('<a title="<template>">x</a></template>')).toBe(
27
+ '<a title="<template>">x</a>&lt;/template>',
28
+ );
29
+ expect(balanceTemplateCloses("<a title='>'<template>'>x</a></template>")).toBe(
30
+ "<a title='>'<template>'>x</a>&lt;/template>",
31
+ );
32
+ });
33
+
34
+ it("rewrites </template inside comments and raw-text elements too (fail-closed)", () => {
35
+ // Where the walk calls something text, the browser may not agree, so
36
+ // a `</template` there is rewritten to a form that still reads the same.
37
+ const html =
38
+ "<!-- </template> --><script>var s='</template>'</script><style>/*</template>*/</style><textarea></template></textarea>";
39
+ expect(balanceTemplateCloses(html)).toBe(
40
+ "<!-- &lt;/template> --><script>var s='<\\/template>'</script><style>/*<\\/template>*/</style><textarea>&lt;/template></textarea>",
41
+ );
42
+ });
43
+
44
+ it("does not trust <style>/<script>/<title> as raw text inside SVG or MathML", () => {
45
+ // In foreign content these are ordinary elements, so a </template in
46
+ // them is a real end tag in the browser.
47
+ for (const html of [
48
+ "<svg><style></template><img src=x></style></svg>",
49
+ "<svg><script></template><img src=x></script></svg>",
50
+ "<math><style></template><img src=x></style></math>",
51
+ "<svg><title></template><img src=x></title></svg>",
52
+ ]) {
53
+ expect(keptCloses(balanceTemplateCloses(html))).toBe(0);
54
+ }
55
+ });
56
+
57
+ it("never counts a <template> inside SVG, which </svg> pops implicitly", () => {
58
+ const out = balanceTemplateCloses("<svg><template></svg></template><img src=x>");
59
+ expect(keptCloses(out)).toBe(0);
60
+ expect(out).toBe("<svg><template></svg>&lt;/template><img src=x>");
61
+ });
62
+
63
+ it("ends abrupt comments (<!--> and <!--->) where the browser does", () => {
64
+ expect(balanceTemplateCloses("<!--> </template><img src=x> -->")).toBe(
65
+ "<!--> &lt;/template><img src=x> -->",
66
+ );
67
+ expect(balanceTemplateCloses("<!---> </template><img src=x> -->")).toBe(
68
+ "<!---> &lt;/template><img src=x> -->",
69
+ );
70
+ });
71
+
72
+ it("reads tag names with HTML whitespace only (U+00A0 is part of the name)", () => {
73
+ // `<template\u00a0>` is not a template element in the browser, so the
74
+ // close after it must not be kept as its match.
75
+ const out = balanceTemplateCloses("<template\u00a0></template><img src=x>");
76
+ expect(keptCloses(out)).toBe(0);
77
+ });
78
+
79
+ it("keeps offsets right when lowercasing would change the string length", () => {
80
+ // "İ".toLowerCase() is two code units; the walk must not shift.
81
+ const html = "<script>var s='İİ';</script><p>x</p></template>";
82
+ expect(balanceTemplateCloses(html)).toBe("<script>var s='İİ';</script><p>x</p>&lt;/template>");
83
+ });
84
+
85
+ it("defuses <!-- in script text so the browser ends the script where the walk does", () => {
86
+ expect(balanceTemplateCloses("<script>a<!--b</script>")).toBe("<script>a<\\!--b</script>");
87
+ });
88
+
89
+ it("keeps a lone < that starts no tag", () => {
90
+ expect(balanceTemplateCloses("a < b </ c")).toBe("a < b </ c");
91
+ });
92
+ });
93
+
94
+ /** How many `</template` end tags survive in `html`. */
95
+ function keptCloses(html: string): number {
96
+ return (html.match(/<\/template/gi) ?? []).length;
97
+ }