@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.
- package/dist/ai-tools.d.ts +125 -50
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +48 -36
- 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 +23 -4
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +201 -100
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +176 -74
- 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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/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/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 +51 -37
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +26 -3
- package/src/content.test.ts +111 -3
- package/src/content.ts +186 -75
- 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/index.ts +3 -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 +55 -19
- package/src/preview-compose.ts +59 -154
- 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/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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The values a template sees inside a list section (epic #1107,
|
|
5
|
+
* `docs/lists.md` §8) — the seam between the resolver (`resolve.ts`),
|
|
6
|
+
* which turns stored entries, page queries, system and plugin sources
|
|
7
|
+
* into these scopes, and the template engine, which only looks keys up.
|
|
8
|
+
* The engine knows nothing about list types, links or pages; the
|
|
9
|
+
* resolver knows nothing about template syntax.
|
|
10
|
+
*
|
|
11
|
+
* Every lookup is lazy: a children list, a query or an embedded module
|
|
12
|
+
* is only resolved when the template actually reaches it, so render
|
|
13
|
+
* dependencies (`ListRenderDeps`) record what a page really rendered,
|
|
14
|
+
* not what it could have.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One value a template key resolves to. */
|
|
18
|
+
export type ScopeValue =
|
|
19
|
+
/** Plain text (`{{f}}`); a section over it renders when it is non-empty. */
|
|
20
|
+
| { readonly kind: "text"; readonly text: string }
|
|
21
|
+
/** A flag (`{{#is_current}}`); `{{f}}` renders `true` / `false`. */
|
|
22
|
+
| { readonly kind: "bool"; readonly value: boolean }
|
|
23
|
+
/**
|
|
24
|
+
* A compound value with dot access (`link`, `media`, `date`, `props`,
|
|
25
|
+
* `page`). `text` is what the bare key renders (`{{link}}` = href);
|
|
26
|
+
* null when the bare key has no meaning (`{{props}}`).
|
|
27
|
+
*/
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: "record";
|
|
30
|
+
readonly text: string | null;
|
|
31
|
+
/** Section truthiness of the bare key (`{{#link}}` — set or not). */
|
|
32
|
+
readonly present: boolean;
|
|
33
|
+
get(key: string): ScopeValue | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Iterable entries (`children`, `items`, a term field, `page.terms.tags`). */
|
|
36
|
+
| { readonly kind: "list"; readonly entries: readonly ScopeEntry[] }
|
|
37
|
+
/** An embedded module (`{{>f}}`). */
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: "module";
|
|
40
|
+
readonly ref: { readonly moduleId: string; readonly contentInstanceId: string };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A value that could not resolve (a list source of an inactive plugin,
|
|
44
|
+
* a malformed query). The engine renders the tag raw and reports
|
|
45
|
+
* `marker` — loud, never silently empty (CLAUDE.md §2).
|
|
46
|
+
*/
|
|
47
|
+
| { readonly kind: "failed"; readonly marker: string };
|
|
48
|
+
|
|
49
|
+
/** One entry a list section iterates. */
|
|
50
|
+
export interface ScopeEntry {
|
|
51
|
+
/** The entry id (stored uuid, page id for page-shaped entries, plugin id). */
|
|
52
|
+
readonly id: string;
|
|
53
|
+
/** Look up a key of this entry: fields, system keys, children, `items`. */
|
|
54
|
+
get(key: string): ScopeValue | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Value of the `data-caelo-list-item` attribute stamped on the entry's
|
|
57
|
+
* root element (`docs/lists.md` §13), or null for generated entries that
|
|
58
|
+
* are no stored list entry (query results, plugin entries).
|
|
59
|
+
*/
|
|
60
|
+
readonly itemAttr: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Loop name of this entry's stored children (`children.name` of its
|
|
63
|
+
* node), which `{{>recurse}}` renders with the enclosing block; null
|
|
64
|
+
* when the node takes no children.
|
|
65
|
+
*/
|
|
66
|
+
readonly childrenKey: string | null;
|
|
67
|
+
/** 1-based depth in its tree. */
|
|
68
|
+
readonly depth: number;
|
|
69
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preview / generator parity for lists (epic #1107, #1110 acceptance):
|
|
5
|
+
* a mega menu (entry → column / news feed / promo → sub link, with an
|
|
6
|
+
* embedded teaser module) rendered as a LAYOUT chrome placement and
|
|
7
|
+
* inside a NESTED module, composed the way the editor preview does it
|
|
8
|
+
* (page modules pre-rendered with `renderModuleWithContent`, chrome
|
|
9
|
+
* through the composer) and the way the static generator does it
|
|
10
|
+
* (everything through the composer with a `nestedModules` resolver).
|
|
11
|
+
* Same input, same HTML.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "bun:test";
|
|
15
|
+
import {
|
|
16
|
+
fixtureData,
|
|
17
|
+
fixtureLinkIndex,
|
|
18
|
+
INSTANCE,
|
|
19
|
+
ITEM,
|
|
20
|
+
LIST,
|
|
21
|
+
MODULE,
|
|
22
|
+
PAGE,
|
|
23
|
+
} from "./lists/list-fixtures.test.js";
|
|
24
|
+
import { createListRenderContext } from "./lists/resolve.js";
|
|
25
|
+
import {
|
|
26
|
+
type NestedContentInstanceResource,
|
|
27
|
+
type NestedModuleResource,
|
|
28
|
+
type NestedRenderResolver,
|
|
29
|
+
renderModuleWithContent,
|
|
30
|
+
} from "./nested-module-render.js";
|
|
31
|
+
import { type ComposeModule, composePageWithLayout } from "./preview-compose.js";
|
|
32
|
+
import type { TemplateField } from "./template-engine.js";
|
|
33
|
+
|
|
34
|
+
const HEADER_MOD = "00000000-0000-4000-8000-000000000311";
|
|
35
|
+
const WRAPPER_MOD = "00000000-0000-4000-8000-000000000312";
|
|
36
|
+
const HEADER_CI = "00000000-0000-4000-8000-000000000411";
|
|
37
|
+
const WRAPPER_CI = "00000000-0000-4000-8000-000000000412";
|
|
38
|
+
|
|
39
|
+
const MEGA_HTML =
|
|
40
|
+
'<nav class="mega"><ul>{{#mega}}<li class="{{#is_current}}current{{/is_current}}{{#is_ancestor}}open{{/is_ancestor}}">' +
|
|
41
|
+
'<a href="{{link}}">{{label}}</a>' +
|
|
42
|
+
'{{#has_columns}}<div class="panel">{{#columns}}' +
|
|
43
|
+
'{{#is_column}}<div class="col"><h4>{{heading}}</h4><ul>{{#links}}<li><a href="{{link}}">{{label}}</a>{{#badge_hot}}!{{/badge_hot}}</li>{{/links}}</ul></div>{{/is_column}}' +
|
|
44
|
+
'{{#is_news_feed}}<aside><h4>{{heading}}</h4>{{#items}}<a href="{{link}}">{{title}} · {{published_at.formatted}}</a>{{/items}}{{^items}}No news{{/items}}</aside>{{/is_news_feed}}' +
|
|
45
|
+
'{{#is_promo}}<div class="promo">{{>teaser}}</div>{{/is_promo}}' +
|
|
46
|
+
"{{/columns}}</div>{{/has_columns}}</li>{{/mega}}</ul><p>{{page.title}}</p></nav>";
|
|
47
|
+
|
|
48
|
+
const HEADER_FIELDS: TemplateField[] = [
|
|
49
|
+
{
|
|
50
|
+
name: "mega",
|
|
51
|
+
kind: "list",
|
|
52
|
+
listType: "mega-menu",
|
|
53
|
+
bind: "shared",
|
|
54
|
+
list: `caelo:list/${LIST.mega}`,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const modules: NestedModuleResource[] = [
|
|
59
|
+
{
|
|
60
|
+
moduleId: HEADER_MOD,
|
|
61
|
+
slug: "mega-header",
|
|
62
|
+
html: MEGA_HTML,
|
|
63
|
+
css: ".mega{}",
|
|
64
|
+
js: "",
|
|
65
|
+
fields: HEADER_FIELDS,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
moduleId: MODULE.teaser,
|
|
69
|
+
slug: "teaser",
|
|
70
|
+
html: '<article class="teaser"><h5>{{title}}</h5></article>',
|
|
71
|
+
css: ".teaser{}",
|
|
72
|
+
js: "",
|
|
73
|
+
fields: [{ name: "title", kind: "text" }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
moduleId: WRAPPER_MOD,
|
|
77
|
+
slug: "wrapper",
|
|
78
|
+
html: '<section class="wrap">{{>inner}}</section>',
|
|
79
|
+
css: ".wrap{}",
|
|
80
|
+
js: "",
|
|
81
|
+
fields: [{ name: "inner", kind: "module" }],
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
const instances: NestedContentInstanceResource[] = [
|
|
85
|
+
{
|
|
86
|
+
id: INSTANCE.teaser,
|
|
87
|
+
moduleId: MODULE.teaser,
|
|
88
|
+
values: { title: "Try it free" },
|
|
89
|
+
deletedAt: null,
|
|
90
|
+
},
|
|
91
|
+
{ id: HEADER_CI, moduleId: HEADER_MOD, values: {}, deletedAt: null },
|
|
92
|
+
{
|
|
93
|
+
id: WRAPPER_CI,
|
|
94
|
+
moduleId: WRAPPER_MOD,
|
|
95
|
+
values: { inner: { moduleId: HEADER_MOD, contentInstanceId: HEADER_CI } },
|
|
96
|
+
deletedAt: null,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
const resolver: NestedRenderResolver = {
|
|
100
|
+
getModule: (id) => modules.find((m) => m.moduleId === id) ?? null,
|
|
101
|
+
getContentInstance: (id) => instances.find((c) => c.id === id) ?? null,
|
|
102
|
+
};
|
|
103
|
+
const mod = (id: string): NestedModuleResource =>
|
|
104
|
+
modules.find((m) => m.moduleId === id) as NestedModuleResource;
|
|
105
|
+
|
|
106
|
+
const LAYOUT =
|
|
107
|
+
'<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>';
|
|
108
|
+
const TEMPLATE = '<body><caelo-slot name="content"></caelo-slot></body>';
|
|
109
|
+
|
|
110
|
+
/** The chrome placement: renders from field defaults, no content instance. */
|
|
111
|
+
function chrome(): ComposeModule {
|
|
112
|
+
const m = mod(HEADER_MOD);
|
|
113
|
+
return {
|
|
114
|
+
moduleId: m.moduleId,
|
|
115
|
+
slug: m.slug,
|
|
116
|
+
displayName: "Header",
|
|
117
|
+
html: m.html,
|
|
118
|
+
css: m.css,
|
|
119
|
+
js: m.js,
|
|
120
|
+
fields: m.fields,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function contexts(pageId: string) {
|
|
125
|
+
const linkResolver = fixtureLinkIndex().forPage(pageId);
|
|
126
|
+
const lists = createListRenderContext(fixtureData()).forPage(pageId, { linkResolver });
|
|
127
|
+
return { linkResolver, lists };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The editor preview's path (`pages.render_preview`). */
|
|
131
|
+
function previewCompose(pageId: string) {
|
|
132
|
+
const { linkResolver, lists } = contexts(pageId);
|
|
133
|
+
const rendered = renderModuleWithContent(
|
|
134
|
+
WRAPPER_MOD,
|
|
135
|
+
WRAPPER_CI,
|
|
136
|
+
resolver,
|
|
137
|
+
undefined,
|
|
138
|
+
lists,
|
|
139
|
+
linkResolver,
|
|
140
|
+
);
|
|
141
|
+
const w = mod(WRAPPER_MOD);
|
|
142
|
+
const nested = [...rendered.touchedModuleIds].filter((id) => id !== WRAPPER_MOD).map(mod);
|
|
143
|
+
return composePageWithLayout({
|
|
144
|
+
templateHtml: TEMPLATE,
|
|
145
|
+
templateCss: "",
|
|
146
|
+
blocks: [
|
|
147
|
+
{
|
|
148
|
+
blockName: "content",
|
|
149
|
+
modules: [
|
|
150
|
+
{
|
|
151
|
+
moduleId: w.moduleId,
|
|
152
|
+
slug: w.slug,
|
|
153
|
+
displayName: "Wrapper",
|
|
154
|
+
html: rendered.html,
|
|
155
|
+
css: w.css,
|
|
156
|
+
js: w.js,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
blockName: "__nested__",
|
|
162
|
+
modules: nested.map((n) => ({
|
|
163
|
+
moduleId: n.moduleId,
|
|
164
|
+
slug: n.slug,
|
|
165
|
+
displayName: n.slug,
|
|
166
|
+
html: "",
|
|
167
|
+
css: n.css,
|
|
168
|
+
js: n.js,
|
|
169
|
+
})),
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
layoutHtml: LAYOUT,
|
|
173
|
+
layoutCss: "",
|
|
174
|
+
layoutBlocks: [{ blockName: "header", modules: [chrome()] }],
|
|
175
|
+
nestedModules: resolver,
|
|
176
|
+
lists,
|
|
177
|
+
linkResolver,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The static generator's path (`generate.ts`). */
|
|
182
|
+
function generatorCompose(pageId: string) {
|
|
183
|
+
const { linkResolver, lists } = contexts(pageId);
|
|
184
|
+
const w = mod(WRAPPER_MOD);
|
|
185
|
+
return composePageWithLayout({
|
|
186
|
+
templateHtml: TEMPLATE,
|
|
187
|
+
templateCss: "",
|
|
188
|
+
blocks: [
|
|
189
|
+
{
|
|
190
|
+
blockName: "content",
|
|
191
|
+
modules: [
|
|
192
|
+
{
|
|
193
|
+
moduleId: w.moduleId,
|
|
194
|
+
slug: w.slug,
|
|
195
|
+
displayName: "Wrapper",
|
|
196
|
+
html: w.html,
|
|
197
|
+
css: w.css,
|
|
198
|
+
js: w.js,
|
|
199
|
+
fields: w.fields,
|
|
200
|
+
contentValues: (
|
|
201
|
+
instances.find((c) => c.id === WRAPPER_CI) as NestedContentInstanceResource
|
|
202
|
+
).values,
|
|
203
|
+
contentInstanceId: WRAPPER_CI,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
layoutHtml: LAYOUT,
|
|
209
|
+
layoutCss: "",
|
|
210
|
+
layoutBlocks: [{ blockName: "header", modules: [chrome()] }],
|
|
211
|
+
nestedModules: resolver,
|
|
212
|
+
lists,
|
|
213
|
+
linkResolver,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const bodyOf = (html: string) => html.slice(html.indexOf("<body>"), html.indexOf("</body>"));
|
|
218
|
+
|
|
219
|
+
describe("mega menu — preview / generator parity", () => {
|
|
220
|
+
it("renders the same HTML on both paths, in chrome and inside a nested module", () => {
|
|
221
|
+
const preview = previewCompose(PAGE.team);
|
|
222
|
+
const build = generatorCompose(PAGE.team);
|
|
223
|
+
expect(preview.moduleFailures).toEqual([]);
|
|
224
|
+
expect(build.moduleFailures).toEqual([]);
|
|
225
|
+
expect(bodyOf(preview.html)).toBe(bodyOf(build.html));
|
|
226
|
+
// CSS of every module the lists reached joins the page once on both paths.
|
|
227
|
+
for (const out of [preview, build]) {
|
|
228
|
+
for (const css of [".mega{}", ".teaser{}", ".wrap{}"]) {
|
|
229
|
+
expect(out.html.split(css).length - 1).toBe(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("renders every node kind with page-aware state", () => {
|
|
235
|
+
const body = bodyOf(generatorCompose(PAGE.team).html);
|
|
236
|
+
// Twice: once in the chrome, once inside the wrapper module.
|
|
237
|
+
expect(body.split('<nav class="mega"').length - 1).toBe(2);
|
|
238
|
+
expect(body).toContain(
|
|
239
|
+
`<li class="open" data-caelo-list-item="${LIST.mega}/${ITEM.products}"><a href="/about/">Products</a>`,
|
|
240
|
+
);
|
|
241
|
+
expect(body).toContain(
|
|
242
|
+
`<div class="col" data-caelo-list-item="${LIST.mega}/${ITEM.column}"><h4>Explore</h4>`,
|
|
243
|
+
);
|
|
244
|
+
expect(body).toContain(
|
|
245
|
+
`<li data-caelo-list-item="${LIST.mega}/${ITEM.subA}"><a href="/about/team/">Team</a></li>`,
|
|
246
|
+
);
|
|
247
|
+
expect(body).toContain(
|
|
248
|
+
'<a href="/news/meetup/">Meetup · October 5, 2026</a><a href="/news/launch/">Launch day · October 1, 2026</a>',
|
|
249
|
+
);
|
|
250
|
+
expect(body).toContain(
|
|
251
|
+
`<div class="promo" data-caelo-list-item="${LIST.mega}/${ITEM.promo}"><article class="teaser" data-caelo-module-id="${MODULE.teaser}"><h5>Try it free</h5></article></div>`,
|
|
252
|
+
);
|
|
253
|
+
expect(body).toContain("<p>Team</p>");
|
|
254
|
+
expect(body).not.toContain("{{");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("re-renders per page: the page title and current state follow the rendering page", () => {
|
|
258
|
+
const body = bodyOf(generatorCompose(PAGE.home).html);
|
|
259
|
+
expect(body).toContain("<p>Home</p>");
|
|
260
|
+
expect(body).toContain('<li class=""');
|
|
261
|
+
});
|
|
262
|
+
});
|
package/src/markup-structure.ts
CHANGED
|
@@ -40,6 +40,14 @@ export interface MarkupNode {
|
|
|
40
40
|
readonly classes: readonly string[];
|
|
41
41
|
/** The `data-caelo-module-id` the composer stamps on every module root. */
|
|
42
42
|
readonly moduleId: string | null;
|
|
43
|
+
/** The `href` attribute (links), as written. */
|
|
44
|
+
readonly href: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* The `data-caelo-list-item` the template engine stamps on a rendered
|
|
47
|
+
* list entry (docs/lists.md §13): `<listId>/<itemId>` for a shared
|
|
48
|
+
* list, `instance/…` / `local/…` for a module's local list.
|
|
49
|
+
*/
|
|
50
|
+
readonly listItem: string | null;
|
|
43
51
|
readonly children: MarkupNode[];
|
|
44
52
|
readonly parent: MarkupNode | null;
|
|
45
53
|
}
|
|
@@ -76,6 +84,8 @@ export function parseMarkup(html: string): MarkupNode[] {
|
|
|
76
84
|
tag,
|
|
77
85
|
classes: splitClasses(attribs.class),
|
|
78
86
|
moduleId: attribs["data-caelo-module-id"] ?? null,
|
|
87
|
+
href: attribs.href ?? null,
|
|
88
|
+
listItem: attribs["data-caelo-list-item"] ?? null,
|
|
79
89
|
children: [],
|
|
80
90
|
parent,
|
|
81
91
|
};
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { describe, expect, it } from "bun:test";
|
|
22
|
+
import { fixtureData, INSTANCE, ITEM, LIST, MODULE, PAGE } from "./lists/list-fixtures.test.js";
|
|
23
|
+
import { createListRenderContext } from "./lists/resolve.js";
|
|
22
24
|
import {
|
|
23
25
|
collectNestedRefs,
|
|
24
26
|
type NestedContentInstanceResource,
|
|
@@ -288,148 +290,176 @@ describe("renderModuleWithContent — empty optional module field (#1041)", () =
|
|
|
288
290
|
});
|
|
289
291
|
});
|
|
290
292
|
|
|
291
|
-
describe("renderModuleWithContent — list
|
|
292
|
-
|
|
293
|
+
describe("renderModuleWithContent — list fields (epic #1107)", () => {
|
|
294
|
+
const lists = createListRenderContext(fixtureData()).forPage(PAGE.home);
|
|
295
|
+
const T = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
|
|
296
|
+
const texts = (...t: string[]) =>
|
|
297
|
+
t.map((text, i) => ({ id: T(i + 1), node: "text", values: { text } }));
|
|
298
|
+
const PARENT_CI = "00000000-0000-4000-8000-00000000bbb1";
|
|
299
|
+
|
|
300
|
+
it("iterates a local list and addresses entries by the content instance", () => {
|
|
293
301
|
const resolver = buildResolver(
|
|
294
302
|
[
|
|
295
303
|
{
|
|
296
304
|
moduleId: PARENT_MOD_ID,
|
|
297
305
|
slug: "tag-cloud",
|
|
298
|
-
html: "<div>{{#tags}}<span>{{
|
|
306
|
+
html: "<div>{{#tags}}<span>{{text}}</span>{{/tags}}</div>",
|
|
299
307
|
css: "",
|
|
300
308
|
js: "",
|
|
301
|
-
fields: [{ name: "tags", kind: "
|
|
309
|
+
fields: [{ name: "tags", kind: "list", listType: "texts" }],
|
|
302
310
|
},
|
|
303
311
|
],
|
|
304
312
|
[
|
|
305
313
|
{
|
|
306
|
-
id:
|
|
314
|
+
id: PARENT_CI,
|
|
307
315
|
moduleId: PARENT_MOD_ID,
|
|
308
|
-
values: { tags:
|
|
316
|
+
values: { tags: texts("alpha", "beta") },
|
|
309
317
|
deletedAt: null,
|
|
310
318
|
},
|
|
311
319
|
],
|
|
312
320
|
);
|
|
313
|
-
const r = renderModuleWithContent(PARENT_MOD_ID,
|
|
314
|
-
expect(r.html).toBe(
|
|
321
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI, resolver, undefined, lists);
|
|
322
|
+
expect(r.html).toBe(
|
|
323
|
+
`<div><span data-caelo-list-item="instance/${PARENT_CI}/tags/${T(1)}">alpha</span>` +
|
|
324
|
+
`<span data-caelo-list-item="instance/${PARENT_CI}/tags/${T(2)}">beta</span></div>`,
|
|
325
|
+
);
|
|
315
326
|
expect(r.missingSlots).toEqual([]);
|
|
316
327
|
});
|
|
317
328
|
|
|
318
|
-
it("
|
|
329
|
+
it("an edit-marked copy keeps the real instance as the entries' address", () => {
|
|
319
330
|
const resolver = buildResolver(
|
|
320
331
|
[
|
|
321
332
|
{
|
|
322
333
|
moduleId: PARENT_MOD_ID,
|
|
323
|
-
slug: "
|
|
324
|
-
html: "
|
|
334
|
+
slug: "tag-cloud",
|
|
335
|
+
html: "{{#tags}}<i>{{text}}</i>{{/tags}}",
|
|
325
336
|
css: "",
|
|
326
337
|
js: "",
|
|
327
|
-
fields: [{ name: "
|
|
338
|
+
fields: [{ name: "tags", kind: "list", listType: "texts" }],
|
|
328
339
|
},
|
|
329
340
|
],
|
|
330
341
|
[
|
|
331
342
|
{
|
|
332
|
-
id:
|
|
343
|
+
id: "edit:content#0",
|
|
333
344
|
moduleId: PARENT_MOD_ID,
|
|
334
|
-
values: {
|
|
345
|
+
values: { tags: texts("a") },
|
|
335
346
|
deletedAt: null,
|
|
347
|
+
listHolderId: PARENT_CI,
|
|
336
348
|
},
|
|
337
349
|
],
|
|
338
350
|
);
|
|
339
|
-
const r = renderModuleWithContent(PARENT_MOD_ID,
|
|
340
|
-
expect(r.html).
|
|
351
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, "edit:content#0", resolver, undefined, lists);
|
|
352
|
+
expect(r.html).toContain(`instance/${PARENT_CI}/tags/`);
|
|
341
353
|
});
|
|
342
354
|
|
|
343
|
-
it("
|
|
355
|
+
it("falls back to the field default when the values entry is absent", () => {
|
|
344
356
|
const resolver = buildResolver(
|
|
345
357
|
[
|
|
346
358
|
{
|
|
347
359
|
moduleId: PARENT_MOD_ID,
|
|
348
360
|
slug: "tag-cloud",
|
|
349
|
-
html: "<div>{{#tags}}
|
|
361
|
+
html: "<div>{{#tags}}{{text}};{{/tags}}</div>",
|
|
350
362
|
css: "",
|
|
351
363
|
js: "",
|
|
352
364
|
fields: [
|
|
353
|
-
{
|
|
354
|
-
name: "tags",
|
|
355
|
-
kind: "text-list",
|
|
356
|
-
default: ["alpha", "beta"],
|
|
357
|
-
},
|
|
365
|
+
{ name: "tags", kind: "list", listType: "texts", default: texts("alpha", "beta") },
|
|
358
366
|
],
|
|
359
367
|
},
|
|
360
368
|
],
|
|
361
369
|
[{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
|
|
362
370
|
);
|
|
363
|
-
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
364
|
-
expect(r.html).toBe("<div
|
|
371
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
|
|
372
|
+
expect(r.html).toBe("<div>alpha;beta;</div>");
|
|
365
373
|
});
|
|
366
374
|
|
|
367
|
-
it("
|
|
375
|
+
it("renders a list entry's embedded module through the recursion (depth + cycle guards apply)", () => {
|
|
368
376
|
const resolver = buildResolver(
|
|
369
377
|
[
|
|
370
378
|
{
|
|
371
379
|
moduleId: PARENT_MOD_ID,
|
|
372
|
-
slug: "
|
|
373
|
-
html:
|
|
380
|
+
slug: "mega",
|
|
381
|
+
html: "<nav>{{#mega}}{{#columns}}{{#is_promo}}<div>{{>teaser}}</div>{{/is_promo}}{{/columns}}{{/mega}}</nav>",
|
|
382
|
+
css: "",
|
|
383
|
+
js: "",
|
|
384
|
+
fields: [
|
|
385
|
+
{
|
|
386
|
+
name: "mega",
|
|
387
|
+
kind: "list",
|
|
388
|
+
listType: "mega-menu",
|
|
389
|
+
bind: "shared",
|
|
390
|
+
list: `caelo:list/${LIST.mega}`,
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
moduleId: MODULE.teaser,
|
|
396
|
+
slug: "teaser",
|
|
397
|
+
html: "<p>{{title}}</p>",
|
|
374
398
|
css: "",
|
|
375
399
|
js: "",
|
|
376
|
-
fields: [{ name: "
|
|
400
|
+
fields: [{ name: "title", kind: "text" }],
|
|
377
401
|
},
|
|
378
402
|
],
|
|
379
403
|
[
|
|
380
|
-
{
|
|
381
|
-
|
|
382
|
-
moduleId: PARENT_MOD_ID,
|
|
383
|
-
values: {
|
|
384
|
-
menu: [
|
|
385
|
-
{ label: "Home", href: "/" },
|
|
386
|
-
{ label: "About", href: "/about" },
|
|
387
|
-
{ label: "Contact", href: "/contact" },
|
|
388
|
-
],
|
|
389
|
-
},
|
|
390
|
-
deletedAt: null,
|
|
391
|
-
},
|
|
404
|
+
{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null },
|
|
405
|
+
{ id: INSTANCE.teaser, moduleId: MODULE.teaser, values: { title: "Try" }, deletedAt: null },
|
|
392
406
|
],
|
|
393
407
|
);
|
|
394
|
-
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
408
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
|
|
395
409
|
expect(r.html).toBe(
|
|
396
|
-
|
|
410
|
+
`<nav><div data-caelo-list-item="${LIST.mega}/${ITEM.promo}"><p ${tag(MODULE.teaser)}>Try</p></div></nav>`,
|
|
397
411
|
);
|
|
412
|
+
expect(r.touchedModuleIds.has(MODULE.teaser)).toBe(true);
|
|
398
413
|
expect(r.missingSlots).toEqual([]);
|
|
399
414
|
});
|
|
400
415
|
|
|
401
|
-
it("
|
|
416
|
+
it("a broken embedded module is attributed to the list entry", () => {
|
|
402
417
|
const resolver = buildResolver(
|
|
403
418
|
[
|
|
404
419
|
{
|
|
405
420
|
moduleId: PARENT_MOD_ID,
|
|
406
|
-
slug: "
|
|
407
|
-
html:
|
|
421
|
+
slug: "mega",
|
|
422
|
+
html: "{{#mega}}{{#columns}}{{#is_promo}}{{>teaser}}{{/is_promo}}{{/columns}}{{/mega}}",
|
|
408
423
|
css: "",
|
|
409
424
|
js: "",
|
|
410
|
-
fields: [
|
|
425
|
+
fields: [
|
|
426
|
+
{
|
|
427
|
+
name: "mega",
|
|
428
|
+
kind: "list",
|
|
429
|
+
listType: "mega-menu",
|
|
430
|
+
bind: "shared",
|
|
431
|
+
list: `caelo:list/${LIST.mega}`,
|
|
432
|
+
},
|
|
433
|
+
],
|
|
411
434
|
},
|
|
412
435
|
],
|
|
413
|
-
[
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
436
|
+
[{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
|
|
437
|
+
);
|
|
438
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
|
|
439
|
+
expect(r.failures).toEqual([
|
|
440
|
+
{ field: `mega[${ITEM.promo}].teaser`, reason: `module-missing:${MODULE.teaser}` },
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("collectNestedRefs finds module fields inside local list entries", () => {
|
|
445
|
+
expect(
|
|
446
|
+
collectNestedRefs({
|
|
447
|
+
mega: [
|
|
448
|
+
{
|
|
449
|
+
id: T(1),
|
|
450
|
+
node: "entry",
|
|
451
|
+
values: {},
|
|
452
|
+
children: [
|
|
453
|
+
{
|
|
454
|
+
id: T(2),
|
|
455
|
+
node: "promo",
|
|
456
|
+
values: { teaser: { moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser } },
|
|
457
|
+
},
|
|
422
458
|
],
|
|
423
459
|
},
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
);
|
|
428
|
-
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
429
|
-
expect(r.html).toContain('<a href="/">Home</a>');
|
|
430
|
-
expect(r.html).toContain('<a href="/about">About</a>');
|
|
431
|
-
expect(r.html).toContain("link-list-malformed");
|
|
432
|
-
expect(r.missingSlots.some((s) => s.startsWith("link-list-malformed"))).toBe(true);
|
|
460
|
+
],
|
|
461
|
+
}),
|
|
462
|
+
).toEqual([{ moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser }]);
|
|
433
463
|
});
|
|
434
464
|
});
|
|
435
465
|
|
|
@@ -668,7 +698,7 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
|
|
|
668
698
|
});
|
|
669
699
|
|
|
670
700
|
it("kind-mismatch on {{>name}} against a list field emits the exact legacy string", () => {
|
|
671
|
-
const expected = "kind-mismatch:items expected=module actual=
|
|
701
|
+
const expected = "kind-mismatch:items expected=module actual=list";
|
|
672
702
|
const resolver = buildResolver(
|
|
673
703
|
[
|
|
674
704
|
{
|
|
@@ -677,7 +707,7 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
|
|
|
677
707
|
html: "<aside>{{>items}}</aside>",
|
|
678
708
|
css: "",
|
|
679
709
|
js: "",
|
|
680
|
-
fields: [{ name: "items", kind: "
|
|
710
|
+
fields: [{ name: "items", kind: "list", listType: "texts" }],
|
|
681
711
|
},
|
|
682
712
|
],
|
|
683
713
|
[{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
|
|
@@ -713,32 +743,36 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
|
|
|
713
743
|
expect(r.missingSlots).toContain("module-ref-malformed:hero");
|
|
714
744
|
});
|
|
715
745
|
|
|
716
|
-
it("
|
|
746
|
+
it("list-malformed names the entry path and keeps the section raw", () => {
|
|
717
747
|
const resolver = buildResolver(
|
|
718
748
|
[
|
|
719
749
|
{
|
|
720
750
|
moduleId: PARENT_MOD_ID,
|
|
721
|
-
slug: "
|
|
722
|
-
html: "<ul>{{#items}}<li>{{
|
|
751
|
+
slug: "list-malformed",
|
|
752
|
+
html: "<ul>{{#items}}<li>{{text}}</li>{{/items}}</ul>",
|
|
723
753
|
css: "",
|
|
724
754
|
js: "",
|
|
725
|
-
fields: [{ name: "items", kind: "
|
|
755
|
+
fields: [{ name: "items", kind: "list", listType: "texts" }],
|
|
726
756
|
},
|
|
727
757
|
],
|
|
728
758
|
[
|
|
729
759
|
{
|
|
730
760
|
id: PARENT_CI_ID,
|
|
731
761
|
moduleId: PARENT_MOD_ID,
|
|
732
|
-
values: {
|
|
762
|
+
values: {
|
|
763
|
+
items: [
|
|
764
|
+
{ id: "00000000-0000-4000-9000-000000000001", node: "text", values: { text: "a" } },
|
|
765
|
+
{ bogus: true },
|
|
766
|
+
],
|
|
767
|
+
},
|
|
733
768
|
deletedAt: null,
|
|
734
769
|
},
|
|
735
770
|
],
|
|
736
771
|
);
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
expect(r.html).
|
|
740
|
-
expect(r.
|
|
741
|
-
expect(r.missingSlots).toContain("text-list-malformed:items[1]");
|
|
772
|
+
const lists = createListRenderContext(fixtureData()).forPage(null);
|
|
773
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
|
|
774
|
+
expect(r.html).toBe("<ul>{{#items}}<li>{{text}}</li>{{/items}}</ul>");
|
|
775
|
+
expect(r.missingSlots).toContain("list-malformed:items[items[1]]");
|
|
742
776
|
});
|
|
743
777
|
|
|
744
778
|
it("module-list-malformed per element preserves the exact legacy marker", () => {
|