@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.
Files changed (202) hide show
  1. package/dist/ai-tools.d.ts +125 -50
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +50 -38
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +110 -30
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +26 -7
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +203 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +178 -76
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-var-aliases.d.ts +20 -0
  14. package/dist/css-var-aliases.d.ts.map +1 -0
  15. package/dist/css-var-aliases.js +95 -0
  16. package/dist/css-var-aliases.js.map +1 -0
  17. package/dist/css-var-scan.d.ts +0 -16
  18. package/dist/css-var-scan.d.ts.map +1 -1
  19. package/dist/css-var-scan.js +24 -0
  20. package/dist/css-var-scan.js.map +1 -1
  21. package/dist/edit-markers.d.ts +41 -5
  22. package/dist/edit-markers.d.ts.map +1 -1
  23. package/dist/edit-markers.js +47 -50
  24. package/dist/edit-markers.js.map +1 -1
  25. package/dist/id-safe-truncate.d.ts +15 -0
  26. package/dist/id-safe-truncate.d.ts.map +1 -0
  27. package/dist/id-safe-truncate.js +47 -0
  28. package/dist/id-safe-truncate.js.map +1 -0
  29. package/dist/index.d.ts +5 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/interactions.js +2 -2
  34. package/dist/interactions.js.map +1 -1
  35. package/dist/link-canonicalise.d.ts.map +1 -1
  36. package/dist/link-canonicalise.js +7 -1
  37. package/dist/link-canonicalise.js.map +1 -1
  38. package/dist/link-refs.d.ts +1 -1
  39. package/dist/link-refs.js +3 -3
  40. package/dist/lists/address.d.ts +34 -0
  41. package/dist/lists/address.d.ts.map +1 -0
  42. package/dist/lists/address.js +43 -0
  43. package/dist/lists/address.js.map +1 -0
  44. package/dist/lists/default-types.d.ts +10 -0
  45. package/dist/lists/default-types.d.ts.map +1 -0
  46. package/dist/lists/default-types.js +91 -0
  47. package/dist/lists/default-types.js.map +1 -0
  48. package/dist/lists/fields.d.ts +152 -0
  49. package/dist/lists/fields.d.ts.map +1 -0
  50. package/dist/lists/fields.js +258 -0
  51. package/dist/lists/fields.js.map +1 -0
  52. package/dist/lists/index.d.ts +14 -0
  53. package/dist/lists/index.d.ts.map +1 -0
  54. package/dist/lists/index.js +15 -0
  55. package/dist/lists/index.js.map +1 -0
  56. package/dist/lists/item-attr.d.ts +26 -0
  57. package/dist/lists/item-attr.d.ts.map +1 -0
  58. package/dist/lists/item-attr.js +31 -0
  59. package/dist/lists/item-attr.js.map +1 -0
  60. package/dist/lists/items.d.ts +105 -0
  61. package/dist/lists/items.d.ts.map +1 -0
  62. package/dist/lists/items.js +304 -0
  63. package/dist/lists/items.js.map +1 -0
  64. package/dist/lists/list-type.d.ts +401 -0
  65. package/dist/lists/list-type.d.ts.map +1 -0
  66. package/dist/lists/list-type.js +275 -0
  67. package/dist/lists/list-type.js.map +1 -0
  68. package/dist/lists/model.d.ts +14 -0
  69. package/dist/lists/model.d.ts.map +1 -0
  70. package/dist/lists/model.js +15 -0
  71. package/dist/lists/model.js.map +1 -0
  72. package/dist/lists/page-type.d.ts +115 -0
  73. package/dist/lists/page-type.d.ts.map +1 -0
  74. package/dist/lists/page-type.js +66 -0
  75. package/dist/lists/page-type.js.map +1 -0
  76. package/dist/lists/refs.d.ts +13 -0
  77. package/dist/lists/refs.d.ts.map +1 -0
  78. package/dist/lists/refs.js +18 -0
  79. package/dist/lists/refs.js.map +1 -0
  80. package/dist/lists/render-data.d.ts +114 -0
  81. package/dist/lists/render-data.d.ts.map +1 -0
  82. package/dist/lists/render-data.js +3 -0
  83. package/dist/lists/render-data.js.map +1 -0
  84. package/dist/lists/render-deps.d.ts +68 -0
  85. package/dist/lists/render-deps.d.ts.map +1 -0
  86. package/dist/lists/render-deps.js +91 -0
  87. package/dist/lists/render-deps.js.map +1 -0
  88. package/dist/lists/resolve-values.d.ts +63 -0
  89. package/dist/lists/resolve-values.d.ts.map +1 -0
  90. package/dist/lists/resolve-values.js +181 -0
  91. package/dist/lists/resolve-values.js.map +1 -0
  92. package/dist/lists/resolve.d.ts +106 -0
  93. package/dist/lists/resolve.d.ts.map +1 -0
  94. package/dist/lists/resolve.js +878 -0
  95. package/dist/lists/resolve.js.map +1 -0
  96. package/dist/lists/scope.d.ts +81 -0
  97. package/dist/lists/scope.d.ts.map +1 -0
  98. package/dist/lists/scope.js +3 -0
  99. package/dist/lists/scope.js.map +1 -0
  100. package/dist/markup-structure.d.ts +8 -0
  101. package/dist/markup-structure.d.ts.map +1 -1
  102. package/dist/markup-structure.js +2 -0
  103. package/dist/markup-structure.js.map +1 -1
  104. package/dist/media.d.ts +1 -1
  105. package/dist/nested-module-render.d.ts +25 -20
  106. package/dist/nested-module-render.d.ts.map +1 -1
  107. package/dist/nested-module-render.js +122 -31
  108. package/dist/nested-module-render.js.map +1 -1
  109. package/dist/page-log.d.ts +1 -1
  110. package/dist/preview-compose.d.ts +24 -21
  111. package/dist/preview-compose.d.ts.map +1 -1
  112. package/dist/preview-compose.js +34 -118
  113. package/dist/preview-compose.js.map +1 -1
  114. package/dist/preview-scanner.d.ts +5 -2
  115. package/dist/preview-scanner.d.ts.map +1 -1
  116. package/dist/preview-scanner.js +5 -3
  117. package/dist/preview-scanner.js.map +1 -1
  118. package/dist/source-text-transport.d.ts +17 -0
  119. package/dist/source-text-transport.d.ts.map +1 -0
  120. package/dist/source-text-transport.js +71 -0
  121. package/dist/source-text-transport.js.map +1 -0
  122. package/dist/template-engine.d.ts +44 -31
  123. package/dist/template-engine.d.ts.map +1 -1
  124. package/dist/template-engine.js +75 -174
  125. package/dist/template-engine.js.map +1 -1
  126. package/dist/template-sections.d.ts +69 -0
  127. package/dist/template-sections.d.ts.map +1 -0
  128. package/dist/template-sections.js +423 -0
  129. package/dist/template-sections.js.map +1 -0
  130. package/dist/uuid-diagnose.d.ts +9 -0
  131. package/dist/uuid-diagnose.d.ts.map +1 -0
  132. package/dist/uuid-diagnose.js +47 -0
  133. package/dist/uuid-diagnose.js.map +1 -0
  134. package/dist/version.d.ts +2 -2
  135. package/dist/version.d.ts.map +1 -1
  136. package/dist/version.js +1 -1
  137. package/dist/version.js.map +1 -1
  138. package/package.json +8 -1
  139. package/src/__tests__/redos-hardening.test.ts +50 -16
  140. package/src/ai-tools-add-module-modes.test.ts +50 -0
  141. package/src/ai-tools.test.ts +10 -0
  142. package/src/ai-tools.ts +53 -39
  143. package/src/build-page.test.ts +34 -3
  144. package/src/build-page.ts +29 -6
  145. package/src/content.test.ts +111 -3
  146. package/src/content.ts +188 -77
  147. package/src/css-var-aliases.test.ts +61 -0
  148. package/src/css-var-aliases.ts +109 -0
  149. package/src/css-var-scan.ts +9 -0
  150. package/src/edit-markers.test.ts +37 -21
  151. package/src/edit-markers.ts +52 -20
  152. package/src/id-safe-truncate.test.ts +35 -0
  153. package/src/id-safe-truncate.ts +46 -0
  154. package/src/index.ts +5 -1
  155. package/src/interactions.ts +2 -2
  156. package/src/link-canonicalise.test.ts +36 -6
  157. package/src/link-canonicalise.ts +6 -1
  158. package/src/link-refs.test.ts +66 -42
  159. package/src/link-refs.ts +3 -3
  160. package/src/lists/address.ts +75 -0
  161. package/src/lists/default-types.ts +102 -0
  162. package/src/lists/fields.ts +285 -0
  163. package/src/lists/index.ts +16 -0
  164. package/src/lists/item-attr.test.ts +36 -0
  165. package/src/lists/item-attr.ts +49 -0
  166. package/src/lists/items.ts +402 -0
  167. package/src/lists/list-fixtures.test.ts +302 -0
  168. package/src/lists/list-type.ts +306 -0
  169. package/src/lists/lists.test.ts +328 -0
  170. package/src/lists/model.ts +16 -0
  171. package/src/lists/page-type.ts +71 -0
  172. package/src/lists/refs.ts +21 -0
  173. package/src/lists/render-data.ts +125 -0
  174. package/src/lists/render-deps.test.ts +132 -0
  175. package/src/lists/render-deps.ts +144 -0
  176. package/src/lists/resolve-values.ts +224 -0
  177. package/src/lists/resolve.test.ts +295 -0
  178. package/src/lists/resolve.ts +1148 -0
  179. package/src/lists/scope.ts +69 -0
  180. package/src/lists-compose-parity.test.ts +262 -0
  181. package/src/markup-structure.ts +10 -0
  182. package/src/nested-module-render.test.ts +111 -77
  183. package/src/nested-module-render.ts +85 -48
  184. package/src/preview-compose.test.ts +74 -19
  185. package/src/preview-compose.ts +59 -154
  186. package/src/preview-scanner.test.ts +8 -4
  187. package/src/preview-scanner.ts +5 -3
  188. package/src/source-text-transport.test.ts +63 -0
  189. package/src/source-text-transport.ts +70 -0
  190. package/src/template-engine-lists.test.ts +553 -0
  191. package/src/template-engine.test.ts +62 -80
  192. package/src/template-engine.ts +102 -245
  193. package/src/template-sections.ts +500 -0
  194. package/src/uuid-diagnose.test.ts +28 -0
  195. package/src/uuid-diagnose.ts +50 -0
  196. package/src/version.ts +1 -1
  197. package/dist/structured-sets.d.ts +0 -70
  198. package/dist/structured-sets.d.ts.map +0 -1
  199. package/dist/structured-sets.js +0 -92
  200. package/dist/structured-sets.js.map +0 -1
  201. package/src/structured-sets.ts +0 -114
  202. package/src/template-engine-data-lists.test.ts +0 -90
