@caelo-cms/shared 0.10.72 → 0.11.1

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 (204) hide show
  1. package/dist/ai-tools.d.ts +103 -142
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +79 -167
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +115 -35
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +31 -12
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +214 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +219 -84
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-scope-scan.js +1 -1
  14. package/dist/css-var-aliases.d.ts +20 -0
  15. package/dist/css-var-aliases.d.ts.map +1 -0
  16. package/dist/css-var-aliases.js +95 -0
  17. package/dist/css-var-aliases.js.map +1 -0
  18. package/dist/css-var-scan.d.ts +0 -16
  19. package/dist/css-var-scan.d.ts.map +1 -1
  20. package/dist/css-var-scan.js +24 -0
  21. package/dist/css-var-scan.js.map +1 -1
  22. package/dist/edit-markers.d.ts +41 -5
  23. package/dist/edit-markers.d.ts.map +1 -1
  24. package/dist/edit-markers.js +47 -50
  25. package/dist/edit-markers.js.map +1 -1
  26. package/dist/escaped-line-breaks.d.ts +44 -0
  27. package/dist/escaped-line-breaks.d.ts.map +1 -0
  28. package/dist/escaped-line-breaks.js +203 -0
  29. package/dist/escaped-line-breaks.js.map +1 -0
  30. package/dist/index.d.ts +4 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interactions.js +6 -6
  35. package/dist/interactions.js.map +1 -1
  36. package/dist/link-canonicalise.d.ts.map +1 -1
  37. package/dist/link-canonicalise.js +7 -1
  38. package/dist/link-canonicalise.js.map +1 -1
  39. package/dist/link-refs.d.ts +1 -1
  40. package/dist/link-refs.js +3 -3
  41. package/dist/lists/address.d.ts +34 -0
  42. package/dist/lists/address.d.ts.map +1 -0
  43. package/dist/lists/address.js +43 -0
  44. package/dist/lists/address.js.map +1 -0
  45. package/dist/lists/default-types.d.ts +22 -0
  46. package/dist/lists/default-types.d.ts.map +1 -0
  47. package/dist/lists/default-types.js +127 -0
  48. package/dist/lists/default-types.js.map +1 -0
  49. package/dist/lists/fields.d.ts +152 -0
  50. package/dist/lists/fields.d.ts.map +1 -0
  51. package/dist/lists/fields.js +259 -0
  52. package/dist/lists/fields.js.map +1 -0
  53. package/dist/lists/index.d.ts +14 -0
  54. package/dist/lists/index.d.ts.map +1 -0
  55. package/dist/lists/index.js +15 -0
  56. package/dist/lists/index.js.map +1 -0
  57. package/dist/lists/item-attr.d.ts +26 -0
  58. package/dist/lists/item-attr.d.ts.map +1 -0
  59. package/dist/lists/item-attr.js +31 -0
  60. package/dist/lists/item-attr.js.map +1 -0
  61. package/dist/lists/items.d.ts +105 -0
  62. package/dist/lists/items.d.ts.map +1 -0
  63. package/dist/lists/items.js +304 -0
  64. package/dist/lists/items.js.map +1 -0
  65. package/dist/lists/list-type.d.ts +401 -0
  66. package/dist/lists/list-type.d.ts.map +1 -0
  67. package/dist/lists/list-type.js +275 -0
  68. package/dist/lists/list-type.js.map +1 -0
  69. package/dist/lists/model.d.ts +14 -0
  70. package/dist/lists/model.d.ts.map +1 -0
  71. package/dist/lists/model.js +15 -0
  72. package/dist/lists/model.js.map +1 -0
  73. package/dist/lists/page-type.d.ts +115 -0
  74. package/dist/lists/page-type.d.ts.map +1 -0
  75. package/dist/lists/page-type.js +66 -0
  76. package/dist/lists/page-type.js.map +1 -0
  77. package/dist/lists/refs.d.ts +13 -0
  78. package/dist/lists/refs.d.ts.map +1 -0
  79. package/dist/lists/refs.js +18 -0
  80. package/dist/lists/refs.js.map +1 -0
  81. package/dist/lists/render-data.d.ts +123 -0
  82. package/dist/lists/render-data.d.ts.map +1 -0
  83. package/dist/lists/render-data.js +3 -0
  84. package/dist/lists/render-data.js.map +1 -0
  85. package/dist/lists/render-deps.d.ts +69 -0
  86. package/dist/lists/render-deps.d.ts.map +1 -0
  87. package/dist/lists/render-deps.js +93 -0
  88. package/dist/lists/render-deps.js.map +1 -0
  89. package/dist/lists/resolve-values.d.ts +63 -0
  90. package/dist/lists/resolve-values.d.ts.map +1 -0
  91. package/dist/lists/resolve-values.js +181 -0
  92. package/dist/lists/resolve-values.js.map +1 -0
  93. package/dist/lists/resolve.d.ts +119 -0
  94. package/dist/lists/resolve.d.ts.map +1 -0
  95. package/dist/lists/resolve.js +970 -0
  96. package/dist/lists/resolve.js.map +1 -0
  97. package/dist/lists/scope.d.ts +81 -0
  98. package/dist/lists/scope.d.ts.map +1 -0
  99. package/dist/lists/scope.js +3 -0
  100. package/dist/lists/scope.js.map +1 -0
  101. package/dist/markup-structure.d.ts +8 -0
  102. package/dist/markup-structure.d.ts.map +1 -1
  103. package/dist/markup-structure.js +2 -0
  104. package/dist/markup-structure.js.map +1 -1
  105. package/dist/media.d.ts +1 -1
  106. package/dist/nested-module-render.d.ts +38 -20
  107. package/dist/nested-module-render.d.ts.map +1 -1
  108. package/dist/nested-module-render.js +156 -31
  109. package/dist/nested-module-render.js.map +1 -1
  110. package/dist/page-log.d.ts +1 -1
  111. package/dist/preview-compose.d.ts +24 -21
  112. package/dist/preview-compose.d.ts.map +1 -1
  113. package/dist/preview-compose.js +34 -118
  114. package/dist/preview-compose.js.map +1 -1
  115. package/dist/redirect-plan.d.ts +13 -0
  116. package/dist/redirect-plan.d.ts.map +1 -1
  117. package/dist/redirect-plan.js +22 -0
  118. package/dist/redirect-plan.js.map +1 -1
  119. package/dist/seo.d.ts +21 -0
  120. package/dist/seo.d.ts.map +1 -1
  121. package/dist/seo.js +32 -0
  122. package/dist/seo.js.map +1 -1
  123. package/dist/source-text-transport.d.ts +43 -0
  124. package/dist/source-text-transport.d.ts.map +1 -0
  125. package/dist/source-text-transport.js +79 -0
  126. package/dist/source-text-transport.js.map +1 -0
  127. package/dist/template-engine.d.ts +44 -31
  128. package/dist/template-engine.d.ts.map +1 -1
  129. package/dist/template-engine.js +75 -174
  130. package/dist/template-engine.js.map +1 -1
  131. package/dist/template-sections.d.ts +69 -0
  132. package/dist/template-sections.d.ts.map +1 -0
  133. package/dist/template-sections.js +436 -0
  134. package/dist/template-sections.js.map +1 -0
  135. package/dist/version.d.ts +2 -2
  136. package/dist/version.d.ts.map +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/version.js.map +1 -1
  139. package/package.json +8 -1
  140. package/src/__tests__/redos-hardening.test.ts +51 -16
  141. package/src/ai-tools-add-module-modes.test.ts +51 -1
  142. package/src/ai-tools.test.ts +7 -10
  143. package/src/ai-tools.ts +90 -180
  144. package/src/bug-intake.test.ts +1 -1
  145. package/src/build-page.test.ts +34 -3
  146. package/src/build-page.ts +34 -11
  147. package/src/content.test.ts +137 -4
  148. package/src/content.ts +230 -85
  149. package/src/css-scope-scan.ts +1 -1
  150. package/src/css-var-aliases.test.ts +61 -0
  151. package/src/css-var-aliases.ts +109 -0
  152. package/src/css-var-scan.ts +9 -0
  153. package/src/edit-markers.test.ts +37 -21
  154. package/src/edit-markers.ts +52 -20
  155. package/src/escaped-line-breaks.ts +221 -0
  156. package/src/index.ts +4 -1
  157. package/src/interactions.ts +6 -6
  158. package/src/link-canonicalise.test.ts +36 -6
  159. package/src/link-canonicalise.ts +6 -1
  160. package/src/link-refs.test.ts +67 -42
  161. package/src/link-refs.ts +3 -3
  162. package/src/lists/address.ts +75 -0
  163. package/src/lists/default-types.ts +141 -0
  164. package/src/lists/fields.ts +286 -0
  165. package/src/lists/index.ts +16 -0
  166. package/src/lists/item-attr.test.ts +36 -0
  167. package/src/lists/item-attr.ts +49 -0
  168. package/src/lists/items.ts +402 -0
  169. package/src/lists/list-fixtures.test.ts +308 -0
  170. package/src/lists/list-type.ts +306 -0
  171. package/src/lists/lists.test.ts +328 -0
  172. package/src/lists/model.ts +16 -0
  173. package/src/lists/page-type.ts +71 -0
  174. package/src/lists/refs.ts +21 -0
  175. package/src/lists/render-data.ts +134 -0
  176. package/src/lists/render-deps.test.ts +132 -0
  177. package/src/lists/render-deps.ts +147 -0
  178. package/src/lists/resolve-values.ts +224 -0
  179. package/src/lists/resolve.test.ts +472 -0
  180. package/src/lists/resolve.ts +1246 -0
  181. package/src/lists/scope.ts +69 -0
  182. package/src/lists-compose-parity.test.ts +262 -0
  183. package/src/markup-structure.ts +10 -0
  184. package/src/nested-module-render.test.ts +147 -74
  185. package/src/nested-module-render.ts +120 -48
  186. package/src/preview-compose.test.ts +55 -19
  187. package/src/preview-compose.ts +59 -154
  188. package/src/redirect-plan.test.ts +25 -0
  189. package/src/redirect-plan.ts +24 -0
  190. package/src/seo.test.ts +58 -0
  191. package/src/seo.ts +49 -0
  192. package/src/source-text-transport.test.ts +158 -0
  193. package/src/source-text-transport.ts +81 -0
  194. package/src/template-engine-lists.test.ts +608 -0
  195. package/src/template-engine.test.ts +62 -80
  196. package/src/template-engine.ts +102 -245
  197. package/src/template-sections.ts +514 -0
  198. package/src/version.ts +1 -1
  199. package/dist/structured-sets.d.ts +0 -70
  200. package/dist/structured-sets.d.ts.map +0 -1
  201. package/dist/structured-sets.js +0 -92
  202. package/dist/structured-sets.js.map +0 -1
  203. package/src/structured-sets.ts +0 -114
  204. package/src/template-engine-data-lists.test.ts +0 -90
