@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
package/src/content.ts CHANGED
@@ -11,6 +11,9 @@
11
11
  */
12
12
 
13
13
  import { z } from "zod";
14
+ import { listItemInputSchema } from "./lists/items.js";
15
+ import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
16
+ import { LIST_REF_RE } from "./lists/refs.js";
14
17
 
15
18
  /** Lowercase slug, hyphenated, 1–64 chars, no leading/trailing hyphen. */
16
19
  export const slugSchema = z
@@ -93,7 +96,49 @@ export const MODULE_JS_MAX = 256 * 1024;
93
96
  export const TEMPLATE_HTML_MAX = 512 * 1024;
94
97
  export const TEMPLATE_CSS_MAX = 128 * 1024;
95
98
 
96
- const moduleHtml = z.string().max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`);
99
+ /**
100
+ * Code payloads a model sometimes corrupts before they reach us, both seen
101
+ * in PR #1121's real-AI runs and stored verbatim before this guard:
102
+ * - JSON-encoded: the whole value wrapped in quotes with `\"` escapes
103
+ * (`"<footer class=\"x\">…"`) — the page showed literal quotes;
104
+ * - HTML-escaped markup: `&lt;div&gt;&lt;caelo-slot …` with no real tag
105
+ * — a template without a single slot, so it had no blocks at all.
106
+ * Refusing at the write boundary with the fix turns a broken render the
107
+ * AI only notices from a screenshot into one corrected call (CLAUDE.md
108
+ * §2, §11; same reasoning as the CDATA guard below). AI tool arguments
109
+ * never reach this in that shape: `sourceTextArg` decodes both encodings
110
+ * first (`source-text-transport.ts`); this guards every other writer.
111
+ */
112
+ export function jsonEncodedCodeMessage(field: string, value: string): string | null {
113
+ const t = value.trim();
114
+ if (t.length < 2 || !t.startsWith('"') || !t.endsWith('"')) return null;
115
+ try {
116
+ if (typeof JSON.parse(t) !== "string") return null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ return `${field} is JSON-encoded (wrapped in quotes with \\" escapes) — send the raw ${field} itself, not a JSON string of it.`;
121
+ }
122
+
123
+ /** See {@link jsonEncodedCodeMessage}: markup sent HTML-escaped. */
124
+ export function htmlEscapedMarkupMessage(field: string, value: string): string | null {
125
+ if (value.includes("<") || !/&lt;\/?[a-zA-Z]/.test(value)) return null;
126
+ return `${field} is HTML-escaped (&lt;…&gt; instead of <…>) — send the markup itself, unescaped.`;
127
+ }
128
+
129
+ const noCorruptMarkup = (field: string) => (value: string, ctx: z.RefinementCtx) => {
130
+ const message = jsonEncodedCodeMessage(field, value) ?? htmlEscapedMarkupMessage(field, value);
131
+ if (message) ctx.addIssue({ code: "custom", message });
132
+ };
133
+ const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
134
+ const message = jsonEncodedCodeMessage(field, value);
135
+ if (message) ctx.addIssue({ code: "custom", message });
136
+ };
137
+
138
+ const moduleHtml = z
139
+ .string()
140
+ .max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`)
141
+ .superRefine(noCorruptMarkup("html"));
97
142
 
98
143
  /**
99
144
  * issue #432 root-cause guard — reject XML CDATA wrappers in module css/js.
@@ -122,11 +167,13 @@ const cdataMessage = (field: "css" | "js"): string =>
122
167
  const moduleCss = z
123
168
  .string()
124
169
  .max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
125
- .refine(noCdata, cdataMessage("css"));
170
+ .refine(noCdata, cdataMessage("css"))
171
+ .superRefine(noJsonEncodedCode("css"));
126
172
  const moduleJs = z
127
173
  .string()
128
174
  .max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
129
- .refine(noCdata, cdataMessage("js"));
175
+ .refine(noCdata, cdataMessage("js"))
176
+ .superRefine(noJsonEncodedCode("js"));
130
177
 
131
178
  /**
132
179
  * v0.4.0 — module field schema. Each field declares one substitution slot in
@@ -161,29 +208,29 @@ export const MODULE_FIELD_PRIMITIVE_KINDS = [
161
208
  ] as const;
162
209
 
163
210
  /**
164
- * v0.12.0 — list-of-primitive field kinds. The grammar mirrors
165
- * `module-list`'s `{{#field}}{{/field}}` iteration but the elements
166
- * are primitives (or a fixed {label,href} pair for link-list), not
167
- * `{moduleId, contentInstanceId}` refs.
211
+ * Epic #1107 — the data-list field kind (`docs/lists.md` §7). One kind
212
+ * for every repeated piece of data: feature bullets, a footer link group,
213
+ * a three-level menu, a mega dropdown, a "latest news" block. The entry
214
+ * shape comes from a LIST TYPE (`listType`, a slug); the entries come from
215
+ * the field's binding:
168
216
  *
169
- * Use `text-list` for "list of strings" — menu labels, tag chips,
170
- * bullet points where each item is just text. Inner template
171
- * references `{{.}}` (Mustache convention) or `{{item}}` to mean
172
- * the current element.
217
+ * - `local` (default) — entries stored in the placement's value
218
+ * (`ListItem[]`), only this module shows them;
219
+ * - `shared` — a `caelo:list/<id>` list, fixed on the field (`list`) or
220
+ * chosen per placement; navigation and anything needed in
221
+ * more than one place;
222
+ * - `source` — entries generated per page from `source` (a page query,
223
+ * a system source like breadcrumbs, a plugin source).
173
224
  *
174
- * Use `link-list` for "list of links" — primary nav, footer columns,
175
- * sidebar menus. Each element is `{href, label}`. Inner template
176
- * uses `{{href}}` + `{{label}}` per iteration.
177
- *
178
- * For lists with richer per-item structure (cards with image + title
179
- * + body + CTA), use `module-list` pointing at a sub-module — that's
180
- * what nested modules are for.
225
+ * The template iterates `{{#field}}…{{/field}}` the same way for every
226
+ * binding. `module-list` stays: it composes modules, lists carry data.
227
+ * Replaces the `text-list` / `link-list` kinds.
181
228
  */
182
- export const MODULE_FIELD_LIST_KINDS = ["text-list", "link-list"] as const;
229
+ export const MODULE_FIELD_LIST_KINDS = ["list"] as const;
183
230
 
184
231
  export const MODULE_FIELD_NESTED_KINDS = ["module", "module-list"] as const;
185
232
 
186
- /** All eleven v0.12.0 kinds. */
233
+ /** Every module field kind. */
187
234
  export const MODULE_FIELD_KINDS = [
188
235
  ...MODULE_FIELD_PRIMITIVE_KINDS,
189
236
  ...MODULE_FIELD_LIST_KINDS,
@@ -200,10 +247,9 @@ export type ModuleFieldKind = (typeof MODULE_FIELD_KINDS)[number];
200
247
 
201
248
  /**
202
249
  * Field kinds the shared template engine iterates via the
203
- * `{{#name}}…{{/name}}` Mustache-section operator. Adding a new
204
- * list-shaped kind to MODULE_FIELD_LIST_KINDS / MODULE_FIELD_NESTED_KINDS
205
- * automatically extends this set; the engine's section-dispatch branch
206
- * keys off it (`packages/shared/src/template-engine.ts`).
250
+ * `{{#name}}…{{/name}}` Mustache-section operator (`list` and
251
+ * `module-list`); the engine's section-dispatch keys off it
252
+ * (`packages/shared/src/template-engine.ts`).
207
253
  */
208
254
  export const MODULE_FIELD_SECTION_KINDS = [...MODULE_FIELD_LIST_KINDS, "module-list"] as const;
209
255
 
@@ -255,43 +301,84 @@ const moduleFieldModuleListSchema = z
255
301
  .strict();
256
302
 
257
303
  /**
258
- * v0.12.0 — list-of-strings field. Inner template uses `{{.}}` (or
259
- * the alias `{{item}}`) to reference the current element. Default
260
- * is an optional array of strings rendered when the placement's
261
- * content_instance has no override.
304
+ * Epic #1107 — a `list` field (`docs/lists.md` §7). The static schema
305
+ * checks the SHAPE: the binding's parts fit together and a local default
306
+ * is well-formed entries. Whether those entries fit the named list type
307
+ * (nodes, fields, depth) needs the type, which is stored data — the op
308
+ * layer validates that with `validateListItems` when the module is
309
+ * written, and the renderer reports `list-type-missing:<slug>` for a type
310
+ * that is gone.
262
311
  */
263
- const moduleFieldTextListSchema = z
312
+ const moduleFieldListSchema = z
264
313
  .object({
265
314
  name: moduleFieldName,
266
- kind: z.literal("text-list"),
315
+ kind: z.literal("list"),
267
316
  label: moduleFieldLabel,
317
+ /** Slug of the list type that defines the entries. */
318
+ listType: slugSchema,
319
+ bind: z.enum(["local", "shared", "source"]).optional(),
320
+ /** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
321
+ list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
322
+ /** `bind: "source"` — where generated entries come from. */
323
+ source: sourceDefSchema.optional(),
268
324
  min: z.number().int().nonnegative().optional(),
269
- max: z.number().int().positive().max(256).optional(),
270
- default: z.array(z.string()).optional(),
325
+ max: z.number().int().positive().max(LIST_MAX_ITEMS).optional(),
326
+ /** `local`: entries; `shared`: `caelo:list/<id>`. */
327
+ default: z
328
+ .union([
329
+ z.array(listItemInputSchema),
330
+ z.string().regex(LIST_REF_RE, "a shared default must be caelo:list/<id>"),
331
+ ])
332
+ .optional(),
271
333
  })
272
334
  .strict();
273
335
 
274
- /**
275
- * v0.12.0 — list-of-{label,href} field. Inner template uses
276
- * `{{label}}` + `{{href}}` per iteration. Targets the common
277
- * primary-nav / footer-column / sidebar-menu pattern without
278
- * forcing a sub-module per link.
279
- */
280
- const moduleFieldLinkListSchema = z
281
- .object({
282
- name: moduleFieldName,
283
- kind: z.literal("link-list"),
284
- label: moduleFieldLabel,
285
- min: z.number().int().nonnegative().optional(),
286
- max: z.number().int().positive().max(256).optional(),
287
- default: z.array(z.object({ label: z.string(), href: z.string() }).strict()).optional(),
288
- })
289
- .strict();
336
+ /** Binding consistency of one `list` field; every problem, AI-actionable. */
337
+ function listFieldIssues(f: z.infer<typeof moduleFieldListSchema>): string[] {
338
+ const bind = f.bind ?? "local";
339
+ const issues: string[] = [];
340
+ if (bind === "source" && f.source === undefined) {
341
+ issues.push(`list field "${f.name}": bind "source" needs a source (query, system or plugin).`);
342
+ }
343
+ if (bind !== "source" && f.source !== undefined) {
344
+ issues.push(`list field "${f.name}": source is only allowed with bind "source".`);
345
+ }
346
+ if (bind === "local" && f.list !== undefined) {
347
+ issues.push(
348
+ `list field "${f.name}": list is only allowed with bind "shared" — a local list stores its entries in the placement.`,
349
+ );
350
+ }
351
+ if (bind === "source" && f.list !== undefined && f.source?.kind !== "system") {
352
+ issues.push(
353
+ `list field "${f.name}": list on a source field names the vocabulary of a page_terms source only.`,
354
+ );
355
+ }
356
+ if (f.default !== undefined) {
357
+ if (bind === "local" && typeof f.default === "string") {
358
+ issues.push(
359
+ `list field "${f.name}": a local default is an array of entries, not a list reference.`,
360
+ );
361
+ }
362
+ if (bind === "shared" && Array.isArray(f.default)) {
363
+ issues.push(
364
+ `list field "${f.name}": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.`,
365
+ );
366
+ }
367
+ if (bind === "source") {
368
+ issues.push(
369
+ `list field "${f.name}": a source field has no default; its entries are generated.`,
370
+ );
371
+ }
372
+ }
373
+ if (f.min !== undefined && f.max !== undefined && f.min > f.max) {
374
+ issues.push(`list field "${f.name}": min is greater than max.`);
375
+ }
376
+ return issues;
377
+ }
290
378
 
291
379
  export const moduleFieldSchema = z.discriminatedUnion("kind", [
292
380
  moduleFieldPrimitiveSchema,
293
- moduleFieldTextListSchema,
294
- moduleFieldLinkListSchema,
381
+ moduleFieldListSchema,
295
382
  moduleFieldModuleSchema,
296
383
  moduleFieldModuleListSchema,
297
384
  ]);
@@ -368,6 +455,11 @@ const moduleFieldsArray = z
368
455
  return;
369
456
  }
370
457
  seen.add(f.name);
458
+ if (f.kind === "list") {
459
+ for (const message of listFieldIssues(f)) {
460
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message });
461
+ }
462
+ }
371
463
  }
