@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
package/src/preview-compose.ts
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
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 {
|
|
32
|
+
type NestedModuleResource,
|
|
33
|
+
type NestedRenderFailure,
|
|
34
|
+
type NestedRenderResolver,
|
|
35
|
+
renderPlacedModule,
|
|
36
|
+
} from "./nested-module-render.js";
|
|
31
37
|
import {
|
|
32
38
|
applySlotReplacements,
|
|
33
39
|
extractInnerOfTopLevelContentSlot,
|
|
@@ -60,9 +66,8 @@ export interface ComposeModule {
|
|
|
60
66
|
* visible to visitors as literal `{{name}}` text.
|
|
61
67
|
*
|
|
62
68
|
* Per-placement overrides (content_instances.values) are applied
|
|
63
|
-
* here via the `contentValues` field below.
|
|
64
|
-
*
|
|
65
|
-
* preview-render before reaching here.
|
|
69
|
+
* here via the `contentValues` field below. Nested-module refs
|
|
70
|
+
* render through `ComposeInput.nestedModules`.
|
|
66
71
|
*/
|
|
67
72
|
readonly fields?: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[];
|
|
68
73
|
/**
|
|
@@ -161,12 +166,38 @@ export interface ComposeInput {
|
|
|
161
166
|
* gating plugin active.
|
|
162
167
|
*/
|
|
163
168
|
readonly deferredModules?: Readonly<Record<string, ComposeDeferral>>;
|
|
169
|
+
/**
|
|
170
|
+
* Every module and content instance a `module` / `module-list` field
|
|
171
|
+
* can reference, batch-loaded by the caller (the static generator).
|
|
172
|
+
* With it, nested modules render through the same recursive renderer
|
|
173
|
+
* the editor preview uses, and their CSS/JS joins the page bundles.
|
|
174
|
+
* Without it, a nested ref renders as a loud comment and a
|
|
175
|
+
* `nested-renderer-unavailable` failure.
|
|
176
|
+
*/
|
|
177
|
+
readonly nestedModules?: NestedRenderResolver;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A failure marker raised while rendering one placed module (or a
|
|
182
|
+
* module nested inside it) — the structured twin of the
|
|
183
|
+
* `<!-- caelo:missing … -->` comments in the HTML. Deploy builds refuse
|
|
184
|
+
* pages that carry any; see the static generator.
|
|
185
|
+
*/
|
|
186
|
+
export interface ComposeModuleFailure {
|
|
187
|
+
readonly blockName: string;
|
|
188
|
+
readonly moduleId: string;
|
|
189
|
+
readonly moduleSlug: string;
|
|
190
|
+
/** Field trail inside the module; empty when the marker names the field. */
|
|
191
|
+
readonly field: string;
|
|
192
|
+
readonly reason: string;
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
export interface ComposeOutput {
|
|
167
196
|
readonly html: string;
|
|
168
197
|
readonly replacedSlots: readonly string[];
|
|
169
198
|
readonly missingSlots: readonly string[];
|
|
199
|
+
/** Per-module failure markers, in render order. */
|
|
200
|
+
readonly moduleFailures: readonly ComposeModuleFailure[];
|
|
170
201
|
}
|
|
171
202
|
|
|
172
203
|
const HEAD_CLOSE_RE = /<\/head\s*>/i;
|
|
@@ -235,6 +266,8 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
235
266
|
let navRendered = false;
|
|
236
267
|
// issue #158 — same per-module dedup as composePageWithLayout.
|
|
237
268
|
const seenAssetModules = new Set<string>();
|
|
269
|
+
const moduleFailures: ComposeModuleFailure[] = [];
|
|
270
|
+
const nestedAssetIds: string[] = [];
|
|
238
271
|
// Template CSS first so module CSS can override it via source-order specificity.
|
|
239
272
|
if (input.templateCss.trim().length > 0) allCss.push(input.templateCss);
|
|
240
273
|
|
|
@@ -254,7 +287,10 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
254
287
|
navRendered = true;
|
|
255
288
|
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
256
289
|
} else {
|
|
257
|
-
|
|
290
|
+
const rendered = renderModuleFields(m, input);
|
|
291
|
+
baseHtml = rendered.html;
|
|
292
|
+
recordFailures(moduleFailures, block.blockName, m, rendered.failures);
|
|
293
|
+
nestedAssetIds.push(...rendered.nestedModuleIds);
|
|
258
294
|
}
|
|
259
295
|
return tagModuleId(baseHtml, m.moduleId);
|
|
260
296
|
});
|
|
@@ -266,6 +302,14 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
266
302
|
if (m.css.trim().length > 0) allCss.push(m.css);
|
|
267
303
|
if (m.js.trim().length > 0) allJs.push(m.js);
|
|
268
304
|
}
|
|
305
|
+
for (const id of nestedAssetIds.splice(0)) {
|
|
306
|
+
if (seenAssetModules.has(id)) continue;
|
|
307
|
+
seenAssetModules.add(id);
|
|
308
|
+
const nested = input.nestedModules?.getModule(id);
|
|
309
|
+
if (!nested) continue;
|
|
310
|
+
if (nested.css.trim().length > 0) allCss.push(nested.css);
|
|
311
|
+
if (nested.js.trim().length > 0) allJs.push(nested.js);
|
|
312
|
+
}
|
|
269
313
|
}
|
|
270
314
|
|
|
271
315
|
if (navRendered) {
|
|
@@ -321,6 +365,7 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
321
365
|
html,
|
|
322
366
|
replacedSlots: replaced.replacedSlots,
|
|
323
367
|
missingSlots: replaced.missingSlots,
|
|
368
|
+
moduleFailures,
|
|
324
369
|
};
|
|
325
370
|
}
|
|
326
371
|
|
|
@@ -420,70 +465,104 @@ function renderThemeCss(theme: ComposeTheme | undefined): string | null {
|
|
|
420
465
|
return renderThemeCssFromTokens(theme.tokens);
|
|
421
466
|
}
|
|
422
467
|
|
|
468
|
+
/** What rendering one placed module's fields produced. */
|
|
469
|
+
interface RenderedModuleFields {
|
|
470
|
+
readonly html: string;
|
|
471
|
+
readonly failures: readonly NestedRenderFailure[];
|
|
472
|
+
/** Nested modules rendered inside it, in first-seen order. */
|
|
473
|
+
readonly nestedModuleIds: readonly string[];
|
|
474
|
+
}
|
|
475
|
+
|
|
423
476
|
/**
|
|
424
477
|
* Substitute `{{name}}` placeholders and `{{#name}}…{{/name}}`
|
|
425
|
-
* sections in module HTML. Thin wrapper around the shared
|
|
426
|
-
* engine (#71); see `template-engine.ts` for the full
|
|
427
|
-
* grammar and the loud-raw / failure-marker invariants.
|
|
478
|
+
* sections in a placed module's HTML. Thin wrapper around the shared
|
|
479
|
+
* template engine (#71); see `template-engine.ts` for the full
|
|
480
|
+
* substitution grammar and the loud-raw / failure-marker invariants.
|
|
428
481
|
*
|
|
429
|
-
*
|
|
430
|
-
* `module-
|
|
431
|
-
*
|
|
432
|
-
* emits a loud
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* tracks pre-resolving in static-gen so the loud comment goes away
|
|
438
|
-
* there too.
|
|
482
|
+
* Nested-module field kinds (`module`, `module-list`) render through
|
|
483
|
+
* the recursive renderer (`nested-module-render.ts`, the one the editor
|
|
484
|
+
* preview uses) when the caller supplied `nestedModules`. Without it
|
|
485
|
+
* the engine emits a loud comment plus a `nested-renderer-unavailable`
|
|
486
|
+
* failure (CLAUDE.md §2) — visible-broken, never silent-empty. The
|
|
487
|
+
* editor preview pre-renders its page modules through the same renderer
|
|
488
|
+
* before they reach the composer, so their `html` arrives substituted
|
|
489
|
+
* and with no `fields`.
|
|
439
490
|
*
|
|
440
491
|
* Both `fields` and `contentValues` are optional. Field `kind` is
|
|
441
492
|
* optional for back-compat with callers that haven't been updated;
|
|
442
493
|
* the engine treats absent kinds as primitives (the legacy
|
|
443
494
|
* compose-path behaviour).
|
|
444
495
|
*/
|
|
445
|
-
function
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
theme: ComposeTheme | undefined,
|
|
450
|
-
lists: {
|
|
496
|
+
function renderModuleFields(
|
|
497
|
+
m: ComposeModule,
|
|
498
|
+
input: {
|
|
499
|
+
readonly theme?: ComposeTheme;
|
|
451
500
|
readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
452
501
|
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
502
|
+
readonly nestedModules?: NestedRenderResolver;
|
|
453
503
|
},
|
|
454
|
-
):
|
|
504
|
+
): RenderedModuleFields {
|
|
455
505
|
// The substitution engine (renderTemplate) already unwraps CDATA
|
|
456
506
|
// guards; cover the no-op early-return path so a chrome module with no
|
|
457
507
|
// fields/values/theme is cleaned too.
|
|
458
508
|
const hasLists =
|
|
459
|
-
Object.keys(
|
|
460
|
-
Object.keys(
|
|
461
|
-
if (!fields && !contentValues && !theme && !hasLists)
|
|
462
|
-
|
|
509
|
+
Object.keys(input.dataLists ?? {}).length > 0 ||
|
|
510
|
+
Object.keys(input.dormantDataLists ?? {}).length > 0;
|
|
511
|
+
if (!m.fields && !m.contentValues && !input.theme && !hasLists) {
|
|
512
|
+
return { html: stripCdataGuards(m.html), failures: [], nestedModuleIds: [] };
|
|
513
|
+
}
|
|
514
|
+
const fields: TemplateField[] = (m.fields ?? []).map((f) => ({
|
|
463
515
|
name: f.name,
|
|
464
516
|
kind: f.kind ?? "text",
|
|
465
517
|
default: f.default,
|
|
466
518
|
}));
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
519
|
+
// v0.11.1 (issue #76) — thread the active theme's asset URLs so
|
|
520
|
+
// module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
|
|
521
|
+
// slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
|
|
522
|
+
const themeAssets = input.theme
|
|
523
|
+
? {
|
|
524
|
+
logo: input.theme.assets.logo?.url ?? null,
|
|
525
|
+
logoDark: input.theme.assets.logoDark?.url ?? null,
|
|
526
|
+
favicon: input.theme.assets.favicon?.url ?? null,
|
|
527
|
+
socialShare: input.theme.assets.socialShare?.url ?? null,
|
|
528
|
+
}
|
|
529
|
+
: undefined;
|
|
530
|
+
if (input.nestedModules) {
|
|
531
|
+
const r = renderPlacedModule(
|
|
532
|
+
{
|
|
533
|
+
html: m.html,
|
|
534
|
+
fields,
|
|
535
|
+
values: m.contentValues ?? {},
|
|
536
|
+
},
|
|
537
|
+
input.nestedModules,
|
|
538
|
+
{ themeAssets, dataLists: input.dataLists, dormantDataLists: input.dormantDataLists },
|
|
539
|
+
);
|
|
540
|
+
return { html: r.html, failures: r.failures, nestedModuleIds: [...r.touchedModuleIds] };
|
|
541
|
+
}
|
|
542
|
+
const r = renderTemplate({
|
|
543
|
+
html: m.html,
|
|
544
|
+
fields,
|
|
545
|
+
contentValues: m.contentValues,
|
|
546
|
+
dataLists: input.dataLists,
|
|
547
|
+
dormantDataLists: input.dormantDataLists,
|
|
548
|
+
themeAssets,
|
|
549
|
+
});
|
|
550
|
+
return {
|
|
551
|
+
html: r.html,
|
|
552
|
+
failures: r.missingSlots.map((reason) => ({ field: "", reason })),
|
|
553
|
+
nestedModuleIds: [],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function recordFailures(
|
|
558
|
+
out: ComposeModuleFailure[],
|
|
559
|
+
blockName: string,
|
|
560
|
+
m: ComposeModule,
|
|
561
|
+
failures: readonly NestedRenderFailure[],
|
|
562
|
+
): void {
|
|
563
|
+
for (const f of failures) {
|
|
564
|
+
out.push({ blockName, moduleId: m.moduleId, moduleSlug: m.slug, ...f });
|
|
565
|
+
}
|
|
487
566
|
}
|
|
488
567
|
|
|
489
568
|
export function tagModuleId(html: string, moduleId: string): string {
|
|
@@ -542,20 +621,32 @@ export interface ComposeDeferral {
|
|
|
542
621
|
* `<iframe src>` does. The JS is parked as `type="text/plain"` (inert
|
|
543
622
|
* even once cloned) and the plugin runtime executes it once per module,
|
|
544
623
|
* after the markup it expects is in the DOM.
|
|
624
|
+
*
|
|
625
|
+
* The same holds for the modules nested inside it (its `module` /
|
|
626
|
+
* `module-list` fields): their markup is already part of `moduleHtml`,
|
|
627
|
+
* and their CSS/JS ride in the same `<template>`, each script under its
|
|
628
|
+
* own module id, so the runtime runs every one of them once.
|
|
545
629
|
*/
|
|
546
630
|
function wrapDeferredModule(
|
|
547
631
|
moduleHtml: string,
|
|
548
632
|
module: Pick<ComposeModule, "moduleId" | "slug" | "css" | "js">,
|
|
633
|
+
nestedModules: readonly Pick<NestedModuleResource, "moduleId" | "css" | "js">[],
|
|
549
634
|
deferral: ComposeDeferral,
|
|
550
635
|
): string {
|
|
551
636
|
const attr = (v: string): string =>
|
|
552
637
|
v.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
638
|
+
const gated = [module, ...nestedModules];
|
|
639
|
+
const css = gated
|
|
640
|
+
.filter((g) => g.css.trim().length > 0)
|
|
641
|
+
.map((g) => `<style data-source="module">${g.css}</style>`)
|
|
642
|
+
.join("");
|
|
643
|
+
const js = gated
|
|
644
|
+
.filter((g) => g.js.trim().length > 0)
|
|
645
|
+
.map(
|
|
646
|
+
(g) =>
|
|
647
|
+
`<script type="text/plain" data-caelo-deferred-script="${attr(g.moduleId)}">${g.js}</script>`,
|
|
648
|
+
)
|
|
649
|
+
.join("");
|
|
559
650
|
return [
|
|
560
651
|
`<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}">`,
|
|
561
652
|
`<div data-caelo-deferred-placeholder>${deferral.placeholderHtml}</div>`,
|
|
@@ -582,11 +673,17 @@ export interface ComposeWithLayoutInput extends ComposeInput {
|
|
|
582
673
|
* - `layout-missing-content`: the layout HTML lacks
|
|
583
674
|
* `<caelo-slot name="content">…</caelo-slot>` so the page body has
|
|
584
675
|
* nowhere to land.
|
|
676
|
+
* - `nested-module-deferred`: a plugin withholds a module that sits
|
|
677
|
+
* INSIDE another module's `module` / `module-list` field. Only placed
|
|
678
|
+
* modules can be parked behind the plugin's gate, so rendering it
|
|
679
|
+
* would ship the withheld content ungated.
|
|
585
680
|
*/
|
|
681
|
+
export type ComposeErrorKind = "layout-missing-content" | "nested-module-deferred";
|
|
682
|
+
|
|
586
683
|
export class ComposeError extends Error {
|
|
587
|
-
readonly kind:
|
|
684
|
+
readonly kind: ComposeErrorKind;
|
|
588
685
|
readonly layoutSlug: string | undefined;
|
|
589
|
-
constructor(kind:
|
|
686
|
+
constructor(kind: ComposeErrorKind, message: string, layoutSlug?: string) {
|
|
590
687
|
super(message);
|
|
591
688
|
this.name = "ComposeError";
|
|
592
689
|
this.kind = kind;
|
|
@@ -659,26 +756,60 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
659
756
|
// Placeholder CSS, keyed by placeholder slug: one withheld module's
|
|
660
757
|
// placeholder used on five placements is emitted once.
|
|
661
758
|
const deferredCss = new Map<string, string>();
|
|
759
|
+
const moduleFailures: ComposeModuleFailure[] = [];
|
|
760
|
+
// Nested modules rendered since the last asset flush (see flushNestedAssets).
|
|
761
|
+
const nestedAssetIds: string[] = [];
|
|
662
762
|
if (input.layoutCss.trim().length > 0) cssParts.push(input.layoutCss);
|
|
663
763
|
if (input.templateCss.trim().length > 0) cssParts.push(input.templateCss);
|
|
664
764
|
|
|
665
765
|
// 1. Render the page modules into the template (slot replacement only;
|
|
666
766
|
// no head/body manipulation here — that belongs to the layout).
|
|
667
767
|
const templateContentByName = new Map<string, string>();
|
|
668
|
-
const renderPlaced = (m: ComposeModule): string => {
|
|
768
|
+
const renderPlaced = (blockName: string, m: ComposeModule): string => {
|
|
769
|
+
const deferral = input.deferredModules?.[m.moduleId];
|
|
770
|
+
// Nested modules rendered inside THIS placement when it is withheld.
|
|
771
|
+
const gatedNestedIds = new Set<string>();
|
|
669
772
|
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
670
773
|
let baseHtml: string;
|
|
671
774
|
if (navMenuItems !== null) {
|
|
672
775
|
navRendered = true;
|
|
673
776
|
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
674
777
|
} else {
|
|
675
|
-
|
|
778
|
+
const rendered = renderModuleFields(m, input);
|
|
779
|
+
baseHtml = rendered.html;
|
|
780
|
+
recordFailures(moduleFailures, blockName, m, rendered.failures);
|
|
781
|
+
for (const id of rendered.nestedModuleIds) {
|
|
782
|
+
const nestedDeferral = input.deferredModules?.[id];
|
|
783
|
+
if (nestedDeferral) {
|
|
784
|
+
const nestedSlug = input.nestedModules?.getModule(id)?.slug ?? id;
|
|
785
|
+
throw new ComposeError(
|
|
786
|
+
"nested-module-deferred",
|
|
787
|
+
`module "${nestedSlug}" is withheld by plugin "${nestedDeferral.pluginSlug}" (${nestedDeferral.reason}) but sits inside module "${m.slug}" in block "${blockName}", where it cannot be gated — ` +
|
|
788
|
+
"place it on the page directly instead of inside another module's field",
|
|
789
|
+
input.layoutSlug,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
// Inside a withheld parent the nested module is withheld too:
|
|
793
|
+
// its CSS/JS go into the parent's <template>, never the page-wide
|
|
794
|
+
// bundles, or its `fetch()` / `url(…)` would run before consent.
|
|
795
|
+
if (deferral) {
|
|
796
|
+
if (id !== m.moduleId) gatedNestedIds.add(id);
|
|
797
|
+
} else {
|
|
798
|
+
nestedAssetIds.push(id);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
676
801
|
}
|
|
677
802
|
const tagged = tagModuleId(baseHtml, m.moduleId);
|
|
678
|
-
const deferral = input.deferredModules?.[m.moduleId];
|
|
679
803
|
if (!deferral) return tagged;
|
|
680
804
|
deferredCss.set(deferral.placeholderModuleSlug, deferral.placeholderCss);
|
|
681
|
-
|
|
805
|
+
const gatedNested = [...gatedNestedIds].map((id) => {
|
|
806
|
+
const nested = input.nestedModules?.getModule(id);
|
|
807
|
+
// renderModuleFields resolved every id it reports through this
|
|
808
|
+
// same resolver; an unresolvable one here is a broken resolver.
|
|
809
|
+
if (!nested) throw new Error(`nested module ${id} rendered but not resolvable`);
|
|
810
|
+
return nested;
|
|
811
|
+
});
|
|
812
|
+
return wrapDeferredModule(tagged, m, gatedNested, deferral);
|
|
682
813
|
};
|
|
683
814
|
// A withheld module's CSS/JS travel inside its <template> (see
|
|
684
815
|
// wrapDeferredModule); only modules that render normally feed the
|
|
@@ -690,10 +821,23 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
690
821
|
if (m.css.trim().length > 0) cssParts.push(m.css);
|
|
691
822
|
if (m.js.trim().length > 0) jsParts.push(m.js);
|
|
692
823
|
};
|
|
824
|
+
// Nested modules' CSS/JS join the bundles right after the block whose
|
|
825
|
+
// modules contain them, deduped with the placed modules (#158).
|
|
826
|
+
const flushNestedAssets = (): void => {
|
|
827
|
+
for (const id of nestedAssetIds.splice(0)) {
|
|
828
|
+
if (seenAssetModules.has(id)) continue;
|
|
829
|
+
seenAssetModules.add(id);
|
|
830
|
+
const nested = input.nestedModules?.getModule(id);
|
|
831
|
+
if (!nested) continue;
|
|
832
|
+
if (nested.css.trim().length > 0) cssParts.push(nested.css);
|
|
833
|
+
if (nested.js.trim().length > 0) jsParts.push(nested.js);
|
|
834
|
+
}
|
|
835
|
+
};
|
|
693
836
|
for (const block of input.blocks) {
|
|
694
|
-
const renderedModuleHtml = block.modules.map(renderPlaced);
|
|
837
|
+
const renderedModuleHtml = block.modules.map((m) => renderPlaced(block.blockName, m));
|
|
695
838
|
templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
696
839
|
for (const m of block.modules) collectAssets(m);
|
|
840
|
+
flushNestedAssets();
|
|
697
841
|
}
|
|
698
842
|
const renderedTemplate = applySlotReplacements(input.templateHtml, {
|
|
699
843
|
contentByName: templateContentByName,
|
|
@@ -708,9 +852,10 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
708
852
|
layoutContentByName.set("content", innerBody);
|
|
709
853
|
for (const block of input.layoutBlocks) {
|
|
710
854
|
if (block.blockName === "content") continue; // reserved for the page body
|
|
711
|
-
const renderedModuleHtml = block.modules.map(renderPlaced);
|
|
855
|
+
const renderedModuleHtml = block.modules.map((m) => renderPlaced(block.blockName, m));
|
|
712
856
|
layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
713
857
|
for (const m of block.modules) collectAssets(m);
|
|
858
|
+
flushNestedAssets();
|
|
714
859
|
}
|
|
715
860
|
|
|
716
861
|
for (const css of deferredCss.values()) {
|
|
@@ -779,5 +924,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
779
924
|
html,
|
|
780
925
|
replacedSlots: [...replacedSet],
|
|
781
926
|
missingSlots: [...missingSet],
|
|
927
|
+
moduleFailures,
|
|
782
928
|
};
|
|
783
929
|
}
|
package/src/snapshots.ts
CHANGED
|
@@ -32,6 +32,9 @@ export const snapshotsListInput = z
|
|
|
32
32
|
* Used by `revert_chat_changes` to enumerate everything one chat did
|
|
33
33
|
* without scanning the full timeline. */
|
|
34
34
|
forChatBranchId: z.string().uuid().optional(),
|
|
35
|
+
/** Issue #620 — only snapshots written by this chat (or its
|
|
36
|
+
* subagents). On the shared draft many chats share one branch. */
|
|
37
|
+
forChatSessionId: z.string().uuid().optional(),
|
|
35
38
|
})
|
|
36
39
|
.strict();
|
|
37
40
|
|
|
@@ -215,6 +215,8 @@ describe("renderTemplate — module-list section", () => {
|
|
|
215
215
|
"<!-- caelo:module-list cards needs recursive renderer (compose path) -->",
|
|
216
216
|
);
|
|
217
217
|
expect(r.html).not.toMatch(/\{\{[#/]/);
|
|
218
|
+
// Structured twin of the comment, so a deploy can refuse it.
|
|
219
|
+
expect(r.missingSlots).toEqual(["nested-renderer-unavailable:cards[0]"]);
|
|
218
220
|
});
|
|
219
221
|
|
|
220
222
|
it("per-element malformed ref becomes loud comment + tracks module-list-malformed", () => {
|
|
@@ -253,6 +255,7 @@ describe("renderTemplate — single nested {{>name}}", () => {
|
|
|
253
255
|
contentValues: { hero: { moduleId: "m", contentInstanceId: "c" } },
|
|
254
256
|
});
|
|
255
257
|
expect(r.html).toContain("<!-- caelo:module hero needs recursive renderer (compose path) -->");
|
|
258
|
+
expect(r.missingSlots).toEqual(["nested-renderer-unavailable:hero"]);
|
|
256
259
|
});
|
|
257
260
|
|
|
258
261
|
it("malformed ref value becomes module-ref-malformed comment + tracks marker", () => {
|
package/src/template-engine.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Shared template engine for AI-authored module HTML. Consolidates the
|
|
5
5
|
* no-DB `applyFieldSubstitution` in `preview-compose.ts` and the
|
|
6
|
-
* DB-aware `substituteWithRecursion` in `
|
|
6
|
+
* DB-aware `substituteWithRecursion` in `nested-module-render.ts` into a
|
|
7
7
|
* single engine built on `mustache.js` (Plan B per issue #71).
|
|
8
8
|
*
|
|
9
9
|
* Grammar (a Mustache subset — see CMS_REQUIREMENTS §3.1 / §5):
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
* for `missingSlots`: `field-not-declared:<name>`,
|
|
36
36
|
* `kind-mismatch:<name> expected=<…> actual=<kind>`,
|
|
37
37
|
* `text-list-malformed:<name>[<i>]`, `link-list-malformed:<name>[<i>]`,
|
|
38
|
-
* `module-list-malformed:<name>[<i>]`, `module-ref-malformed:<name
|
|
38
|
+
* `module-list-malformed:<name>[<i>]`, `module-ref-malformed:<name>`,
|
|
39
|
+
* `nested-renderer-unavailable:<name>` / `…:<name>[<i>]` (a nested ref
|
|
40
|
+
* rendered without the recursive renderer's partial).
|
|
39
41
|
* v0.11.1 (issue #76): `theme-asset-unbound:<slot>` for the four
|
|
40
42
|
* `{{theme_<slot>_url}}` placeholders when the active theme's asset
|
|
41
43
|
* slot isn't bound.
|
|
@@ -51,12 +53,12 @@
|
|
|
51
53
|
* effect.
|
|
52
54
|
*
|
|
53
55
|
* Partials are caller-supplied (sync `Record<string, string>`). The
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
56
|
+
* recursive renderer (`nested-module-render.ts` — used by the editor
|
|
57
|
+
* preview and, through the composer's `nestedModules` resolver, by the
|
|
58
|
+
* static generator) pre-resolves each nested ref (depth-limit +
|
|
59
|
+
* cycle-detection live there) and supplies the rendered HTML as a
|
|
60
|
+
* partial. A caller without it gets loud HTML comments plus a
|
|
61
|
+
* `nested-renderer-unavailable` marker instead of silent-empty output.
|
|
60
62
|
*/
|
|
61
63
|
|
|
62
64
|
import Mustache from "mustache";
|
|
@@ -80,19 +82,29 @@ export interface TemplateField {
|
|
|
80
82
|
readonly default?: unknown;
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Renders the nested-module partial for `key` (see
|
|
87
|
+
* {@link RenderTemplateInput.partials}) on demand, or `undefined` when
|
|
88
|
+
* the caller has no renderer for it.
|
|
89
|
+
*/
|
|
90
|
+
export type PartialResolver = (key: string) => string | undefined;
|
|
91
|
+
|
|
83
92
|
export interface RenderTemplateInput {
|
|
84
93
|
readonly html: string;
|
|
85
94
|
readonly fields: readonly TemplateField[];
|
|
86
95
|
/** Per-placement values from `content_instances.values`. */
|
|
87
96
|
readonly contentValues?: Readonly<Record<string, unknown>>;
|
|
88
97
|
/**
|
|
89
|
-
*
|
|
98
|
+
* Nested-module HTML keyed by:
|
|
90
99
|
* - `<name>` for single `{{>name}}` (module field kind)
|
|
91
100
|
* - `<name>__<index>` for each `{{#name}}` element (module-list)
|
|
92
|
-
*
|
|
93
|
-
*
|
|
101
|
+
* Either pre-rendered, or a {@link PartialResolver} the engine calls
|
|
102
|
+
* only for the refs the template actually dispatches — the recursive
|
|
103
|
+
* renderer (nested-module-render.ts) passes a resolver so a declared
|
|
104
|
+
* but unreferenced field is never rendered. Absent / `undefined` for
|
|
105
|
+
* a key → loud HTML comments + `nested-renderer-unavailable` markers.
|
|
94
106
|
*/
|
|
95
|
-
readonly partials?: Readonly<Record<string, string
|
|
107
|
+
readonly partials?: Readonly<Record<string, string>> | PartialResolver;
|
|
96
108
|
/**
|
|
97
109
|
* Plugin-provided lists for THIS page, keyed by the claimed name a
|
|
98
110
|
* module iterates as `{{#name}}`. The plugin supplies the data, the
|
|
@@ -193,8 +205,8 @@ function isNestedRef(v: unknown): v is NestedRef {
|
|
|
193
205
|
* public failure-marker contract — the chat-runner diag pass reads
|
|
194
206
|
* the comment text out of rendered HTML, and the editor's missing-
|
|
195
207
|
* content surface highlights it for the operator. Exported so the
|
|
196
|
-
*
|
|
197
|
-
*
|
|
208
|
+
* recursive nested-module renderer (`nested-module-render.ts`) emits
|
|
209
|
+
* the exact same shape without redeclaring.
|
|
198
210
|
*/
|
|
199
211
|
export function caeloMissingComment(reason: string): string {
|
|
200
212
|
return `<!-- caelo:missing reason=${reason} -->`;
|
|
@@ -202,6 +214,13 @@ export function caeloMissingComment(reason: string): string {
|
|
|
202
214
|
|
|
203
215
|
const comment = caeloMissingComment;
|
|
204
216
|
|
|
217
|
+
/**
|
|
218
|
+
* `missingSlots` marker prefix for a `module` / `module-list` ref the
|
|
219
|
+
* caller gave the engine no partial for — it rendered without the
|
|
220
|
+
* recursive nested-module renderer. Never legitimate in deployed HTML.
|
|
221
|
+
*/
|
|
222
|
+
const NESTED_RENDERER_UNAVAILABLE = "nested-renderer-unavailable";
|
|
223
|
+
|
|
205
224
|
/**
|
|
206
225
|
* Render `html` against `contentValues` + `fields` + `partials`,
|
|
207
226
|
* returning the substituted HTML plus a structured `missingSlots`
|
|
@@ -234,7 +253,11 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
|
|
|
234
253
|
const fieldByName = new Map<string, TemplateField>();
|
|
235
254
|
for (const f of input.fields) fieldByName.set(f.name, f);
|
|
236
255
|
const cvs = input.contentValues ?? {};
|
|
237
|
-
const
|
|
256
|
+
const partialSource = input.partials ?? {};
|
|
257
|
+
const partials: PartialResolver =
|
|
258
|
+
typeof partialSource === "function"
|
|
259
|
+
? partialSource
|
|
260
|
+
: (key) => (Object.hasOwn(partialSource, key) ? partialSource[key] : undefined);
|
|
238
261
|
|
|
239
262
|
// Sentinels survive Mustache.render untouched (they contain no
|
|
240
263
|
// `{{` `}}`), then get restored to the original Mustache source
|
|
@@ -341,7 +364,7 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
|
|
|
341
364
|
});
|
|
342
365
|
|
|
343
366
|
// 4. Render. The view holds only lowercase keys; sentinels survive
|
|
344
|
-
// untouched; sections + partials are already
|
|
367
|
+
// untouched; sections + partials are already substituted.
|
|
345
368
|
const rendered = Mustache.render(html, view);
|
|
346
369
|
|
|
347
370
|
// 5. Restore loud-raw sentinels.
|
|
@@ -359,7 +382,7 @@ function renderSection(
|
|
|
359
382
|
inner: string,
|
|
360
383
|
fields: Map<string, TemplateField>,
|
|
361
384
|
cvs: Readonly<Record<string, unknown>>,
|
|
362
|
-
partials:
|
|
385
|
+
partials: PartialResolver,
|
|
363
386
|
missing: string[],
|
|
364
387
|
mkSentinel: (original: string) => string,
|
|
365
388
|
dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>,
|
|
@@ -530,7 +553,7 @@ function renderModuleList(
|
|
|
530
553
|
name: string,
|
|
531
554
|
field: TemplateField,
|
|
532
555
|
cvs: Readonly<Record<string, unknown>>,
|
|
533
|
-
partials:
|
|
556
|
+
partials: PartialResolver,
|
|
534
557
|
missing: string[],
|
|
535
558
|
): string {
|
|
536
559
|
const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
|
|
@@ -544,13 +567,14 @@ function renderModuleList(
|
|
|
544
567
|
continue;
|
|
545
568
|
}
|
|
546
569
|
const partialKey = `${name}__${i}`;
|
|
547
|
-
const partialHtml = partials
|
|
570
|
+
const partialHtml = partials(partialKey);
|
|
548
571
|
if (partialHtml === undefined) {
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
// static
|
|
572
|
+
// The caller rendered without a nested-module resolver (the
|
|
573
|
+
// recursive renderer in nested-module-render.ts supplies a
|
|
574
|
+
// partial — or a structured failure marker — for every
|
|
575
|
+
// well-formed ref). Loud comment + structured marker, so the
|
|
576
|
+
// editor flags it and the static generator refuses to ship it.
|
|
577
|
+
missing.push(`${NESTED_RENDERER_UNAVAILABLE}:${name}[${i}]`);
|
|
554
578
|
parts.push(`<!-- caelo:module-list ${name} needs recursive renderer (compose path) -->`);
|
|
555
579
|
continue;
|
|
556
580
|
}
|
|
@@ -564,7 +588,7 @@ function renderPartialRef(
|
|
|
564
588
|
name: string,
|
|
565
589
|
fields: Map<string, TemplateField>,
|
|
566
590
|
cvs: Readonly<Record<string, unknown>>,
|
|
567
|
-
partials:
|
|
591
|
+
partials: PartialResolver,
|
|
568
592
|
missing: string[],
|
|
569
593
|
mkSentinel: (original: string) => string,
|
|
570
594
|
): string {
|
|
@@ -583,8 +607,10 @@ function renderPartialRef(
|
|
|
583
607
|
missing.push(`module-ref-malformed:${name}`);
|
|
584
608
|
return comment(`module-ref-malformed ${name}`);
|
|
585
609
|
}
|
|
586
|
-
const partialHtml = partials
|
|
610
|
+
const partialHtml = partials(name);
|
|
587
611
|
if (partialHtml === undefined) {
|
|
612
|
+
// See renderModuleList: rendered without a nested-module resolver.
|
|
613
|
+
missing.push(`${NESTED_RENDERER_UNAVAILABLE}:${name}`);
|
|
588
614
|
return `<!-- caelo:module ${name} needs recursive renderer (compose path) -->`;
|
|
589
615
|
}
|
|
590
616
|
return partialHtml;
|