@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
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List entries — storage shape, validation against a list type, and the
|
|
5
|
+
* pure item-level patch operations (epic #1107, `docs/lists.md` §4).
|
|
6
|
+
*
|
|
7
|
+
* A shared list and a local list (a module field value) store the same
|
|
8
|
+
* array. Children always live under `children` in storage; the loop name
|
|
9
|
+
* a template uses (`children.name` on the node) is a rendering concern,
|
|
10
|
+
* so renaming a loop never rewrites stored data.
|
|
11
|
+
*
|
|
12
|
+
* Every entry carries a UUID that never changes, also not on a move.
|
|
13
|
+
* Diffs, translations (by address, `address.ts`) and page terms
|
|
14
|
+
* (`caelo:term/<listId>/<itemId>`) hang off it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { type ListIssue, validateFieldValue } from "./fields.js";
|
|
19
|
+
import {
|
|
20
|
+
effectiveMaxDepth,
|
|
21
|
+
LIST_MAX_ITEMS,
|
|
22
|
+
type ListTypeDef,
|
|
23
|
+
pageQueryPartialSchema,
|
|
24
|
+
} from "./list-type.js";
|
|
25
|
+
|
|
26
|
+
export interface ListItem {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly node: string;
|
|
29
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
30
|
+
readonly children?: readonly ListItem[];
|
|
31
|
+
readonly query?: Readonly<Record<string, unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
35
|
+
|
|
36
|
+
/** Input shape: `id` optional (assigned on write). Recursive. */
|
|
37
|
+
export const listItemInputSchema: z.ZodType<ListItemInput> = z.lazy(() =>
|
|
38
|
+
z
|
|
39
|
+
.object({
|
|
40
|
+
id: z.string().regex(UUID_RE, "id must be a lowercase uuid").optional(),
|
|
41
|
+
node: z.string().min(1).max(64),
|
|
42
|
+
values: z.record(z.string(), z.unknown()).default({}),
|
|
43
|
+
children: z.array(listItemInputSchema).optional(),
|
|
44
|
+
query: z.record(z.string(), z.unknown()).optional(),
|
|
45
|
+
})
|
|
46
|
+
.strict(),
|
|
47
|
+
);
|
|
48
|
+
export interface ListItemInput {
|
|
49
|
+
id?: string;
|
|
50
|
+
node: string;
|
|
51
|
+
values: Record<string, unknown>;
|
|
52
|
+
children?: ListItemInput[];
|
|
53
|
+
query?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ValidateItemsOptions {
|
|
57
|
+
/** Mints ids for new entries; injectable for deterministic tests. */
|
|
58
|
+
readonly newId?: () => string;
|
|
59
|
+
/** Path prefix for issues (`items` by default). */
|
|
60
|
+
readonly path?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ValidatedItems {
|
|
64
|
+
readonly items: ListItem[];
|
|
65
|
+
readonly issues: ListIssue[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate entries against `type`, assigning ids to entries without one.
|
|
70
|
+
* Checks node placement, required fields and value shapes, depth, per-level
|
|
71
|
+
* `max`, the total count, duplicate ids, and query overrides (only the
|
|
72
|
+
* source's `editable` knobs). Values for fields a node does not declare
|
|
73
|
+
* are an issue — data the type does not know about would be invisible in
|
|
74
|
+
* the editor and silently dropped by the next full replace.
|
|
75
|
+
*/
|
|
76
|
+
export function validateListItems(
|
|
77
|
+
type: ListTypeDef,
|
|
78
|
+
input: unknown,
|
|
79
|
+
opts: ValidateItemsOptions = {},
|
|
80
|
+
): ValidatedItems {
|
|
81
|
+
const root = opts.path ?? "items";
|
|
82
|
+
const issues: ListIssue[] = [];
|
|
83
|
+
const parsed = z.array(listItemInputSchema).safeParse(input);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
return {
|
|
86
|
+
items: [],
|
|
87
|
+
issues: parsed.error.issues.slice(0, 10).map((i) => ({
|
|
88
|
+
path: `${root}${i.path.map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`)).join("")}`,
|
|
89
|
+
message: i.message,
|
|
90
|
+
})),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const newId = opts.newId ?? (() => globalThis.crypto.randomUUID());
|
|
94
|
+
const maxDepth = effectiveMaxDepth(type);
|
|
95
|
+
const ids = new Set<string>();
|
|
96
|
+
let count = 0;
|
|
97
|
+
|
|
98
|
+
const walk = (
|
|
99
|
+
items: readonly ListItemInput[],
|
|
100
|
+
allow: readonly string[],
|
|
101
|
+
max: number | undefined,
|
|
102
|
+
depth: number,
|
|
103
|
+
path: string,
|
|
104
|
+
): ListItem[] => {
|
|
105
|
+
if (max !== undefined && items.length > max) {
|
|
106
|
+
issues.push({ path, message: `at most ${max} entries allowed here, got ${items.length}.` });
|
|
107
|
+
}
|
|
108
|
+
return items.map((it, i) => {
|
|
109
|
+
const at = `${path}[${i}]`;
|
|
110
|
+
count += 1;
|
|
111
|
+
const id = it.id ?? newId();
|
|
112
|
+
if (ids.has(id)) issues.push({ path: `${at}.id`, message: `duplicate entry id ${id}.` });
|
|
113
|
+
ids.add(id);
|
|
114
|
+
const node = type.nodes[it.node];
|
|
115
|
+
if (!node) {
|
|
116
|
+
issues.push({
|
|
117
|
+
path: `${at}.node`,
|
|
118
|
+
message: `unknown node kind "${it.node}" — the type defines ${Object.keys(type.nodes).join(", ")}.`,
|
|
119
|
+
});
|
|
120
|
+
return { id, node: it.node, values: it.values };
|
|
121
|
+
}
|
|
122
|
+
if (!allow.includes(it.node)) {
|
|
123
|
+
issues.push({
|
|
124
|
+
path: `${at}.node`,
|
|
125
|
+
message: `"${it.node}" is not allowed here — allowed: ${allow.join(", ")}.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (depth > maxDepth) {
|
|
129
|
+
issues.push({ path: at, message: `nesting deeper than ${maxDepth} levels.` });
|
|
130
|
+
}
|
|
131
|
+
const values: Record<string, unknown> = {};
|
|
132
|
+
for (const f of node.fields) {
|
|
133
|
+
const v = Object.hasOwn(it.values, f.name) ? it.values[f.name] : f.default;
|
|
134
|
+
issues.push(...validateFieldValue(f, v, `${at}.values.${f.name}`));
|
|
135
|
+
if (v !== undefined) values[f.name] = v;
|
|
136
|
+
}
|
|
137
|
+
for (const k of Object.keys(it.values)) {
|
|
138
|
+
if (!node.fields.some((f) => f.name === k)) {
|
|
139
|
+
issues.push({
|
|
140
|
+
path: `${at}.values.${k}`,
|
|
141
|
+
message: `node "${it.node}" has no field "${k}" — add the field to the type or drop the value.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const out: {
|
|
146
|
+
id: string;
|
|
147
|
+
node: string;
|
|
148
|
+
values: Record<string, unknown>;
|
|
149
|
+
children?: ListItem[];
|
|
150
|
+
query?: Record<string, unknown>;
|
|
151
|
+
} = {
|
|
152
|
+
id,
|
|
153
|
+
node: it.node,
|
|
154
|
+
values,
|
|
155
|
+
};
|
|
156
|
+
if (it.query !== undefined) {
|
|
157
|
+
if (node.source?.kind !== "query") {
|
|
158
|
+
issues.push({ path: `${at}.query`, message: `node "${it.node}" has no query source.` });
|
|
159
|
+
} else {
|
|
160
|
+
const q = pageQueryPartialSchema.safeParse(it.query);
|
|
161
|
+
if (!q.success) {
|
|
162
|
+
issues.push({
|
|
163
|
+
path: `${at}.query`,
|
|
164
|
+
message: `invalid query: ${q.error.issues[0]?.message ?? ""}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// A non-editable knob sent with exactly the type's default value
|
|
168
|
+
// restates the type; it is dropped, not refused (e2e-livedit run
|
|
169
|
+
// 40360756064: a news entry repeated `page_type: "news"` and
|
|
170
|
+
// `order` from the type's defaults and the whole list was refused).
|
|
171
|
+
// A different value is still refused: it would be ignored silently.
|
|
172
|
+
const defaults = (node.source.defaults ?? {}) as Record<string, unknown>;
|
|
173
|
+
const query: Record<string, unknown> = {};
|
|
174
|
+
for (const [k, v] of Object.entries(it.query)) {
|
|
175
|
+
if ((node.source.editable as readonly string[]).includes(k)) {
|
|
176
|
+
query[k] = v;
|
|
177
|
+
} else if (JSON.stringify(v) !== JSON.stringify(defaults[k])) {
|
|
178
|
+
issues.push({
|
|
179
|
+
path: `${at}.query.${k}`,
|
|
180
|
+
message: `"${k}" is not editable on "${it.node}" — editable: ${node.source.editable.join(", ") || "none"}.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
out.query = query;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (it.children !== undefined && it.children.length > 0) {
|
|
188
|
+
if (!node.children) {
|
|
189
|
+
issues.push({ path: `${at}.children`, message: `node "${it.node}" takes no children.` });
|
|
190
|
+
} else {
|
|
191
|
+
out.children = walk(
|
|
192
|
+
it.children,
|
|
193
|
+
node.children.allow,
|
|
194
|
+
node.children.max,
|
|
195
|
+
depth + 1,
|
|
196
|
+
`${at}.children`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const items = walk(parsed.data, type.root.allow, type.root.max, 1, root);
|
|
205
|
+
if (count > LIST_MAX_ITEMS) {
|
|
206
|
+
issues.push({
|
|
207
|
+
path: root,
|
|
208
|
+
message: `a list holds at most ${LIST_MAX_ITEMS} entries, got ${count}.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return { items, issues };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Depth-first visit of every entry with its parent id and depth (1-based). */
|
|
215
|
+
export function walkListItems(
|
|
216
|
+
items: readonly ListItem[],
|
|
217
|
+
visit: (item: ListItem, parentId: string | null, depth: number, index: number) => void,
|
|
218
|
+
): void {
|
|
219
|
+
const go = (level: readonly ListItem[], parentId: string | null, depth: number): void => {
|
|
220
|
+
level.forEach((it, i) => {
|
|
221
|
+
visit(it, parentId, depth, i);
|
|
222
|
+
if (it.children) go(it.children, it.id, depth + 1);
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
go(items, null, 1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Find an entry by id anywhere in the tree. */
|
|
229
|
+
export function findListItem(items: readonly ListItem[], id: string): ListItem | null {
|
|
230
|
+
let hit: ListItem | null = null;
|
|
231
|
+
walkListItems(items, (it) => {
|
|
232
|
+
if (hit === null && it.id === id) hit = it;
|
|
233
|
+
});
|
|
234
|
+
return hit;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type ListItemPatch =
|
|
238
|
+
| {
|
|
239
|
+
readonly op: "add";
|
|
240
|
+
readonly parentId?: string | null;
|
|
241
|
+
readonly position?: number;
|
|
242
|
+
readonly item: ListItemInput;
|
|
243
|
+
}
|
|
244
|
+
| {
|
|
245
|
+
readonly op: "update";
|
|
246
|
+
readonly itemId: string;
|
|
247
|
+
/** Merged into the entry's values; `null` removes a value. */
|
|
248
|
+
readonly values?: Readonly<Record<string, unknown>>;
|
|
249
|
+
readonly query?: Readonly<Record<string, unknown>> | null;
|
|
250
|
+
}
|
|
251
|
+
| {
|
|
252
|
+
readonly op: "move";
|
|
253
|
+
readonly itemId: string;
|
|
254
|
+
readonly parentId?: string | null;
|
|
255
|
+
readonly position: number;
|
|
256
|
+
}
|
|
257
|
+
| { readonly op: "remove"; readonly itemId: string };
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Apply item-level patches to a tree. Pure; the result still needs
|
|
261
|
+
* {@link validateListItems}. Throws {@link ListPatchError} when a patch
|
|
262
|
+
* names an entry that does not exist (or moves an entry into itself).
|
|
263
|
+
*/
|
|
264
|
+
export function applyListItemPatches(
|
|
265
|
+
items: readonly ListItem[],
|
|
266
|
+
patches: readonly ListItemPatch[],
|
|
267
|
+
): ListItemInput[] {
|
|
268
|
+
const tree: ListItemInput[] = structuredClone(items as ListItem[]) as ListItemInput[];
|
|
269
|
+
patches.forEach((p, i) => {
|
|
270
|
+
const at = `patches[${i}]`;
|
|
271
|
+
switch (p.op) {
|
|
272
|
+
case "add": {
|
|
273
|
+
const level = levelOf(tree, p.parentId ?? null, at);
|
|
274
|
+
insertAt(level, structuredClone(p.item), p.position);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "update": {
|
|
278
|
+
const hit = locate(tree, p.itemId);
|
|
279
|
+
if (!hit) throw new ListPatchError(at, `no entry with id ${p.itemId}.`);
|
|
280
|
+
const entry = hit.level[hit.index] as ListItemInput;
|
|
281
|
+
const values = { ...entry.values };
|
|
282
|
+
for (const [k, v] of Object.entries(p.values ?? {})) {
|
|
283
|
+
if (v === null) delete values[k];
|
|
284
|
+
else values[k] = v;
|
|
285
|
+
}
|
|
286
|
+
entry.values = values;
|
|
287
|
+
if (p.query === null) delete entry.query;
|
|
288
|
+
else if (p.query !== undefined) entry.query = { ...p.query };
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "move": {
|
|
292
|
+
const hit = locate(tree, p.itemId);
|
|
293
|
+
if (!hit) throw new ListPatchError(at, `no entry with id ${p.itemId}.`);
|
|
294
|
+
const [moved] = hit.level.splice(hit.index, 1) as [ListItemInput];
|
|
295
|
+
if (p.parentId && (p.parentId === p.itemId || containsId(moved, p.parentId))) {
|
|
296
|
+
throw new ListPatchError(at, "an entry cannot move into itself or its own children.");
|
|
297
|
+
}
|
|
298
|
+
insertAt(levelOf(tree, p.parentId ?? null, at), moved, p.position);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "remove": {
|
|
302
|
+
const hit = locate(tree, p.itemId);
|
|
303
|
+
if (!hit) throw new ListPatchError(at, `no entry with id ${p.itemId}.`);
|
|
304
|
+
hit.level.splice(hit.index, 1);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
return tree;
|
|
310
|
+
|
|
311
|
+
function levelOf(t: ListItemInput[], parentId: string | null, at: string): ListItemInput[] {
|
|
312
|
+
if (parentId === null) return t;
|
|
313
|
+
const hit = locate(t, parentId);
|
|
314
|
+
if (!hit) throw new ListPatchError(at, `no parent entry with id ${parentId}.`);
|
|
315
|
+
const parent = hit.level[hit.index] as ListItemInput;
|
|
316
|
+
parent.children ??= [];
|
|
317
|
+
return parent.children;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A patch that cannot apply (unknown id, move into itself). */
|
|
322
|
+
export class ListPatchError extends Error {
|
|
323
|
+
readonly path: string;
|
|
324
|
+
constructor(path: string, message: string) {
|
|
325
|
+
super(`${path}: ${message}`);
|
|
326
|
+
this.path = path;
|
|
327
|
+
this.name = "ListPatchError";
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function insertAt(level: ListItemInput[], item: ListItemInput, position: number | undefined): void {
|
|
332
|
+
const pos = position === undefined ? level.length : Math.max(0, Math.min(position, level.length));
|
|
333
|
+
level.splice(pos, 0, item);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function locate(
|
|
337
|
+
level: ListItemInput[],
|
|
338
|
+
id: string,
|
|
339
|
+
): { level: ListItemInput[]; index: number } | null {
|
|
340
|
+
for (let i = 0; i < level.length; i += 1) {
|
|
341
|
+
const it = level[i] as ListItemInput;
|
|
342
|
+
if (it.id === id) return { level, index: i };
|
|
343
|
+
if (it.children) {
|
|
344
|
+
const hit = locate(it.children, id);
|
|
345
|
+
if (hit) return hit;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function containsId(item: ListItemInput, id: string): boolean {
|
|
352
|
+
return (item.children ?? []).some((c) => c.id === id || containsId(c, id));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Per-entry change between two versions of a tree (domain events, `docs/lists.md` §9). */
|
|
356
|
+
export type ListItemChange =
|
|
357
|
+
| { readonly kind: "added"; readonly itemId: string }
|
|
358
|
+
| {
|
|
359
|
+
readonly kind: "updated";
|
|
360
|
+
readonly itemId: string;
|
|
361
|
+
readonly fields: readonly string[];
|
|
362
|
+
readonly before: Readonly<Record<string, unknown>>;
|
|
363
|
+
}
|
|
364
|
+
| { readonly kind: "moved"; readonly itemId: string }
|
|
365
|
+
| { readonly kind: "removed"; readonly itemId: string };
|
|
366
|
+
|
|
367
|
+
/** Diff two trees by entry id. Deterministic order: removed, added, updated, moved. */
|
|
368
|
+
export function diffListItems(
|
|
369
|
+
before: readonly ListItem[],
|
|
370
|
+
after: readonly ListItem[],
|
|
371
|
+
): ListItemChange[] {
|
|
372
|
+
const index = (items: readonly ListItem[]) => {
|
|
373
|
+
const m = new Map<string, { item: ListItem; parentId: string | null; position: number }>();
|
|
374
|
+
walkListItems(items, (item, parentId, _d, position) =>
|
|
375
|
+
m.set(item.id, { item, parentId, position }),
|
|
376
|
+
);
|
|
377
|
+
return m;
|
|
378
|
+
};
|
|
379
|
+
const a = index(before);
|
|
380
|
+
const b = index(after);
|
|
381
|
+
const out: ListItemChange[] = [];
|
|
382
|
+
for (const id of a.keys()) if (!b.has(id)) out.push({ kind: "removed", itemId: id });
|
|
383
|
+
for (const id of b.keys()) if (!a.has(id)) out.push({ kind: "added", itemId: id });
|
|
384
|
+
for (const [id, next] of b) {
|
|
385
|
+
const prev = a.get(id);
|
|
386
|
+
if (!prev) continue;
|
|
387
|
+
const keys = new Set([...Object.keys(prev.item.values), ...Object.keys(next.item.values)]);
|
|
388
|
+
const fields = [...keys].filter(
|
|
389
|
+
(k) => JSON.stringify(prev.item.values[k]) !== JSON.stringify(next.item.values[k]),
|
|
390
|
+
);
|
|
391
|
+
if (JSON.stringify(prev.item.query ?? null) !== JSON.stringify(next.item.query ?? null)) {
|
|
392
|
+
fields.push("query");
|
|
393
|
+
}
|
|
394
|
+
if (fields.length > 0) {
|
|
395
|
+
out.push({ kind: "updated", itemId: id, fields, before: prev.item.values });
|
|
396
|
+
}
|
|
397
|
+
if (prev.parentId !== next.parentId || prev.position !== next.position) {
|
|
398
|
+
out.push({ kind: "moved", itemId: id });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared fixture data for the list rendering tests (epic #1107). A
|
|
5
|
+
* `.test.ts` file so it never ships in `dist`; it declares no tests of
|
|
6
|
+
* its own.
|
|
7
|
+
*
|
|
8
|
+
* The site: a home page, About (with Team below it), a News section
|
|
9
|
+
* with two published news pages and one draft, a header menu, a mega
|
|
10
|
+
* menu (entry → column / news feed / promo → sub link), a tag
|
|
11
|
+
* vocabulary and an image.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createSiteLinkIndex, type LinkPageVariant } from "../link-refs.js";
|
|
15
|
+
import { DEFAULT_LIST_TYPES } from "./default-types.js";
|
|
16
|
+
import type { ListItem } from "./items.js";
|
|
17
|
+
import type { ListTypeDef } from "./list-type.js";
|
|
18
|
+
import type {
|
|
19
|
+
ListPageRecord,
|
|
20
|
+
ListRenderData,
|
|
21
|
+
ListVariantOverlay,
|
|
22
|
+
LoadedList,
|
|
23
|
+
} from "./render-data.js";
|
|
24
|
+
|
|
25
|
+
const id = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
|
26
|
+
|
|
27
|
+
export const PAGE = {
|
|
28
|
+
home: id(1),
|
|
29
|
+
about: id(2),
|
|
30
|
+
team: id(3),
|
|
31
|
+
news: id(4),
|
|
32
|
+
newsA: id(5),
|
|
33
|
+
newsB: id(6),
|
|
34
|
+
newsDraft: id(7),
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export const LIST = { header: id(101), mega: id(102), tags: id(103) } as const;
|
|
38
|
+
|
|
39
|
+
export const ITEM = {
|
|
40
|
+
home: id(201),
|
|
41
|
+
about: id(202),
|
|
42
|
+
team: id(203),
|
|
43
|
+
newsEntry: id(204),
|
|
44
|
+
products: id(205),
|
|
45
|
+
column: id(206),
|
|
46
|
+
subA: id(207),
|
|
47
|
+
subB: id(208),
|
|
48
|
+
feed: id(209),
|
|
49
|
+
promo: id(210),
|
|
50
|
+
tagRelease: id(211),
|
|
51
|
+
tagEvent: id(212),
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export const MODULE = { teaser: id(301) } as const;
|
|
55
|
+
export const INSTANCE = { teaser: id(401), placement: id(402) } as const;
|
|
56
|
+
export const MEDIA = { hero: id(501) } as const;
|
|
57
|
+
|
|
58
|
+
const termRef = (item: string): string => `caelo:term/${LIST.tags}/${item}`;
|
|
59
|
+
export const TERM = { release: termRef(ITEM.tagRelease), event: termRef(ITEM.tagEvent) };
|
|
60
|
+
|
|
61
|
+
/** A mega menu: entries with columns of links, a "latest news" feed and a promo teaser module. */
|
|
62
|
+
export const MEGA_MENU_TYPE: ListTypeDef = {
|
|
63
|
+
slug: "mega-menu",
|
|
64
|
+
name: "Mega menu",
|
|
65
|
+
purpose: "Header navigation with dropdown columns, a news feed and a promo.",
|
|
66
|
+
role: "navigation",
|
|
67
|
+
root: { allow: ["entry"] },
|
|
68
|
+
nodes: {
|
|
69
|
+
entry: {
|
|
70
|
+
label: "Entry",
|
|
71
|
+
fields: [
|
|
72
|
+
{ name: "label", label: "Label", type: "text", required: true },
|
|
73
|
+
{ name: "link", label: "Link", type: "link" },
|
|
74
|
+
],
|
|
75
|
+
children: { name: "columns", allow: ["column", "news_feed", "promo"] },
|
|
76
|
+
current_from: "link",
|
|
77
|
+
},
|
|
78
|
+
column: {
|
|
79
|
+
label: "Column",
|
|
80
|
+
fields: [{ name: "heading", label: "Heading", type: "text" }],
|
|
81
|
+
children: { name: "links", allow: ["sub_link"] },
|
|
82
|
+
},
|
|
83
|
+
sub_link: {
|
|
84
|
+
label: "Link",
|
|
85
|
+
fields: [
|
|
86
|
+
{ name: "label", label: "Label", type: "text", required: true },
|
|
87
|
+
{ name: "link", label: "Link", type: "link", required: true },
|
|
88
|
+
{ name: "badge", label: "Badge", type: "select", options: ["new", "hot"] },
|
|
89
|
+
],
|
|
90
|
+
current_from: "link",
|
|
91
|
+
},
|
|
92
|
+
news_feed: {
|
|
93
|
+
label: "Latest news",
|
|
94
|
+
fields: [{ name: "heading", label: "Heading", type: "text" }],
|
|
95
|
+
source: {
|
|
96
|
+
kind: "query",
|
|
97
|
+
editable: ["limit", "terms"],
|
|
98
|
+
defaults: { page_type: "news", order: "published_desc", limit: 3 },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
promo: {
|
|
102
|
+
label: "Promo",
|
|
103
|
+
fields: [
|
|
104
|
+
{ name: "title", label: "Title", type: "text" },
|
|
105
|
+
{ name: "teaser", label: "Teaser", type: "module" },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
max_depth: 3,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const page = (
|
|
113
|
+
pageId: string,
|
|
114
|
+
title: string,
|
|
115
|
+
extra: Partial<ListPageRecord> = {},
|
|
116
|
+
): ListPageRecord => ({
|
|
117
|
+
id: pageId,
|
|
118
|
+
title,
|
|
119
|
+
parentId: null,
|
|
120
|
+
pageTypeSlug: "page",
|
|
121
|
+
properties: {},
|
|
122
|
+
terms: [],
|
|
123
|
+
firstPublishedAt: "2026-01-01T00:00:00.000Z",
|
|
124
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
125
|
+
summary: null,
|
|
126
|
+
image: null,
|
|
127
|
+
inBuild: true,
|
|
128
|
+
...extra,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const PAGES: readonly ListPageRecord[] = [
|
|
132
|
+
page(PAGE.home, "Home"),
|
|
133
|
+
page(PAGE.about, "About"),
|
|
134
|
+
page(PAGE.team, "Team", { parentId: PAGE.about }),
|
|
135
|
+
page(PAGE.news, "News"),
|
|
136
|
+
page(PAGE.newsA, "Launch day", {
|
|
137
|
+
parentId: PAGE.news,
|
|
138
|
+
pageTypeSlug: "news",
|
|
139
|
+
terms: [TERM.release],
|
|
140
|
+
firstPublishedAt: "2026-10-01T09:00:00.000Z",
|
|
141
|
+
properties: { rating: 4, event_date: "2026-11-02" },
|
|
142
|
+
summary: "We launched.",
|
|
143
|
+
image: `caelo:media/${MEDIA.hero}`,
|
|
144
|
+
}),
|
|
145
|
+
page(PAGE.newsB, "Meetup", {
|
|
146
|
+
parentId: PAGE.news,
|
|
147
|
+
pageTypeSlug: "news",
|
|
148
|
+
terms: [TERM.event],
|
|
149
|
+
firstPublishedAt: "2026-10-05T09:00:00.000Z",
|
|
150
|
+
properties: { rating: 5 },
|
|
151
|
+
}),
|
|
152
|
+
page(PAGE.newsDraft, "Draft post", {
|
|
153
|
+
parentId: PAGE.news,
|
|
154
|
+
pageTypeSlug: "news",
|
|
155
|
+
firstPublishedAt: null,
|
|
156
|
+
inBuild: false,
|
|
157
|
+
}),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const entry = (
|
|
161
|
+
itemId: string,
|
|
162
|
+
node: string,
|
|
163
|
+
values: Record<string, unknown>,
|
|
164
|
+
extra: Partial<ListItem> = {},
|
|
165
|
+
): ListItem => ({ id: itemId, node, values, ...extra });
|
|
166
|
+
|
|
167
|
+
export const HEADER_ITEMS: readonly ListItem[] = [
|
|
168
|
+
entry(ITEM.home, "entry", { label: "Home", link: { href: `caelo:page/${PAGE.home}` } }),
|
|
169
|
+
entry(
|
|
170
|
+
ITEM.about,
|
|
171
|
+
"entry",
|
|
172
|
+
{ label: "About", link: { href: `caelo:page/${PAGE.about}` } },
|
|
173
|
+
{
|
|
174
|
+
children: [
|
|
175
|
+
entry(ITEM.team, "entry", { label: "Team", link: { href: `caelo:page/${PAGE.team}` } }),
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
),
|
|
179
|
+
entry(ITEM.newsEntry, "entry", { label: "News", link: { href: `caelo:page/${PAGE.news}` } }),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
export const MEGA_ITEMS: readonly ListItem[] = [
|
|
183
|
+
entry(
|
|
184
|
+
ITEM.products,
|
|
185
|
+
"entry",
|
|
186
|
+
{ label: "Products", link: { href: `caelo:page/${PAGE.about}` } },
|
|
187
|
+
{
|
|
188
|
+
children: [
|
|
189
|
+
entry(
|
|
190
|
+
ITEM.column,
|
|
191
|
+
"column",
|
|
192
|
+
{ heading: "Explore" },
|
|
193
|
+
{
|
|
194
|
+
children: [
|
|
195
|
+
entry(ITEM.subA, "sub_link", {
|
|
196
|
+
label: "Team",
|
|
197
|
+
link: { href: `caelo:page/${PAGE.team}` },
|
|
198
|
+
badge: "new",
|
|
199
|
+
}),
|
|
200
|
+
entry(ITEM.subB, "sub_link", {
|
|
201
|
+
label: "Docs",
|
|
202
|
+
link: { href: "https://docs.example" },
|
|
203
|
+
}),
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
entry(ITEM.feed, "news_feed", { heading: "Latest" }, { query: { limit: 2 } }),
|
|
208
|
+
entry(ITEM.promo, "promo", {
|
|
209
|
+
title: "Try it",
|
|
210
|
+
teaser: { moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser },
|
|
211
|
+
}),
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const list = (
|
|
218
|
+
listId: string,
|
|
219
|
+
slug: string,
|
|
220
|
+
typeSlug: string,
|
|
221
|
+
items: readonly ListItem[],
|
|
222
|
+
): LoadedList => ({
|
|
223
|
+
id: listId,
|
|
224
|
+
slug,
|
|
225
|
+
name: slug,
|
|
226
|
+
typeSlug,
|
|
227
|
+
items,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
export const LISTS: readonly LoadedList[] = [
|
|
231
|
+
list(LIST.header, "header", "menu", HEADER_ITEMS),
|
|
232
|
+
list(LIST.mega, "mega", "mega-menu", MEGA_ITEMS),
|
|
233
|
+
list(LIST.tags, "tags", "tags", [
|
|
234
|
+
entry(ITEM.tagRelease, "tag", { name: "Release", slug: "release" }),
|
|
235
|
+
entry(ITEM.tagEvent, "tag", { name: "Event", slug: "event" }),
|
|
236
|
+
]),
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/** The render data of the fixture site; `overlays` / `pageVariants` per test. */
|
|
240
|
+
export function fixtureData(
|
|
241
|
+
extra: {
|
|
242
|
+
overlays?: ReadonlyMap<string, ListVariantOverlay>;
|
|
243
|
+
pageVariants?: ReadonlyMap<string, LinkPageVariant>;
|
|
244
|
+
pages?: readonly ListPageRecord[];
|
|
245
|
+
} = {},
|
|
246
|
+
): ListRenderData {
|
|
247
|
+
const types = [...DEFAULT_LIST_TYPES, MEGA_MENU_TYPE];
|
|
248
|
+
return {
|
|
249
|
+
listTypes: new Map(types.map((t, i) => [t.slug, { id: id(600 + i), def: t }])),
|
|
250
|
+
lists: new Map(LISTS.map((l) => [l.id, l])),
|
|
251
|
+
pageTypes: new Map([
|
|
252
|
+
[
|
|
253
|
+
"news",
|
|
254
|
+
{
|
|
255
|
+
id: id(701),
|
|
256
|
+
def: {
|
|
257
|
+
slug: "news",
|
|
258
|
+
name: "News",
|
|
259
|
+
purpose: "News posts.",
|
|
260
|
+
templates: [id(801)],
|
|
261
|
+
defaultTemplate: id(801),
|
|
262
|
+
properties: [
|
|
263
|
+
{ name: "rating", label: "Rating", type: "number" },
|
|
264
|
+
{ name: "event_date", label: "Event date", type: "date" },
|
|
265
|
+
],
|
|
266
|
+
vocabularies: [{ list: LIST.tags, cardinality: "many" }],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
]),
|
|
271
|
+
pages: new Map((extra.pages ?? PAGES).map((p) => [p.id, p])),
|
|
272
|
+
media: new Map([[MEDIA.hero, { id: MEDIA.hero, alt: "Hero", width: 1200, height: 600 }]]),
|
|
273
|
+
...(extra.pageVariants ? { pageVariants: extra.pageVariants } : {}),
|
|
274
|
+
...(extra.overlays ? { overlays: extra.overlays } : {}),
|
|
275
|
+
siteLanguage: "en",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const PATHS: Record<string, string> = {
|
|
280
|
+
[PAGE.home]: "/",
|
|
281
|
+
[PAGE.about]: "/about",
|
|
282
|
+
[PAGE.team]: "/about/team",
|
|
283
|
+
[PAGE.news]: "/news",
|
|
284
|
+
[PAGE.newsA]: "/news/launch",
|
|
285
|
+
[PAGE.newsB]: "/news/meetup",
|
|
286
|
+
[PAGE.newsDraft]: "/news/draft",
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** A link index over the fixture pages (preview env, directory URLs). */
|
|
290
|
+
export function fixtureLinkIndex(pageVariants?: ReadonlyMap<string, LinkPageVariant>) {
|
|
291
|
+
return createSiteLinkIndex({
|
|
292
|
+
pages: PAGES.map((p) => ({
|
|
293
|
+
id: p.id,
|
|
294
|
+
path: PATHS[p.id] ?? "/",
|
|
295
|
+
status: p.inBuild ? "published" : "draft",
|
|
296
|
+
})),
|
|
297
|
+
media: [{ id: MEDIA.hero, slug: "hero" }],
|
|
298
|
+
env: "preview",
|
|
299
|
+
pageUrlStyle: "directory",
|
|
300
|
+
...(pageVariants ? { pageVariants } : {}),
|
|
301
|
+
});
|
|
302
|
+
}
|