@caelo-cms/shared 0.10.71 → 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.
- package/dist/ai-tools.d.ts +125 -50
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +50 -38
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +110 -30
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +26 -7
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +203 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +178 -76
- package/dist/content.js.map +1 -1
- package/dist/css-var-aliases.d.ts +20 -0
- package/dist/css-var-aliases.d.ts.map +1 -0
- package/dist/css-var-aliases.js +95 -0
- package/dist/css-var-aliases.js.map +1 -0
- package/dist/css-var-scan.d.ts +0 -16
- package/dist/css-var-scan.d.ts.map +1 -1
- package/dist/css-var-scan.js +24 -0
- package/dist/css-var-scan.js.map +1 -1
- package/dist/edit-markers.d.ts +41 -5
- package/dist/edit-markers.d.ts.map +1 -1
- package/dist/edit-markers.js +47 -50
- package/dist/edit-markers.js.map +1 -1
- package/dist/id-safe-truncate.d.ts +15 -0
- package/dist/id-safe-truncate.d.ts.map +1 -0
- package/dist/id-safe-truncate.js +47 -0
- package/dist/id-safe-truncate.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +2 -2
- package/dist/interactions.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +7 -1
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/link-refs.d.ts +1 -1
- package/dist/link-refs.js +3 -3
- package/dist/lists/address.d.ts +34 -0
- package/dist/lists/address.d.ts.map +1 -0
- package/dist/lists/address.js +43 -0
- package/dist/lists/address.js.map +1 -0
- package/dist/lists/default-types.d.ts +10 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +91 -0
- package/dist/lists/default-types.js.map +1 -0
- package/dist/lists/fields.d.ts +152 -0
- package/dist/lists/fields.d.ts.map +1 -0
- package/dist/lists/fields.js +258 -0
- package/dist/lists/fields.js.map +1 -0
- package/dist/lists/index.d.ts +14 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +15 -0
- package/dist/lists/index.js.map +1 -0
- package/dist/lists/item-attr.d.ts +26 -0
- package/dist/lists/item-attr.d.ts.map +1 -0
- package/dist/lists/item-attr.js +31 -0
- package/dist/lists/item-attr.js.map +1 -0
- package/dist/lists/items.d.ts +105 -0
- package/dist/lists/items.d.ts.map +1 -0
- package/dist/lists/items.js +304 -0
- package/dist/lists/items.js.map +1 -0
- package/dist/lists/list-type.d.ts +401 -0
- package/dist/lists/list-type.d.ts.map +1 -0
- package/dist/lists/list-type.js +275 -0
- package/dist/lists/list-type.js.map +1 -0
- package/dist/lists/model.d.ts +14 -0
- package/dist/lists/model.d.ts.map +1 -0
- package/dist/lists/model.js +15 -0
- package/dist/lists/model.js.map +1 -0
- package/dist/lists/page-type.d.ts +115 -0
- package/dist/lists/page-type.d.ts.map +1 -0
- package/dist/lists/page-type.js +66 -0
- package/dist/lists/page-type.js.map +1 -0
- package/dist/lists/refs.d.ts +13 -0
- package/dist/lists/refs.d.ts.map +1 -0
- package/dist/lists/refs.js +18 -0
- package/dist/lists/refs.js.map +1 -0
- package/dist/lists/render-data.d.ts +114 -0
- package/dist/lists/render-data.d.ts.map +1 -0
- package/dist/lists/render-data.js +3 -0
- package/dist/lists/render-data.js.map +1 -0
- package/dist/lists/render-deps.d.ts +68 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +91 -0
- package/dist/lists/render-deps.js.map +1 -0
- package/dist/lists/resolve-values.d.ts +63 -0
- package/dist/lists/resolve-values.d.ts.map +1 -0
- package/dist/lists/resolve-values.js +181 -0
- package/dist/lists/resolve-values.js.map +1 -0
- package/dist/lists/resolve.d.ts +106 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +878 -0
- package/dist/lists/resolve.js.map +1 -0
- package/dist/lists/scope.d.ts +81 -0
- package/dist/lists/scope.d.ts.map +1 -0
- package/dist/lists/scope.js +3 -0
- package/dist/lists/scope.js.map +1 -0
- package/dist/markup-structure.d.ts +8 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +2 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +25 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +122 -31
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts +24 -21
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +34 -118
- package/dist/preview-compose.js.map +1 -1
- package/dist/preview-scanner.d.ts +5 -2
- package/dist/preview-scanner.d.ts.map +1 -1
- package/dist/preview-scanner.js +5 -3
- package/dist/preview-scanner.js.map +1 -1
- package/dist/source-text-transport.d.ts +17 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +71 -0
- package/dist/source-text-transport.js.map +1 -0
- package/dist/template-engine.d.ts +44 -31
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +75 -174
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts +69 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/dist/template-sections.js +423 -0
- package/dist/template-sections.js.map +1 -0
- package/dist/uuid-diagnose.d.ts +9 -0
- package/dist/uuid-diagnose.d.ts.map +1 -0
- package/dist/uuid-diagnose.js +47 -0
- package/dist/uuid-diagnose.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/src/__tests__/redos-hardening.test.ts +50 -16
- package/src/ai-tools-add-module-modes.test.ts +50 -0
- package/src/ai-tools.test.ts +10 -0
- package/src/ai-tools.ts +53 -39
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +29 -6
- package/src/content.test.ts +111 -3
- package/src/content.ts +188 -77
- package/src/css-var-aliases.test.ts +61 -0
- package/src/css-var-aliases.ts +109 -0
- package/src/css-var-scan.ts +9 -0
- package/src/edit-markers.test.ts +37 -21
- package/src/edit-markers.ts +52 -20
- package/src/id-safe-truncate.test.ts +35 -0
- package/src/id-safe-truncate.ts +46 -0
- package/src/index.ts +5 -1
- package/src/interactions.ts +2 -2
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +66 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +102 -0
- package/src/lists/fields.ts +285 -0
- package/src/lists/index.ts +16 -0
- package/src/lists/item-attr.test.ts +36 -0
- package/src/lists/item-attr.ts +49 -0
- package/src/lists/items.ts +402 -0
- package/src/lists/list-fixtures.test.ts +302 -0
- package/src/lists/list-type.ts +306 -0
- package/src/lists/lists.test.ts +328 -0
- package/src/lists/model.ts +16 -0
- package/src/lists/page-type.ts +71 -0
- package/src/lists/refs.ts +21 -0
- package/src/lists/render-data.ts +125 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +144 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +295 -0
- package/src/lists/resolve.ts +1148 -0
- package/src/lists/scope.ts +69 -0
- package/src/lists-compose-parity.test.ts +262 -0
- package/src/markup-structure.ts +10 -0
- package/src/nested-module-render.test.ts +111 -77
- package/src/nested-module-render.ts +85 -48
- package/src/preview-compose.test.ts +74 -19
- package/src/preview-compose.ts +59 -154
- package/src/preview-scanner.test.ts +8 -4
- package/src/preview-scanner.ts +5 -3
- package/src/source-text-transport.test.ts +63 -0
- package/src/source-text-transport.ts +70 -0
- package/src/template-engine-lists.test.ts +553 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +500 -0
- package/src/uuid-diagnose.test.ts +28 -0
- package/src/uuid-diagnose.ts +50 -0
- package/src/version.ts +1 -1
- package/dist/structured-sets.d.ts +0 -70
- package/dist/structured-sets.d.ts.map +0 -1
- package/dist/structured-sets.js +0 -92
- package/dist/structured-sets.js.map +0 -1
- package/src/structured-sets.ts +0 -114
- package/src/template-engine-data-lists.test.ts +0 -90
package/src/template-engine.ts
CHANGED
|
@@ -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
|
-
* (
|
|
13
|
-
*
|
|
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
|
|
84
|
-
*
|
|
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 = (
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
180
|
+
readonly lists?: ListPageContext;
|
|
126
181
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
298
|
+
* // A local list of the shipped `links` type; `lists` comes from
|
|
299
|
+
* // createListRenderContext(data).forPage(pageId, { linkResolver }).
|
|
254
300
|
* renderTemplate({
|
|
255
|
-
* html: '<nav>{{#
|
|
256
|
-
* fields: [{ name: '
|
|
301
|
+
* html: '<nav>{{#nav}}<a href="{{link}}">{{label}}</a>{{/nav}}</nav>',
|
|
302
|
+
* fields: [{ name: 'nav', kind: 'list', listType: 'links' }],
|
|
257
303
|
* contentValues: {
|
|
258
|
-
*
|
|
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}}` (
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 /
|
|
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
|