@caelo-cms/shared 0.10.53 → 0.10.57
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 +17 -3
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +21 -9
- 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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/media.d.ts +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 +27 -6
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +37 -3
- package/dist/platform-updates.js.map +1 -1
- package/dist/preview-compose.d.ts +10 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +58 -20
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +1 -1
- 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/redirect-emit-gcp.d.ts +11 -1
- package/dist/redirect-emit-gcp.d.ts.map +1 -1
- package/dist/redirect-emit-gcp.js +16 -2
- package/dist/redirect-emit-gcp.js.map +1 -1
- package/dist/release-manifest.d.ts +14 -4
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +19 -3
- package/dist/release-manifest.js.map +1 -1
- 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 +74 -4
- package/src/auto-update.ts +31 -9
- package/src/fonts.test.ts +37 -0
- package/src/fonts.ts +38 -1
- package/src/index.ts +2 -0
- package/src/link-refs.test.ts +552 -0
- package/src/link-refs.ts +569 -0
- package/src/nested-module-render.ts +18 -1
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +39 -3
- package/src/preview-compose-deferrals.test.ts +103 -0
- package/src/preview-compose.ts +73 -24
- package/src/raw-text-escape.ts +44 -0
- package/src/redirect-emit-gcp.ts +16 -2
- package/src/redirect-emit.test.ts +6 -4
- package/src/release-manifest.ts +39 -7
- 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
package/src/platform-updates.ts
CHANGED
|
@@ -19,6 +19,12 @@ export const PLATFORM_UPDATE_STATUSES = [
|
|
|
19
19
|
"refused",
|
|
20
20
|
"needs_cloud_shell",
|
|
21
21
|
"cancelled",
|
|
22
|
+
/**
|
|
23
|
+
* Asked the Owner to act (`needs_cloud_shell`), but the install has since
|
|
24
|
+
* moved past it: it runs the target release (or a newer one), or a newer
|
|
25
|
+
* request exists (#817, migration 0317). Shown in the history only.
|
|
26
|
+
*/
|
|
27
|
+
"superseded",
|
|
22
28
|
] as const;
|
|
23
29
|
export type PlatformUpdateStatusValue = (typeof PLATFORM_UPDATE_STATUSES)[number];
|
|
24
30
|
|
|
@@ -32,7 +38,9 @@ export function isFinishedUpdateStatus(status: PlatformUpdateStatusValue): boole
|
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* The phases of an upgrader run, in order (`platform_update_events.phase`).
|
|
35
|
-
* `rollback` only appears when a roll had to be undone
|
|
41
|
+
* `rollback` only appears when a roll had to be undone; `converge` only on
|
|
42
|
+
* the converge route (#819), where the install's cloud setup is brought to
|
|
43
|
+
* what the release expects before and around the roll.
|
|
36
44
|
*/
|
|
37
45
|
export const PLATFORM_UPDATE_PHASES = [
|
|
38
46
|
"queued",
|
|
@@ -40,6 +48,7 @@ export const PLATFORM_UPDATE_PHASES = [
|
|
|
40
48
|
"verify",
|
|
41
49
|
"assess",
|
|
42
50
|
"backup",
|
|
51
|
+
"converge",
|
|
43
52
|
"health-baseline",
|
|
44
53
|
"migrations",
|
|
45
54
|
"roll",
|
|
@@ -51,6 +60,22 @@ export const PLATFORM_UPDATE_PHASES = [
|
|
|
51
60
|
] as const;
|
|
52
61
|
export type PlatformUpdatePhase = (typeof PLATFORM_UPDATE_PHASES)[number];
|
|
53
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Which in-cloud job installs a request (#819, `platform_update_requests.route`):
|
|
65
|
+
*
|
|
66
|
+
* - `app_roll` — the upgrader job (its own narrow service account): new
|
|
67
|
+
* images + expand-only migrations, nothing in the cloud setup changes;
|
|
68
|
+
* - `converge` — the converge job, running as the install's existing
|
|
69
|
+
* `caelo-provisioner` account: the same upgrade an Owner runs from a
|
|
70
|
+
* terminal (backup, converge, roll, health check, rollback), for a
|
|
71
|
+
* release that raises the infrastructure contract.
|
|
72
|
+
*
|
|
73
|
+
* The admin picks the route from the release manifest before the click;
|
|
74
|
+
* either job re-checks everything against the signed manifest.
|
|
75
|
+
*/
|
|
76
|
+
export const PLATFORM_UPDATE_ROUTES = ["app_roll", "converge"] as const;
|
|
77
|
+
export type PlatformUpdateRoute = (typeof PLATFORM_UPDATE_ROUTES)[number];
|
|
78
|
+
|
|
54
79
|
/** Severity of one timeline line. */
|
|
55
80
|
export const PLATFORM_UPDATE_EVENT_LEVELS = ["info", "success", "warn", "error"] as const;
|
|
56
81
|
export type PlatformUpdateEventLevel = (typeof PLATFORM_UPDATE_EVENT_LEVELS)[number];
|
|
@@ -128,8 +153,12 @@ function comparePrerelease(x: string, y: string): number {
|
|
|
128
153
|
return 0;
|
|
129
154
|
}
|
|
130
155
|
|
|
131
|
-
/**
|
|
132
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The automatic-update modes. There is no "off": security releases are
|
|
158
|
+
* always announced (maintainer decision on #862; migration 0301 moved
|
|
159
|
+
* stored `off` rows to `notify`).
|
|
160
|
+
*/
|
|
161
|
+
export const UPDATE_POLICY_MODES = ["notify", "auto"] as const;
|
|
133
162
|
export type UpdatePolicyMode = (typeof UPDATE_POLICY_MODES)[number];
|
|
134
163
|
|
|
135
164
|
/** Release channels a policy may follow (only `stable` installs automatically). */
|
|
@@ -158,6 +187,13 @@ export const updatePolicySchema = z
|
|
|
158
187
|
autoSecurity: z.boolean(),
|
|
159
188
|
autoPatch: z.boolean(),
|
|
160
189
|
autoMinor: z.boolean(),
|
|
190
|
+
/**
|
|
191
|
+
* Releases that change the install's Google Cloud project (an
|
|
192
|
+
* infrastructure-contract bump) may install on their own. Stored and
|
|
193
|
+
* shown, but inert until those releases can run from Caelo (#819): the
|
|
194
|
+
* scheduler treats them as notify-only regardless (auto-update.ts).
|
|
195
|
+
*/
|
|
196
|
+
autoConverge: z.boolean(),
|
|
161
197
|
channel: z.enum(UPDATE_POLICY_CHANNELS),
|
|
162
198
|
/** `HH:MM` local time in `windowTimezone`, or null with windowEnd null = any time. */
|
|
163
199
|
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(""");
|
|
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";
|
|
@@ -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
|
|
231
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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("&", "&").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);
|
|
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}${
|
|
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
|
-
|
|
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
|
+
}
|
package/src/redirect-emit-gcp.ts
CHANGED
|
@@ -161,5 +161,19 @@ export function assertGcpRedirectBudget(
|
|
|
161
161
|
withGcpRedirects({ name: "budget-check", pathRules: baseRules }, plan);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The site's own routing in the `public` path matcher (stack + budget): the
|
|
166
|
+
* paths the load balancer sends to the gateway instead of the static bucket.
|
|
167
|
+
* `/api/*` is the gateway's whole API, the remote-MCP broker included
|
|
168
|
+
* (#806). The two `/.well-known/oauth-*` prefixes are the broker's
|
|
169
|
+
* discovery documents, which MCP clients look up at the host root (RFC 9728
|
|
170
|
+
* protected-resource metadata, RFC 8414 authorization-server metadata) —
|
|
171
|
+
* they must reach the gateway, not 404 in the bucket. No
|
|
172
|
+
* `openid-configuration` prefix: the broker is not an OpenID provider and
|
|
173
|
+
* serves no such document (#899), so the site keeps that path.
|
|
174
|
+
*/
|
|
175
|
+
export const GCP_PUBLIC_BASE_PATHS: readonly string[] = [
|
|
176
|
+
"/api/*",
|
|
177
|
+
"/.well-known/oauth-protected-resource/*",
|
|
178
|
+
"/.well-known/oauth-authorization-server/*",
|
|
179
|
+
];
|
|
@@ -153,20 +153,22 @@ describe("GCP URL-map rules", () => {
|
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
it("refuses a plan over the 1,000-predicate quota with the counts, before any change", () => {
|
|
156
|
+
// The base rule routes /api/* and the remote-MCP discovery paths (#806).
|
|
157
|
+
expect(GCP_PUBLIC_BASE_PATHS).toHaveLength(3);
|
|
156
158
|
const base = [{ paths: [...GCP_PUBLIC_BASE_PATHS] }];
|
|
157
159
|
const rows = (n: number) => Array.from({ length: n }, (_, i) => row(`/p${i}`, "/x"));
|
|
158
|
-
expect(() => assertGcpRedirectBudget(planOf(rows(
|
|
160
|
+
expect(() => assertGcpRedirectBudget(planOf(rows(498)), base)).not.toThrow();
|
|
159
161
|
try {
|
|
160
|
-
assertGcpRedirectBudget(planOf(rows(
|
|
162
|
+
assertGcpRedirectBudget(planOf(rows(499)), base);
|
|
161
163
|
throw new Error("expected GcpUrlMapBudgetError");
|
|
162
164
|
} catch (e) {
|
|
163
165
|
expect(e).toBeInstanceOf(GcpUrlMapBudgetError);
|
|
164
166
|
expect((e as GcpUrlMapBudgetError).predicatesNeeded).toBe(1001);
|
|
165
|
-
expect((e as Error).message).toContain("
|
|
167
|
+
expect((e as Error).message).toContain("499 redirect(s) need 1001 paths");
|
|
166
168
|
expect((e as Error).message).toContain("bulk_delete_redirects");
|
|
167
169
|
}
|
|
168
170
|
// File-like paths cost one predicate each.
|
|
169
|
-
const files = Array.from({ length:
|
|
171
|
+
const files = Array.from({ length: 997 }, (_, i) => row(`/p${i}.html`, "/x"));
|
|
170
172
|
expect(() => assertGcpRedirectBudget(planOf(files), base)).not.toThrow();
|
|
171
173
|
});
|
|
172
174
|
|
package/src/release-manifest.ts
CHANGED
|
@@ -71,12 +71,23 @@ export const SIGSTORE_BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v
|
|
|
71
71
|
/** Why fetching the artifact failed. */
|
|
72
72
|
export type ReleaseArtifactErrorCode = "registry" | "artifact-shape" | "blob-digest";
|
|
73
73
|
|
|
74
|
+
/** Why {@link fetchReleaseArtifact} failed. */
|
|
75
|
+
export interface ReleaseArtifactError {
|
|
76
|
+
readonly code: ReleaseArtifactErrorCode;
|
|
77
|
+
readonly message: string;
|
|
78
|
+
/**
|
|
79
|
+
* Set on `registry` errors a retry may cure: the registry could not be
|
|
80
|
+
* reached, timed out, rate-limited (429) or failed (5xx). Absent when the
|
|
81
|
+
* registry answered that the manifest, tag or blob does not exist (404 and
|
|
82
|
+
* other 4xx) — retrying that changes nothing, so callers that would
|
|
83
|
+
* otherwise "try again later" must treat it as a verdict.
|
|
84
|
+
*/
|
|
85
|
+
readonly transient?: true;
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
export type ReleaseArtifactResult =
|
|
75
89
|
| { readonly ok: true; readonly manifestBytes: Uint8Array; readonly bundleBytes: Uint8Array }
|
|
76
|
-
| {
|
|
77
|
-
readonly ok: false;
|
|
78
|
-
readonly error: { readonly code: ReleaseArtifactErrorCode; readonly message: string };
|
|
79
|
-
};
|
|
90
|
+
| { readonly ok: false; readonly error: ReleaseArtifactError };
|
|
80
91
|
|
|
81
92
|
/** Where and how {@link fetchReleaseArtifact} reaches the registry. */
|
|
82
93
|
export interface ReleaseArtifactDeps {
|
|
@@ -101,6 +112,22 @@ const fail = (code: ReleaseArtifactErrorCode, message: string): ReleaseArtifactR
|
|
|
101
112
|
error: { code, message },
|
|
102
113
|
});
|
|
103
114
|
|
|
115
|
+
/** A `registry` failure a retry may cure (see {@link ReleaseArtifactError.transient}). */
|
|
116
|
+
const failTransient = (message: string): ReleaseArtifactResult => ({
|
|
117
|
+
ok: false,
|
|
118
|
+
error: { code: "registry", message, transient: true },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** Whether an HTTP status says "try again" rather than "it does not exist". */
|
|
122
|
+
function isTransientStatus(status: number): boolean {
|
|
123
|
+
return status >= 500 || status === 429 || status === 408;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A non-OK registry response as a failure, transient or not by its status. */
|
|
127
|
+
function failStatus(status: number, message: string): ReleaseArtifactResult {
|
|
128
|
+
return isTransientStatus(status) ? failTransient(message) : fail("registry", message);
|
|
129
|
+
}
|
|
130
|
+
|
|
104
131
|
/**
|
|
105
132
|
* Fetch the release manifest artifact of `version` and both its layers,
|
|
106
133
|
* each checked against its content digest. Verifies NO signature: callers
|
|
@@ -120,6 +147,11 @@ export async function fetchReleaseArtifact(
|
|
|
120
147
|
headers: { Accept: "application/vnd.oci.image.manifest.v1+json" },
|
|
121
148
|
});
|
|
122
149
|
if (!res.ok) {
|
|
150
|
+
if (isTransientStatus(res.status)) {
|
|
151
|
+
return failTransient(
|
|
152
|
+
`the registry at ${base} could not serve the release manifest of v${version} (HTTP ${res.status}); try again later.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
123
155
|
return fail(
|
|
124
156
|
"registry",
|
|
125
157
|
`no release manifest for v${version} at ${base} (HTTP ${res.status}). Releases before the signed manifest (epic #696) have none; otherwise check the version exists.`,
|
|
@@ -131,7 +163,7 @@ export async function fetchReleaseArtifact(
|
|
|
131
163
|
}
|
|
132
164
|
artifact = parsed.data;
|
|
133
165
|
} catch (e) {
|
|
134
|
-
return
|
|
166
|
+
return failTransient(`registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
if (artifact.artifactType !== RELEASE_MANIFEST_ARTIFACT_TYPE) {
|
|
@@ -153,7 +185,7 @@ export async function fetchReleaseArtifact(
|
|
|
153
185
|
|
|
154
186
|
const blob = async (layerDigest: string): Promise<Uint8Array | ReleaseArtifactResult> => {
|
|
155
187
|
const res = await deps.fetch(`${base}/blobs/${layerDigest}`);
|
|
156
|
-
if (!res.ok) return
|
|
188
|
+
if (!res.ok) return failStatus(res.status, `blob ${layerDigest}: HTTP ${res.status}`);
|
|
157
189
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
158
190
|
const actual = await sha256Digest(bytes);
|
|
159
191
|
if (actual !== layerDigest) {
|
|
@@ -171,7 +203,7 @@ export async function fetchReleaseArtifact(
|
|
|
171
203
|
if (!(bundleBytes instanceof Uint8Array)) return bundleBytes;
|
|
172
204
|
return { ok: true, manifestBytes, bundleBytes };
|
|
173
205
|
} catch (e) {
|
|
174
|
-
return
|
|
206
|
+
return failTransient(`registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
175
207
|
}
|
|
176
208
|
}
|
|
177
209
|
|
|
@@ -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", () => {
|