@@ -10,6 +10,7 @@ import {
10
10
  pageUpdateSchema,
11
11
  slugSchema,
12
12
  templateBlocksSetSchema,
13
+ templateCreateSchema,
13
14
  } from "./content.js";
14
15
 
15
16
  describe("slugSchema", () => {
@@ -90,7 +91,32 @@ describe("moduleCreateSchema", () => {
90
91
  expect(moduleCreateSchema.safeParse({ ...base, js: 'xml += "]]>";' }).success).toBe(true);
91
92
  });
92
93
 
93
- it("rejects CDATA on the update path too (edit_module / edit_content funnel)", () => {
94
+ // e2e-livedit run 40532297620 — html / css whose line breaks arrived as
95
+ // literal `\n` sequences rendered `\n` text and broke the CSS. AI tool
96
+ // arguments are decoded first (`decodeEscapedLineBreaks`); every other
97
+ // writer is refused with the fix.
98
+ it("rejects html and css whose line breaks are escaped, with the fix", () => {
99
+ const base = { slug: "site-footer", displayName: "Site footer", html: "<footer></footer>" };
100
+ const html = moduleCreateSchema.safeParse({
101
+ ...base,
102
+ html: '<footer class="f">\\n <nav>\\n <a href="/">Home</a>\\n </nav>\\n</footer>',
103
+ });
104
+ expect(html.success).toBe(false);
105
+ if (!html.success) {
106
+ expect(html.error.issues.map((i) => i.message).join(" ")).toContain("real line breaks");
107
+ }
108
+ const css = moduleUpdateSchema.safeParse({
109
+ moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
110
+ css: ".f {\\n padding: 2rem;\\n}\\n.f a {\\n color: inherit;\\n}",
111
+ });
112
+ expect(css.success).toBe(false);
113
+ // One escaped newline inside a CSS string on a single line is not the pattern.
114
+ expect(
115
+ moduleCreateSchema.safeParse({ ...base, css: '.f::after { content: "\\n " }' }).success,
116
+ ).toBe(true);
117
+ });
118
+
119
+ it("rejects CDATA on the update path too (update_modules_many / edit_content funnel)", () => {
94
120
  const r = moduleUpdateSchema.safeParse({
95
121
  moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
96
122
  css: "/*<![CDATA[*/ .x{} /*]]>*/",
@@ -120,8 +146,9 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
120
146
  expect(msg).toContain("numbered-scalar fanout");
121
147
  expect(msg).toContain('"label", "label2", "label3"');
122
148
  // AI-actionable: the error names every list-kind fix.
123
- expect(msg).toContain("text-list");
124
- expect(msg).toContain("link-list");
149
+ expect(msg).toContain('kind "list"');
150
+ expect(msg).toContain('listType "texts"');
151
+ expect(msg).toContain('"links"');
125
152
  expect(msg).toContain("module-list");
126
153
  expect(msg).toContain("§1A");
127
154
  }
@@ -153,11 +180,83 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
153
180
  });
154
181
 
155
182
  it("scopes to scalar kinds — numbered list fields are a modelling question, not fanout", () => {
156
- const linkList = (name: string) => ({ name, kind: "link-list", label: name });
183
+ const linkList = (name: string) => ({ name, kind: "list", listType: "links", label: name });
157
184
  expect(mod([linkList("links1"), linkList("links2"), linkList("links3")]).success).toBe(true);
158
185
  });
159
186
  });
160
187
 
188
+ // Epic #1107 — the `list` field kind (docs/lists.md §7): the static
189
+ // schema checks the binding's shape; fitting the type is the op's job.
190
+ describe("moduleFields list kind", () => {
191
+ const mod = (fields: unknown) =>
192
+ moduleCreateSchema.safeParse({ slug: "m", displayName: "M", html: "<div></div>", fields });
193
+ const LIST = "caelo:list/00000000-0000-4000-8000-000000000101";
194
+ const messages = (r: ReturnType<typeof mod>) =>
195
+ r.success ? [] : r.error.issues.map((i) => i.message);
196
+
197
+ it("accepts every binding", () => {
198
+ const ok = mod([
199
+ {
200
+ name: "bullets",
201
+ label: "B",
202
+ kind: "list",
203
+ listType: "texts",
204
+ default: [{ node: "text", values: { text: "x" } }],
205
+ },
206
+ { name: "menu", label: "M", kind: "list", listType: "menu", bind: "shared", list: LIST },
207
+ {
208
+ name: "footer",
209
+ label: "F",
210
+ kind: "list",
211
+ listType: "links",
212
+ bind: "shared",
213
+ default: LIST,
214
+ },
215
+ {
216
+ name: "latest",
217
+ label: "L",
218
+ kind: "list",
219
+ listType: "links",
220
+ bind: "source",
221
+ source: {
222
+ kind: "query",
223
+ editable: ["limit"],
224
+ defaults: { page_type: "news", order: "published_desc", limit: 3 },
225
+ },
226
+ },
227
+ {
228
+ name: "crumbs",
229
+ label: "C",
230
+ kind: "list",
231
+ listType: "links",
232
+ bind: "source",
233
+ source: { kind: "system", name: "breadcrumbs" },
234
+ },
235
+ ]);
236
+ expect(ok.success).toBe(true);
237
+ });
238
+
239
+ it("refuses bindings whose parts do not fit together, naming the fix", () => {
240
+ const r = mod([
241
+ { name: "a", label: "A", kind: "list", listType: "menu", bind: "source" },
242
+ { name: "b", label: "B", kind: "list", listType: "menu", list: LIST },
243
+ { name: "c", label: "C", kind: "list", listType: "menu", bind: "shared", default: [] },
244
+ { name: "d", label: "D", kind: "list", listType: "texts", default: LIST },
245
+ ]);
246
+ expect(messages(r)).toEqual([
247
+ 'list field "a": bind "source" needs a source (query, system or plugin).',
248
+ 'list field "b": list is only allowed with bind "shared" — a local list stores its entries in the placement.',
249
+ 'list field "c": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.',
250
+ 'list field "d": a local default is an array of entries, not a list reference.',
251
+ ]);
252
+ });
253
+
254
+ it("refuses the removed text-list / link-list kinds", () => {
255
+ expect(mod([{ name: "t", label: "T", kind: "text-list" }]).success).toBe(false);
256
+ expect(mod([{ name: "l", label: "L", kind: "link-list" }]).success).toBe(false);
257
+ });
258
+ });
259
+
161
260
  describe("pageCreateSchema (no raw HTML invariant)", () => {
162
261
  it("accepts a structured page payload", () => {
163
262
  const r = pageCreateSchema.safeParse({
@@ -250,3 +349,37 @@ describe("templateBlocksSetSchema", () => {
250
349
  expect(r.success).toBe(true);
251
350
  });
252
351
  });
352
+
353
+ describe("corrupted code payloads are refused at the boundary (PR #1121 e2e)", () => {
354
+ const base = { slug: "footer", displayName: "Footer", html: "<footer>x</footer>" };
355
+ it("refuses JSON-encoded html and css with the fix in the message", () => {
356
+ const html = moduleCreateSchema.safeParse({
357
+ ...base,
358
+ html: JSON.stringify('<footer class="x">{{#links}}{{label}}{{/links}}</footer>'),
359
+ });
360
+ expect(html.success).toBe(false);
361
+ expect(html.error?.issues[0]?.message).toContain("JSON-encoded");
362
+ const css = moduleCreateSchema.safeParse({
363
+ ...base,
364
+ css: JSON.stringify(".x { color: red; }"),
365
+ });
366
+ expect(css.success).toBe(false);
367
+ });
368
+ it("refuses HTML-escaped template markup", () => {
369
+ const r = templateCreateSchema.safeParse({
370
+ slug: "page",
371
+ displayName: "Page",
372
+ html: '&lt;div&gt;&lt;caelo-slot name="content"&gt;&lt;/caelo-slot&gt;&lt;/div&gt;',
373
+ });
374
+ expect(r.success).toBe(false);
375
+ expect(r.error?.issues[0]?.message).toContain("HTML-escaped");
376
+ });
377
+ it("keeps legitimate markup: entities inside real tags, a quote-led text node", () => {
378
+ expect(
379
+ moduleCreateSchema.safeParse({ ...base, html: "<pre>&lt;div&gt; is a tag</pre>" }).success,
380
+ ).toBe(true);
381
+ expect(moduleCreateSchema.safeParse({ ...base, html: '"Quoted" <em>text</em>' }).success).toBe(
382
+ true,
383
+ );
384
+ });
385
+ });
package/src/content.ts CHANGED
@@ -11,6 +11,10 @@
11
11
  */
12
12
 
13
13
  import { z } from "zod";
14
+ import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
15
+ import { listItemInputSchema } from "./lists/items.js";
16
+ import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
17
+ import { LIST_REF_RE } from "./lists/refs.js";
14
18
 
15
19
  /** Lowercase slug, hyphenated, 1–64 chars, no leading/trailing hyphen. */
16
20
  export const slugSchema = z
@@ -93,7 +97,69 @@ export const MODULE_JS_MAX = 256 * 1024;
93
97
  export const TEMPLATE_HTML_MAX = 512 * 1024;
94
98
  export const TEMPLATE_CSS_MAX = 128 * 1024;
95
99
 
96
- const moduleHtml = z.string().max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`);
100
+ /**
101
+ * Code payloads a model sometimes corrupts before they reach us, both seen
102
+ * in PR #1121's real-AI runs and stored verbatim before this guard:
103
+ * - JSON-encoded: the whole value wrapped in quotes with `\"` escapes
104
+ * (`"<footer class=\"x\">…"`) — the page showed literal quotes;
105
+ * - HTML-escaped markup: `&lt;div&gt;&lt;caelo-slot …` with no real tag
106
+ * — a template without a single slot, so it had no blocks at all.
107
+ * Refusing at the write boundary with the fix turns a broken render the
108
+ * AI only notices from a screenshot into one corrected call (CLAUDE.md
109
+ * §2, §11; same reasoning as the CDATA guard below). AI tool arguments
110
+ * never reach this in that shape: `sourceTextArg` decodes both encodings
111
+ * first (`source-text-transport.ts`); this guards every other writer.
112
+ */
113
+ export function jsonEncodedCodeMessage(field: string, value: string): string | null {
114
+ const t = value.trim();
115
+ if (t.length < 2 || !t.startsWith('"') || !t.endsWith('"')) return null;
116
+ try {
117
+ if (typeof JSON.parse(t) !== "string") return null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ return `${field} is JSON-encoded (wrapped in quotes with \\" escapes) — send the raw ${field} itself, not a JSON string of it.`;
122
+ }
123
+
124
+ /** See {@link jsonEncodedCodeMessage}: markup sent HTML-escaped. */
125
+ export function htmlEscapedMarkupMessage(field: string, value: string): string | null {
126
+ if (value.includes("<") || !/&lt;\/?[a-zA-Z]/.test(value)) return null;
127
+ return `${field} is HTML-escaped (&lt;…&gt; instead of <…>) — send the markup itself, unescaped.`;
128
+ }
129
+
130
+ /**
131
+ * See {@link jsonEncodedCodeMessage}: source whose line breaks arrived as
132
+ * literal `\n` sequences (`hasEscapedLineBreaks`) — the page shows the
133
+ * `\n` text and the CSS rules after it break. AI tool arguments are decoded
134
+ * first (`decodeEscapedLineBreaks`); this guards every other writer. Module
135
+ * js is checked by parsing it (admin-core `module-js-syntax.ts`), which
136
+ * catches this and every other syntax error.
137
+ */
138
+ export function escapedLineBreaksMessage(field: "html" | "css", value: string): string | null {
139
+ if (!hasEscapedLineBreaks(value, field)) return null;
140
+ return `${field} has its line breaks escaped (literal \\n sequences, no real line break) — send the ${field} with real line breaks.`;
141
+ }
142
+
143
+ const noCorruptMarkup = (field: "html") => (value: string, ctx: z.RefinementCtx) => {
144
+ const message =
145
+ jsonEncodedCodeMessage(field, value) ??
146
+ htmlEscapedMarkupMessage(field, value) ??
147
+ escapedLineBreaksMessage(field, value);
148
+ if (message) ctx.addIssue({ code: "custom", message });
149
+ };
150
+ const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
151
+ const message = jsonEncodedCodeMessage(field, value);
152
+ if (message) ctx.addIssue({ code: "custom", message });
153
+ };
154
+ const noCorruptCss = (value: string, ctx: z.RefinementCtx) => {
155
+ const message = jsonEncodedCodeMessage("css", value) ?? escapedLineBreaksMessage("css", value);
156
+ if (message) ctx.addIssue({ code: "custom", message });
157
+ };
158
+
159
+ const moduleHtml = z
160
+ .string()
161
+ .max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`)
162
+ .superRefine(noCorruptMarkup("html"));
97
163
 
98
164
  /**
99
165
  * issue #432 root-cause guard — reject XML CDATA wrappers in module css/js.
@@ -122,11 +188,13 @@ const cdataMessage = (field: "css" | "js"): string =>
122
188
  const moduleCss = z
123
189
  .string()
124
190
  .max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
125
- .refine(noCdata, cdataMessage("css"));
191
+ .refine(noCdata, cdataMessage("css"))
192
+ .superRefine(noCorruptCss);
126
193
  const moduleJs = z
127
194
  .string()
128
195
  .max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
129
- .refine(noCdata, cdataMessage("js"));
196
+ .refine(noCdata, cdataMessage("js"))
197
+ .superRefine(noJsonEncodedCode("js"));
130
198
 
131
199
  /**
132
200
  * v0.4.0 — module field schema. Each field declares one substitution slot in
@@ -161,29 +229,29 @@ export const MODULE_FIELD_PRIMITIVE_KINDS = [
161
229
  ] as const;
162
230
 
163
231
  /**
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.
168
- *
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.
232
+ * Epic #1107 — the data-list field kind (`docs/lists.md` §7). One kind
233
+ * for every repeated piece of data: feature bullets, a footer link group,
234
+ * a three-level menu, a mega dropdown, a "latest news" block. The entry
235
+ * shape comes from a LIST TYPE (`listType`, a slug); the entries come from
236
+ * the field's binding:
173
237
  *
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.
238
+ * - `local` (default) — entries stored in the placement's value
239
+ * (`ListItem[]`), only this module shows them;
240
+ * - `shared` — a `caelo:list/<id>` list, fixed on the field (`list`) or
241
+ * chosen per placement; navigation and anything needed in
242
+ * more than one place;
243
+ * - `source` — entries generated per page from `source` (a page query,
244
+ * a system source like breadcrumbs, a plugin source).
177
245
  *
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.
246
+ * The template iterates `{{#field}}…{{/field}}` the same way for every
247
+ * binding. `module-list` stays: it composes modules, lists carry data.
248
+ * Replaces the `text-list` / `link-list` kinds.
181
249
  */
182
- export const MODULE_FIELD_LIST_KINDS = ["text-list", "link-list"] as const;
250
+ export const MODULE_FIELD_LIST_KINDS = ["list"] as const;
183
251
 
184
252
  export const MODULE_FIELD_NESTED_KINDS = ["module", "module-list"] as const;
185
253
 
186
- /** All eleven v0.12.0 kinds. */
254
+ /** Every module field kind. */
187
255
  export const MODULE_FIELD_KINDS = [
188
256
  ...MODULE_FIELD_PRIMITIVE_KINDS,
189
257
  ...MODULE_FIELD_LIST_KINDS,
@@ -200,10 +268,9 @@ export type ModuleFieldKind = (typeof MODULE_FIELD_KINDS)[number];
200
268
 
201
269
  /**
202
270
  * 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`).
271
+ * `{{#name}}…{{/name}}` Mustache-section operator (`list` and
272
+ * `module-list`); the engine's section-dispatch keys off it
273
+ * (`packages/shared/src/template-engine.ts`).
207
274
  */
208
275
  export const MODULE_FIELD_SECTION_KINDS = [...MODULE_FIELD_LIST_KINDS, "module-list"] as const;
209
276
 
@@ -230,7 +297,7 @@ const moduleFieldModuleSchema = z
230
297
  * stable `modules.type` class, e.g. `button` — NOT the unique
231
298
  * `modules.slug` like `button-mpqxq3ch`). When absent, any module is
232
299
  * permitted. The op-layer validator enforces the whitelist against
233
- * the referenced module's `type` at `set_content_instance_values`
300
+ * the referenced module's `type` at `content_instances.set_values`
234
301
  * time. v0.12.3 (issue #106) renamed this from `allowedModuleSlugs`:
235
302
  * an exact-slug whitelist could never match an AI-minted module
236
303
  * because every minted slug carries a uniqueness suffix.
@@ -255,43 +322,84 @@ const moduleFieldModuleListSchema = z
255
322
  .strict();
256
323
 
257
324
  /**
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.
325
+ * Epic #1107 — a `list` field (`docs/lists.md` §7). The static schema
326
+ * checks the SHAPE: the binding's parts fit together and a local default
327
+ * is well-formed entries. Whether those entries fit the named list type
328
+ * (nodes, fields, depth) needs the type, which is stored data — the op
329
+ * layer validates that with `validateListItems` when the module is
330
+ * written, and the renderer reports `list-type-missing:<slug>` for a type
331
+ * that is gone.
262
332
  */
263
- const moduleFieldTextListSchema = z
333
+ const moduleFieldListSchema = z
264
334
  .object({
265
335
  name: moduleFieldName,
266
- kind: z.literal("text-list"),
336
+ kind: z.literal("list"),
267
337
  label: moduleFieldLabel,
338
+ /** Slug of the list type that defines the entries. */
339
+ listType: slugSchema,
340
+ bind: z.enum(["local", "shared", "source"]).optional(),
341
+ /** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
342
+ list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
343
+ /** `bind: "source"` — where generated entries come from. */
344
+ source: sourceDefSchema.optional(),
268
345
  min: z.number().int().nonnegative().optional(),
269
- max: z.number().int().positive().max(256).optional(),
270
- default: z.array(z.string()).optional(),
346
+ max: z.number().int().positive().max(LIST_MAX_ITEMS).optional(),
347
+ /** `local`: entries; `shared`: `caelo:list/<id>`. */
348
+ default: z
349
+ .union([
350
+ z.array(listItemInputSchema),
351
+ z.string().regex(LIST_REF_RE, "a shared default must be caelo:list/<id>"),
352
+ ])
353
+ .optional(),
271
354
  })
272
355
  .strict();
273
356
 
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();
357
+ /** Binding consistency of one `list` field; every problem, AI-actionable. */
358
+ function listFieldIssues(f: z.infer<typeof moduleFieldListSchema>): string[] {
359
+ const bind = f.bind ?? "local";
360
+ const issues: string[] = [];
361
+ if (bind === "source" && f.source === undefined) {
362
+ issues.push(`list field "${f.name}": bind "source" needs a source (query, system or plugin).`);
363
+ }
364
+ if (bind !== "source" && f.source !== undefined) {
365
+ issues.push(`list field "${f.name}": source is only allowed with bind "source".`);
366
+ }
367
+ if (bind === "local" && f.list !== undefined) {
368
+ issues.push(
369
+ `list field "${f.name}": list is only allowed with bind "shared" — a local list stores its entries in the placement.`,
370
+ );
371
+ }
372
+ if (bind === "source" && f.list !== undefined && f.source?.kind !== "system") {
373
+ issues.push(
374
+ `list field "${f.name}": list on a source field names the vocabulary of a page_terms source only.`,
375
+ );
376
+ }
377
+ if (f.default !== undefined) {
378
+ if (bind === "local" && typeof f.default === "string") {
379
+ issues.push(
380
+ `list field "${f.name}": a local default is an array of entries, not a list reference.`,
381
+ );
382
+ }
383
+ if (bind === "shared" && Array.isArray(f.default)) {
384
+ issues.push(
385
+ `list field "${f.name}": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.`,
386
+ );
387
+ }
388
+ if (bind === "source") {
389
+ issues.push(
390
+ `list field "${f.name}": a source field has no default; its entries are generated.`,
391
+ );
392
+ }
393
+ }
394
+ if (f.min !== undefined && f.max !== undefined && f.min > f.max) {
395
+ issues.push(`list field "${f.name}": min is greater than max.`);
396
+ }
397
+ return issues;
398
+ }
290
399
 
291
400
  export const moduleFieldSchema = z.discriminatedUnion("kind", [
292
401
  moduleFieldPrimitiveSchema,
293
- moduleFieldTextListSchema,
294
- moduleFieldLinkListSchema,
402
+ moduleFieldListSchema,
295
403
  moduleFieldModuleSchema,
296
404
  moduleFieldModuleListSchema,
297
405
  ]);
@@ -368,6 +476,11 @@ const moduleFieldsArray = z
368
476
  return;
369
477
  }
370
478
  seen.add(f.name);
479
+ if (f.kind === "list") {
480
+ for (const message of listFieldIssues(f)) {
481
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message });
482
+ }
483
+ }
371
484
  }
372
485
  for (const run of findNumberedScalarFanoutRuns(arr)) {
373
486
  ctx.addIssue({
@@ -375,8 +488,9 @@ const moduleFieldsArray = z
375
488
  message:
376
489
  `numbered-scalar fanout: ${run.map((n) => `"${n}"`).join(", ")} repeat one role as ` +
377
490
  "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 ' +
491
+ '(CLAUDE.md §1A). Replace the run with a single field: kind "list" with a list type ' +
492
+ '(listType "texts" for plain strings — the template iterates {{#f}}{{text}}{{/f}}; ' +
493
+ '"links" for label + link — {{#f}}<a href="{{link}}">{{label}}</a>{{/f}}), or ' +
380
494
  '"module-list" (rich per-item structure: mint a card sub-module and reference it per ' +
381
495
  "item — in build_page, author the card detached with `ref` and pass " +
382
496
  '[{"$ref": "<ref>"}, …] as the field value). A two-field split like col1/col2 is fine; ' +
@@ -398,7 +512,7 @@ export type ModuleKind = (typeof MODULE_KINDS)[number];
398
512
  /**
399
513
  * v0.12.0 — description is `.default("")` at the Zod boundary so the
400
514
  * 82+ legacy callers in tests/seed scripts keep working unchanged.
401
- * AI tool descriptions (create_module / edit_module) require the AI
515
+ * AI tool descriptions (add_module / build_page / update_modules_many) require the AI
402
516
  * to supply a real description explicitly — see CLAUDE.md §1A.
403
517
  */
404
518
  const moduleDescription = z.string().max(1000);
@@ -440,31 +554,29 @@ export const moduleUpdateSchema = z
440
554
  })
441
555
  .strict();
442
556
 
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];
557
+ /** A page type slug (epic #1107, docs/lists.md §5). */
558
+ export const pageTypeSlugSchema = z
559
+ .string()
560
+ .regex(
561
+ /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
562
+ "page type slug: lowercase letters, digits, hyphens",
563
+ );
460
564
 
461
565
  export const templateCreateSchema = z
462
566
  .object({
463
567
  slug: slugSchema,
464
568
  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`),
569
+ /**
570
+ * Epic #1107 — the page type (slug) this template serves; the template
571
+ * is appended to that type's `templates`. Omit for the site's default
572
+ * page type (`site_defaults.default_page_type_id`, stored data — not a
573
+ * render-time fallback).
574
+ */
575
+ pageType: pageTypeSlugSchema.optional(),
576
+ html: z
577
+ .string()
578
+ .max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`)
579
+ .superRefine(noCorruptMarkup("html")),
468
580
  css: z.string().max(TEMPLATE_CSS_MAX, `css exceeds ${TEMPLATE_CSS_MAX} bytes`).default(""),
469
581
  /**
470
582
  * P6.7.6 — every template binds to one layout. When omitted, the
@@ -505,9 +617,9 @@ export const templateUpdateSchema = z
505
617
  .object({
506
618
  templateId: z.string().uuid(),
507
619
  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(),
620
+ /** Epic #1107 — also allow this template for the page type (slug); appended to its `templates`. */
621
+ pageType: pageTypeSlugSchema.optional(),
622
+ html: z.string().max(TEMPLATE_HTML_MAX).superRefine(noCorruptMarkup("html")).optional(),
511
623
  css: z.string().max(TEMPLATE_CSS_MAX).optional(),
512
624
  /** P6.7.6 — re-point the template to a different layout. */
513
625
  layoutId: z.string().uuid().optional(),
@@ -536,6 +648,15 @@ export const templateBlocksSetSchema = z
536
648
 
537
649
  export const pageStatusSchema = z.enum(["draft", "published"]);
538
650
 
651
+ /** `pages.short_title` (#1142): a crumb-sized public label, never blank. */
652
+ export const pageShortTitleSchema = z
653
+ .string()
654
+ .trim()
655
+ .min(1, "the short title is the page's breadcrumb label and cannot be blank")
656
+ // Same bound as the title it is backfilled and defaulted from, so every
657
+ // stored page stays editable; keeping it crumb-sized is guidance.
658
+ .max(256);
659
+
539
660
  /**
540
661
  * Page payloads are `.strict()` — passing an `html` field is rejected by Zod
541
662
  * before the handler runs. This is the §3.1 "no raw HTML on pages" invariant
@@ -545,9 +666,13 @@ export const pageStatusSchema = z.enum(["draft", "published"]);
545
666
  export const pageCreateSchema = z
546
667
  .object({
547
668
  slug: slugSchema,
548
- /** P6.7.5 — internal editor label. Defaults to title if omitted. */
549
- name: z.string().min(1).max(256).optional(),
550
669
  title: z.string().min(1).max(256),
670
+ /**
671
+ * #1142 — the page's public label in breadcrumbs, navigation and the
672
+ * page picker ("Pricing" for "Pricing for teams of every size").
673
+ * Omitted = the create-time resolver stores `title` (stored data).
674
+ */
675
+ shortTitle: pageShortTitleSchema.optional(),
551
676
  /**
552
677
  * P6.7.6 — optional. When omitted, the handler resolves to
553
678
  * `site_defaults.default_template_id` at create time. Stored data,
@@ -568,6 +693,16 @@ export const pageCreateSchema = z
568
693
  * becomes "<parent's path>/<slug>". Omit (or null) for a top-level page.
569
694
  */
570
695
  parentPageId: z.string().uuid().nullable().optional(),
696
+ /**
697
+ * Epic #1107 — what the page IS (page type slug). Omit to take the type
698
+ * whose `templates` contain the template; with neither `pageType` nor
699
+ * `templateId`, the site's default page type and its default template.
700
+ */
701
+ pageType: pageTypeSlugSchema.optional(),
702
+ /** Property values, validated against the page type. */
703
+ properties: z.record(z.string(), z.unknown()).optional(),
704
+ /** Terms (`caelo:term/<listId>/<itemId>`) from the page type's vocabularies. */
705
+ terms: z.array(z.string().max(120)).max(100).optional(),
571
706
  })
572
707
  .strict();
573
708
 
@@ -602,9 +737,9 @@ export const pageUpdateSchema = z
602
737
  * Routes that load the page first should always pass it back.
603
738
  */
604
739
  expectedVersion: z.number().int().nonnegative().optional(),
605
- /** P6.7.5 — three independently-editable identifiers. */
606
- name: z.string().min(1).max(256).optional(),
607
740
  title: z.string().min(1).max(256).optional(),
741
+ /** #1142 — the breadcrumb / navigation label; see {@link pageCreateSchema}. */
742
+ shortTitle: pageShortTitleSchema.optional(),
608
743
  slug: slugSchema.optional(),
609
744
  templateId: z.string().uuid().optional(),
610
745
  status: pageStatusSchema.optional(),
@@ -620,6 +755,16 @@ export const pageUpdateSchema = z
620
755
  * every inbound link, which is never a safe default (CLAUDE.md §2).
621
756
  */
622
757
  redirectFromOld: z.enum(["auto", "skip"]).optional(),
758
+ /**
759
+ * Epic #1107 — switch the page type (slug). The template must be one of
760
+ * the type's templates (pass `templateId` too when it is not), and the
761
+ * page's properties and terms must fit the new type.
762
+ */
763
+ pageType: pageTypeSlugSchema.optional(),
764
+ /** Merged into the page's properties; `null` removes a property. */
765
+ properties: z.record(z.string(), z.unknown()).optional(),
766
+ /** Replaces the page's terms (`caelo:term/<listId>/<itemId>`). */
767
+ terms: z.array(z.string().max(120)).max(100).optional(),
623
768
  })
624
769
  .strict();
625
770
 
@@ -669,10 +814,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
669
814
 
670
815
  /**
671
816
  * v0.12.0 — why this row exists as a shared/reusable instance.
672
- * Surfaced in the `## Content Library` system-prompt block so the AI
673
- * can decide *reuse the synced row* vs *fork to unsynced* vs *mint
674
- * new* without a tool round-trip. Required by the AI's
675
- * create_content_instance tool description; legacy callers may pass
817
+ * Shown by the AI's list_content_instances tool so the AI can decide
818
+ * *reuse the synced row* vs *fork to unsynced* vs *mint new* without
819
+ * asking the operator. Required by the AI's
820
+ * create_content_instances tool description; legacy callers may pass
676
821
  * null (the migration-0093 unsynced rows have purpose=NULL).
677
822
  * See CLAUDE.md §1A.
678
823
  */
@@ -7,7 +7,7 @@
7
7
  * that is not tied to the module bleeds into every other module on the
8
8
  * page: an `h1 {}` in the hero restyles the pricing table's heading, a
9
9
  * `.card {}` in one module restyles another module's cards. The
10
- * convention (edit_module description, design-quality skill) is to nest
10
+ * convention (update_modules_many description, design-quality skill) is to nest
11
11
  * module CSS under the module's own root class; this scanner is the
12
12
  * mechanical write-time check that rides the authoring tool's result,
13
13
  * so the AI fixes the selector in the same turn. A warning, not a gate: