@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.
- package/dist/ai-tools.d.ts +4 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +9 -1
- package/dist/ai-tools.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/nested-module-render.d.ts +170 -0
- package/dist/nested-module-render.d.ts.map +1 -0
- package/dist/nested-module-render.js +219 -0
- package/dist/nested-module-render.js.map +1 -0
- package/dist/preview-compose.d.ts +35 -5
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +148 -57
- package/dist/preview-compose.js.map +1 -1
- package/dist/snapshots.d.ts +1 -0
- package/dist/snapshots.d.ts.map +1 -1
- package/dist/snapshots.js +3 -0
- package/dist/snapshots.js.map +1 -1
- package/dist/template-engine.d.ts +15 -6
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +33 -19
- package/dist/template-engine.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +9 -1
- package/src/index.ts +1 -0
- package/src/nested-module-render.test.ts +887 -0
- package/src/nested-module-render.ts +424 -0
- package/src/preview-compose-nested.test.ts +181 -0
- package/src/preview-compose.ts +208 -62
- package/src/snapshots.ts +3 -0
- package/src/template-engine.test.ts +3 -0
- package/src/template-engine.ts +52 -26
- package/src/version.ts +1 -1
|
@@ -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
|
+
});
|