@caelo-cms/shared 0.10.52 → 0.10.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auto-update.d.ts +5 -2
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +13 -6
- package/dist/auto-update.js.map +1 -1
- package/dist/fonts.d.ts +12 -0
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +37 -1
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/link-refs.d.ts +250 -0
- package/dist/link-refs.d.ts.map +1 -0
- package/dist/link-refs.js +416 -0
- package/dist/link-refs.js.map +1 -0
- package/dist/markup-structure.d.ts +79 -0
- package/dist/markup-structure.d.ts.map +1 -0
- package/dist/markup-structure.js +274 -0
- package/dist/markup-structure.js.map +1 -0
- package/dist/media.d.ts +25 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +35 -0
- package/dist/media.js.map +1 -1
- package/dist/nested-module-render.d.ts +4 -1
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +14 -2
- package/dist/nested-module-render.js.map +1 -1
- package/dist/platform-updates.d.ts +7 -3
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +13 -2
- package/dist/platform-updates.js.map +1 -1
- package/dist/preview-compose.d.ts +14 -3
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +60 -21
- package/dist/preview-compose.js.map +1 -1
- package/dist/publish-live-parity.d.ts +18 -0
- package/dist/publish-live-parity.d.ts.map +1 -0
- package/dist/publish-live-parity.js +21 -0
- package/dist/publish-live-parity.js.map +1 -0
- package/dist/raw-text-escape.d.ts +18 -0
- package/dist/raw-text-escape.d.ts.map +1 -0
- package/dist/raw-text-escape.js +40 -0
- package/dist/raw-text-escape.js.map +1 -0
- package/dist/responsive-images.d.ts.map +1 -1
- package/dist/responsive-images.js +3 -3
- package/dist/responsive-images.js.map +1 -1
- package/dist/seo.js +1 -1
- package/dist/seo.js.map +1 -1
- package/dist/template-balance.d.ts +3 -0
- package/dist/template-balance.d.ts.map +1 -0
- package/dist/template-balance.js +214 -0
- package/dist/template-balance.js.map +1 -0
- package/dist/template-engine.d.ts +11 -0
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +12 -0
- package/dist/template-engine.js.map +1 -1
- package/dist/theme-render.d.ts +2 -2
- package/dist/theme-render.d.ts.map +1 -1
- package/dist/theme-render.js +29 -5
- package/dist/theme-render.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/auto-update.test.ts +32 -1
- package/src/auto-update.ts +13 -6
- package/src/fonts.test.ts +37 -0
- package/src/fonts.ts +38 -1
- package/src/index.ts +4 -0
- package/src/link-refs.test.ts +552 -0
- package/src/link-refs.ts +569 -0
- package/src/markup-structure.test.ts +184 -0
- package/src/markup-structure.ts +304 -0
- package/src/media.ts +43 -0
- package/src/nested-module-render.ts +18 -1
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +13 -2
- package/src/preview-compose-deferrals.test.ts +103 -0
- package/src/preview-compose.ts +79 -28
- package/src/publish-live-parity.ts +25 -0
- package/src/raw-text-escape.ts +44 -0
- package/src/responsive-images.test.ts +10 -0
- package/src/responsive-images.ts +6 -3
- package/src/seo.test.ts +7 -0
- package/src/seo.ts +1 -1
- package/src/template-balance.test.ts +97 -0
- package/src/template-balance.ts +216 -0
- package/src/template-engine.ts +23 -0
- package/src/theme-render-sanitize.test.ts +40 -0
- package/src/theme-render.ts +40 -8
- package/src/version.ts +1 -1
|
@@ -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(""");
|
|
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("</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
|
+
}
|
package/src/preview-compose.ts
CHANGED
|
@@ -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";
|
|
@@ -108,9 +111,10 @@ export interface ComposeThemeAsset {
|
|
|
108
111
|
readonly mediaId: string;
|
|
109
112
|
readonly url: string;
|
|
110
113
|
/**
|
|
111
|
-
*
|
|
112
|
-
* `orig`
|
|
113
|
-
*
|
|
114
|
+
* Content type of the bytes the URL serves: `media_assets.mime` for the
|
|
115
|
+
* `orig` URL, `image/png` for a raster favicon's rendition (see
|
|
116
|
+
* `themeFaviconRendition`). Carried so `<head>` metadata that declares
|
|
117
|
+
* a type (`<link rel="icon" type=…>`) states the real one.
|
|
114
118
|
*/
|
|
115
119
|
readonly mime: string;
|
|
116
120
|
}
|
|
@@ -175,6 +179,15 @@ export interface ComposeInput {
|
|
|
175
179
|
* `nested-renderer-unavailable` failure.
|
|
176
180
|
*/
|
|
177
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;
|
|
178
191
|
}
|
|
179
192
|
|
|
180
193
|
/**
|
|
@@ -223,11 +236,11 @@ export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | nul
|
|
|
223
236
|
const links = fonts.preloads
|
|
224
237
|
.map((href) => {
|
|
225
238
|
const format = /\.(woff2?|ttf|otf)(?:[?#]|$)/.exec(href)?.[1];
|
|
226
|
-
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)}">`;
|
|
227
240
|
})
|
|
228
241
|
.join("");
|
|
229
|
-
const
|
|
230
|
-
|
|
242
|
+
const safeCss = escapeStyleText(fonts.css);
|
|
243
|
+
const style = safeCss.trim().length > 0 ? `<style data-source="fonts">${safeCss}</style>` : "";
|
|
231
244
|
const fragment = links + style;
|
|
232
245
|
return fragment.length > 0 ? fragment : null;
|
|
233
246
|
}
|
|
@@ -239,7 +252,8 @@ export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | nul
|
|
|
239
252
|
* regardless of which layout or chrome modules the page uses, so the
|
|
240
253
|
* composer emits it from the theme binding instead of relying on a
|
|
241
254
|
* module to carry the tag. The href is the media URL as composed
|
|
242
|
-
* (`/_caelo/media/<slug
|
|
255
|
+
* (`/_caelo/media/<slug>/icon-64` for a raster favicon, the `orig` URL
|
|
256
|
+
* for ICO/SVG — see `themeFaviconRendition`); the static generator's media pass rewrites
|
|
243
257
|
* it to the published `/_assets/<slug>.<hash>.<ext>` and copies the bytes, the
|
|
244
258
|
* same as any other media reference. Returns null when no theme is
|
|
245
259
|
* threaded or no favicon is bound (nothing to declare; browsers fall
|
|
@@ -285,7 +299,9 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
285
299
|
let baseHtml: string;
|
|
286
300
|
if (navMenuItems !== null) {
|
|
287
301
|
navRendered = true;
|
|
288
|
-
|
|
302
|
+
const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
|
|
303
|
+
baseHtml = nav.html;
|
|
304
|
+
recordFailures(moduleFailures, block.blockName, m, nav.failures);
|
|
289
305
|
} else {
|
|
290
306
|
const rendered = renderModuleFields(m, input);
|
|
291
307
|
baseHtml = rendered.html;
|
|
@@ -353,7 +369,7 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
353
369
|
);
|
|
354
370
|
|
|
355
371
|
if (allCss.length > 0) {
|
|
356
|
-
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>`;
|
|
357
373
|
html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
|
|
358
374
|
}
|
|
359
375
|
if (allJs.length > 0) {
|
|
@@ -421,8 +437,25 @@ function escapeText(s: string): string {
|
|
|
421
437
|
* Render a nav-menu's typed items into HTML. Recursively handles
|
|
422
438
|
* children for submenus. Plain `<nav><ul><li>` so site CSS can theme
|
|
423
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.
|
|
424
444
|
*/
|
|
425
|
-
function renderNavMenuHtml(
|
|
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
|
+
};
|
|
426
459
|
const safeItems = items.filter((it): it is NavMenuItem => {
|
|
427
460
|
if (!it || typeof it !== "object") return false;
|
|
428
461
|
const o = it as { label?: unknown; href?: unknown };
|
|
@@ -435,15 +468,8 @@ function renderNavMenuHtml(items: readonly unknown[]): string {
|
|
|
435
468
|
'<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
|
|
436
469
|
'<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
|
|
437
470
|
"</button>";
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
function renderNavItem(item: NavMenuItem): string {
|
|
441
|
-
const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
|
|
442
|
-
const inner =
|
|
443
|
-
item.children && item.children.length > 0
|
|
444
|
-
? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
|
|
445
|
-
: "";
|
|
446
|
-
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 })) };
|
|
447
473
|
}
|
|
448
474
|
|
|
449
475
|
/**
|
|
@@ -500,6 +526,7 @@ function renderModuleFields(
|
|
|
500
526
|
readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
501
527
|
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
502
528
|
readonly nestedModules?: NestedRenderResolver;
|
|
529
|
+
readonly linkResolver?: LinkResolver;
|
|
503
530
|
},
|
|
504
531
|
): RenderedModuleFields {
|
|
505
532
|
// The substitution engine (renderTemplate) already unwraps CDATA
|
|
@@ -509,7 +536,15 @@ function renderModuleFields(
|
|
|
509
536
|
Object.keys(input.dataLists ?? {}).length > 0 ||
|
|
510
537
|
Object.keys(input.dormantDataLists ?? {}).length > 0;
|
|
511
538
|
if (!m.fields && !m.contentValues && !input.theme && !hasLists) {
|
|
512
|
-
|
|
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
|
+
};
|
|
513
548
|
}
|
|
514
549
|
const fields: TemplateField[] = (m.fields ?? []).map((f) => ({
|
|
515
550
|
name: f.name,
|
|
@@ -535,7 +570,12 @@ function renderModuleFields(
|
|
|
535
570
|
values: m.contentValues ?? {},
|
|
536
571
|
},
|
|
537
572
|
input.nestedModules,
|
|
538
|
-
{
|
|
573
|
+
{
|
|
574
|
+
themeAssets,
|
|
575
|
+
dataLists: input.dataLists,
|
|
576
|
+
dormantDataLists: input.dormantDataLists,
|
|
577
|
+
linkResolver: input.linkResolver,
|
|
578
|
+
},
|
|
539
579
|
);
|
|
540
580
|
return { html: r.html, failures: r.failures, nestedModuleIds: [...r.touchedModuleIds] };
|
|
541
581
|
}
|
|
@@ -546,6 +586,7 @@ function renderModuleFields(
|
|
|
546
586
|
dataLists: input.dataLists,
|
|
547
587
|
dormantDataLists: input.dormantDataLists,
|
|
548
588
|
themeAssets,
|
|
589
|
+
linkResolver: input.linkResolver,
|
|
549
590
|
});
|
|
550
591
|
return {
|
|
551
592
|
html: r.html,
|
|
@@ -635,22 +676,30 @@ function wrapDeferredModule(
|
|
|
635
676
|
): string {
|
|
636
677
|
const attr = (v: string): string =>
|
|
637
678
|
v.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
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);
|
|
638
686
|
const gated = [module, ...nestedModules];
|
|
639
687
|
const css = gated
|
|
640
688
|
.filter((g) => g.css.trim().length > 0)
|
|
641
|
-
.map((g) => `<style data-source="module">${g.css}</style>`)
|
|
689
|
+
.map((g) => `<style data-source="module">${escapeStyleText(g.css)}</style>`)
|
|
642
690
|
.join("");
|
|
643
691
|
const js = gated
|
|
644
692
|
.filter((g) => g.js.trim().length > 0)
|
|
645
|
-
.map(
|
|
646
|
-
(g)
|
|
647
|
-
`<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>`,
|
|
648
697
|
)
|
|
649
698
|
.join("");
|
|
650
699
|
return [
|
|
651
700
|
`<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}">`,
|
|
652
701
|
`<div data-caelo-deferred-placeholder>${deferral.placeholderHtml}</div>`,
|
|
653
|
-
`<template data-caelo-deferred-content>${css}${
|
|
702
|
+
`<template data-caelo-deferred-content>${css}${safeHtml}${js}</template>`,
|
|
654
703
|
`</div>`,
|
|
655
704
|
].join("");
|
|
656
705
|
}
|
|
@@ -773,7 +822,9 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
773
822
|
let baseHtml: string;
|
|
774
823
|
if (navMenuItems !== null) {
|
|
775
824
|
navRendered = true;
|
|
776
|
-
|
|
825
|
+
const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
|
|
826
|
+
baseHtml = nav.html;
|
|
827
|
+
recordFailures(moduleFailures, blockName, m, nav.failures);
|
|
777
828
|
} else {
|
|
778
829
|
const rendered = renderModuleFields(m, input);
|
|
779
830
|
baseHtml = rendered.html;
|
|
@@ -900,7 +951,7 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
900
951
|
html = injectBefore(
|
|
901
952
|
html,
|
|
902
953
|
HEAD_CLOSE_RE,
|
|
903
|
-
`<style data-source="modules">\n${cssParts.join("\n")}\n</style>`,
|
|
954
|
+
`<style data-source="modules">\n${escapeStyleText(cssParts.join("\n"))}\n</style>`,
|
|
904
955
|
);
|
|
905
956
|
}
|
|
906
957
|
if (jsParts.length > 0) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #834 / #835 — the wording Publish live's parity refusal shares between
|
|
5
|
+
* the server that writes it (admin-core `build-parity.ts`) and the Live
|
|
6
|
+
* edit toolbar that renders it. The toolbar truncates long errors, so it
|
|
7
|
+
* recognises a parity refusal by its prefix and shows the next step
|
|
8
|
+
* ("Stage again") as its own, untruncated hint next to the button.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Every parity refusal starts with this. */
|
|
12
|
+
export const PARITY_REFUSAL_PREFIX = "Publish live stopped:";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The next step every parity refusal ends with. Operators use the
|
|
16
|
+
* toolbar's "Stage again" button next to Publish live; the AI re-stages
|
|
17
|
+
* with its tools.
|
|
18
|
+
*/
|
|
19
|
+
export const RESTAGE_NEXT_STEP =
|
|
20
|
+
'Next step: Stage again (the "Stage again" button next to Publish live, or stage_changes / deploy_staging in chat), check staging, then Publish live.';
|
|
21
|
+
|
|
22
|
+
/** True when `message` is a Publish-live parity refusal. */
|
|
23
|
+
export function isParityRefusal(message: string): boolean {
|
|
24
|
+
return message.startsWith(PARITY_REFUSAL_PREFIX);
|
|
25
|
+
}
|
|
@@ -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, {
|
package/src/responsive-images.ts
CHANGED
|
@@ -137,7 +137,10 @@ export function enrichResponsiveImages(
|
|
|
137
137
|
|
|
138
138
|
let out = tag;
|
|
139
139
|
if (options.rewriteSrc) {
|
|
140
|
-
out = out.replace(
|
|
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></template><script>alert(1)</script></div>",
|
|
15
|
+
);
|
|
16
|
+
expect(balanceTemplateCloses("<template></template></TEMPLATE >")).toBe(
|
|
17
|
+
"<template></template></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></template>',
|
|
28
|
+
);
|
|
29
|
+
expect(balanceTemplateCloses("<a title='>'<template>'>x</a></template>")).toBe(
|
|
30
|
+
"<a title='>'<template>'>x</a></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
|
+
"<!-- </template> --><script>var s='<\\/template>'</script><style>/*<\\/template>*/</style><textarea></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></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
|
+
"<!--> </template><img src=x> -->",
|
|
66
|
+
);
|
|
67
|
+
expect(balanceTemplateCloses("<!---> </template><img src=x> -->")).toBe(
|
|
68
|
+
"<!---> </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></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
|
+
}
|