@caelo-cms/shared 0.10.72 → 0.11.0

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.
Files changed (184) hide show
  1. package/dist/ai-tools.d.ts +125 -50
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +48 -36
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +110 -30
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +23 -4
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +201 -100
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +176 -74
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-var-aliases.d.ts +20 -0
  14. package/dist/css-var-aliases.d.ts.map +1 -0
  15. package/dist/css-var-aliases.js +95 -0
  16. package/dist/css-var-aliases.js.map +1 -0
  17. package/dist/css-var-scan.d.ts +0 -16
  18. package/dist/css-var-scan.d.ts.map +1 -1
  19. package/dist/css-var-scan.js +24 -0
  20. package/dist/css-var-scan.js.map +1 -1
  21. package/dist/edit-markers.d.ts +41 -5
  22. package/dist/edit-markers.d.ts.map +1 -1
  23. package/dist/edit-markers.js +47 -50
  24. package/dist/edit-markers.js.map +1 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/interactions.js +2 -2
  30. package/dist/interactions.js.map +1 -1
  31. package/dist/link-canonicalise.d.ts.map +1 -1
  32. package/dist/link-canonicalise.js +7 -1
  33. package/dist/link-canonicalise.js.map +1 -1
  34. package/dist/link-refs.d.ts +1 -1
  35. package/dist/link-refs.js +3 -3
  36. package/dist/lists/address.d.ts +34 -0
  37. package/dist/lists/address.d.ts.map +1 -0
  38. package/dist/lists/address.js +43 -0
  39. package/dist/lists/address.js.map +1 -0
  40. package/dist/lists/default-types.d.ts +10 -0
  41. package/dist/lists/default-types.d.ts.map +1 -0
  42. package/dist/lists/default-types.js +91 -0
  43. package/dist/lists/default-types.js.map +1 -0
  44. package/dist/lists/fields.d.ts +152 -0
  45. package/dist/lists/fields.d.ts.map +1 -0
  46. package/dist/lists/fields.js +258 -0
  47. package/dist/lists/fields.js.map +1 -0
  48. package/dist/lists/index.d.ts +14 -0
  49. package/dist/lists/index.d.ts.map +1 -0
  50. package/dist/lists/index.js +15 -0
  51. package/dist/lists/index.js.map +1 -0
  52. package/dist/lists/item-attr.d.ts +26 -0
  53. package/dist/lists/item-attr.d.ts.map +1 -0
  54. package/dist/lists/item-attr.js +31 -0
  55. package/dist/lists/item-attr.js.map +1 -0
  56. package/dist/lists/items.d.ts +105 -0
  57. package/dist/lists/items.d.ts.map +1 -0
  58. package/dist/lists/items.js +304 -0
  59. package/dist/lists/items.js.map +1 -0
  60. package/dist/lists/list-type.d.ts +401 -0
  61. package/dist/lists/list-type.d.ts.map +1 -0
  62. package/dist/lists/list-type.js +275 -0
  63. package/dist/lists/list-type.js.map +1 -0
  64. package/dist/lists/model.d.ts +14 -0
  65. package/dist/lists/model.d.ts.map +1 -0
  66. package/dist/lists/model.js +15 -0
  67. package/dist/lists/model.js.map +1 -0
  68. package/dist/lists/page-type.d.ts +115 -0
  69. package/dist/lists/page-type.d.ts.map +1 -0
  70. package/dist/lists/page-type.js +66 -0
  71. package/dist/lists/page-type.js.map +1 -0
  72. package/dist/lists/refs.d.ts +13 -0
  73. package/dist/lists/refs.d.ts.map +1 -0
  74. package/dist/lists/refs.js +18 -0
  75. package/dist/lists/refs.js.map +1 -0
  76. package/dist/lists/render-data.d.ts +114 -0
  77. package/dist/lists/render-data.d.ts.map +1 -0
  78. package/dist/lists/render-data.js +3 -0
  79. package/dist/lists/render-data.js.map +1 -0
  80. package/dist/lists/render-deps.d.ts +68 -0
  81. package/dist/lists/render-deps.d.ts.map +1 -0
  82. package/dist/lists/render-deps.js +91 -0
  83. package/dist/lists/render-deps.js.map +1 -0
  84. package/dist/lists/resolve-values.d.ts +63 -0
  85. package/dist/lists/resolve-values.d.ts.map +1 -0
  86. package/dist/lists/resolve-values.js +181 -0
  87. package/dist/lists/resolve-values.js.map +1 -0
  88. package/dist/lists/resolve.d.ts +106 -0
  89. package/dist/lists/resolve.d.ts.map +1 -0
  90. package/dist/lists/resolve.js +878 -0
  91. package/dist/lists/resolve.js.map +1 -0
  92. package/dist/lists/scope.d.ts +81 -0
  93. package/dist/lists/scope.d.ts.map +1 -0
  94. package/dist/lists/scope.js +3 -0
  95. package/dist/lists/scope.js.map +1 -0
  96. package/dist/markup-structure.d.ts +8 -0
  97. package/dist/markup-structure.d.ts.map +1 -1
  98. package/dist/markup-structure.js +2 -0
  99. package/dist/markup-structure.js.map +1 -1
  100. package/dist/media.d.ts +1 -1
  101. package/dist/nested-module-render.d.ts +25 -20
  102. package/dist/nested-module-render.d.ts.map +1 -1
  103. package/dist/nested-module-render.js +122 -31
  104. package/dist/nested-module-render.js.map +1 -1
  105. package/dist/page-log.d.ts +1 -1
  106. package/dist/preview-compose.d.ts +24 -21
  107. package/dist/preview-compose.d.ts.map +1 -1
  108. package/dist/preview-compose.js +34 -118
  109. package/dist/preview-compose.js.map +1 -1
  110. package/dist/source-text-transport.d.ts +17 -0
  111. package/dist/source-text-transport.d.ts.map +1 -0
  112. package/dist/source-text-transport.js +71 -0
  113. package/dist/source-text-transport.js.map +1 -0
  114. package/dist/template-engine.d.ts +44 -31
  115. package/dist/template-engine.d.ts.map +1 -1
  116. package/dist/template-engine.js +75 -174
  117. package/dist/template-engine.js.map +1 -1
  118. package/dist/template-sections.d.ts +69 -0
  119. package/dist/template-sections.d.ts.map +1 -0
  120. package/dist/template-sections.js +423 -0
  121. package/dist/template-sections.js.map +1 -0
  122. package/dist/version.d.ts +2 -2
  123. package/dist/version.d.ts.map +1 -1
  124. package/dist/version.js +1 -1
  125. package/dist/version.js.map +1 -1
  126. package/package.json +8 -1
  127. package/src/__tests__/redos-hardening.test.ts +50 -16
  128. package/src/ai-tools-add-module-modes.test.ts +50 -0
  129. package/src/ai-tools.test.ts +10 -0
  130. package/src/ai-tools.ts +51 -37
  131. package/src/build-page.test.ts +34 -3
  132. package/src/build-page.ts +26 -3
  133. package/src/content.test.ts +111 -3
  134. package/src/content.ts +186 -75
  135. package/src/css-var-aliases.test.ts +61 -0
  136. package/src/css-var-aliases.ts +109 -0
  137. package/src/css-var-scan.ts +9 -0
  138. package/src/edit-markers.test.ts +37 -21
  139. package/src/edit-markers.ts +52 -20
  140. package/src/index.ts +3 -1
  141. package/src/interactions.ts +2 -2
  142. package/src/link-canonicalise.test.ts +36 -6
  143. package/src/link-canonicalise.ts +6 -1
  144. package/src/link-refs.test.ts +66 -42
  145. package/src/link-refs.ts +3 -3
  146. package/src/lists/address.ts +75 -0
  147. package/src/lists/default-types.ts +102 -0
  148. package/src/lists/fields.ts +285 -0
  149. package/src/lists/index.ts +16 -0
  150. package/src/lists/item-attr.test.ts +36 -0
  151. package/src/lists/item-attr.ts +49 -0
  152. package/src/lists/items.ts +402 -0
  153. package/src/lists/list-fixtures.test.ts +302 -0
  154. package/src/lists/list-type.ts +306 -0
  155. package/src/lists/lists.test.ts +328 -0
  156. package/src/lists/model.ts +16 -0
  157. package/src/lists/page-type.ts +71 -0
  158. package/src/lists/refs.ts +21 -0
  159. package/src/lists/render-data.ts +125 -0
  160. package/src/lists/render-deps.test.ts +132 -0
  161. package/src/lists/render-deps.ts +144 -0
  162. package/src/lists/resolve-values.ts +224 -0
  163. package/src/lists/resolve.test.ts +295 -0
  164. package/src/lists/resolve.ts +1148 -0
  165. package/src/lists/scope.ts +69 -0
  166. package/src/lists-compose-parity.test.ts +262 -0
  167. package/src/markup-structure.ts +10 -0
  168. package/src/nested-module-render.test.ts +111 -77
  169. package/src/nested-module-render.ts +85 -48
  170. package/src/preview-compose.test.ts +55 -19
  171. package/src/preview-compose.ts +59 -154
  172. package/src/source-text-transport.test.ts +63 -0
  173. package/src/source-text-transport.ts +70 -0
  174. package/src/template-engine-lists.test.ts +553 -0
  175. package/src/template-engine.test.ts +62 -80
  176. package/src/template-engine.ts +102 -245
  177. package/src/template-sections.ts +500 -0
  178. package/src/version.ts +1 -1
  179. package/dist/structured-sets.d.ts +0 -70
  180. package/dist/structured-sets.d.ts.map +0 -1
  181. package/dist/structured-sets.js +0 -92
  182. package/dist/structured-sets.js.map +0 -1
  183. package/src/structured-sets.ts +0 -114
  184. package/src/template-engine-data-lists.test.ts +0 -90
