@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
package/src/ai-tools.ts CHANGED
@@ -17,16 +17,18 @@ import {
17
17
  contentInstanceDeleteSchema,
18
18
  contentInstanceUpdateSchema,
19
19
  forkPlacementContentSchema,
20
- MODULE_CSS_MAX,
21
- MODULE_HTML_MAX,
22
- MODULE_JS_MAX,
20
+ type ModuleField,
23
21
  moduleFieldSchema,
22
+ pageShortTitleSchema,
23
+ pageTypeSlugSchema,
24
24
  setPlacementContentSchema,
25
25
  slugSchema,
26
26
  } from "./content.js";
27
+ import type { SourceLanguage } from "./escaped-line-breaks.js";
28
+ import { LIST_ITEM_ATTR_RE } from "./lists/item-attr.js";
27
29
  import { pluginPreviewSelectionSchema } from "./plugin-preview.js";
28
30
  import { redirectTargetPathSchema } from "./redirect-target.js";
29
- import { unwrapCdataEnvelope } from "./strip-cdata.js";
31
+ import { decodeSourceTextTransport } from "./source-text-transport.js";
30
32
 
31
33
  /**
32
34
  * v0.6.2 — `position` argument shared across the three `add_module_to_*`
@@ -61,15 +63,17 @@ function stripSurroundingQuotes(v: unknown): unknown {
61
63
  }
62
64
  /**
63
65
  * An AI tool argument holding source text (html / css / js): the given
64
- * string schema, with a whole-value `<![CDATA[ … ]]>` transport envelope
65
- * decoded first (see `unwrapCdataEnvelope`). Every AI authoring surface for
66
- * module, template and layout source uses it, so one encoding habit of the
67
- * model is handled in one place instead of refused per tool.
66
+ * string schema, with whole-value transport encodings decoded first — a
67
+ * CDATA envelope, a JSON string literal, HTML-escaped markup (see
68
+ * `decodeSourceTextTransport`). Every AI authoring surface for module,
69
+ * template and layout source uses it, so one encoding habit of the model
70
+ * is handled in one place instead of refused per tool.
68
71
  *
69
72
  * @param schema - the string schema (length caps, min) the decoded value must satisfy.
73
+ * @param language - the grammar of the value (`html`, `css`, `js`).
70
74
  */
71
- export function sourceTextArg<T extends z.ZodType<string>>(schema: T) {
72
- return z.preprocess(unwrapCdataEnvelope, schema);
75
+ export function sourceTextArg<T extends z.ZodType<string>>(schema: T, language: SourceLanguage) {
76
+ return z.preprocess((value) => decodeSourceTextTransport(value, language), schema);
73
77
  }
74
78
 
75
79
  export const positionInputSchema = z.preprocess(
@@ -83,55 +87,6 @@ export const positionInputSchema = z.preprocess(
83
87
  ]),
84
88
  );
85
89
 
86
- export const editModuleToolInput = z
87
- .object({
88
- moduleId: z.string().uuid(),
89
- displayName: z.string().min(1).max(128).optional(),
90
- /**
91
- * v0.12.0 — rewrite the module's purpose. Optional; passing it
92
- * updates the `list_modules` catalog your future self will read.
93
- */
94
- description: z.string().max(1000).optional(),
95
- /** v0.12.0 — re-classify the module's role tag. */
96
- kind: z.enum(["chrome", "hero", "content", "cta", "utility"]).optional(),
97
- /**
98
- * v0.12.3 (issue #106) — re-classify the module's stable `type` (the
99
- * class a parent's `allowedModuleTypes` whitelist matches against,
100
- * e.g. `button`). Rarely needed; usually derived from displayName at
101
- * create time. Set it to make this module satisfy a parent's allowlist.
102
- */
103
- type: slugSchema.optional(),
104
- html: sourceTextArg(z.string().max(MODULE_HTML_MAX)).optional(),
105
- css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
106
- js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
107
- /**
108
- * v0.4.0 — module field schema. Each field declares one substitution
109
- * slot referenced in the module HTML as `{{name}}`. Page placements
110
- * fill these via `set_page_module_content`. Optional on edits — pass
111
- * to replace the declared schema.
112
- */
113
- fields: z.array(moduleFieldSchema).max(64).optional(),
114
- /** issue #164 slice 2 — see addModuleToolInput.bindThemeLiterals. */
115
- bindThemeLiterals: z.boolean().optional(),
116
- })
117
- .strict();
118
-
119
- /**
120
- * v0.4.0 — `set_page_module_content` AI tool. Fills the content values for
121
- * a module placement on a specific page. Page-bound + branch-isolated per
122
- * chat until publish (unlike `edit_module` which is global + immediate).
123
- */
124
- export const setPageModuleContentToolInput = z
125
- .object({
126
- pageId: z.string().uuid(),
127
- blockName: z.string().min(1).max(64),
128
- position: z.number().int().nonnegative(),
129
- /** Map of module field name → value. Fully replaces existing values. */
130
- contentValues: z.record(z.string(), z.unknown()),
131
- })
132
- .strict();
133
- export type SetPageModuleContentToolInput = z.infer<typeof setPageModuleContentToolInput>;
134
-
135
90
  export const siteMemoryProposeToolInput = z
