@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.
@@ -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. The chat-branched
64
- * preview path additionally walks nested-module refs in
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
- baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme, input);
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 template
426
- * engine (#71); see `template-engine.ts` for the full substitution
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
- * Compose is the no-DB path: nested-module field kinds (`module`,
430
- * `module-list`) cannot be resolved here because the partial loader
431
- * needs a DB walk to fetch nested content_instances. The engine
432
- * emits a loud HTML comment for those refs so static-gen output is
433
- * visible-broken (per CLAUDE.md §2) rather than silent-empty. The
434
- * chat-branched preview path resolves nested refs via
435
- * `preview-render.ts` BEFORE the substituted HTML reaches the
436
- * composer, so this branch never trips for that path. Issue #70
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 applyFieldSubstitution(
446
- html: string,
447
- fields: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[] | undefined,
448
- contentValues: Readonly<Record<string, unknown>> | undefined,
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
- ): string {
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(lists.dataLists ?? {}).length > 0 ||
460
- Object.keys(lists.dormantDataLists ?? {}).length > 0;
461
- if (!fields && !contentValues && !theme && !hasLists) return stripCdataGuards(html);
462
- const engineFields: TemplateField[] = (fields ?? []).map((f) => ({
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
- return renderTemplate({
468
- html,
469
- fields: engineFields,
470
- contentValues,
471
- dataLists: lists.dataLists,
472
- dormantDataLists: lists.dormantDataLists,
473
- // v0.11.1 (issue #76) — thread the active theme's asset URLs so
474
- // module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
475
- // slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
476
- themeAssets: theme
477
- ? {
478
- logo: theme.assets.logo?.url ?? null,
479
- logoDark: theme.assets.logoDark?.url ?? null,
480
- favicon: theme.assets.favicon?.url ?? null,
481
- socialShare: theme.assets.socialShare?.url ?? null,
482
- }
483
- : undefined,
484
- // Compose path has no DB; pass no partials so module/module-list
485
- // refs emit the loud HTML comment per CLAUDE.md §2.
486
- }).html;
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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
553
- const css =
554
- module.css.trim().length > 0 ? `<style data-source="module">${module.css}</style>` : "";
555
- const js =
556
- module.js.trim().length > 0
557
- ? `<script type="text/plain" data-caelo-deferred-script="${attr(module.moduleId)}">${module.js}</script>`
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: "layout-missing-content";
684
+ readonly kind: ComposeErrorKind;
588
685
  readonly layoutSlug: string | undefined;
589
- constructor(kind: "layout-missing-content", message: string, layoutSlug?: string) {
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
- baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme, input);
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
- return wrapDeferredModule(tagged, m, deferral);
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", () => {
@@ -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 `preview-render.ts` into a
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
- * compose path passes an empty map (no DB) — module / module-list
55
- * refs become loud HTML comments so static-gen output is visible-
56
- * broken instead of silent-empty. The preview-render path
57
- * pre-resolves each nested ref via its existing RenderResolver walk
58
- * (depth-limit + cycle-detection live there, untouched) and supplies
59
- * the rendered HTML as a partial.
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
- * Pre-rendered nested-module HTML keyed by:
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
- * Compose path: empty map (no DB → loud HTML comments emit).
93
- * Preview-render path: built from RenderResolver walks.
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
- * DB-aware preview-render path (`packages/admin-core/src/ops/content/
197
- * preview-render.ts`) emits the exact same shape without redeclaring.
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 partials = input.partials ?? {};
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 pre-substituted.
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: Readonly<Record<string, string>>,
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: Readonly<Record<string, string>>,
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[partialKey];
570
+ const partialHtml = partials(partialKey);
548
571
  if (partialHtml === undefined) {
549
- // Compose path: no DB → no partials → loud comment so
550
- // operators see the gap. The preview-render path always
551
- // supplies a partial (or routes a structured failure marker
552
- // through it from renderInner), so this branch is the
553
- // static-gen escape hatch until #70 lands.
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: Readonly<Record<string, string>>,
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[name];
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;
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.34";
25
+ export const CAELO_VERSION = "0.10.36";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this