@@ -6,18 +6,27 @@
6
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
- * Grammar (a Mustache subset — see CMS_REQUIREMENTS §3.1 / §5):
9
+ * Grammar (a Mustache subset — see CMS_REQUIREMENTS §3.1 / §5, and
10
+ * `docs/lists.md` §8 for lists):
10
11
  * {{name}} — primitive substitution
11
- * {{#name}}…inner…{{/name}} — section: ITERATES over a list field
12
- * (text-list / link-list / module-list);
13
- * for a SCALAR (text/image/…) or single
12
+ * {{#name}}…inner…{{/name}} — section: ITERATES over a `list` field
13
+ * (any binding — local, shared, query,
14
+ * system, plugin source) or a
15
+ * `module-list`; for a SCALAR or single
14
16
  * `module` field it is a CONDITIONAL —
15
17
  * the block renders once when the field
16
18
  * has a value, and is dropped when empty
17
19
  * (never a kind-mismatch — that used to
18
20
  * silently drop real scalar content)
21
+ * {{^name}}…inner…{{/name}} — inverted section: renders when the list
22
+ * is empty or the value absent / false
19
23
  * {{>name}} — single nested module reference
20
24
  * (module field kind)
25
+ * {{page.title}}, {{a.b}} — dot access (`page.*`, compound entry
26
+ * values); inside a list entry the
27
+ * entry's keys, `{{>recurse}}`,
28
+ * `{{>f}}` (embedded module) and the
29
+ * system keys — see template-sections.ts
21
30
  *
22
31
  * Substitution priority for {{name}}:
23
32
  * 1. contentValues[name] — per-placement value
@@ -34,13 +43,18 @@
34
43
  * Failure markers preserved verbatim from the legacy preview-render
35
44
  * for `missingSlots`: `field-not-declared:<name>`,
36
45
  * `kind-mismatch:<name> expected=<…> actual=<kind>`,
37
- * `text-list-malformed:<name>[<i>]`, `link-list-malformed:<name>[<i>]`,
38
46
  * `module-list-malformed:<name>[<i>]`, `module-ref-malformed:<name>`,
39
47
  * `nested-renderer-unavailable:<name>` / `…:<name>[<i>]` (a nested ref
40
48
  * rendered without the recursive renderer's partial).
41
49
  * v0.11.1 (issue #76): `theme-asset-unbound:<slot>` for the four
42
50
  * `{{theme_<slot>_url}}` placeholders when the active theme's asset
43
51
  * slot isn't bound.
52
+ * Epic #1107 (lists): `list-unbound:<field>`, `list-missing:<listId>`,
53
+ * `list-type-missing:<slug>`, `list-source-unavailable:<name> plugin=<slug>`,
54
+ * `list-malformed:<field>[<path>]`, `field-not-declared:<field>.<key>`
55
+ * for a key no entry, enclosing entry or module field has, and
56
+ * `list-context-unavailable:<field>` / `…:page` for a list rendered by a
57
+ * caller that gave the engine no list context (never legitimate output).
44
58
  * Epic #724: `link-target-missing:page/<id>` / `…:media/<id>` for an
45
59
  * internal reference (`caelo:page/<id>`, `caelo:media/<id>`) whose
46
60
  * target does not exist, `link-resolver-unavailable:<kind>/<id>` for
@@ -70,7 +84,10 @@
70
84
  import Mustache from "mustache";
71
85
  import { MODULE_FIELD_SECTION_KINDS, type ModuleFieldKind } from "./content.js";
72
86
  import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
87
+ import { type SourceDef, sourceDefSchema } from "./lists/list-type.js";
88
+ import type { ListHolder, ListPageContext } from "./lists/resolve.js";
73
89
  import { stripCdataGuards } from "./strip-cdata.js";
90
+ import { renderSections } from "./template-sections.js";
74
91
 
75
92
  // Override Mustache's default HTML escape — module HTML substitutes
76
93
  // raw. The engine module is the only workspace importer of mustache,
@@ -80,21 +97,62 @@ Mustache.escape = (s: unknown): string => (s === null || s === undefined ? "" :
80
97
 
81
98
  /**
82
99
  * Subset of `modules.fields[]` the engine cares about. The full
83
- * schema lives in `content.ts`; the engine only needs `name`, `kind`,
84
- * and the optional `default`. Importers pass the full row through.
100
+ * schema lives in `content.ts`; the engine needs `name`, `kind`, the
101
+ * optional `default` and, for a `list` field, its binding
102
+ * (`listType`, `bind`, `list`, `source` — `docs/lists.md` §7).
103
+ * Importers pass the full row through.
85
104
  */
86
105
  export interface TemplateField {
87
106
  readonly name: string;
88
107
  readonly kind: ModuleFieldKind;
89
108
  readonly default?: unknown;
109
+ readonly listType?: string;
110
+ readonly bind?: "local" | "shared" | "source";
111
+ readonly list?: string;
112
+ readonly source?: SourceDef;
113
+ }
114
+
115
+ /**
116
+ * Parse a stored `modules.fields` jsonb (array or JSON text) into the
117
+ * fields the engine renders with: name, kind (absent = `text`), default,
118
+ * and a `list` field's binding. A typo'd kind passes through as is — the
119
+ * engine reports it loudly (kind-mismatch) instead of the parser hiding
120
+ * it. Entries without a string name are skipped; they cannot be rendered.
121
+ */
122
+ export function templateFieldsFromJson(raw: unknown): TemplateField[] {
123
+ const parsed = typeof raw === "string" ? (JSON.parse(raw) as unknown) : raw;
124
+ if (!Array.isArray(parsed)) return [];
125
+ const out: TemplateField[] = [];
126
+ for (const f of parsed) {
127
+ if (!f || typeof f !== "object") continue;
128
+ const o = f as Record<string, unknown>;
129
+ if (typeof o.name !== "string") continue;
130
+ out.push({
131
+ name: o.name,
132
+ kind: (typeof o.kind === "string" ? o.kind : "text") as ModuleFieldKind,
133
+ ...(o.default !== undefined ? { default: o.default } : {}),
134
+ ...(typeof o.listType === "string" ? { listType: o.listType } : {}),
135
+ ...(o.bind === "local" || o.bind === "shared" || o.bind === "source" ? { bind: o.bind } : {}),
136
+ ...(typeof o.list === "string" ? { list: o.list } : {}),
137
+ // A source that fails its schema stays absent: the resolver then
138
+ // reports `list-malformed:<field>[source]` instead of guessing.
139
+ ...(sourceDefSchema.safeParse(o.source).success ? { source: o.source as SourceDef } : {}),
140
+ });
141
+ }
142
+ return out;
90
143
  }
91
144
 
92
145
  /**
93
146
  * Renders the nested-module partial for `key` (see
94
147
  * {@link RenderTemplateInput.partials}) on demand, or `undefined` when
95
- * the caller has no renderer for it.
148
+ * the caller has no renderer for it. `ref` is passed for the module
149
+ * field of a list entry (key `list:<field>/<itemId>/<entryField>`),
150
+ * whose value the caller cannot look up by field name.
96
151
  */
97
- export type PartialResolver = (key: string) => string | undefined;
152
+ export type PartialResolver = (
153
+ key: string,
154
+ ref?: { readonly moduleId: string; readonly contentInstanceId: string },
155
+ ) => string | undefined;
98
156
 
99
157
  export interface RenderTemplateInput {
100
158
  readonly html: string;
@@ -105,6 +163,8 @@ export interface RenderTemplateInput {
105
163
  * Nested-module HTML keyed by:
106
164
  * - `<name>` for single `{{>name}}` (module field kind)
107
165
  * - `<name>__<index>` for each `{{#name}}` element (module-list)
166
+ * - `list:<field>/<itemId>/<entryField>` for `{{>entryField}}`
167
+ * inside an entry of list field `<field>` (resolver gets the ref)
108
168
  * Either pre-rendered, or a {@link PartialResolver} the engine calls
109
169
  * only for the refs the template actually dispatches — the recursive
110
170
  * renderer (nested-module-render.ts) passes a resolver so a declared
@@ -113,23 +173,17 @@ export interface RenderTemplateInput {
113
173
  */
114
174
  readonly partials?: Readonly<Record<string, string>> | PartialResolver;
115
175
  /**
116
- * Plugin-provided lists for THIS page, keyed by the claimed name a
117
- * module iterates as `{{#name}}`. The plugin supplies the data, the
118
- * module owns the markup — so a language switcher looks like the site
119
- * it lives on instead of carrying a plugin's fixed HTML.
120
- *
121
- * Resolved per page, which is what lets a module carrying one sit in
122
- * a LAYOUT and cover every page. The `staticRender` placeholder can't:
123
- * it needs the page's own id baked into its HTML.
176
+ * The list view of THIS page (`lists/resolve.ts`): resolves `list`
177
+ * fields, `{{page.*}}` and plugin list sources. Without it a `list`
178
+ * field renders raw with `list-context-unavailable:<field>`.
124
179
  */
125
- readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
180
+ readonly lists?: ListPageContext;
126
181
  /**
127
- * Names an INSTALLED plugin declares that are not live right now,
128
- * mapped to the owning plugin. A module written while the plugin ran
129
- * still says `{{#language_links}}`; this is what lets the render
130
- * report "that plugin is switched off" instead of "unknown field".
182
+ * The content instance whose values are rendered — the address of
183
+ * local list entries for overlays and `data-caelo-list-item`. Absent
184
+ * for field defaults (site chrome).
131
185
  */
132
- readonly dormantDataLists?: Readonly<Record<string, string>>;
186
+ readonly listHolder?: ListHolder;
133
187
  /**
134
188
  * v0.11.1 (issue #76) — active theme's resolved asset URLs. When
135
189
  * present, `{{theme_logo_url}}` / `{{theme_logo_dark_url}}` /
@@ -151,7 +205,7 @@ export interface RenderTemplateInput {
151
205
  * `caelo:media/<id>`) into URLs for the page being rendered. Applied
152
206
  * to the fully substituted output, so a reference resolves wherever it
153
207
  * came from: module HTML attributes, a field value, a field default, a
154
- * link-list item, rich text. A missing target stays raw and pushes
208
+ * list entry, rich text. A missing target stays raw and pushes
155
209
  * `link-target-missing:<kind>/<id>`; with no resolver every reference
156
210
  * pushes `link-resolver-unavailable:<kind>/<id>` (loud, never empty).
157
211
  */
@@ -176,15 +230,6 @@ interface NestedRef {
176
230
  // between `{{` and the sigil (#, >, /) is permitted to match the
177
231
  // Mustache spec — an extractor that pretty-prints AI-authored HTML
178
232
  // shouldn't silently break section / partial dispatch.
179
- // The section body uses a "tempered dot" — `(?:(?!CLOSE)[\s\S])*` — instead
180
- // of a lazy `[\s\S]*?`. The lazy form makes the engine try, then backtrack,
181
- // the close-tag match at every interior position, which is O(n²) on
182
- // unclosed/large input (CodeQL js/polynomial-redos). The tempered form
183
- // consumes one character only when the close tag does not start there, so
184
- // there is a single unambiguous path. The capture stops at the first close
185
- // tag for the captured name, exactly as the lazy form did — same output.
186
- const SECTION_RE =
187
- /\{\{\s*#\s*([a-z][a-z0-9_]*)\s*\}\}((?:(?!\{\{\s*\/\s*\1\s*\}\})[\s\S])*)\{\{\s*\/\s*\1\s*\}\}/g;
188
233
  const PARTIAL_RE = /\{\{\s*>\s*([a-z][a-z0-9_]*)\s*\}\}/g;
189
234
  const PRIMITIVE_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
190
235
  // A plain tag (no Mustache sigil) of any name, and the identifier test
@@ -250,26 +295,22 @@ const NESTED_RENDERER_UNAVAILABLE = "nested-renderer-unavailable";
250
295
  * channel. Pure / sync / no IO.
251
296
  *
252
297
  * @example
253
- * // AC #1 fixture (issue #71). Iterates a link-list field per element.
298
+ * // A local list of the shipped `links` type; `lists` comes from
299
+ * // createListRenderContext(data).forPage(pageId, { linkResolver }).
254
300
  * renderTemplate({
255
- * html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
256
- * fields: [{ name: 'nav_items', kind: 'link-list' }],
301
+ * html: '<nav>{{#nav}}<a href="{{link}}">{{label}}</a>{{/nav}}</nav>',
302
+ * fields: [{ name: 'nav', kind: 'list', listType: 'links' }],
257
303
  * contentValues: {
258
- * nav_items: [
259
- * { label: 'Docs', href: '/docs' },
260
- * { label: 'Blog', href: '/blog' },
261
- * ],
304
+ * nav: [{ id: '<uuid>', node: 'link', values: { label: 'Docs', link: { href: '/docs' } } }],
262
305
  * },
306
+ * lists,
263
307
  * });
264
- * // → {
265
- * // html: '<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>',
266
- * // missingSlots: [],
267
- * // }
308
+ * // → html: '<nav><a href="/docs" data-caelo-list-item="local/nav/<uuid>">Docs</a></nav>'
268
309
  *
269
310
  * @see {@link TemplateField} for the field shape (name + kind + optional default).
270
311
  * @see {@link ./content.js#MODULE_FIELD_KINDS} for the canonical kind union.
271
312
  * @see {@link ./content.js#MODULE_FIELD_SECTION_KINDS} for the kinds the engine
272
- * iterates via `{{#name}}` (text-list / link-list / module-list).
313
+ * iterates via `{{#name}}` (list / module-list).
273
314
  */
274
315
  export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput {
275
316
  const missing: string[] = [];
@@ -280,7 +321,7 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
280
321
  const partials: PartialResolver =
281
322
  typeof partialSource === "function"
282
323
  ? partialSource
283
- : (key) => (Object.hasOwn(partialSource, key) ? partialSource[key] : undefined);
324
+ : (key: string) => (Object.hasOwn(partialSource, key) ? partialSource[key] : undefined);
284
325
 
285
326
  // Sentinels survive Mustache.render untouched (they contain no
286
327
  // `{{` `}}`), then get restored to the original Mustache source
@@ -313,21 +354,20 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
313
354
  // modules; this covers any already-stored HTML + the deploy render.
314
355
  const sourceHtml = stripCdataGuards(input.html);
315
356
 
316
- // 1. {{#name}}…{{/name}} sections.
317
- let html = sourceHtml.replace(SECTION_RE, (match, name: string, inner: string) =>
318
- renderSection(
319
- match,
320
- name,
321
- inner,
322
- fieldByName,
323
- cvs,
324
- partials,
325
- missing,
326
- mkSentinel,
327
- input.dataLists ?? {},
328
- input.dormantDataLists ?? {},
329
- ),
330
- );
357
+ // 1. {{#name}}…{{/name}} and {{^name}}…{{/name}} sections, list
358
+ // entries and dot access (template-sections.ts).
359
+ let html = renderSections(sourceHtml, {
360
+ fields: fieldByName,
361
+ values: cvs,
362
+ partials,
363
+ missing,
364
+ mkSentinel,
365
+ lists: input.lists,
366
+ holder: input.listHolder ?? { contentInstanceId: null },
367
+ moduleLevelKeys: new Set(THEME_ASSET_KEYS),
368
+ renderModuleList: (name, field) =>
369
+ renderModuleList(name, field as TemplateField, cvs, partials, missing, mkSentinel),
370
+ });
331
371
 
332
372
  // 2. {{>name}} single partials.
333
373
  html = html.replace(PARTIAL_RE, (match, name: string) =>
@@ -426,189 +466,6 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
426
466
  return { html: final, missingSlots: missing };
427
467
  }
428
468
 
429
- function renderSection(
430
- match: string,
431
- name: string,
432
- inner: string,
433
- fields: Map<string, TemplateField>,
434
- cvs: Readonly<Record<string, unknown>>,
435
- partials: PartialResolver,
436
- missing: string[],
437
- mkSentinel: (original: string) => string,
438
- dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>,
439
- dormantLists: Readonly<Record<string, string>>,
440
- ): string {
441
- const field = fields.get(name);
442
- if (!field) {
443
- // No declared field — a plugin may still offer this name. Module
444
- // fields are looked up FIRST by construction, so a plugin can never
445
- // shadow something the module's author declared.
446
- const items = dataLists[name];
447
- if (items) return renderDataList(inner, items, mkSentinel);
448
- // Declared by an installed plugin that is NOT running. Naming the
449
- // plugin turns "unknown field" (hunt for a typo) into "switch that
450
- // plugin back on", which is the actual fix.
451
- const owner = dormantLists[name];
452
- if (owner) {
453
- missing.push(`plugin-list-unavailable:${name} plugin=${owner}`);
454
- return mkSentinel(match);
455
- }
456
- missing.push(`field-not-declared:${name}`);
457
- return mkSentinel(match);
458
- }
459
- if (field.kind === "module-list") {
460
- return renderModuleList(name, field, cvs, partials, missing, mkSentinel);
461
- }
462
- if (field.kind === "text-list") {
463
- return renderTextList(name, inner, field, cvs, missing, mkSentinel);
464
- }
465
- if (field.kind === "link-list") {
466
- return renderLinkList(name, inner, field, cvs, missing, mkSentinel);
467
- }
468
- // Any NON-list field used as a `{{#name}}…{{/name}}` section is a
469
- // CONDITIONAL (Mustache semantics): render the inner block once when the
470
- // field has a present, non-empty value; drop it when empty. This is what
471
- // makes `{{#subtitle}}<p>{{subtitle}}</p>{{/subtitle}}` wrap a scalar
472
- // text/image field — previously a scalar in a section fell through to
473
- // `kind-mismatch` and SILENTLY DROPPED real content (headings, body copy,
474
- // an image, a single nested module). `{{name}}` inside the block resolves
475
- // in the later primitive pass (a `module` field's `{{>name}}` in the
476
- // partial pass); `{{.}}`/`{{item}}` are substituted here for parity with
477
- // the *-list renderers. An empty conditional is intentional, not
478
- // "missing" — nothing is pushed to `missing`.
479
- return renderConditionalSection(name, inner, field, cvs, mkSentinel);
480
- }
481
-
482
- /**
483
- * Render a `{{#name}}…{{/name}}` section over a scalar/module field as a
484
- * Mustache conditional: the block once when the value is present, else "".
485
- */
486
- function renderConditionalSection(
487
- name: string,
488
- inner: string,
489
- field: TemplateField,
490
- cvs: Readonly<Record<string, unknown>>,
491
- mkSentinel: (original: string) => string,
492
- ): string {
493
- const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
494
- if (!isPresentSectionValue(raw)) return "";
495
- const value = scalarSectionString(raw);
496
- return value.length > 0
497
- ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => mkSentinel(value))
498
- : inner;
499
- }
500
-
501
- /**
502
- * Mustache-style truthiness for a conditional section value. Empty string,
503
- * null/undefined, `false`, and an empty array/object are absent; everything
504
- * else (incl. a numeric 0 — a real content value, not a control flag) is
505
- * present.
506
- */
507
- function isPresentSectionValue(raw: unknown): boolean {
508
- if (raw === undefined || raw === null || raw === false) return false;
509
- if (typeof raw === "string") return raw.trim().length > 0;
510
- if (typeof raw === "number") return !Number.isNaN(raw);
511
- if (Array.isArray(raw)) return raw.length > 0;
512
- if (typeof raw === "object") return Object.keys(raw as object).length > 0;
513
- return true;
514
- }
515
-
516
- /** String form for `{{.}}`/`{{item}}` inside a scalar conditional. Objects
517
- * (module refs / image objects) render through `{{name}}`/`{{>name}}`, not
518
- * `{{.}}`, so they stringify to "". */
519
- function scalarSectionString(raw: unknown): string {
520
- if (typeof raw === "string") return raw;
521
- if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
522
- return "";
523
- }
524
-
525
- function renderTextList(
526
- name: string,
527
- inner: string,
528
- field: TemplateField,
529
- cvs: Readonly<Record<string, unknown>>,
530
- missing: string[],
531
- mkSentinel: (original: string) => string,
532
- ): string {
533
- const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
534
- if (!Array.isArray(raw)) return "";
535
- const parts: string[] = [];
536
- for (let i = 0; i < raw.length; i += 1) {
537
- const el = raw[i];
538
- if (typeof el !== "string" && typeof el !== "number" && typeof el !== "boolean") {
539
- missing.push(`text-list-malformed:${name}[${i}]`);
540
- parts.push(comment(`text-list-malformed ${name}[${i}]`));
541
- continue;
542
- }
543
- const value = String(el);
544
- parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => mkSentinel(value)));
545
- }
546
- return parts.join("");
547
- }
548
-
549
- /**
550
- * Iterate a plugin-provided list, substituting each item's keys inside
551
- * the section body.
552
- *
553
- * Unlike `link-list` no shape is imposed: the keys are whatever the
554
- * plugin declared in `itemFields`, so a language switcher uses
555
- * href/label/locale and a comment list something else entirely. A
556
- * `{{key}}` the item does not carry is left raw, matching the engine's
557
- * loud-raw rule everywhere else — a typo'd key stays visible instead of
558
- * quietly rendering as nothing.
559
- */
560
- function renderDataList(
561
- inner: string,
562
- items: ReadonlyArray<Readonly<Record<string, string>>>,
563
- mkSentinel: (original: string) => string,
564
- ): string {
565
- const parts: string[] = [];
566
- for (const item of items) {
567
- let rendered = inner;
568
- for (const [key, value] of Object.entries(item)) {
569
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
570
- rendered = rendered.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), () =>
571
- mkSentinel(value),
572
- );
573
- }
574
- parts.push(rendered);
575
- }
576
- return parts.join("");
577
- }
578
-
579
- function renderLinkList(
580
- name: string,
581
- inner: string,
582
- field: TemplateField,
583
- cvs: Readonly<Record<string, unknown>>,
584
- missing: string[],
585
- mkSentinel: (original: string) => string,
586
- ): string {
587
- const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
588
- if (!Array.isArray(raw)) return "";
589
- const parts: string[] = [];
590
- for (let i = 0; i < raw.length; i += 1) {
591
- const el = raw[i];
592
- if (
593
- typeof el !== "object" ||
594
- el === null ||
595
- typeof (el as { label?: unknown }).label !== "string" ||
596
- typeof (el as { href?: unknown }).href !== "string"
597
- ) {
598
- missing.push(`link-list-malformed:${name}[${i}]`);
599
- parts.push(comment(`link-list-malformed ${name}[${i}]`));
600
- continue;
601
- }
602
- const { label, href } = el as { label: string; href: string };
603
- parts.push(
604
- inner
605
- .replace(/\{\{\s*label\s*\}\}/g, () => mkSentinel(label))
606
- .replace(/\{\{\s*href\s*\}\}/g, () => mkSentinel(href)),
607
- );
608
- }
609
- return parts.join("");
610
- }
611
-
612
469
  function renderModuleList(
613
470
  name: string,
614
471
  field: TemplateField,
@@ -665,7 +522,7 @@ function renderPartialRef(
665
522
  }
666
523
  const ref = Object.hasOwn(cvs, name) ? cvs[name] : undefined;
667
524
  // An unset or explicitly null single `module` field is an EMPTY slot,
668
- // the same as an empty module-list / text-list: it renders nothing and
525
+ // the same as an empty module-list / list: it renders nothing and
669
526
  // is not a failure (#1041). The static generator refuses a Stage on any
670
527
  // marker, so flagging "empty" here made an optional nested slot
671
528
  // impossible to leave blank. A present value that is not a ref is still