136
91
  .object({
137
92
  slot: z.enum(["purpose", "brand-voice", "tone", "banned-phrases", "instructions", "glossary"]),
@@ -206,9 +161,9 @@ export const addModuleToolInput = z
206
161
  description: z.string().max(1000).optional(),
207
162
  kind: z.enum(["chrome", "hero", "content", "cta", "utility"]).optional(),
208
163
  type: slugSchema.optional(),
209
- html: sourceTextArg(z.string().min(1).max(50_000)).optional(),
210
- css: sourceTextArg(z.string().max(50_000)).optional(),
211
- js: sourceTextArg(z.string().max(50_000)).optional(),
164
+ html: sourceTextArg(z.string().min(1).max(50_000), "html").optional(),
165
+ css: sourceTextArg(z.string().max(50_000), "css").optional(),
166
+ js: sourceTextArg(z.string().max(50_000), "js").optional(),
212
167
  fields: z.array(moduleFieldSchema).max(64).optional(),
213
168
  bindThemeLiterals: z.boolean().optional(),
214
169
  /**
@@ -221,8 +176,8 @@ export const addModuleToolInput = z
221
176
  * stores the values as the minted module's `fields[].default` —
222
177
  * which requires explicit `fields` (moduleize field names are
223
178
  * unknowable up front) and is rejected for `moduleId` reuse (a
224
- * shared module renders its stored defaults; edit_module changes
225
- * them).
179
+ * shared module renders its stored defaults; update_modules_many
180
+ * changes them).
226
181
  */
227
182
  values: z.record(z.string(), z.unknown()).optional(),
228
183
  })
@@ -233,7 +188,7 @@ export const addModuleToolInput = z
233
188
  ctx.addIssue({
234
189
  code: z.ZodIssueCode.custom,
235
190
  message:
236
- "Reusing a module on a layout renders the module's STORED field defaults — `values` has nothing to bind to. Drop `values` (the chrome shows the module as-is), or change the shared copy via edit_module.",
191
+ "Reusing a module on a layout renders the module's STORED field defaults — `values` has nothing to bind to. Drop `values` (the chrome shows the module as-is), or change the shared copy via update_modules_many.",
237
192
  });