@@ -0,0 +1,306 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * List types — the definition part of a list (epic #1107,
5
+ * `docs/lists.md` §3). A list type names the entry kinds (`nodes`), the
6
+ * fields each carries, what may hang under what, and which nodes are
7
+ * filled from a source instead of stored children. Every form of list
8
+ * is expressed with it: a flat link group, a three-level menu, a mega
9
+ * dropdown with columns, teasers and a "latest news" block, an unbounded
10
+ * category tree.
11
+ */
12
+
13
+ import { z } from "zod";
14
+ import {
15
+ checkFieldDef,
16
+ type FieldDef,
17
+ fieldDefSchema,
18
+ fieldExtSchema,
19
+ isReservedListName,
20
+ type ListIssue,
21
+ listNameSchema,
22
+ } from "./fields.js";
23
+
24
+ /** Hard ceiling for any tree, recursive or not. */
25
+ export const LIST_MAX_DEPTH = 8;
26
+ /** Entries per list (all levels together). */
27
+ export const LIST_MAX_ITEMS = 500;
28
+ /** Entries one query may return. */
29
+ export const LIST_QUERY_MAX_LIMIT = 50;
30
+
31
+ export const QUERY_KNOBS = [
32
+ "page_type",
33
+ "terms",
34
+ "terms_mode",
35
+ "parent",
36
+ "exclude_current",
37
+ "order",
38
+ "limit",
39
+ ] as const;
40
+ export type QueryKnob = (typeof QUERY_KNOBS)[number];
41
+
42
+ export const SYSTEM_SOURCE_NAMES = [
43
+ "breadcrumbs",
44
+ "children",
45
+ "siblings",
46
+ "page_terms",
47
+ "term_pages",
48
+ ] as const;
49
+ export type SystemSourceName = (typeof SYSTEM_SOURCE_NAMES)[number];
50
+
51
+ const queryOrderSchema = z.union([
52
+ z.enum(["published_desc", "published_asc", "title_asc"]),
53
+ z.string().regex(/^prop_(?:desc|asc):[a-z][a-z0-9_]{0,63}$/),
54
+ ]);
55
+
56
+ /** A query over pages. Resolved at render time; nothing is stored. */
57
+ export const pageQuerySchema = z
58
+ .object({
59
+ page_type: z
60
+ .string()
61
+ .regex(/^[a-z0-9-]+$/)
62
+ .optional(),
63
+ terms: z
64
+ .array(z.string().regex(/^caelo:term\/[0-9a-f-]{36}\/[0-9a-f-]{36}$/))
65
+ .max(20)
66
+ .optional(),
67
+ terms_mode: z.enum(["any", "all"]).optional(),
68
+ parent: z
69
+ .string()
70
+ .regex(/^caelo:page\/[0-9a-f-]{36}$/)
71
+ .optional(),
72
+ exclude_current: z.boolean().optional(),
73
+ order: queryOrderSchema,
74
+ limit: z.number().int().min(1).max(LIST_QUERY_MAX_LIMIT),
75
+ })
76
+ .strict();
77
+ export type PageQuery = z.infer<typeof pageQuerySchema>;
78
+
79
+ /** Partial query: defaults on a node, or the knobs an entry overrides. */
80
+ export const pageQueryPartialSchema = pageQuerySchema.partial();
81
+
82
+ export const sourceDefSchema = z.discriminatedUnion("kind", [
83
+ z
84
+ .object({
85
+ kind: z.literal("query"),
86
+ /** Which query parts an editor (or the AI) may set per entry/placement. */
87
+ editable: z.array(z.enum(QUERY_KNOBS)).max(QUERY_KNOBS.length),
88
+ defaults: pageQueryPartialSchema,
89
+ })
90
+ .strict(),
91
+ z
92
+ .object({
93
+ kind: z.literal("system"),
94
+ // The AI reached for {kind:"system", name:"consent_categories"} in the
95
+ // PR #1121 e2e run: name the plugin form in the refusal itself.
96
+ name: z.enum(SYSTEM_SOURCE_NAMES, {
97
+ error: () =>
98
+ `system sources are ${SYSTEM_SOURCE_NAMES.join(", ")} — a plugin's list source (consent_categories, language_links, …) is {kind:"plugin", name:"<source>"}, and a module that only iterates it needs no field at all: {{#<source>}}…{{/<source>}}.`,
99
+ }),
100
+ })
101
+ .strict(),
102
+ z
103
+ .object({
104
+ kind: z.literal("plugin"),
105
+ /** The list-source name an active plugin declared. */
106
+ name: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
107
+ })
108
+ .strict(),
109
+ ]);
110
+ export type SourceDef = z.infer<typeof sourceDefSchema>;
111
+
112
+ const childrenSchema = z
113
+ .object({
114
+ /** Loop name in templates (`{{#columns}}`). Storage always uses `children`. */
115
+ name: listNameSchema,
116
+ allow: z.array(listNameSchema).min(1).max(32),
117
+ max: z.number().int().min(1).max(LIST_MAX_ITEMS).optional(),
118
+ })
119
+ .strict();
120
+
121
+ export const nodeDefSchema = z
122
+ .object({
123
+ label: z.string().min(1).max(128),
124
+ fields: z.array(fieldDefSchema).max(32),
125
+ children: childrenSchema.optional(),
126
+ /** Link field that decides `is_current` / `is_ancestor`. */
127
+ current_from: listNameSchema.optional(),
128
+ /** Generated children (`items`) instead of stored ones. */
129
+ source: sourceDefSchema.optional(),
130
+ ext: fieldExtSchema.optional(),
131
+ })
132
+ .strict();
133
+ export type NodeDef = z.infer<typeof nodeDefSchema>;
134
+
135
+ export const LIST_TYPE_ROLES = ["navigation", "vocabulary", "content"] as const;
136
+ export type ListTypeRole = (typeof LIST_TYPE_ROLES)[number];
137
+
138
+ export const listTypeDefSchema = z
139
+ .object({
140
+ slug: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/),
141
+ name: z.string().min(1).max(200),
142
+ purpose: z.string().min(1).max(1000),
143
+ role: z.enum(LIST_TYPE_ROLES),
144
+ root: z
145
+ .object({
146
+ allow: z.array(listNameSchema).min(1).max(32),
147
+ max: z.number().int().min(1).max(LIST_MAX_ITEMS).optional(),
148
+ })
149
+ .strict(),
150
+ nodes: z.record(listNameSchema, nodeDefSchema),
151
+ max_depth: z.number().int().min(1).max(LIST_MAX_DEPTH).optional(),
152
+ })
153
+ .strict();
154
+ export type ListTypeDef = z.infer<typeof listTypeDefSchema>;
155
+
156
+ /**
157
+ * Static checks Zod cannot express. Returns every issue found so the AI
158
+ * fixes a definition in one round instead of one error per call.
159
+ */
160
+ export function checkListTypeDef(def: ListTypeDef): ListIssue[] {
161
+ const issues: ListIssue[] = [];
162
+ const kinds = new Set(Object.keys(def.nodes));
163
+ if (kinds.size === 0) {
164
+ issues.push({ path: "nodes", message: "a list type needs at least one node kind." });
165
+ }
166
+ const checkAllow = (allow: readonly string[], path: string): void => {
167
+ for (const k of allow) {
168
+ if (!kinds.has(k)) {
169
+ issues.push({
170
+ path,
171
+ message: `node kind "${k}" is not defined — add it under nodes or remove it from allow.`,
172
+ });
173
+ }
174
+ }
175
+ };
176
+ checkAllow(def.root.allow, "root.allow");
177
+
178
+ for (const [kind, node] of Object.entries(def.nodes)) {
179
+ const at = `nodes.${kind}`;
180
+ if (isReservedListName(kind)) {
181
+ issues.push({ path: at, message: `node kind "${kind}" is a reserved name — rename it.` });
182
+ }
183
+ const seen = new Set<string>();
184
+ node.fields.forEach((f, i) => {
185
+ const fp = `${at}.fields[${i}]`;
186
+ if (seen.has(f.name)) issues.push({ path: fp, message: `duplicate field name "${f.name}".` });
187
+ seen.add(f.name);
188
+ issues.push(...checkFieldDef(f, fp));
189
+ });
190
+ if (node.children) {
191
+ if (isReservedListName(node.children.name)) {
192
+ issues.push({
193
+ path: `${at}.children.name`,
194
+ message: `children name "${node.children.name}" is reserved — pick another loop name.`,
195
+ });
196
+ }
197
+ if (seen.has(node.children.name)) {
198
+ issues.push({
199
+ path: `${at}.children.name`,
200
+ message: `children name "${node.children.name}" collides with a field of the same node.`,
201
+ });
202
+ }
203
+ checkAllow(node.children.allow, `${at}.children.allow`);
204
+ }
205
+ if (node.children && node.source) {
206
+ issues.push({
207
+ path: at,
208
+ message: "a node has either stored children or a source, not both — remove one.",
209
+ });
210
+ }
211
+ if (node.current_from !== undefined) {
212
+ const target = node.fields.find((f) => f.name === node.current_from);
213
+ if (target?.type !== "link") {
214
+ issues.push({
215
+ path: `${at}.current_from`,
216
+ message: `current_from must name a link field of this node ("${node.current_from}" is not one).`,
217
+ });
218
+ }
219
+ }
220
+ if (node.source?.kind === "query") {
221
+ const q = pageQueryPartialSchema.safeParse(node.source.defaults);
222
+ if (!q.success) {
223
+ issues.push({ path: `${at}.source.defaults`, message: "query defaults are invalid." });
224
+ } else if (
225
+ node.source.defaults.order === undefined &&
226
+ !node.source.editable.includes("order")
227
+ ) {
228
+ issues.push({
229
+ path: `${at}.source`,
230
+ message: 'a query source needs a default "order" or "order" in editable.',
231
+ });
232
+ } else if (
233
+ node.source.defaults.limit === undefined &&
234
+ !node.source.editable.includes("limit")
235
+ ) {
236
+ issues.push({
237
+ path: `${at}.source`,
238
+ message: 'a query source needs a default "limit" or "limit" in editable.',
239
+ });
240
+ }
241
+ }
242
+ }
243
+
244
+ if (hasRecursion(def) && def.max_depth === undefined) {
245
+ issues.push({
246
+ path: "max_depth",
247
+ message: "this type nests a node kind inside itself — set max_depth (1–8).",
248
+ });
249
+ }
250
+
251
+ if (def.role === "vocabulary") {
252
+ const rootKinds = def.root.allow;
253
+ for (const k of rootKinds) {
254
+ const node = def.nodes[k];
255
+ if (!node) continue;
256
+ const hasText = (n: string): boolean =>
257
+ node.fields.some((f: FieldDef) => f.name === n && f.type === "text");
258
+ if (!hasText("name") || !hasText("slug")) {
259
+ issues.push({
260
+ path: `nodes.${k}`,
261
+ message: 'vocabulary entries need a text field "name" and a text field "slug".',
262
+ });
263
+ }
264
+ if (node.source) {
265
+ issues.push({ path: `nodes.${k}`, message: "vocabulary entries cannot have a source." });
266
+ }
267
+ }
268
+ }
269
+ return issues;
270
+ }
271
+
272
+ /** True when some node kind can (transitively) contain itself. */
273
+ export function hasRecursion(def: ListTypeDef): boolean {
274
+ const edges = new Map<string, readonly string[]>();
275
+ for (const [k, n] of Object.entries(def.nodes)) edges.set(k, n.children?.allow ?? []);
276
+ const visiting = new Set<string>();
277
+ const done = new Set<string>();
278
+ const visit = (k: string): boolean => {
279
+ if (visiting.has(k)) return true;
280
+ if (done.has(k)) return false;
281
+ visiting.add(k);
282
+ for (const c of edges.get(k) ?? []) if (visit(c)) return true;
283
+ visiting.delete(k);
284
+ done.add(k);
285
+ return false;
286
+ };
287
+ return [...edges.keys()].some(visit);
288
+ }
289
+
290
+ /**
291
+ * Effective depth limit of a type: `max_depth` when set, else the
292
+ * longest non-recursive path (bounded by {@link LIST_MAX_DEPTH}).
293
+ */
294
+ export function effectiveMaxDepth(def: ListTypeDef): number {
295
+ if (def.max_depth !== undefined) return def.max_depth;
296
+ const longest = (k: string, seen: ReadonlySet<string>): number => {
297
+ if (seen.has(k)) return LIST_MAX_DEPTH;
298
+ const next = new Set(seen).add(k);
299
+ const kids = def.nodes[k]?.children?.allow ?? [];
300
+ return 1 + kids.reduce((m, c) => Math.max(m, longest(c, next)), 0);
301
+ };
302
+ return Math.min(
303
+ LIST_MAX_DEPTH,
304
+ def.root.allow.reduce((m, k) => Math.max(m, longest(k, new Set())), 0),
305
+ );
306
+ }
@@ -0,0 +1,328 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { formatValueAddress, parseValueAddress } from "./address.js";
5
+ import { DEFAULT_LIST_TYPES } from "./default-types.js";
6
+ import { checkFieldDef, type FieldDef, validateFieldValue } from "./fields.js";
7
+ import {
8
+ applyListItemPatches,
9
+ diffListItems,
10
+ findListItem,
11
+ type ListItem,
12
+ ListPatchError,
13
+ validateListItems,
14
+ } from "./items.js";
15
+ import {
16
+ checkListTypeDef,
17
+ effectiveMaxDepth,
18
+ hasRecursion,
19
+ type ListTypeDef,
20
+ listTypeDefSchema,
21
+ sourceDefSchema,
22
+ } from "./list-type.js";
23
+ import { checkPageTypeDef, pageTypeDefSchema } from "./page-type.js";
24
+
25
+ const PAGE = "11111111-1111-4111-8111-111111111111";
26
+ const LIST = "22222222-2222-4222-8222-222222222222";
27
+ const ITEM = "33333333-3333-4333-8333-333333333333";
28
+
29
+ function ids(): () => string {
30
+ let n = 0;
31
+ return () => {
32
+ n += 1;
33
+ return `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
34
+ };
35
+ }
36
+
37
+ const MEGA: ListTypeDef = listTypeDefSchema.parse({
38
+ slug: "main-menu",
39
+ name: "Main menu",
40
+ purpose: "Header navigation with mega panels",
41
+ role: "navigation",
42
+ root: { allow: ["entry"], max: 8 },
43
+ nodes: {
44
+ entry: {
45
+ label: "Entry",
46
+ fields: [
47
+ { name: "label", label: "Label", type: "text", required: true },
48
+ { name: "link", label: "Link", type: "link" },
49
+ { name: "panel", label: "Panel", type: "select", options: ["none", "dropdown", "mega"] },
50
+ ],
51
+ children: { name: "columns", allow: ["column", "news_feed", "promo"], max: 4 },
52
+ current_from: "link",
53
+ },
54
+ column: {
55
+ label: "Column",
56
+ fields: [{ name: "heading", label: "Heading", type: "text" }],
57
+ children: { name: "links", allow: ["sub_link"], max: 12 },
58
+ },
59
+ sub_link: {
60
+ label: "Sub link",
61
+ fields: [
62
+ { name: "label", label: "Label", type: "text", required: true },
63
+ { name: "link", label: "Link", type: "link", required: true },
64
+ { name: "icon", label: "Icon", type: "icon" },
65
+ ],
66
+ current_from: "link",
67
+ },
68
+ news_feed: {
69
+ label: "Latest posts",
70
+ fields: [{ name: "heading", label: "Heading", type: "text" }],
71
+ source: {
72
+ kind: "query",
73
+ editable: ["page_type", "terms", "limit"],
74
+ defaults: { page_type: "news", order: "published_desc", limit: 3 },
75
+ },
76
+ },
77
+ promo: {
78
+ label: "Teaser",
79
+ fields: [{ name: "teaser", label: "Teaser", type: "module", accepts: ["promo-card"] }],
80
+ },
81
+ },
82
+ });
83
+
84
+ describe("field values", () => {
85
+ const text: FieldDef = { name: "label", label: "Label", type: "text", required: true };
86
+ it("requires required fields and accepts valid values", () => {
87
+ expect(validateFieldValue(text, undefined, "p")).toHaveLength(1);
88
+ expect(validateFieldValue(text, " ", "p")).toHaveLength(1);
89
+ expect(validateFieldValue(text, "Docs", "p")).toEqual([]);
90
+ });
91
+ it("checks every type's shape", () => {
92
+ const cases: [FieldDef, unknown, boolean][] = [
93
+ [{ name: "l", label: "L", type: "link" }, { href: "/about" }, true],
94
+ [{ name: "l", label: "L", type: "link" }, "/about", false],
95
+ [{ name: "m", label: "M", type: "media" }, `caelo:media/${PAGE}`, true],
96
+ [{ name: "m", label: "M", type: "media" }, "/x.png", false],
97
+ [{ name: "s", label: "S", type: "select", options: ["a", "b"] }, "b", true],
98
+ [{ name: "s", label: "S", type: "select", options: ["a", "b"] }, "c", false],
99
+ [{ name: "b", label: "B", type: "boolean" }, false, true],
100
+ [{ name: "n", label: "N", type: "number", min: 1 }, 0, false],
101
+ [{ name: "d", label: "D", type: "date" }, "2026-10-25", true],
102
+ [{ name: "d", label: "D", type: "date" }, "25.10.2026", false],
103
+ [{ name: "i", label: "I", type: "icon" }, "radar", true],
104
+ [{ name: "t", label: "T", type: "term", list: "tags" }, [`caelo:term/${LIST}/${ITEM}`], true],
105
+ [
106
+ { name: "t", label: "T", type: "term", list: "tags" },
107
+ [`caelo:term/${LIST}/${ITEM}`, `caelo:term/${LIST}/${PAGE}`],
108
+ false,
109
+ ],
110
+ ];
111
+ for (const [f, v, ok] of cases) {
112
+ expect(validateFieldValue(f, v, "p").length === 0).toBe(ok);
113
+ }
114
+ });
115
+ it("rejects reserved names and invalid defaults", () => {
116
+ expect(checkFieldDef({ name: "is_open", label: "x", type: "boolean" }, "f")).toHaveLength(1);
117
+ expect(checkFieldDef({ name: "depth", label: "x", type: "number" }, "f")).toHaveLength(1);
118
+ expect(
119
+ checkFieldDef({ name: "n", label: "x", type: "number", default: "1" }, "f"),
120
+ ).toHaveLength(1);
121
+ });
122
+ });
123
+
124
+ describe("list type definitions", () => {
125
+ it("accepts the mega menu and every shipped type", () => {
126
+ expect(checkListTypeDef(MEGA)).toEqual([]);
127
+ for (const t of DEFAULT_LIST_TYPES)
128
+ expect(checkListTypeDef(listTypeDefSchema.parse(t))).toEqual([]);
129
+ });
130
+ it("requires max_depth for recursive types", () => {
131
+ const t = { ...DEFAULT_LIST_TYPES[2], max_depth: undefined } as ListTypeDef;
132
+ expect(hasRecursion(t)).toBe(true);
133
+ expect(checkListTypeDef(t).map((i) => i.path)).toContain("max_depth");
134
+ });
135
+ it("reports unknown node kinds, bad current_from, children+source", () => {
136
+ const t: ListTypeDef = {
137
+ ...MEGA,
138
+ root: { allow: ["entry", "ghost"] },
139
+ nodes: {
140
+ ...MEGA.nodes,
141
+ column: {
142
+ ...MEGA.nodes.column!,
143
+ current_from: "heading",
144
+ source: { kind: "system", name: "breadcrumbs" },
145
+ },
146
+ },
147
+ };
148
+ const paths = checkListTypeDef(t).map((i) => i.path);
149
+ expect(paths).toContain("root.allow");
150
+ expect(paths).toContain("nodes.column.current_from");
151
+ expect(paths).toContain("nodes.column");
152
+ });
153
+ it("requires name+slug on vocabulary entries", () => {
154
+ const t: ListTypeDef = {
155
+ slug: "v",
156
+ name: "V",
157
+ purpose: "p",
158
+ role: "vocabulary",
159
+ root: { allow: ["t"] },
160
+ nodes: { t: { label: "T", fields: [{ name: "name", label: "N", type: "text" }] } },
161
+ };
162
+ expect(checkListTypeDef(t)).toHaveLength(1);
163
+ });
164
+ it("computes the effective depth", () => {
165
+ expect(effectiveMaxDepth(MEGA)).toBe(3);
166
+ expect(effectiveMaxDepth(listTypeDefSchema.parse(DEFAULT_LIST_TYPES[2]))).toBe(3);
167
+ });
168
+ });
169
+
170
+ describe("list items", () => {
171
+ const items = [
172
+ {
173
+ node: "entry",
174
+ values: { label: "Products", link: { href: `caelo:page/${PAGE}` }, panel: "mega" },
175
+ children: [
176
+ {
177
+ node: "column",
178
+ values: { heading: "Platform" },
179
+ children: [
180
+ { node: "sub_link", values: { label: "Crawler", link: { href: "/crawler" } } },
181
+ ],
182
+ },
183
+ { node: "news_feed", values: { heading: "New" }, query: { limit: 5 } },
184
+ ],
185
+ },
186
+ ];
187
+
188
+ it("validates a mega menu and assigns ids", () => {
189
+ const r = validateListItems(MEGA, items, { newId: ids() });
190
+ expect(r.issues).toEqual([]);
191
+ expect(r.items[0]?.id).toBe("00000000-0000-4000-8000-000000000001");
192
+ expect(r.items[0]?.children?.[0]?.children?.[0]?.values.label).toBe("Crawler");
193
+ });
194
+
195
+ it("reports misplaced nodes, missing fields, unknown values and query knobs", () => {
196
+ const r = validateListItems(MEGA, [
197
+ { node: "sub_link", values: { label: "x" } },
198
+ {
199
+ node: "entry",
200
+ values: { label: "y", colour: "red" },
201
+ children: [{ node: "news_feed", values: {}, query: { order: "title_asc" } }],
202
+ },
203
+ ]);
204
+ const paths = r.issues.map((i) => i.path);
205
+ expect(paths).toContain("items[0].node");
206
+ expect(paths).toContain("items[0].values.link");
207
+ expect(paths).toContain("items[1].values.colour");
208
+ expect(paths).toContain("items[1].children[0].query.order");
209
+ });
210
+
211
+ // e2e-livedit run 40360756064 — the AI restated the type's defaults.
212
+ it("drops a non-editable query knob that equals the type default", () => {
213
+ const r = validateListItems(MEGA, [
214
+ {
215
+ node: "entry",
216
+ values: { label: "y" },
217
+ children: [{ node: "news_feed", values: {}, query: { limit: 3, order: "published_desc" } }],
218
+ },
219
+ ]);
220
+ expect(r.issues).toEqual([]);
221
+ expect(r.items[0]?.children?.[0]?.query).toEqual({ limit: 3 });
222
+ });
223
+
224
+ it("enforces depth and per-level max", () => {
225
+ const menu = listTypeDefSchema.parse(DEFAULT_LIST_TYPES[2]);
226
+ const deep = {
227
+ node: "entry",
228
+ values: { label: "a" },
229
+ children: [
230
+ {
231
+ node: "entry",
232
+ values: { label: "b" },
233
+ children: [
234
+ {
235
+ node: "entry",
236
+ values: { label: "c" },
237
+ children: [{ node: "entry", values: { label: "d" } }],
238
+ },
239
+ ],
240
+ },
241
+ ],
242
+ };
243
+ expect(validateListItems(menu, [deep]).issues.some((i) => i.message.includes("deeper"))).toBe(
244
+ true,
245
+ );
246
+ const many = Array.from({ length: 9 }, (_, i) => ({
247
+ node: "entry",
248
+ values: { label: `e${i}` },
249
+ }));
250
+ expect(validateListItems(MEGA, many).issues.some((i) => i.message.includes("at most 8"))).toBe(
251
+ true,
252
+ );
253
+ });
254
+
255
+ it("patches by id and diffs", () => {
256
+ const base = validateListItems(MEGA, items, { newId: ids() }).items;
257
+ const entry = base[0] as ListItem;
258
+ const column = entry.children?.[0] as ListItem;
259
+ const next = applyListItemPatches(base, [
260
+ { op: "update", itemId: entry.id, values: { label: "Platform" } },
261
+ {
262
+ op: "add",
263
+ parentId: column.id,
264
+ position: 0,
265
+ item: { id: ITEM, node: "sub_link", values: { label: "API", link: { href: "/api" } } },
266
+ },
267
+ { op: "move", itemId: column.id, parentId: entry.id, position: 1 },
268
+ ]);
269
+ const validated = validateListItems(MEGA, next).items;
270
+ expect(findListItem(validated, ITEM)?.values.label).toBe("API");
271
+ const changes = diffListItems(base, validated);
272
+ expect(changes).toContainEqual({ kind: "added", itemId: ITEM });
273
+ expect(changes.find((c) => c.kind === "updated" && c.itemId === entry.id)).toBeDefined();
274
+ expect(changes).toContainEqual({ kind: "moved", itemId: column.id });
275
+ });
276
+
277
+ it("refuses patches on unknown ids and moves into a child", () => {
278
+ const base = validateListItems(MEGA, items, { newId: ids() }).items;
279
+ const entry = base[0] as ListItem;
280
+ const column = entry.children?.[0] as ListItem;
281
+ expect(() => applyListItemPatches(base, [{ op: "remove", itemId: ITEM }])).toThrow(
282
+ ListPatchError,
283
+ );
284
+ expect(() =>
285
+ applyListItemPatches(base, [
286
+ { op: "move", itemId: entry.id, parentId: column.id, position: 0 },
287
+ ]),
288
+ ).toThrow(ListPatchError);
289
+ });
290
+ });
291
+
292
+ describe("page types and addresses", () => {
293
+ it("checks the default template and module properties", () => {
294
+ const t = pageTypeDefSchema.parse({
295
+ slug: "news",
296
+ name: "News",
297
+ purpose: "Dated announcements",
298
+ templates: [PAGE],
299
+ defaultTemplate: LIST,
300
+ properties: [{ name: "teaser", label: "T", type: "module" }],
301
+ });
302
+ expect(checkPageTypeDef(t).map((i) => i.path)).toEqual(["defaultTemplate", "properties[0]"]);
303
+ });
304
+ it("round-trips addresses", () => {
305
+ for (const s of [
306
+ `list/${LIST}/item/${ITEM}/label`,
307
+ `instance/${LIST}/features/item/${ITEM}/text`,
308
+ `page/${PAGE}/props/author`,
309
+ `list_type/${LIST}/node/entry/label`,
310
+ ]) {
311
+ const a = parseValueAddress(s);
312
+ expect(a).not.toBeNull();
313
+ expect(formatValueAddress(a!)).toBe(s);
314
+ }
315
+ expect(parseValueAddress("pages/x")).toBeNull();
316
+ });
317
+ });
318
+
319
+ describe("source definitions", () => {
320
+ it("a plugin source written as a system source is refused with the plugin form (PR #1121 e2e)", () => {
321
+ const r = sourceDefSchema.safeParse({ kind: "system", name: "consent_categories" });
322
+ expect(r.success).toBe(false);
323
+ expect(r.error?.issues[0]?.message).toContain('{kind:"plugin", name:"<source>"}');
324
+ expect(sourceDefSchema.safeParse({ kind: "plugin", name: "consent_categories" }).success).toBe(
325
+ true,
326
+ );
327
+ });
328
+ });
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Epic #1107 part G — the list definitions without the renderer: field
5
+ * system, list and page type schemas, entries and value addresses. Only
6
+ * Zod underneath, so the plugin SDK can re-export it into plugin code —
7
+ * the Deno sandbox bundles the SDK with everything it imports, and the
8
+ * package root pulls in the template engine and HTML parser.
9
+ * Exposed as `@caelo-cms/shared/lists-model`.
10
+ */
11
+
12
+ export * from "./address.js";
13
+ export * from "./fields.js";
14
+ export * from "./items.js";
15
+ export * from "./list-type.js";
16
+ export * from "./page-type.js";