@caelo-cms/shared 0.10.34 → 0.10.36

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.
@@ -0,0 +1,424 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Recursive module renderer — the ONE renderer for nested modules
5
+ * (`module` / `module-list` fields), shared by the editor preview
6
+ * (`pages.render_preview`, which pre-renders each placement through
7
+ * `renderModuleWithContent`) and the composer (`composePageWithLayout`
8
+ * with a `nestedModules` resolver, which the static generator supplies
9
+ * so deployed pages render nested modules exactly as the preview does).
10
+ * Pure function so it's unit-testable without the compose stack; the
11
+ * callers fetch the data and hand it over through a resolver.
12
+ *
13
+ * Substitution + iteration live in the shared template engine
14
+ * (`./template-engine.ts`). This file keeps the recursion +
15
+ * cycle-detection + depth-limit guards. Per-call shape:
16
+ * 1. `renderInner` validates the (moduleId, contentInstanceId)
17
+ * pair against the resolver and the depth / cycle bookkeeping.
18
+ * 2. `substituteWithRecursion` hands the engine the HTML + view +
19
+ * a partial resolver (`<name>` for single refs, `<name>__<index>`
20
+ * for list elements) that recurses through `renderInner` only
21
+ * for the nested refs the template actually dispatches.
22
+ * 3. The engine performs primitive substitution, section
23
+ * iteration, and loud-raw / failure-marker emission. Its
24
+ * `missingSlots` are merged into the recursion context's
25
+ * `missing` array.
26
+ *
27
+ * Template grammar (extends v0.4.0's `{{fieldName}}` with two nested
28
+ * forms):
29
+ *
30
+ * {{fieldName}} — primitive substitution (text,
31
+ * richtext, url, image, ...).
32
+ * {{>fieldName}} — single nested module slot
33
+ * (field kind = 'module').
34
+ * values[fieldName] is
35
+ * { moduleId, contentInstanceId }.
36
+ * {{#fieldName}}…{{/fieldName}} — section over a list field
37
+ * (kind = text-list / link-list
38
+ * / module-list). text-list /
39
+ * link-list iterate the inner
40
+ * template; module-list ignores
41
+ * the inner and renders each
42
+ * element's nested module HTML
43
+ * via `renderInner` (recursion).
44
+ *
45
+ * Pre-1.0 fail-loud (CLAUDE.md §2):
46
+ * - Depth limit 8 — beyond that, emit an HTML comment naming the
47
+ * limit + add a `missingSlots` entry; do NOT silently truncate.
48
+ * - Cycle detection — track `(moduleId, contentInstanceId)` pairs on
49
+ * the recursion path; on revisit, comment + missingSlots entry.
50
+ * - Missing referenced module / soft-deleted content_instance —
51
+ * comment + missingSlots entry. Same channel; the operator sees the
52
+ * gap in the preview, and the static generator refuses the build.
53
+ *
54
+ * CSS + JS dedup: every unique module touched during recursion
55
+ * contributes its CSS + JS once. The caller collects the seen-set out
56
+ * of band so the page's <head>/<style> + footer scripts are stable.
57
+ */
58
+
59
+ import type { ModuleFieldKind } from "./content.js";
60
+ import { caeloMissingComment, renderTemplate, type TemplateField } from "./template-engine.js";
61
+
62
+ const MAX_RECURSION_DEPTH = 8;
63
+
64
+ /** A module the recursion can render: its code plus its field schema. */
65
+ export interface NestedModuleResource {
66
+ readonly moduleId: string;
67
+ readonly slug: string;
68
+ readonly html: string;
69
+ readonly css: string;
70
+ readonly js: string;
71
+ readonly fields: readonly {
72
+ readonly name: string;
73
+ readonly kind: ModuleFieldKind;
74
+ readonly default?: unknown;
75
+ }[];
76
+ }
77
+
78
+ /** A content instance the recursion can render a nested module with. */
79
+ export interface NestedContentInstanceResource {
80
+ readonly id: string;
81
+ readonly moduleId: string;
82
+ readonly values: Record<string, unknown>;
83
+ readonly deletedAt: string | null;
84
+ }
85
+
86
+ /** The stored shape of a `module` field value / `module-list` element. */
87
+ export interface NestedRefValue {
88
+ readonly moduleId: string;
89
+ readonly contentInstanceId: string;
90
+ }
91
+
92
+ /**
93
+ * Resolver supplied by `pages.render_preview` and the static generator
94
+ * after batch-loading every module + content_instance the page might
95
+ * reference (walks values recursively before render to avoid N+1
96
+ * queries during the recursion itself).
97
+ */
98
+ export interface NestedRenderResolver {
99
+ getModule(moduleId: string): NestedModuleResource | null;
100
+ getContentInstance(contentInstanceId: string): NestedContentInstanceResource | null;
101
+ }
102
+
103
+ /**
104
+ * One failure marker, attributed to the field it happened under so a
105
+ * refused deploy can name it.
106
+ */
107
+ export interface NestedRenderFailure {
108
+ /**
109
+ * Field trail from the rendered module down to where the failure
110
+ * happened, e.g. `plans[1]` or `plans[1] > cta`; empty when the
111
+ * failure is in the rendered module's own template (the marker itself
112
+ * then names the field).
113
+ */
114
+ readonly field: string;
115
+ /** The `missingSlots` marker, verbatim. */
116
+ readonly reason: string;
117
+ }
118
+
119
+ /** Output of the recursive renderer. */
120
+ export interface NestedRenderResult {
121
+ readonly html: string;
122
+ /** Modules whose CSS/JS this render touched (caller dedupes by slug). */
123
+ readonly touchedModuleIds: ReadonlySet<string>;
124
+ /** Slots whose nested ref couldn't resolve (cycle / missing / depth limit). */
125
+ readonly missingSlots: readonly string[];
126
+ /** The same markers as `missingSlots`, each with its field trail. */
127
+ readonly failures: readonly NestedRenderFailure[];
128
+ }
129
+
130
+ /**
131
+ * v0.11.1 (issue #76) — active theme's resolved asset URLs threaded
132
+ * through the recursion so module HTML carrying `{{theme_logo_url}}`
133
+ * etc. resolves inside nested modules the same way it does in placed
134
+ * ones.
135
+ */
136
+ export interface NestedRenderThemeAssets {
137
+ readonly logo: string | null;
138
+ readonly logoDark: string | null;
139
+ readonly favicon: string | null;
140
+ readonly socialShare: string | null;
141
+ }
142
+
143
+ interface RenderContext {
144
+ readonly resolver: NestedRenderResolver;
145
+ readonly touched: Set<string>;
146
+ readonly missing: string[];
147
+ readonly failures: NestedRenderFailure[];
148
+ /** Field trail of the module being rendered (see NestedRenderFailure). */
149
+ readonly via: string;
150
+ readonly path: ReadonlySet<string>;
151
+ readonly depth: number;
152
+ /** v0.11.1 (issue #76) — see NestedRenderThemeAssets. Undefined when no
153
+ * active theme on this install (renderer emits loud-raw for any
154
+ * `{{theme_<slot>_url}}` placeholders). */
155
+ readonly themeAssets: NestedRenderThemeAssets | undefined;
156
+ /** Plugin data lists for the page being rendered, and the names of
157
+ * installed-but-inactive plugins' lists. Threaded to every nested
158
+ * module: a switcher can sit inside a nested chrome module. */
159
+ readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
160
+ readonly dormantDataLists: Readonly<Record<string, string>>;
161
+ }
162
+
163
+ function isNestedRef(v: unknown): v is NestedRefValue {
164
+ return (
165
+ typeof v === "object" &&
166
+ v !== null &&
167
+ typeof (v as { moduleId?: unknown }).moduleId === "string" &&
168
+ typeof (v as { contentInstanceId?: unknown }).contentInstanceId === "string"
169
+ );
170
+ }
171
+
172
+ // Shared with the template engine so the failure-comment shape is
173
+ // declared in exactly one place (`packages/shared/src/template-engine.ts`).
174
+ // The chat-runner diag pass + editor missing-content surface depend on
175
+ // this exact `<!-- caelo:missing reason=… -->` byte sequence.
176
+ const comment = caeloMissingComment;
177
+
178
+ function fail(ctx: RenderContext, reason: string): void {
179
+ ctx.missing.push(reason);
180
+ ctx.failures.push({ field: ctx.via, reason });
181
+ }
182
+
183
+ /**
184
+ * Render a module's HTML against a content_instance's values, recursing
185
+ * into nested-module fields.
186
+ */
187
+ export function renderModuleWithContent(
188
+ moduleId: string,
189
+ contentInstanceId: string,
190
+ resolver: NestedRenderResolver,
191
+ themeAssets?: NestedRenderThemeAssets,
192
+ pluginLists?: {
193
+ readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
194
+ readonly dormantDataLists: Readonly<Record<string, string>>;
195
+ },
196
+ ): NestedRenderResult {
197
+ const touched = new Set<string>();
198
+ const missing: string[] = [];
199
+ const failures: NestedRenderFailure[] = [];
200
+ const html = renderInner(moduleId, contentInstanceId, {
201
+ resolver,
202
+ touched,
203
+ missing,
204
+ failures,
205
+ via: "",
206
+ path: new Set<string>(),
207
+ depth: 0,
208
+ themeAssets,
209
+ dataLists: pluginLists?.dataLists ?? {},
210
+ dormantDataLists: pluginLists?.dormantDataLists ?? {},
211
+ });
212
+ return { html, touchedModuleIds: touched, missingSlots: missing, failures };
213
+ }
214
+
215
+ function renderInner(moduleId: string, contentInstanceId: string, ctx: RenderContext): string {
216
+ if (ctx.depth >= MAX_RECURSION_DEPTH) {
217
+ fail(ctx, `depth-limit:${moduleId}/${contentInstanceId}`);
218
+ return comment(`depth-limit-${MAX_RECURSION_DEPTH}`);
219
+ }
220
+ const cycleKey = `${moduleId}:${contentInstanceId}`;
221
+ if (ctx.path.has(cycleKey)) {
222
+ fail(ctx, `cycle:${cycleKey}`);
223
+ return comment(`cycle ${cycleKey}`);
224
+ }
225
+
226
+ const mod = ctx.resolver.getModule(moduleId);
227
+ if (!mod) {
228
+ fail(ctx, `module-missing:${moduleId}`);
229
+ return comment(`module-missing ${moduleId}`);
230
+ }
231
+ const ci = ctx.resolver.getContentInstance(contentInstanceId);
232
+ if (!ci || ci.deletedAt !== null) {
233
+ fail(ctx, `content-instance-missing:${contentInstanceId}`);
234
+ return comment(`content-instance-missing ${contentInstanceId}`);
235
+ }
236
+ if (ci.moduleId !== moduleId) {
237
+ fail(
238
+ ctx,
239
+ `content-instance-mismatch:${contentInstanceId} (for ${ci.moduleId}, expected ${moduleId})`,
240
+ );
241
+ return comment(`content-instance-mismatch ${contentInstanceId}`);
242
+ }
243
+ ctx.touched.add(moduleId);
244
+
245
+ const path = new Set(ctx.path);
246
+ path.add(cycleKey);
247
+ const childCtx: RenderContext = {
248
+ resolver: ctx.resolver,
249
+ touched: ctx.touched,
250
+ missing: ctx.missing,
251
+ failures: ctx.failures,
252
+ via: ctx.via,
253
+ path,
254
+ depth: ctx.depth + 1,
255
+ themeAssets: ctx.themeAssets,
256
+ dataLists: ctx.dataLists,
257
+ dormantDataLists: ctx.dormantDataLists,
258
+ };
259
+
260
+ return substituteWithRecursion(mod.html, mod.fields, ci.values, childCtx);
261
+ }
262
+
263
+ /**
264
+ * v0.13 (#71) — Thin wrapper around the shared template engine.
265
+ * Hands the engine a partial resolver that recurses through
266
+ * `renderInner` (which keeps the depth-limit + cycle-detection +
267
+ * module-missing / content-instance-missing guards) for exactly the
268
+ * nested refs the template dispatches, then lets the engine do
269
+ * substitution + loud-raw + failure-marker emission.
270
+ *
271
+ * Partial-key contract (matches the engine's expectations):
272
+ * - single `{{>name}}` (module field): `<name>` → rendered HTML (or
273
+ * the loud comment from renderInner if recursion failed).
274
+ * - `{{#name}}…{{/name}}` over module-list: `<name>__<i>` → rendered
275
+ * HTML for element i. Malformed elements (non-NestedRef shape)
276
+ * resolve to `undefined` — the engine emits the existing
277
+ * `module-list-malformed:<name>[<i>]` marker.
278
+ *
279
+ * Failure-marker parity: every literal `missingSlots` string the
280
+ * legacy hand-rolled substitution emitted is preserved — half by the
281
+ * engine (kind-mismatch, *-malformed, module-ref-malformed,
282
+ * field-not-declared), half by `renderInner` (depth-limit, cycle,
283
+ * module-missing, content-instance-missing, content-instance-mismatch).
284
+ * The chat-runner diag pass + editor missing-content surface match
285
+ * these strings literally; renaming any is a silent regression.
286
+ */
287
+ function substituteWithRecursion(
288
+ html: string,
289
+ fields: readonly TemplateField[],
290
+ values: Readonly<Record<string, unknown>>,
291
+ ctx: RenderContext,
292
+ ): string {
293
+ // Resolved lazily: the engine asks only for the refs the template
294
+ // dispatches (a `{{>name}}` it reaches, a `{{#name}}` module-list
295
+ // section). A declared field the HTML never references is not
296
+ // rendered, so a stale ref in it cannot fail the build and its CSS/JS
297
+ // stays off the page. Memoised: `{{>name}}` twice renders it once.
298
+ const fieldByName = new Map(fields.map((f) => [f.name, f]));
299
+ const rendered = new Map<string, string | undefined>();
300
+ const partials = (key: string): string | undefined => {
301
+ if (!rendered.has(key)) rendered.set(key, renderPartial(key, fieldByName, values, ctx));
302
+ return rendered.get(key);
303
+ };
304
+
305
+ const result = renderTemplate({
306
+ html,
307
+ fields,
308
+ contentValues: values,
309
+ partials,
310
+ themeAssets: ctx.themeAssets,
311
+ dataLists: ctx.dataLists,
312
+ dormantDataLists: ctx.dormantDataLists,
313
+ });
314
+ for (const m of result.missingSlots) fail(ctx, m);
315
+ return result.html;
316
+ }
317
+
318
+ /**
319
+ * Render the partial the engine asked for: `<name>` (a `module` field)
320
+ * or `<name>__<i>` (element i of a `module-list`). `undefined` when the
321
+ * value is not a well-formed ref — the engine then emits its own
322
+ * `module-ref-malformed` / `module-list-malformed` marker.
323
+ */
324
+ function renderPartial(
325
+ key: string,
326
+ fieldByName: ReadonlyMap<string, TemplateField>,
327
+ values: Readonly<Record<string, unknown>>,
328
+ ctx: RenderContext,
329
+ ): string | undefined {
330
+ const single = fieldByName.get(key);
331
+ if (single?.kind === "module") {
332
+ const ref = values[key];
333
+ if (!isNestedRef(ref)) return undefined;
334
+ return renderInner(ref.moduleId, ref.contentInstanceId, descend(ctx, key));
335
+ }
336
+ const sep = key.lastIndexOf("__");
337
+ const index = key.slice(sep + 2);
338
+ if (sep <= 0 || !/^\d+$/.test(index)) return undefined;
339
+ const name = key.slice(0, sep);
340
+ const list = fieldByName.get(name);
341
+ if (list?.kind !== "module-list") return undefined;
342
+ const raw = Object.hasOwn(values, name) ? values[name] : list.default;
343
+ if (!Array.isArray(raw)) return undefined;
344
+ const i = Number(index);
345
+ const el = raw[i];
346
+ if (!isNestedRef(el)) return undefined;
347
+ return renderInner(el.moduleId, el.contentInstanceId, descend(ctx, `${name}[${i}]`));
348
+ }
349
+
350
+ /** The context for rendering the nested module at `step` under ctx's module. */
351
+ function descend(ctx: RenderContext, step: string): RenderContext {
352
+ return { ...ctx, via: ctx.via.length > 0 ? `${ctx.via} > ${step}` : step };
353
+ }
354
+
355
+ /**
356
+ * A module as the composer places it: its HTML, fields and the values
357
+ * that fill them (a content instance's values, possibly replaced by a
358
+ * content variant), but not necessarily a row the resolver knows.
359
+ */
360
+ export interface NestedRenderPlacement {
361
+ readonly html: string;
362
+ readonly fields: readonly TemplateField[];
363
+ readonly values: Readonly<Record<string, unknown>>;
364
+ }
365
+
366
+ /**
367
+ * Render a placed module whose nested `module` / `module-list` fields
368
+ * resolve through `resolver` — the composer's entry point. Same
369
+ * recursion, depth limit (8) and failure markers as
370
+ * {@link renderModuleWithContent}; the placed module itself is depth 0.
371
+ *
372
+ * @returns the HTML, the nested modules the render touched (the placed
373
+ * module only when it also appears nested — the caller owns its
374
+ * CSS/JS), and every failure marker of the placed module and its
375
+ * descendants, each with its field trail.
376
+ */
377
+ export function renderPlacedModule(
378
+ placement: NestedRenderPlacement,
379
+ resolver: NestedRenderResolver,
380
+ options: {
381
+ readonly themeAssets?: NestedRenderThemeAssets;
382
+ readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
383
+ readonly dormantDataLists?: Readonly<Record<string, string>>;
384
+ },
385
+ ): NestedRenderResult {
386
+ const touched = new Set<string>();
387
+ const missing: string[] = [];
388
+ const failures: NestedRenderFailure[] = [];
389
+ const html = substituteWithRecursion(placement.html, placement.fields, placement.values, {
390
+ resolver,
391
+ touched,
392
+ missing,
393
+ failures,
394
+ via: "",
395
+ // The placed module's values may be a content variant rather than
396
+ // an instance's own, so it does not seed the cycle path; a ref back
397
+ // to its instance is caught one level down.
398
+ path: new Set<string>(),
399
+ depth: 1,
400
+ themeAssets: options.themeAssets,
401
+ dataLists: options.dataLists ?? {},
402
+ dormantDataLists: options.dormantDataLists ?? {},
403
+ });
404
+ return { html, touchedModuleIds: touched, missingSlots: missing, failures };
405
+ }
406
+
407
+ /**
408
+ * Walk a content_instance's values to find every nested-module reference
409
+ * it carries (single + list shapes). Used by the caller to pre-batch
410
+ * the modules + content_instances the page will need.
411
+ */
412
+ export function collectNestedRefs(values: Record<string, unknown>): NestedRefValue[] {
413
+ const refs: NestedRefValue[] = [];
414
+ for (const v of Object.values(values)) {
415
+ if (isNestedRef(v)) {
416
+ refs.push(v);
417
+ } else if (Array.isArray(v)) {
418
+ for (const el of v) {
419
+ if (isNestedRef(el)) refs.push(el);
420
+ }
421
+ }
422
+ }
423
+ return refs;
424
+ }
@@ -0,0 +1,181 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The composer renders nested modules (`module` / `module-list` fields)
5
+ * through the shared recursive renderer when the caller supplies
6
+ * `nestedModules` — the static generator's path. Before, the composer
7
+ * had no renderer for them and every nested module shipped as a
8
+ * `needs recursive renderer` comment: pricing cards, card grids and FAQ
9
+ * items silently missing from published pages.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import type {
14
+ NestedContentInstanceResource,
15
+ NestedModuleResource,
16
+ NestedRenderResolver,
17
+ } from "./nested-module-render.js";
18
+ import {
19
+ type ComposeDeferral,
20
+ ComposeError,
21
+ type ComposeModule,
22
+ composePageWithLayout,
23
+ } from "./preview-compose.js";
24
+
25
+ const layoutHtml = `<!doctype html><html><head></head><body><main><caelo-slot name="content">_</caelo-slot></main></body></html>`;
26
+ const templateHtml = `<body><caelo-slot name="content">_</caelo-slot></body>`;
27
+
28
+ const CARD_MOD = "00000000-0000-0000-0000-0000000c0001";
29
+ const CARD_A = "00000000-0000-0000-0000-0000000ca001";
30
+ const CARD_B = "00000000-0000-0000-0000-0000000ca002";
31
+ const GONE = "00000000-0000-0000-0000-00000000dead";
32
+
33
+ const card: NestedModuleResource = {
34
+ moduleId: CARD_MOD,
35
+ slug: "card",
36
+ html: "<article>{{title}}</article>",
37
+ css: ".card{color:red}",
38
+ js: "",
39
+ fields: [{ name: "title", kind: "text" }],
40
+ };
41
+
42
+ function resolver(
43
+ modules: NestedModuleResource[],
44
+ instances: NestedContentInstanceResource[],
45
+ ): NestedRenderResolver {
46
+ return {
47
+ getModule: (id) => modules.find((m) => m.moduleId === id) ?? null,
48
+ getContentInstance: (id) => instances.find((c) => c.id === id) ?? null,
49
+ };
50
+ }
51
+
52
+ const instances: NestedContentInstanceResource[] = [
53
+ { id: CARD_A, moduleId: CARD_MOD, values: { title: "Starter" }, deletedAt: null },
54
+ { id: CARD_B, moduleId: CARD_MOD, values: { title: "Pro" }, deletedAt: null },
55
+ ];
56
+
57
+ function grid(cards: unknown[]): ComposeModule {
58
+ return {
59
+ moduleId: "00000000-0000-0000-0000-0000000e0001",
60
+ slug: "card-grid",
61
+ displayName: "Card grid",
62
+ html: '<div class="grid">{{#cards}}{{/cards}}</div>',
63
+ css: ".grid{display:grid}",
64
+ js: "",
65
+ fields: [{ name: "cards", kind: "module-list" }],
66
+ contentValues: { cards },
67
+ };
68
+ }
69
+
70
+ function compose(m: ComposeModule, nested?: NestedRenderResolver, deferred = {}) {
71
+ return composePageWithLayout({
72
+ templateHtml,
73
+ templateCss: "",
74
+ blocks: [{ blockName: "content", modules: [m] }],
75
+ layoutHtml,
76
+ layoutCss: "",
77
+ layoutBlocks: [],
78
+ layoutSlug: "test",
79
+ deferredModules: deferred,
80
+ ...(nested ? { nestedModules: nested } : {}),
81
+ });
82
+ }
83
+
84
+ describe("composePageWithLayout — nested modules", () => {
85
+ const refs = [
86
+ { moduleId: CARD_MOD, contentInstanceId: CARD_A },
87
+ { moduleId: CARD_MOD, contentInstanceId: CARD_B },
88
+ ];
89
+
90
+ it("renders every module-list element with the nested resolver", () => {
91
+ const out = compose(grid(refs), resolver([card], instances));
92
+ expect(out.html).toContain("<article>Starter</article><article>Pro</article>");
93
+ expect(out.html).not.toContain("needs recursive renderer");
94
+ expect(out.moduleFailures).toEqual([]);
95
+ });
96
+
97
+ it("adds the nested module's CSS to the page bundle exactly once", () => {
98
+ const out = compose(grid(refs), resolver([card], instances));
99
+ expect(out.html.split(".card{color:red}").length - 1).toBe(1);
100
+ });
101
+
102
+ it("names the page module and field trail when a nested ref is broken", () => {
103
+ const out = compose(
104
+ grid([refs[0], { moduleId: CARD_MOD, contentInstanceId: GONE }, "not-a-ref"]),
105
+ resolver([card], instances),
106
+ );
107
+ expect(out.html).toContain("<article>Starter</article>");
108
+ expect(out.moduleFailures).toEqual([
109
+ {
110
+ blockName: "content",
111
+ moduleId: "00000000-0000-0000-0000-0000000e0001",
112
+ moduleSlug: "card-grid",
113
+ field: "cards[1]",
114
+ reason: `content-instance-missing:${GONE}`,
115
+ },
116
+ {
117
+ blockName: "content",
118
+ moduleId: "00000000-0000-0000-0000-0000000e0001",
119
+ moduleSlug: "card-grid",
120
+ field: "",
121
+ reason: "module-list-malformed:cards[2]",
122
+ },
123
+ ]);
124
+ });
125
+
126
+ it("without a resolver reports the escape hatch as a failure instead of hiding it", () => {
127
+ const out = compose(grid(refs));
128
+ expect(out.html).toContain("needs recursive renderer");
129
+ expect(out.moduleFailures.map((f) => f.reason)).toEqual([
130
+ "nested-renderer-unavailable:cards[0]",
131
+ "nested-renderer-unavailable:cards[1]",
132
+ ]);
133
+ });
134
+
135
+ it("refuses to render a plugin-withheld module nested inside another module", () => {
136
+ const deferral: ComposeDeferral = {
137
+ pluginSlug: "consent",
138
+ reason: "needs-consent",
139
+ placeholderModuleSlug: "consent-placeholder",
140
+ placeholderHtml: "<p>consent</p>",
141
+ placeholderCss: "",
142
+ };
143
+ expect(() =>
144
+ compose(grid(refs), resolver([card], instances), { [CARD_MOD]: deferral }),
145
+ ).toThrow(ComposeError);
146
+ });
147
+
148
+ it("keeps a withheld parent's nested CSS/JS inside its gate, never in the page bundles", () => {
149
+ const tracker: NestedModuleResource = {
150
+ ...card,
151
+ css: ".card{background:url(https://vendor.example/bg.png)}",
152
+ js: "fetch('https://vendor.example/beacon')",
153
+ };
154
+ const parent = grid(refs);
155
+ const deferral: ComposeDeferral = {
156
+ pluginSlug: "consent",
157
+ reason: "needs-consent",
158
+ placeholderModuleSlug: "consent-placeholder",
159
+ placeholderHtml: "<p>consent</p>",
160
+ placeholderCss: "",
161
+ };
162
+ const out = compose(parent, resolver([tracker], instances), {
163
+ [parent.moduleId]: deferral,
164
+ });
165
+ const gateStart = out.html.indexOf("<template data-caelo-deferred-content>");
166
+ const gateEnd = out.html.indexOf("</template>", gateStart);
167
+ expect(gateStart).toBeGreaterThan(-1);
168
+ const gate = out.html.slice(gateStart, gateEnd);
169
+ const outsideGate = out.html.slice(0, gateStart) + out.html.slice(gateEnd);
170
+
171
+ // The nested JS is parked inert under its own module id, once.
172
+ expect(gate).toContain(
173
+ `<script type="text/plain" data-caelo-deferred-script="${CARD_MOD}">${tracker.js}</script>`,
174
+ );
175
+ expect(gate.split(tracker.js).length - 1).toBe(1);
176
+ expect(gate).toContain(`<style data-source="module">${tracker.css}</style>`);
177
+ // ...and nowhere outside the gate: no page-wide module bundle carries it.
178
+ expect(outsideGate).not.toContain("vendor.example");
179
+ expect(out.html).not.toContain('<script defer data-source="modules">');
180
+ });
181
+ });