238
193
  } else if (input.fields === undefined || input.fields.length === 0) {
239
194
  ctx.addIssue({
@@ -257,7 +212,7 @@ export const addModuleToolInput = z
257
212
  // (§1A/§11), so we do NOT reject a moduleId call that also carries authoring
258
213
  // fields. Those fields are not applied here; the tool HANDLER surfaces an
259
214
  // INFO for any ignored field whose value DIFFERS from the module's stored
260
- // one, pointing at edit_module — so a dropped change is never silent (§2).
215
+ // one, pointing at update_modules_many — so a dropped change is never silent (§2).
261
216
  if (input.moduleId !== undefined) return;
262
217
  if (input.displayName === undefined || input.html === undefined) {
263
218
  ctx.addIssue({
@@ -274,17 +229,25 @@ export const addModuleToolInput = z
274
229
  // the exact two-step build_page was created to kill. Only enforced
275
230
  // when the caller authored explicit fields; the raw-HTML path goes
276
231
  // through moduleize, which enforces defaults itself.
232
+ // A `list` field bound to a shared list the module fixes (`list`), or
233
+ // fed by a source, already has its content: requiring a default there
234
+ // refused the very footer-bound-to-the-menu call the manage-menu skill
235
+ // prescribes (PR #1121 e2e-livedit run 40297574140).
236
+ const carriesContent = (f: ModuleField): boolean =>
237
+ ("default" in f && f.default !== undefined) ||
238
+ (f.kind === "list" && (f.bind === "source" || (f.bind === "shared" && f.list !== undefined)));
277
239
  if (
278
240
  input.fields !== undefined &&
279
241
  input.fields.length > 0 &&
280
242
  input.values === undefined &&
281
- !input.fields.some((f) => "default" in f && f.default !== undefined)
243
+ !input.fields.some(carriesContent)
282
244
  ) {
283
245
  ctx.addIssue({
284
246
  code: z.ZodIssueCode.custom,
285
247
  message:
286
248
  "A minted module needs its initial content IN THIS CALL: pass `values` " +
287
249
  "({fieldName: value, …}) for this placement, or give the fields `default`s. " +
250
+ 'A list field bound to a site list carries its content through `list` ({kind:"list", bind:"shared", list:"caelo:list/<id>"} — create the list first with upsert_lists_many). ' +
288
251
  "Without either, the module renders empty placeholders until a second call.",
289
252
  });
290
253
  }
@@ -341,45 +304,6 @@ export const deleteContentInstanceToolInput = contentInstanceDeleteSchema;
341
304
  export const setPlacementContentToolInput = setPlacementContentSchema;
342
305
  export const forkPlacementContentToolInput = forkPlacementContentSchema;
343
306
 
344
- export const AI_TOOLS = [
345
- "edit_module",
346
- "site_memory_propose",
347
- "add_module",
348
- "create_page",
349
- // audit #3 — page metadata (name/title/slug/template/status) is one tool for
350
- // 1..200 pages: `update_pages_many`. The former rename_page / set_page_title
351
- // / change_page_slug were single-field wrappers over the same pages.update.
352
- "update_pages_many",
353
- // audit #4 — one delete tool for 1..200 pages, disposition per page.
354
- // The former singular delete_page folded in (n=1 is a one-item array).
355
- "delete_pages_many",
356
- // one remove-module tool routed by target (page|layout).
357
- "remove_module_from",
358
- "set_structured_set",
359
- "update_theme",
360
- "set_template_layout",
361
- "create_layout",
362
- "set_site_defaults",
363
- "duplicate_page",
364
- "repoint_page_template",
365
- "move_module",
366
- "reorder_module",
367
- "set_nav_menu",
368
- "add_plugin_to_page",
369
- // v0.12.0 — content_instances + placement binding
370
- "list_content_instances",
371
- "get_content_instance",
372
- "create_content_instance",
373
- "set_content_instance_values",
374
- "delete_content_instance",
375
- "set_placement_content",
376
- "fork_placement_content",
377
- // issue #299 — bulk-first build path (§11)
378
- "build_page",
379
- "create_content_instances",
380
- "set_page_module_content_many",
381
- ] as const;
382
- export type AiToolName = (typeof AI_TOOLS)[number];
383
307
  export type AddPluginToPageToolInput = z.infer<typeof addPluginToPageToolInput>;
384
308
  export type ListContentInstancesToolInput = z.infer<typeof listContentInstancesToolInput>;
385
309
  export type GetContentInstanceToolInput = z.infer<typeof getContentInstanceToolInput>;
@@ -451,9 +375,14 @@ export const CHAT_MAX_ATTACHMENTS = 4;
451
375
  * `context` come from a right-click / text selection in the live-edit
452
376
  * preview (#916): what the operator selected and the text of the block
453
377
  * around it, so the AI knows exactly what was meant. `blockName` /
454
- * `position` / `field` / `index` (#917) name the placement field the text
378
+ * `position` / `field` (#917) — plus `item` / `key` for an entry of a
379
+ * local list field (epic #1107) — name the placement field the text
455
380
  * comes from when the preview marked it, so the AI can address it in
456
- * `set_page_module_content` without inspecting the page first.
381
+ * `set_page_module_content_many` without inspecting the page first.
382
+ * `listItem` is the `data-caelo-list-item` of the list entry the element
383
+ * sits in (epic #1107, `docs/lists.md` §13): the chip then names that
384
+ * entry — of a shared list, or of a module's own list — not just the
385
+ * module around it.
457
386
  */
458
387
  export const chatChipSchema = z
459
388
  .object({
@@ -465,7 +394,12 @@ export const chatChipSchema = z
465
394
  blockName: z.string().min(1).max(80).optional(),
466
395
  position: z.number().int().nonnegative().optional(),
467
396
  field: z.string().min(1).max(200).optional(),
468
- index: z.number().int().nonnegative().optional(),
397
+ item: z.string().uuid().optional(),
398
+ key: z
399
+ .string()
400
+ .regex(/^[a-z][a-z0-9_]{0,63}$/)
401
+ .optional(),
402
+ listItem: z.string().max(200).regex(LIST_ITEM_ATTR_RE).optional(),
469
403
  })
470
404
  .strict();
471
405
  export type ChatChip = z.infer<typeof chatChipSchema>;
@@ -555,11 +489,8 @@ export const chatPublishInput = z
555
489
  .array(
556
490
  z
557
491
  .object({
558
- // v0.9.0 — added "layout" + "structuredSet" to the enum so
559
- // the Stage picker can include/exclude them. Pre-v0.9.0 the
560
- // helper accepted "structuredSet" via the underlying SQL
561
- // query but the input schema rejected it — minor gap closed
562
- // alongside the branched-create work.
492
+ // v0.9.0 — "layout" joined the enum so the Stage picker can
493
+ // include/exclude it.
563
494
  kind: z.enum([
564
495
  "module",
565
496
  "template",
@@ -567,7 +498,10 @@ export const chatPublishInput = z
567
498
  "pageLayout",
568
499
  "pageModuleContent",
569
500
  "layout",
570
- "structuredSet",
501
+ // Epic #1107 — shared lists, list types, page types.
502
+ "listType",
503
+ "list",
504
+ "pageType",
571
505
  // v0.11.0 (#45) — themes primitive joins the publish/Stage
572
506
  // surface so chat-branched theme edits replay into live
573
507
  // on chat.publish.
@@ -647,9 +581,11 @@ export const createTemplateToolInput = z
647
581
  .object({
648
582
  slug: slugInputSchema,
649
583
  displayName: z.string().min(1).max(256),
650
- html: sourceTextArg(z.string().min(1).max(2_000_000)),
651
- css: sourceTextArg(z.string().max(2_000_000)).default(""),
584
+ html: sourceTextArg(z.string().min(1).max(2_000_000), "html"),
585
+ css: sourceTextArg(z.string().max(2_000_000), "css").default(""),
652
586
  layoutId: z.string().uuid().optional(),
587
+ /** Epic #1107 — the page type (slug) this template serves; omit for the site default. */
588
+ pageType: pageTypeSlugSchema.optional(),
653
589
  // Optional block-set metadata, symmetric with create_layout. Omit to
654
590
  // auto-derive blocks from <caelo-slot> tags; pass to set displayName /
655
591
  // position (each name must match a slot). Enforced op-side in
@@ -682,25 +618,9 @@ export const removeModuleFromToolInput = z
682
618
  .strict();
683
619
  export type RemoveModuleFromToolInput = z.infer<typeof removeModuleFromToolInput>;
684
620
 
685
- export const setStructuredSetToolInput = z
686
- .object({
687
- // structuredSetKind (the 6th kind that was previously unreachable
688
- // from any AI tool — the kind-specific wrappers covered only
689
- // nav-menu + theme).
690
- kind: z.enum(["nav-menu", "taxonomy", "tags", "link-list"]),
691
- slug: slugInputSchema,
692
- displayName: z.string().min(1).max(200),
693
- items: z.array(z.unknown()),
694
- })
695
- .strict();
696
-
697
- // v0.10.22 — `updateThemeToolInput` removed. The kind-specific `update_theme`
698
- // wrapper was replaced by the unified `set_structured_set` surface.
699
- // v0.11.0 (#45) — theme moved out of structured_sets entirely into its
700
- // own `themes` primitive (DTCG-shaped jsonb). Theme edits are now
701
- // `set_theme_tokens` (loose-name patch) / `propose_create_theme`
702
- // (gated mint) / `propose_activate_theme` (gated flip) — see
703
- // packages/admin-core/src/ai/tools/{update-theme-tokens,get-theme,…}.ts.
621
+ // v0.11.0 (#45) — theme edits are `set_theme_tokens` (loose-name patch) /
622
+ // `propose_create_theme` (gated mint) / `propose_activate_theme` (gated
623
+ // flip) — see packages/admin-core/src/ai/tools/{update-theme-tokens,get-theme,…}.ts.
704
624
 
705
625
  export const setTemplateLayoutToolInput = z
706
626
  .object({
@@ -713,8 +633,8 @@ export const createLayoutToolInput = z
713
633
  .object({
714
634
  slug: slugInputSchema,
715
635
  displayName: z.string().min(1).max(200),
716
- html: sourceTextArg(z.string().min(1).max(50_000)),
717
- css: sourceTextArg(z.string().max(50_000)).optional(),
636
+ html: sourceTextArg(z.string().min(1).max(50_000), "html"),
637
+ css: sourceTextArg(z.string().max(50_000), "css").optional(),
718
638
  blocks: z
719
639
  .array(
720
640
  z
@@ -750,8 +670,9 @@ export const duplicatePageToolInput = z
750
670
  .object({
751
671
  sourcePageId: z.string().uuid(),
752
672
  newSlug: slugInputSchema,
753
- newName: z.string().min(1).max(256).optional(),
754
673
  newTitle: z.string().min(1).max(256).optional(),
674
+ /** #1142 — the copy's breadcrumb label; omitted = newTitle when given, else the source's. */
675
+ newShortTitle: pageShortTitleSchema.optional(),
755
676
  targetTemplateId: z.string().uuid().optional(),
756
677
  /** Where the copy sits: a parent page id, or null for the top level.
757
678
  * Omitted = next to the source (same parent). */
@@ -779,32 +700,40 @@ export const repointPageTemplateToolInput = z
779
700
  })
780
701
  .strict();
781
702
 
782
- export const moveModuleToolInput = z
783
- .object({
784
- pageId: z.string().uuid(),
785
- moduleId: z.string().uuid(),
786
- toBlockName: z.string().min(1).max(80),
787
- /** "top" | "bottom" | a 0-based index inside the destination block. */
788
- position: positionInputSchema,
789
- })
790
- .strict();
703
+ /**
704
+ * `move_module` position: `top` | `bottom` | a 0-based index in the target
705
+ * block, plus `up` / `down` (one slot within the module's CURRENT block).
706
+ * Same quote-stripping + numeric-string coercion as `positionInputSchema`.
707
+ */
708
+ export const movePositionInputSchema = z.preprocess(
709
+ stripSurroundingQuotes,
710
+ z.union([
711
+ z.enum(["top", "bottom", "up", "down"]),
712
+ z.preprocess(
713
+ (v) => (typeof v === "string" && /^\d+$/.test(v) ? Number.parseInt(v, 10) : v),
714
+ z.number().int().min(0).max(1000),
715
+ ),
716
+ ]),
717
+ );
791
718
 
792
- export const reorderModuleToolInput = z
719
+ /**
720
+ * `move_module` — moves a placement to `toBlockName` (omitted = the block it
721
+ * is in now, i.e. a reorder) at `position`. `up` / `down` only make sense
722
+ * inside the current block; the handler refuses them with another block.
723
+ */
724
+ export const moveModuleToolInput = z
793
725
  .object({
794
726
  pageId: z.string().uuid(),
795
727
  moduleId: z.string().uuid(),
796
- /**
797
- * `up` / `down` shift one slot. An integer is an absolute 0-based
798
- * target position within the same block.
799
- */
800
- direction: z.union([z.enum(["up", "down"]), z.number().int().min(0).max(1000)]),
728
+ toBlockName: z.string().min(1).max(80).optional(),
729
+ position: movePositionInputSchema,
801
730
  })
802
731
  .strict();
803
732
 
804
733
  /**
805
734
  * P7 — `find_media`. Searches the media library by alt-text /
806
735
  * filename / mime. Each row carries the media UUID (what
807
- * `set_theme_asset` / `set_media_alt` require) plus a pre-resolved
736
+ * `set_theme_asset` / `set_media_alt_many` require) plus a pre-resolved
808
737
  * variant URL. The AI's only media-inventory surface — the `## Media`
809
738
  * system-prompt block was removed by the context diet (#300), so every
810
739
  * asset lookup ("an image of a sunlit office") goes through this tool.
@@ -849,7 +778,7 @@ export const regenerateMediaVariantsToolInput = z
849
778
  export type RegenerateMediaVariantsToolInput = z.infer<typeof regenerateMediaVariantsToolInput>;
850
779
 
851
780
  /**
852
- * P7 — `set_media_alt`. AI may improve a11y on an existing asset
781
+ * P7 — one item of `set_media_alt_many`. AI may improve a11y on an existing asset
853
782
  * without a human round-trip (e.g. when the editor uploaded an image
854
783
  * with the default alt and the AI reads its content). Updates only
855
784
  * the alt field on `media_assets`.
@@ -879,7 +808,7 @@ export const setNotFoundPageToolInput = z.object({ pageId: z.string().uuid().nul
879
808
  export type SetNotFoundPageToolInput = z.infer<typeof setNotFoundPageToolInput>;
880
809
 
881
810
  /**
882
- * 0181 — `set_media_source`. AI records an asset's provenance (source
811
+ * 0181 — one item of `set_media_source_many`. AI records an asset's provenance (source
883
812
  * kind + origin detail) and licence when known. All fields but the
884
813
  * assetId are optional; the op COALESCEs so omitted fields stay
885
814
  * unchanged.
@@ -895,7 +824,7 @@ export const setMediaSourceToolInput = z
895
824
  export type SetMediaSourceToolInput = z.infer<typeof setMediaSourceToolInput>;
896
825
 
897
826
  /**
898
- * P8 — `set_page_seo`. Manual / panel writes to the per-page SEO
827
+ * P8 — one item of `set_page_seo_many`. Manual / panel writes to the per-page SEO
899
828
  * sidecar. AI calls this only on explicit user intent
900
829
  * ("set the home meta description to ..."). Doesn't bump fingerprints
901
830
  * (autofilled_at / optimized_at).
@@ -916,7 +845,7 @@ export const setPageSeoToolInput = z
916
845
  export type SetPageSeoToolInput = z.infer<typeof setPageSeoToolInput>;
917
846
 
918
847
  /**
919
- * P8 — `autofill_page_seo` (and, per item, `autofill_page_seo_many`).
848
+ * P8 — one item of `autofill_page_seo_many`.
920
849
  * Fill-once. Refuses when the page's SEO was already autofilled. The AI
921
850
  * calls it before a page's first Stage; staging and production builds
922
851
  * refuse pages without a meta description (deploy/missing-seo.ts).
@@ -930,22 +859,6 @@ export const autofillPageSeoToolInput = z
930
859
  .strict();
931
860
  export type AutofillPageSeoToolInput = z.infer<typeof autofillPageSeoToolInput>;
932
861
 
933
- /**
934
- * P8 — `optimize_page_seo`. Explicit re-optimization. Always allowed.
935
- * Takes optional context (keyword analysis / intent shifts / branding
936
- * changes). The AI can call this across N pages in one chat turn —
937
- * the resulting changes batch into one Publish-pill confirm.
938
- */
939
- export const optimizePageSeoToolInput = z
940
- .object({
941
- pageId: z.string().uuid(),
942
- metaDescription: z.string().min(1).max(320),
943
- ogImageAssetId: z.string().uuid().nullable().optional(),
944
- context: z.string().max(4000).optional(),
945
- })
946
- .strict();
947
- export type OptimizePageSeoToolInput = z.infer<typeof optimizePageSeoToolInput>;
948
-
949
862
  /**
950
863
  * P8 AI-first review pass — bulk variants. Per CLAUDE.md §11, bulk
951
864
  * tools save round-trips when the AI knows it has N changes to make
@@ -965,7 +878,7 @@ export type FindRedirectsToolInput = z.infer<typeof findRedirectsToolInput>;
965
878
 
966
879
  export const bulkCreateRedirectsToolInput = z
967
880
  .object({
968
- redirects: z
881
+ items: z
969
882
  .array(
970
883
  z
971
884
  .object({
@@ -1017,7 +930,7 @@ export type BulkDeleteRedirectsToolInput = z.infer<typeof bulkDeleteRedirectsToo
1017
930
 
1018
931
  export const bulkOptimizeSeoToolInput = z
1019
932
  .object({
1020
- updates: z
933
+ items: z
1021
934
  .array(
1022
935
  z
1023
936
  .object({
@@ -1034,10 +947,8 @@ export const bulkOptimizeSeoToolInput = z
1034
947
  .strict();
1035
948
  export type BulkOptimizeSeoToolInput = z.infer<typeof bulkOptimizeSeoToolInput>;
1036
949
 
1037
- export type EditModuleToolInput = z.infer<typeof editModuleToolInput>;
1038
950
  export type SiteMemoryProposeToolInput = z.infer<typeof siteMemoryProposeToolInput>;
1039
951
  export type CreateTemplateToolInput = z.infer<typeof createTemplateToolInput>;
1040
- export type SetStructuredSetToolInput = z.infer<typeof setStructuredSetToolInput>;
1041
952
  // v0.10.22 — `UpdateThemeToolInput` removed alongside `update_theme` tool.
1042
953
  export type ChatCreateSessionInput = z.infer<typeof chatCreateSessionInput>;
1043
954
  export type ChatSendMessageInput = z.infer<typeof chatSendMessageInput>;
@@ -1053,7 +964,6 @@ export type SetSiteDefaultsToolInput = z.infer<typeof setSiteDefaultsToolInput>;
1053
964
  export type DuplicatePageToolInput = z.infer<typeof duplicatePageToolInput>;
1054
965
  export type RepointPageTemplateToolInput = z.infer<typeof repointPageTemplateToolInput>;
1055
966
  export type MoveModuleToolInput = z.infer<typeof moveModuleToolInput>;
1056
- export type ReorderModuleToolInput = z.infer<typeof reorderModuleToolInput>;
1057
967
  // v0.10.22 — `SetNavMenuToolInput` removed alongside `set_nav_menu` tool.
1058
968
 
1059
969
  /**
@@ -32,7 +32,7 @@ describe("computeBugFingerprint", () => {
32
32
  });
33
33
 
34
34
  it("keeps different tools and different quoted field names apart", async () => {
35
- expect(await auto("boom", "build_page")).not.toBe(await auto("boom", "edit_module"));
35
+ expect(await auto("boom", "build_page")).not.toBe(await auto("boom", "update_modules_many"));
36
36
  expect(await auto('Invalid input at "page.slug"')).not.toBe(
37
37
  await auto('Invalid input at "page.title"'),
38
38
  );
@@ -39,6 +39,14 @@ describe("buildPageInputSchema — page target modes", () => {
39
39
  expect(r.success).toBe(true);
40
40
  });
41
41
 
42
+ it("accepts parentPageId: null as top level, like pages.create (PR #1121 e2e)", () => {
43
+ const r = buildPageInputSchema.safeParse({
44
+ page: { slug: "pricing", title: "Pricing", parentPageId: null },
45
+ modules: [mintModule],
46
+ });
47
+ expect(r.success).toBe(true);
48
+ });
49
+
42
50
  it("accepts existing mode (pageId only)", () => {
43
51
  const r = buildPageInputSchema.safeParse({
44
52
  page: { pageId: UUID },
@@ -140,10 +148,10 @@ describe("buildPageInputSchema — module entry modes name the failing index", (
140
148
  {
141
149
  blockName: "content",
142
150
  displayName: "Nav",
143
- html: "<nav>{{#nav_links}}<a href='{{href}}'>{{label}}</a>{{/nav_links}}</nav>",
151
+ html: "<nav>{{#nav_links}}<a href='{{link}}'>{{label}}</a>{{/nav_links}}</nav>",
144
152
  fields: [
145
- { name: "nav_links", kind: "link-list", label: "Nav links" },
146
- { name: "tags", kind: "text-list", label: "Tags", min: 0, max: 12 },
153
+ { name: "nav_links", kind: "list", listType: "links", label: "Nav links" },
154
+ { name: "tags", kind: "list", listType: "texts", label: "Tags", min: 0, max: 12 },
147
155
  ],
148
156
  },
149
157
  ],
@@ -190,6 +198,29 @@ describe("buildPageInputSchema — module entry modes name the failing index", (
190
198
  });
191
199
  });
192
200
 
201
+ describe("buildPageInputSchema — ref handles", () => {
202
+ // e2e-livedit run 40336739359 (PR #1143): the AI named three cards
203
+ // `cardA` / `cardB` / `cardC` and the lowercase-only rule refused the
204
+ // whole call for a handle that never leaves it.
205
+ it("accepts mixed-case handles and refuses ones that do not start with a letter", () => {
206
+ const { blockName: _b, ...detached } = mintModule;
207
+ for (const ref of ["cardA", "card_b", "feat-1"]) {
208
+ const r = buildPageInputSchema.safeParse({
209
+ page: { pageId: UUID },
210
+ modules: [{ ...detached, ref }],
211
+ });
212
+ expect(r.success).toBe(true);
213
+ }
214
+ for (const ref of ["1card", "$card", "card a"]) {
215
+ const r = buildPageInputSchema.safeParse({
216
+ page: { pageId: UUID },
217
+ modules: [{ ...detached, ref }],
218
+ });
219
+ expect(r.success).toBe(false);
220
+ }
221
+ });
222
+ });
223
+
193
224
  describe("buildPageContentSchema — the three sources", () => {
194
225
  it("inline requires values", () => {
195
226
  expect(buildPageContentSchema.safeParse({ source: "inline" }).success).toBe(false);
package/src/build-page.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  MODULE_JS_MAX,
29
29
  moduleFieldSchema,
30
30
  moduleKindSchema,
31
+ pageCreateSchema,
31
32
  pageStatusSchema,
32
33
  slugSchema,
33
34
  syncModeSchema,
@@ -42,10 +43,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
42
43
  * existing content paths so build_page adds NO new semantics, only batching:
43
44
  *
44
45
  * - `inline` → mint a private (unsynced) content_instance carrying
45
- * `values`, exactly what `set_page_module_content` produces for a
46
+ * `values`, exactly what `set_page_module_content_many` produces for a
46
47
  * fresh placement.
47
48
  * - `shared` → mint a reusable content_instance (purpose required —
48
- * same decision-support contract as `create_content_instance`) and
49
+ * same decision-support contract as `create_content_instances`) and
49
50
  * bind this placement to it, `synced` by default.
50
51
  * - `existing` → bind an already-minted content_instance (reuse-first
51
52
  * per CLAUDE.md §1A) — the batched form of `set_placement_content`.
@@ -93,7 +94,7 @@ export type BuildPageContent = z.infer<typeof buildPageContentSchema>;
93
94
  * extra authoring keys are TOLERATED (§1A/§11 — a placement that can
94
95
  * succeed must not fail over an extra field) but NOT applied; the op
95
96
  * surfaces an info naming any carried field whose value differs from the
96
- * module's stored one, pointing at edit_module (§2 — no silent drop).
97
+ * module's stored one, pointing at update_modules_many (§2 — no silent drop).
97
98
  *
98
99
  * The element-level superRefine reports issues WITH the element's array
99
100
  * index in the Zod path, so a mode error fails as `modules[3]: …`.
@@ -130,7 +131,10 @@ export const buildPageModuleSchema = z
130
131
  */
131
132
  ref: z
132
133
  .string()
133
- .regex(/^[a-z][a-z0-9_-]{0,31}$/, "ref must be a short lowercase handle")
134
+ .regex(
135
+ /^[A-Za-z][A-Za-z0-9_-]{0,31}$/,
136
+ "ref must be a short handle: a letter, then letters, digits, _ or -",
137
+ )
134
138
  .optional(),
135
139
  /**
136
140
  * Place mode: an existing module from `list_modules` (UUID), or a
@@ -155,9 +159,9 @@ export const buildPageModuleSchema = z
155
159
  description: z.string().max(1000).optional(),
156
160
  kind: moduleKindSchema.optional(),
157
161
  type: slugSchema.optional(),
158
- html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX)).optional(),
159
- css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
160
- js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
162
+ html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX), "html").optional(),
163
+ css: sourceTextArg(z.string().max(MODULE_CSS_MAX), "css").optional(),
164
+ js: sourceTextArg(z.string().max(MODULE_JS_MAX), "js").optional(),
161
165
  fields: z.array(moduleFieldSchema).max(64).optional(),
162
166
  /** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
163
167
  bindThemeLiterals: z.boolean().optional(),
@@ -188,7 +192,7 @@ export const buildPageModuleSchema = z
188
192
  // (build_page places; it does not re-author a shared module). To avoid a
189
193
  // SILENT dropped change (§2), the HANDLER surfaces an INFO in the result
190
194
  // naming any ignored authoring field whose value DIFFERS from the module's
191
- // stored one, and points at edit_module. The schema can't do that check (no
195
+ // stored one, and points at update_modules_many. The schema can't do that check (no
192
196
  // DB), so it just allows the entry through.
193
197
  if (entry.moduleId !== undefined) return;
194
198
  if (entry.displayName === undefined || entry.html === undefined) {
@@ -216,7 +220,7 @@ export const buildPageTargetSchema = z
216
220
  pageId: z.string().uuid().optional(),
217
221
  slug: slugSchema.optional(),
218
222
  title: z.string().min(1).max(256).optional(),
219
- name: z.string().min(1).max(256).optional(),
223
+ shortTitle: pageCreateSchema.shape.shortTitle,
220
224
  templateId: z.string().uuid().optional(),
221
225
  status: pageStatusSchema.optional(),
222
226
  /**
@@ -224,7 +228,16 @@ export const buildPageTargetSchema = z
224
228
  * "<parent's path>/<slug>". Create mode only (an existing page moves
225
229
  * with `pages.move_many`).
226
230
  */
227
- parentPageId: z.string().uuid().optional(),
231
+ // null = top level, same as pages.create (sibling tools must agree — #106 class).
232
+ parentPageId: z.string().uuid().nullable().optional(),
233
+ /**
234
+ * Epic #1107 — what the new page IS (page type slug), its properties
235
+ * and terms; same semantics as `pages.create` (create mode only — an
236
+ * existing page changes type with `pages.update`).
237
+ */
238
+ pageType: pageCreateSchema.shape.pageType,
239
+ properties: pageCreateSchema.shape.properties,
240
+ terms: pageCreateSchema.shape.terms,
228
241
  /**
229
242
  * Migration linkage (issue #278 flow): the `import_pages` row this
230
243
  * page rebuilds. The op stamps `import_pages.accepted_page_id` with
@@ -252,7 +265,17 @@ export const buildPageTargetSchema = z
252
265
  .strict()
253
266
  .superRefine((page, ctx) => {
254
267
  const createKeys = (
255
- ["slug", "title", "name", "templateId", "status", "parentPageId"] as const
268
+ [
269
+ "slug",
270
+ "title",
271
+ "shortTitle",
272
+ "templateId",
273
+ "status",
274
+ "parentPageId",
275
+ "pageType",
276
+ "properties",
277
+ "terms",
278
+ ] as const
256
279
  ).filter((k) => page[k] !== undefined);
257
280
  if (page.pageId !== undefined) {
258
281
  if (createKeys.length > 0) {