@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,285 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The field system shared by list types (entry fields) and page types
5
+ * (page properties) — epic #1107, `docs/lists.md` §2.
6
+ *
7
+ * A list imposes no shape of its own: no entry must have a label or a
8
+ * link. The list type declares fields once, and every stored value is
9
+ * checked against that declaration at write time. One set of field types
10
+ * keeps list entries, page properties and future definitions speaking the
11
+ * same language, so the editor generates every form from the same code
12
+ * and the template engine renders every value the same way.
13
+ *
14
+ * `ext` carries attributes a plugin declared for fields (translation is
15
+ * the canonical case). Core stores and validates them against the
16
+ * plugin's schema but never interprets them — core has no locale concept
17
+ * (epic #380).
18
+ */
19
+
20
+ import { z } from "zod";
21
+
22
+ /** Every field type a definition may declare. */
23
+ export const LIST_FIELD_TYPES = [
24
+ "text",
25
+ "richtext",
26
+ "link",
27
+ "media",
28
+ "select",
29
+ "boolean",
30
+ "number",
31
+ "date",
32
+ "icon",
33
+ "module",
34
+ "term",
35
+ ] as const;
36
+ export type ListFieldType = (typeof LIST_FIELD_TYPES)[number];
37
+
38
+ /**
39
+ * Names the template engine binds itself inside a list entry, so a field
40
+ * or children list may not take them. Prefixes `is_`, `has_`, `count_`
41
+ * are reserved as a family (`is_<node>`, `has_<children>`,
42
+ * `count_<children>`).
43
+ */
44
+ export const LIST_RESERVED_NAMES: ReadonlySet<string> = new Set([
45
+ "id",
46
+ "node",
47
+ "index",
48
+ "depth",
49
+ "first",
50
+ "last",
51
+ "items",
52
+ "page",
53
+ "recurse",
54
+ ]);
55
+ const RESERVED_PREFIXES = ["is_", "has_", "count_"] as const;
56
+
57
+ /** True when `name` is taken by the engine (see {@link LIST_RESERVED_NAMES}). */
58
+ export function isReservedListName(name: string): boolean {
59
+ return LIST_RESERVED_NAMES.has(name) || RESERVED_PREFIXES.some((p) => name.startsWith(p));
60
+ }
61
+
62
+ export const listNameSchema = z
63
+ .string()
64
+ .regex(/^[a-z][a-z0-9_]{0,63}$/, "must be snake_case (lowercase letters, digits, underscores)");
65
+
66
+ const fieldLabel = z.string().min(1).max(128);
67
+
68
+ /** Plugin-declared attributes keyed by plugin slug; validated by the host. */
69
+ export const fieldExtSchema = z.record(z.string().regex(/^[a-z0-9-]+$/), z.unknown());
70
+
71
+ const base = {
72
+ name: listNameSchema,
73
+ label: fieldLabel,
74
+ required: z.boolean().optional(),
75
+ default: z.unknown().optional(),
76
+ ext: fieldExtSchema.optional(),
77
+ };
78
+
79
+ const textField = z
80
+ .object({
81
+ ...base,
82
+ type: z.literal("text"),
83
+ maxLength: z.number().int().min(1).max(2000).optional(),
84
+ })
85
+ .strict();
86
+ const richtextField = z
87
+ .object({
88
+ ...base,
89
+ type: z.literal("richtext"),
90
+ maxLength: z.number().int().min(1).max(20000).optional(),
91
+ })
92
+ .strict();
93
+ const linkField = z.object({ ...base, type: z.literal("link") }).strict();
94
+ const mediaField = z.object({ ...base, type: z.literal("media") }).strict();
95
+ const selectField = z
96
+ .object({
97
+ ...base,
98
+ type: z.literal("select"),
99
+ options: z.array(listNameSchema).min(1).max(64),
100
+ })
101
+ .strict();
102
+ const booleanField = z.object({ ...base, type: z.literal("boolean") }).strict();
103
+ const numberField = z
104
+ .object({
105
+ ...base,
106
+ type: z.literal("number"),
107
+ min: z.number().optional(),
108
+ max: z.number().optional(),
109
+ })
110
+ .strict();
111
+ const dateField = z.object({ ...base, type: z.literal("date") }).strict();
112
+ const iconField = z.object({ ...base, type: z.literal("icon") }).strict();
113
+ const moduleField = z
114
+ .object({
115
+ ...base,
116
+ type: z.literal("module"),
117
+ /** Stable module `type`s that may fill the slot; absent = any module. */
118
+ accepts: z
119
+ .array(z.string().regex(/^[a-z0-9-]+$/))
120
+ .max(32)
121
+ .optional(),
122
+ })
123
+ .strict();
124
+ const termField = z
125
+ .object({
126
+ ...base,
127
+ type: z.literal("term"),
128
+ /** Slug of the vocabulary list the terms come from. */
129
+ list: z.string().regex(/^[a-z0-9-]+$/),
130
+ multiple: z.boolean().optional(),
131
+ })
132
+ .strict();
133
+
134
+ /** One field declaration. See `docs/lists.md` §2 for the value shapes. */
135
+ export const fieldDefSchema = z.discriminatedUnion("type", [
136
+ textField,
137
+ richtextField,
138
+ linkField,
139
+ mediaField,
140
+ selectField,
141
+ booleanField,
142
+ numberField,
143
+ dateField,
144
+ iconField,
145
+ moduleField,
146
+ termField,
147
+ ]);
148
+ export type FieldDef = z.infer<typeof fieldDefSchema>;
149
+
150
+ /** Stored value of a `link` field. `href` may be an internal reference. */
151
+ export const linkValueSchema = z
152
+ .object({
153
+ href: z.string().min(1).max(2000),
154
+ target: z.enum(["_self", "_blank"]).optional(),
155
+ })
156
+ .strict();
157
+ export type LinkValue = z.infer<typeof linkValueSchema>;
158
+
159
+ /** Stored value of a `module` field — the same ref shape as module fields. */
160
+ export const embeddedModuleValueSchema = z
161
+ .object({ moduleId: z.string().uuid(), contentInstanceId: z.string().uuid() })
162
+ .strict();
163
+
164
+ const MEDIA_REF_RE = /^caelo:media\/[0-9a-f-]{36}(?:\/[a-z0-9-]+)?$/;
165
+ /** `caelo:term/<listId>/<itemId>` */
166
+ export const TERM_REF_RE = /^caelo:term\/([0-9a-f-]{36})\/([0-9a-f-]{36})$/;
167
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
168
+ const ICON_RE = /^[a-z0-9-]{1,64}$/;
169
+
170
+ const DEFAULT_TEXT_MAX = 500;
171
+ const DEFAULT_RICHTEXT_MAX = 5000;
172
+
173
+ /** One problem found while checking a definition or a value. */
174
+ export interface ListIssue {
175
+ /** Where the problem is, e.g. `items[2].values.label` or `nodes.entry.fields[0]`. */
176
+ readonly path: string;
177
+ /** What is wrong and what to do about it, written for the AI caller. */
178
+ readonly message: string;
179
+ }
180
+
181
+ /**
182
+ * Validate one stored value against its field. Returns the issues found
183
+ * (empty = valid). `undefined` is "not set" and only an issue when the
184
+ * field is required.
185
+ */
186
+ export function validateFieldValue(field: FieldDef, value: unknown, path: string): ListIssue[] {
187
+ if (value === undefined || value === null) {
188
+ return field.required
189
+ ? [{ path, message: `"${field.name}" is required (${field.type}) — set a value.` }]
190
+ : [];
191
+ }
192
+ const bad = (expected: string): ListIssue[] => [
193
+ { path, message: `"${field.name}" must be ${expected}, got ${describe(value)}.` },
194
+ ];
195
+ switch (field.type) {
196
+ case "text":
197
+ case "richtext": {
198
+ if (typeof value !== "string") return bad("a string");
199
+ const max =
200
+ field.maxLength ?? (field.type === "text" ? DEFAULT_TEXT_MAX : DEFAULT_RICHTEXT_MAX);
201
+ if (value.length > max) return bad(`at most ${max} characters`);
202
+ if (field.required && value.trim().length === 0) return bad("non-empty text");
203
+ return [];
204
+ }
205
+ case "link": {
206
+ const r = linkValueSchema.safeParse(value);
207
+ return r.success ? [] : bad('{ href, target? } (href like "/about" or "https://…")');
208
+ }
209
+ case "media":
210
+ return typeof value === "string" && MEDIA_REF_RE.test(value)
211
+ ? []
212
+ : bad("a media reference (caelo:media/<id>) — pick an asset from find_media");
213
+ case "select":
214
+ return typeof value === "string" && field.options.includes(value)
215
+ ? []
216
+ : bad(`one of ${field.options.map((o) => `"${o}"`).join(", ")}`);
217
+ case "boolean":
218
+ return typeof value === "boolean" ? [] : bad("true or false");
219
+ case "number": {
220
+ if (typeof value !== "number" || !Number.isFinite(value)) return bad("a number");
221
+ if (field.min !== undefined && value < field.min) return bad(`≥ ${field.min}`);
222
+ if (field.max !== undefined && value > field.max) return bad(`≤ ${field.max}`);
223
+ return [];
224
+ }
225
+ case "date":
226
+ return typeof value === "string" &&
227
+ ISO_DATE_RE.test(value) &&
228
+ !Number.isNaN(Date.parse(value))
229
+ ? []
230
+ : bad("an ISO date YYYY-MM-DD");
231
+ case "icon":
232
+ return typeof value === "string" && ICON_RE.test(value) ? [] : bad("an icon name");
233
+ case "module":
234
+ return embeddedModuleValueSchema.safeParse(value).success
235
+ ? []
236
+ : bad("{ moduleId, contentInstanceId }");
237
+ case "term": {
238
+ if (
239
+ !Array.isArray(value) ||
240
+ value.some((v) => typeof v !== "string" || !TERM_REF_RE.test(v))
241
+ ) {
242
+ return bad("an array of term references (caelo:term/<listId>/<itemId>)");
243
+ }
244
+ if (!field.multiple && value.length > 1) return bad("at most one term (multiple is off)");
245
+ if (field.required && value.length === 0) return bad("at least one term");
246
+ return [];
247
+ }
248
+ }
249
+ }
250
+
251
+ /** Checks on one field declaration that Zod cannot express. */
252
+ export function checkFieldDef(field: FieldDef, path: string): ListIssue[] {
253
+ const issues: ListIssue[] = [];
254
+ if (isReservedListName(field.name)) {
255
+ issues.push({
256
+ path,
257
+ message: `field name "${field.name}" is reserved by the template engine — pick another name.`,
258
+ });
259
+ }
260
+ if (field.default !== undefined) {
261
+ for (const i of validateFieldValue(
262
+ { ...field, required: false },
263
+ field.default,
264
+ `${path}.default`,
265
+ )) {
266
+ issues.push(i);
267
+ }
268
+ }
269
+ if (
270
+ field.type === "number" &&
271
+ field.min !== undefined &&
272
+ field.max !== undefined &&
273
+ field.min > field.max
274
+ ) {
275
+ issues.push({ path, message: "min is greater than max." });
276
+ }
277
+ return issues;
278
+ }
279
+
280
+ function describe(value: unknown): string {
281
+ if (Array.isArray(value)) return "an array";
282
+ if (value === null) return "null";
283
+ const s = typeof value === "string" ? JSON.stringify(value.slice(0, 40)) : typeof value;
284
+ return s;
285
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /** Lists, list types, page types — epic #1107, see `docs/lists.md`. */
4
+
5
+ export * from "./address.js";
6
+ export * from "./default-types.js";
7
+ export * from "./fields.js";
8
+ export * from "./item-attr.js";
9
+ export * from "./items.js";
10
+ export * from "./list-type.js";
11
+ export * from "./page-type.js";
12
+ export * from "./refs.js";
13
+ export * from "./render-data.js";
14
+ export * from "./render-deps.js";
15
+ export * from "./resolve.js";
16
+ export * from "./scope.js";
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { describe, expect, test } from "bun:test";
3
+ import { parseListItemAttr } from "./item-attr.js";
4
+
5
+ const A = "11111111-1111-4111-8111-111111111111";
6
+ const B = "22222222-2222-4222-8222-222222222222";
7
+
8
+ describe("data-caelo-list-item values", () => {
9
+ test("parse the three stamped forms", () => {
10
+ expect(parseListItemAttr(`${A}/${B}`)).toEqual({ kind: "shared", listId: A, itemId: B });
11
+ expect(parseListItemAttr(`instance/${A}/nav_links/${B}`)).toEqual({
12
+ kind: "instance",
13
+ contentInstanceId: A,
14
+ field: "nav_links",
15
+ itemId: B,
16
+ });
17
+ expect(parseListItemAttr(`local/nav_links/${B}`)).toEqual({
18
+ kind: "local",
19
+ field: "nav_links",
20
+ itemId: B,
21
+ });
22
+ });
23
+
24
+ test("refuse anything else", () => {
25
+ for (const v of [
26
+ "",
27
+ `${A}`,
28
+ `${A}/${B}/x`,
29
+ `instance/${A}/${B}`,
30
+ `local/Nav/${B}`,
31
+ `AAAAAAAA-1111-4111-8111-111111111111/${B}`,
32
+ ]) {
33
+ expect(parseListItemAttr(v)).toBeNull();
34
+ }
35
+ });
36
+ });
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The `data-caelo-list-item` value the engine stamps on a rendered stored
5
+ * list entry (epic #1107, `docs/lists.md` §13), parsed. Live Edit sends it
6
+ * with a click-to-chat reference so the chip names the list entry, not the
7
+ * whole module, and the AI edits that entry with the list tools (shared
8
+ * list) or the content tools (a module's own list).
9
+ */
10
+
11
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
12
+ const NAME = "[a-z][a-z0-9_]{0,63}";
13
+
14
+ /** Every form a `data-caelo-list-item` value takes. */
15
+ export const LIST_ITEM_ATTR_RE = new RegExp(
16
+ `^(?:${UUID}/${UUID}|instance/${UUID}/${NAME}/${UUID}|local/${NAME}/${UUID})$`,
17
+ );
18
+
19
+ /** A parsed `data-caelo-list-item` value. */
20
+ export type ListItemAttr =
21
+ /** An entry of a shared list. */
22
+ | { readonly kind: "shared"; readonly listId: string; readonly itemId: string }
23
+ /** An entry of a module's own list, stored in a content instance. */
24
+ | {
25
+ readonly kind: "instance";
26
+ readonly contentInstanceId: string;
27
+ readonly field: string;
28
+ readonly itemId: string;
29
+ }
30
+ /** An entry of a module field's default list (site chrome without stored content). */
31
+ | { readonly kind: "local"; readonly field: string; readonly itemId: string };
32
+
33
+ /** Parse a `data-caelo-list-item` value; null when it is not one. */
34
+ export function parseListItemAttr(value: string): ListItemAttr | null {
35
+ if (!LIST_ITEM_ATTR_RE.test(value)) return null;
36
+ const parts = value.split("/");
37
+ if (parts[0] === "instance") {
38
+ return {
39
+ kind: "instance",
40
+ contentInstanceId: parts[1] as string,
41
+ field: parts[2] as string,
42
+ itemId: parts[3] as string,
43
+ };
44
+ }
45
+ if (parts[0] === "local") {
46
+ return { kind: "local", field: parts[1] as string, itemId: parts[2] as string };
47
+ }
48
+ return { kind: "shared", listId: parts[0] as string, itemId: parts[1] as string };
49
+ }