372
464
  for (const run of findNumberedScalarFanoutRuns(arr)) {
373
465
  ctx.addIssue({
@@ -375,8 +467,9 @@ const moduleFieldsArray = z
375
467
  message:
376
468
  `numbered-scalar fanout: ${run.map((n) => `"${n}"`).join(", ")} repeat one role as ` +
377
469
  "numbered scalar fields — repeating content is ONE list field, never numbered scalars " +
378
- '(CLAUDE.md §1A). Replace the run with a single field: kind "text-list" (list of ' +
379
- 'strings, inner template iterates {{.}}), "link-list" (list of {label, href}), or ' +
470
+ '(CLAUDE.md §1A). Replace the run with a single field: kind "list" with a list type ' +
471
+ '(listType "texts" for plain strings — the template iterates {{#f}}{{text}}{{/f}}; ' +
472
+ '"links" for label + link — {{#f}}<a href="{{link}}">{{label}}</a>{{/f}}), or ' +
380
473
  '"module-list" (rich per-item structure: mint a card sub-module and reference it per ' +
381
474
  "item — in build_page, author the card detached with `ref` and pass " +
382
475
  '[{"$ref": "<ref>"}, …] as the field value). A two-field split like col1/col2 is fine; ' +
@@ -386,10 +479,10 @@ const moduleFieldsArray = z
386
479
  });
387
480
 
388
481
  /**
389
- * v0.12.0 — coarse role tag for the AI's `## Modules` catalog block.
482
+ * v0.12.0 — coarse role tag for the AI's `list_modules` catalog.
390
483
  * Constrained by `modules_kind_check` in migration 0095. Adding a
391
484
  * new kind means: (1) bump the SQL CHECK, (2) update this schema,
392
- * (3) update formatModulesBlock so the AI knows when to use it.
485
+ * (3) update the add_module / build_page kind guidance so the AI knows when to use it.
393
486
  */
394
487
  export const MODULE_KINDS = ["chrome", "hero", "content", "cta", "utility"] as const;
395
488
  export const moduleKindSchema = z.enum(MODULE_KINDS);
@@ -440,31 +533,29 @@ export const moduleUpdateSchema = z
440
533
  })
441
534
  .strict();
442
535
 
443
- /**
444
- * v0.12.0 — page-type tag pages inherit from their template.
445
- * Constrained by templates_kind_check in migration 0096. Surfaced
446
- * in the AI's `## Pages` block so the AI sees three modules-on-
447
- * product-pages as a pattern. See CLAUDE.md §1A.
448
- */
449
- export const TEMPLATE_KINDS = [
450
- "home",
451
- "landing",
452
- "product",
453
- "blog",
454
- "doc",
455
- "content",
456
- "utility",
457
- ] as const;
458
- export const templateKindSchema = z.enum(TEMPLATE_KINDS);
459
- export type TemplateKind = (typeof TEMPLATE_KINDS)[number];
536
+ /** A page type slug (epic #1107, docs/lists.md §5). */
537
+ export const pageTypeSlugSchema = z
538
+ .string()
539
+ .regex(
540
+ /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
541
+ "page type slug: lowercase letters, digits, hyphens",
542
+ );
460
543
 
461
544
  export const templateCreateSchema = z
462
545
  .object({
463
546
  slug: slugSchema,
464
547
  displayName: displayNameSchema,
465
- /** v0.12.0 — what kind of page binds to this template. */
466
- kind: templateKindSchema.default("content"),
467
- html: z.string().max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`),
548
+ /**
549
+ * Epic #1107 — the page type (slug) this template serves; the template
550
+ * is appended to that type's `templates`. Omit for the site's default
551
+ * page type (`site_defaults.default_page_type_id`, stored data — not a
552
+ * render-time fallback).
553
+ */
554
+ pageType: pageTypeSlugSchema.optional(),
555
+ html: z
556
+ .string()
557
+ .max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`)
558
+ .superRefine(noCorruptMarkup("html")),
468
559
  css: z.string().max(TEMPLATE_CSS_MAX, `css exceeds ${TEMPLATE_CSS_MAX} bytes`).default(""),
469
560
  /**
470
561
  * P6.7.6 — every template binds to one layout. When omitted, the
@@ -505,9 +596,9 @@ export const templateUpdateSchema = z
505
596
  .object({
506
597
  templateId: z.string().uuid(),
507
598
  displayName: displayNameSchema.optional(),
508
- /** v0.12.0 — re-classify the template's page-type kind. */
509
- kind: templateKindSchema.optional(),
510
- html: z.string().max(TEMPLATE_HTML_MAX).optional(),
599
+ /** Epic #1107 — also allow this template for the page type (slug); appended to its `templates`. */
600
+ pageType: pageTypeSlugSchema.optional(),
601
+ html: z.string().max(TEMPLATE_HTML_MAX).superRefine(noCorruptMarkup("html")).optional(),
511
602
  css: z.string().max(TEMPLATE_CSS_MAX).optional(),
512
603
  /** P6.7.6 — re-point the template to a different layout. */
513
604
  layoutId: z.string().uuid().optional(),
@@ -568,6 +659,16 @@ export const pageCreateSchema = z
568
659
  * becomes "<parent's path>/<slug>". Omit (or null) for a top-level page.
569
660
  */
570
661
  parentPageId: z.string().uuid().nullable().optional(),
662
+ /**
663
+ * Epic #1107 — what the page IS (page type slug). Omit to take the type
664
+ * whose `templates` contain the template; with neither `pageType` nor
665
+ * `templateId`, the site's default page type and its default template.
666
+ */
667
+ pageType: pageTypeSlugSchema.optional(),
668
+ /** Property values, validated against the page type. */
669
+ properties: z.record(z.string(), z.unknown()).optional(),
670
+ /** Terms (`caelo:term/<listId>/<itemId>`) from the page type's vocabularies. */
671
+ terms: z.array(z.string().max(120)).max(100).optional(),
571
672
  })
572
673
  .strict();
573
674
 
@@ -620,6 +721,16 @@ export const pageUpdateSchema = z
620
721
  * every inbound link, which is never a safe default (CLAUDE.md §2).
621
722
  */
622
723
  redirectFromOld: z.enum(["auto", "skip"]).optional(),
724
+ /**
725
+ * Epic #1107 — switch the page type (slug). The template must be one of
726
+ * the type's templates (pass `templateId` too when it is not), and the
727
+ * page's properties and terms must fit the new type.
728
+ */
729
+ pageType: pageTypeSlugSchema.optional(),
730
+ /** Merged into the page's properties; `null` removes a property. */
731
+ properties: z.record(z.string(), z.unknown()).optional(),
732
+ /** Replaces the page's terms (`caelo:term/<listId>/<itemId>`). */
733
+ terms: z.array(z.string().max(120)).max(100).optional(),
623
734
  })
624
735
  .strict();
625
736
 
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ formatCssVarRenameReport,
6
+ renameCssVarAliases,
7
+ themeVarForAlias,
8
+ } from "./css-var-aliases.js";
9
+ import { scanCssVars } from "./css-var-scan.js";
10
+
11
+ const THEME = [
12
+ "--color-background",
13
+ "--color-foreground",
14
+ "--color-muted",
15
+ "--color-muted-foreground",
16
+ "--color-border",
17
+ "--color-accent",
18
+ ];
19
+
20
+ describe("renameCssVarAliases", () => {
21
+ // The header CSS of e2e-livedit run 40351690739.
22
+ const css =
23
+ ".site-header{border-bottom:1px solid var(--color-border, #e5e5e5);background:var(--color-surface, #fff);}\n" +
24
+ ".site-header__brand{color:var(--color-text, #111);}\n" +
25
+ ".site-header__panel{background:var(--color-surface, #fff);}\n" +
26
+ ".site-header__col h3{color:var(--color-text-muted,#888);}";
27
+
28
+ it("renames common role names to the theme's names, every occurrence", () => {
29
+ const r = renameCssVarAliases(css, THEME, new Set());
30
+ expect(r.css).not.toContain("--color-surface");
31
+ expect(r.css).not.toContain("var(--color-text,");
32
+ expect(r.css).toContain("background:var(--color-background, #fff)");
33
+ expect(r.css).toContain("color:var(--color-foreground, #111)");
34
+ expect(r.css).toContain("color:var(--color-muted-foreground,#888)");
35
+ expect(r.renames).toEqual([
36
+ { from: "--color-surface", to: "--color-background", count: 2 },
37
+ { from: "--color-text", to: "--color-foreground", count: 1 },
38
+ { from: "--color-text-muted", to: "--color-muted-foreground", count: 1 },
39
+ ]);
40
+ expect(formatCssVarRenameReport(r.renames)).toContain("--color-surface→--color-background×2");
41
+ });
42
+
43
+ it("leaves known names, the author's own properties and unmapped names alone", () => {
44
+ const own = ":root{--surface:#fafafa}.a{background:var(--surface);color:var(--brand-ink)}";
45
+ const r = renameCssVarAliases(own, THEME, new Set(["--surface"]));
46
+ expect(r.css).toBe(own);
47
+ expect(r.renames).toEqual([]);
48
+ });
49
+
50
+ it("renames only when the theme emits the target", () => {
51
+ expect(themeVarForAlias("--color-surface-raised", new Set(THEME))).toBeNull();
52
+ expect(themeVarForAlias("--text", new Set(THEME))).toBe("--color-foreground");
53
+ });
54
+ });
55
+
56
+ describe("scanCssVars suggestions", () => {
57
+ it("suggests the theme's role name, not the nearest spelling", () => {
58
+ const [u] = scanCssVars({ css: ".a{color:var(--color-text, #111)}", knownVars: THEME });
59
+ expect(u?.suggestion).toBe("--color-foreground");
60
+ });
61
+ });
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Common names for theme roles, mapped to the names Caelo's theme emits.
5
+ *
6
+ * Module CSS is authored against conventions the model knows from other
7
+ * design systems: `--color-text`, `--color-surface`, `--color-text-muted`.
8
+ * Caelo's theme calls those roles `--color-foreground`, `--color-background`
9
+ * and `--color-muted-foreground`. The write-time scan used to answer with an
10
+ * edit-distance guess (`--color-text` → "did you mean `--color-accent`?"),
11
+ * so the AI loaded the theme and renamed every reference with edit_content
12
+ * — two to three extra tool calls per module, and a failed one when the
13
+ * same declaration appeared twice (e2e-livedit run 40351690739). A name
14
+ * that unambiguously means a role the theme has is renamed before the
15
+ * write instead, and the tool result says so, the same way theme-literal
16
+ * binding rewrites a literal that equals a token (issue #164). Only names
17
+ * in this table are touched, and only when the theme emits the target.
18
+ */
19
+
20
+ /** Role name (without `--` and an optional `color-` prefix) → theme role. */
21
+ const ROLE_ALIASES: Readonly<Record<string, string>> = {
22
+ text: "foreground",
23
+ "text-primary": "foreground",
24
+ "text-color": "foreground",
25
+ "body-text": "foreground",
26
+ fg: "foreground",
27
+ heading: "foreground",
28
+ "text-muted": "muted-foreground",
29
+ "text-secondary": "muted-foreground",
30
+ "text-subtle": "muted-foreground",
31
+ "muted-text": "muted-foreground",
32
+ "fg-muted": "muted-foreground",
33
+ surface: "background",
34
+ bg: "background",
35
+ "page-bg": "background",
36
+ "body-bg": "background",
37
+ "surface-muted": "muted",
38
+ "surface-alt": "muted",
39
+ "bg-muted": "muted",
40
+ "bg-subtle": "muted",
41
+ "surface-raised": "card",
42
+ "card-bg": "card",
43
+ "border-color": "border",
44
+ divider: "border",
45
+ line: "border",
46
+ };
47
+
48
+ /** The theme var an unknown name stands for, or null. */
49
+ export function themeVarForAlias(name: string, knownVars: ReadonlySet<string>): string | null {
50
+ const bare = name.replace(/^--/, "").replace(/^color-/, "");
51
+ const role = ROLE_ALIASES[bare];
52
+ if (role === undefined) return null;
53
+ const target = `--color-${role}`;
54
+ return knownVars.has(target) ? target : null;
55
+ }
56
+
57
+ /** `var(` + the referenced name, when the name ends there. */
58
+ const VAR_NAME_RE = /(var\(\s*)(--[a-zA-Z0-9_-]+)(?![a-zA-Z0-9_-])/g;
59
+
60
+ /** One renamed reference. */
61
+ export interface CssVarRename {
62
+ readonly from: string;
63
+ readonly to: string;
64
+ readonly count: number;
65
+ }
66
+
67
+ /**
68
+ * Rename `var(--alias…)` references to the theme's name for that role.
69
+ * A name the CSS itself defines (`--surface: …`) is the author's own
70
+ * property and stays.
71
+ */
72
+ export function renameCssVarAliases(
73
+ css: string,
74
+ knownVars: readonly string[],
75
+ definedInCss: ReadonlySet<string>,
76
+ ): { css: string; renames: CssVarRename[] } {
77
+ const known = new Set(knownVars);
78
+ const targets = new Map<string, string>();
79
+ const counts = new Map<string, number>();
80
+ // One pass over the references, no pattern built from a name.
81
+ const out = css.replace(VAR_NAME_RE, (match, open: string, name: string) => {
82
+ if (known.has(name) || definedInCss.has(name)) return match;
83
+ let to = targets.get(name);
84
+ if (to === undefined) {
85
+ const resolved = themeVarForAlias(name, known);
86
+ if (resolved === null) return match;
87
+ to = resolved;
88
+ targets.set(name, to);
89
+ }
90
+ counts.set(name, (counts.get(name) ?? 0) + 1);
91
+ return `${open}${to}`;
92
+ });
93
+ const renames: CssVarRename[] = [...targets].map(([from, to]) => ({
94
+ from,
95
+ to,
96
+ count: counts.get(from) ?? 0,
97
+ }));
98
+ return { css: out, renames };
99
+ }
100
+
101
+ /** Tool-result sentence for the renames, or null. */
102
+ export function formatCssVarRenameReport(renames: readonly CssVarRename[]): string | null {
103
+ if (renames.length === 0) return null;
104
+ return ` 🔗 renamed to the theme's names: ${renames
105
+ .map((r) => `${r.from}→${r.to}${r.count > 1 ? `×${r.count}` : ""}`)
106
+ .join(
107
+ ", ",
108
+ )} — the STORED css uses the theme's names, so anchor later edit_content calls on those.`;
109
+ }
@@ -17,6 +17,8 @@
17
17
  * that is legitimate authoring, not drift.
18
18
  */
19
19
 
20
+ import { themeVarForAlias } from "./css-var-aliases.js";
21
+
20
22
  /** One `var(--x)` reference found in CSS. */
21
23
  export interface CssVarReference {
22
24
  readonly name: string;
@@ -97,6 +99,13 @@ export function scanCssVars(input: CssVarScanInput): UnknownCssVar[] {
97
99
  for (const ref of extractCssVarReferences(input.css)) {
98
100
  if (known.has(ref.name) || reported.has(ref.name)) continue;
99
101
  reported.add(ref.name);
102
+ // A common role name (`--color-text`) suggests the theme's name for
103
+ // that role, not the nearest spelling (which was `--color-accent`).
104
+ const alias = themeVarForAlias(ref.name, known);
105
+ if (alias !== null) {
106
+ unknown.push({ name: ref.name, hasFallback: ref.hasFallback, suggestion: alias });
107
+ continue;
108
+ }
100
109
  let best: string | null = null;
101
110
  let bestDist = Number.POSITIVE_INFINITY;
102
111
  for (const candidate of known) {