@caelo-cms/shared 0.10.72 → 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 (184) 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 +48 -36
  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 +23 -4
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +201 -100
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +176 -74
  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/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/interactions.js +2 -2
  30. package/dist/interactions.js.map +1 -1
  31. package/dist/link-canonicalise.d.ts.map +1 -1
  32. package/dist/link-canonicalise.js +7 -1
  33. package/dist/link-canonicalise.js.map +1 -1
  34. package/dist/link-refs.d.ts +1 -1
  35. package/dist/link-refs.js +3 -3
  36. package/dist/lists/address.d.ts +34 -0
  37. package/dist/lists/address.d.ts.map +1 -0
  38. package/dist/lists/address.js +43 -0
  39. package/dist/lists/address.js.map +1 -0
  40. package/dist/lists/default-types.d.ts +10 -0
  41. package/dist/lists/default-types.d.ts.map +1 -0
  42. package/dist/lists/default-types.js +91 -0
  43. package/dist/lists/default-types.js.map +1 -0
  44. package/dist/lists/fields.d.ts +152 -0
  45. package/dist/lists/fields.d.ts.map +1 -0
  46. package/dist/lists/fields.js +258 -0
  47. package/dist/lists/fields.js.map +1 -0
  48. package/dist/lists/index.d.ts +14 -0
  49. package/dist/lists/index.d.ts.map +1 -0
  50. package/dist/lists/index.js +15 -0
  51. package/dist/lists/index.js.map +1 -0
  52. package/dist/lists/item-attr.d.ts +26 -0
  53. package/dist/lists/item-attr.d.ts.map +1 -0
  54. package/dist/lists/item-attr.js +31 -0
  55. package/dist/lists/item-attr.js.map +1 -0
  56. package/dist/lists/items.d.ts +105 -0
  57. package/dist/lists/items.d.ts.map +1 -0
  58. package/dist/lists/items.js +304 -0
  59. package/dist/lists/items.js.map +1 -0
  60. package/dist/lists/list-type.d.ts +401 -0
  61. package/dist/lists/list-type.d.ts.map +1 -0
  62. package/dist/lists/list-type.js +275 -0
  63. package/dist/lists/list-type.js.map +1 -0
  64. package/dist/lists/model.d.ts +14 -0
  65. package/dist/lists/model.d.ts.map +1 -0
  66. package/dist/lists/model.js +15 -0
  67. package/dist/lists/model.js.map +1 -0
  68. package/dist/lists/page-type.d.ts +115 -0
  69. package/dist/lists/page-type.d.ts.map +1 -0
  70. package/dist/lists/page-type.js +66 -0
  71. package/dist/lists/page-type.js.map +1 -0
  72. package/dist/lists/refs.d.ts +13 -0
  73. package/dist/lists/refs.d.ts.map +1 -0
  74. package/dist/lists/refs.js +18 -0
  75. package/dist/lists/refs.js.map +1 -0
  76. package/dist/lists/render-data.d.ts +114 -0
  77. package/dist/lists/render-data.d.ts.map +1 -0
  78. package/dist/lists/render-data.js +3 -0
  79. package/dist/lists/render-data.js.map +1 -0
  80. package/dist/lists/render-deps.d.ts +68 -0
  81. package/dist/lists/render-deps.d.ts.map +1 -0
  82. package/dist/lists/render-deps.js +91 -0
  83. package/dist/lists/render-deps.js.map +1 -0
  84. package/dist/lists/resolve-values.d.ts +63 -0
  85. package/dist/lists/resolve-values.d.ts.map +1 -0
  86. package/dist/lists/resolve-values.js +181 -0
  87. package/dist/lists/resolve-values.js.map +1 -0
  88. package/dist/lists/resolve.d.ts +106 -0
  89. package/dist/lists/resolve.d.ts.map +1 -0
  90. package/dist/lists/resolve.js +878 -0
  91. package/dist/lists/resolve.js.map +1 -0
  92. package/dist/lists/scope.d.ts +81 -0
  93. package/dist/lists/scope.d.ts.map +1 -0
  94. package/dist/lists/scope.js +3 -0
  95. package/dist/lists/scope.js.map +1 -0
  96. package/dist/markup-structure.d.ts +8 -0
  97. package/dist/markup-structure.d.ts.map +1 -1
  98. package/dist/markup-structure.js +2 -0
  99. package/dist/markup-structure.js.map +1 -1
  100. package/dist/media.d.ts +1 -1
  101. package/dist/nested-module-render.d.ts +25 -20
  102. package/dist/nested-module-render.d.ts.map +1 -1
  103. package/dist/nested-module-render.js +122 -31
  104. package/dist/nested-module-render.js.map +1 -1
  105. package/dist/page-log.d.ts +1 -1
  106. package/dist/preview-compose.d.ts +24 -21
  107. package/dist/preview-compose.d.ts.map +1 -1
  108. package/dist/preview-compose.js +34 -118
  109. package/dist/preview-compose.js.map +1 -1
  110. package/dist/source-text-transport.d.ts +17 -0
  111. package/dist/source-text-transport.d.ts.map +1 -0
  112. package/dist/source-text-transport.js +71 -0
  113. package/dist/source-text-transport.js.map +1 -0
  114. package/dist/template-engine.d.ts +44 -31
  115. package/dist/template-engine.d.ts.map +1 -1
  116. package/dist/template-engine.js +75 -174
  117. package/dist/template-engine.js.map +1 -1
  118. package/dist/template-sections.d.ts +69 -0
  119. package/dist/template-sections.d.ts.map +1 -0
  120. package/dist/template-sections.js +423 -0
  121. package/dist/template-sections.js.map +1 -0
  122. package/dist/version.d.ts +2 -2
  123. package/dist/version.d.ts.map +1 -1
  124. package/dist/version.js +1 -1
  125. package/dist/version.js.map +1 -1
  126. package/package.json +8 -1
  127. package/src/__tests__/redos-hardening.test.ts +50 -16
  128. package/src/ai-tools-add-module-modes.test.ts +50 -0
  129. package/src/ai-tools.test.ts +10 -0
  130. package/src/ai-tools.ts +51 -37
  131. package/src/build-page.test.ts +34 -3
  132. package/src/build-page.ts +26 -3
  133. package/src/content.test.ts +111 -3
  134. package/src/content.ts +186 -75
  135. package/src/css-var-aliases.test.ts +61 -0
  136. package/src/css-var-aliases.ts +109 -0
  137. package/src/css-var-scan.ts +9 -0
  138. package/src/edit-markers.test.ts +37 -21
  139. package/src/edit-markers.ts +52 -20
  140. package/src/index.ts +3 -1
  141. package/src/interactions.ts +2 -2
  142. package/src/link-canonicalise.test.ts +36 -6
  143. package/src/link-canonicalise.ts +6 -1
  144. package/src/link-refs.test.ts +66 -42
  145. package/src/link-refs.ts +3 -3
  146. package/src/lists/address.ts +75 -0
  147. package/src/lists/default-types.ts +102 -0
  148. package/src/lists/fields.ts +285 -0
  149. package/src/lists/index.ts +16 -0
  150. package/src/lists/item-attr.test.ts +36 -0
  151. package/src/lists/item-attr.ts +49 -0
  152. package/src/lists/items.ts +402 -0
  153. package/src/lists/list-fixtures.test.ts +302 -0
  154. package/src/lists/list-type.ts +306 -0
  155. package/src/lists/lists.test.ts +328 -0
  156. package/src/lists/model.ts +16 -0
  157. package/src/lists/page-type.ts +71 -0
  158. package/src/lists/refs.ts +21 -0
  159. package/src/lists/render-data.ts +125 -0
  160. package/src/lists/render-deps.test.ts +132 -0
  161. package/src/lists/render-deps.ts +144 -0
  162. package/src/lists/resolve-values.ts +224 -0
  163. package/src/lists/resolve.test.ts +295 -0
  164. package/src/lists/resolve.ts +1148 -0
  165. package/src/lists/scope.ts +69 -0
  166. package/src/lists-compose-parity.test.ts +262 -0
  167. package/src/markup-structure.ts +10 -0
  168. package/src/nested-module-render.test.ts +111 -77
  169. package/src/nested-module-render.ts +85 -48
  170. package/src/preview-compose.test.ts +55 -19
  171. package/src/preview-compose.ts +59 -154
  172. package/src/source-text-transport.test.ts +63 -0
  173. package/src/source-text-transport.ts +70 -0
  174. package/src/template-engine-lists.test.ts +553 -0
  175. package/src/template-engine.test.ts +62 -80
  176. package/src/template-engine.ts +102 -245
  177. package/src/template-sections.ts +500 -0
  178. package/src/version.ts +1 -1
  179. package/dist/structured-sets.d.ts +0 -70
  180. package/dist/structured-sets.d.ts.map +0 -1
  181. package/dist/structured-sets.js +0 -92
  182. package/dist/structured-sets.js.map +0 -1
  183. package/src/structured-sets.ts +0 -114
  184. package/src/template-engine-data-lists.test.ts +0 -90
@@ -104,3 +104,53 @@ describe("addModuleToolInput modes (issue #159)", () => {
104
104
  expect(r.success).toBe(false);
105
105
  });
106
106
  });
107
+
108
+ describe("addModuleToolInput — list fields carry their own content (PR #1121 e2e)", () => {
109
+ const LIST = "caelo:list/22222222-2222-4222-8222-222222222222";
110
+ const footer = (fields: unknown[]) =>
111
+ addModuleToolInput.safeParse({
112
+ ...BASE,
113
+ target: "layout" as const,
114
+ targetRef: "site-default",
115
+ blockName: "footer",
116
+ displayName: "Footer",
117
+ html: '<nav>{{#footer_links}}<a href="{{link}}">{{label}}</a>{{/footer_links}}</nav>',
118
+ fields,
119
+ });
120
+
121
+ it("accepts a footer bound to a shared list without values or defaults", () => {
122
+ const r = footer([
123
+ {
124
+ name: "footer_links",
125
+ kind: "list",
126
+ label: "Links",
127
+ listType: "menu",
128
+ bind: "shared",
129
+ list: LIST,
130
+ },
131
+ ]);
132
+ expect(r.success).toBe(true);
133
+ });
134
+
135
+ it("accepts a list fed by a source", () => {
136
+ const r = footer([
137
+ {
138
+ name: "footer_links",
139
+ kind: "list",
140
+ label: "Crumbs",
141
+ listType: "links",
142
+ bind: "source",
143
+ source: { kind: "system", name: "breadcrumbs" },
144
+ },
145
+ ]);
146
+ expect(r.success).toBe(true);
147
+ });
148
+
149
+ it("still refuses a shared list field with no list and no value, naming the list route", () => {
150
+ const r = footer([
151
+ { name: "footer_links", kind: "list", label: "Links", listType: "menu", bind: "shared" },
152
+ ]);
153
+ expect(r.success).toBe(false);
154
+ expect(r.error?.issues[0]?.message).toContain("upsert_lists_many");
155
+ });
156
+ });
@@ -33,6 +33,16 @@ describe("editModuleToolInput", () => {
33
33
  });
34
34
  expect(r.success).toBe(false);
35
35
  });
36
+
37
+ // e2e-livedit run 40351690739 — `{moduleId}` alone reached the op.
38
+ it("refuses an edit that changes nothing, naming what to pass", () => {
39
+ for (const input of [{ moduleId: UUID }, { moduleId: UUID, bindThemeLiterals: false }]) {
40
+ const r = editModuleToolInput.safeParse(input);
41
+ expect(r.success).toBe(false);
42
+ if (!r.success) expect(r.error.issues[0]?.message).toContain("edit_module changes nothing");
43
+ }
44
+ expect(editModuleToolInput.safeParse({ moduleId: UUID, kind: "hero" }).success).toBe(true);
45
+ });
36
46
  });
37
47
 
38
48
  describe("siteMemoryProposeToolInput", () => {
package/src/ai-tools.ts CHANGED
@@ -20,13 +20,16 @@ import {
20
20
  MODULE_CSS_MAX,
21
21
  MODULE_HTML_MAX,
22
22
  MODULE_JS_MAX,
23
+ type ModuleField,
23
24
  moduleFieldSchema,
25
+ pageTypeSlugSchema,
24
26
  setPlacementContentSchema,
25
27
  slugSchema,
26
28
  } from "./content.js";
29
+ import { LIST_ITEM_ATTR_RE } from "./lists/item-attr.js";
27
30
  import { pluginPreviewSelectionSchema } from "./plugin-preview.js";
28
31
  import { redirectTargetPathSchema } from "./redirect-target.js";
29
- import { unwrapCdataEnvelope } from "./strip-cdata.js";
32
+ import { decodeSourceTextTransport } from "./source-text-transport.js";
30
33
 
31
34
  /**
32
35
  * v0.6.2 — `position` argument shared across the three `add_module_to_*`
@@ -61,15 +64,16 @@ function stripSurroundingQuotes(v: unknown): unknown {
61
64
  }
62
65
  /**
63
66
  * 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.
67
+ * string schema, with whole-value transport encodings decoded first — a
68
+ * CDATA envelope, a JSON string literal, HTML-escaped markup (see
69
+ * `decodeSourceTextTransport`). Every AI authoring surface for module,
70
+ * template and layout source uses it, so one encoding habit of the model
71
+ * is handled in one place instead of refused per tool.
68
72
  *
69
73
  * @param schema - the string schema (length caps, min) the decoded value must satisfy.
70
74
  */
71
75
  export function sourceTextArg<T extends z.ZodType<string>>(schema: T) {
72
- return z.preprocess(unwrapCdataEnvelope, schema);
76
+ return z.preprocess(decodeSourceTextTransport, schema);
73
77
  }
74
78
 
75
79
  export const positionInputSchema = z.preprocess(
@@ -114,7 +118,15 @@ export const editModuleToolInput = z
114
118
  /** issue #164 slice 2 — see addModuleToolInput.bindThemeLiterals. */
115
119
  bindThemeLiterals: z.boolean().optional(),
116
120
  })
117
- .strict();
121
+ .strict()
122
+ // e2e-livedit run 40351690739: a call with only a (placeholder)
123
+ // moduleId reached the op and came back as a bare "module not found".
124
+ // An edit that changes nothing is refused before any lookup, with the
125
+ // fields that make it one.
126
+ .refine((v) => Object.keys(v).some((k) => k !== "moduleId" && k !== "bindThemeLiterals"), {
127
+ message:
128
+ "edit_module changes nothing: pass what to change (html, css, js, fields, displayName, description, kind or type). list_modules shows the modules; add_module creates a new one.",
129
+ });
118
130
 
119
131
  /**
120
132
  * v0.4.0 — `set_page_module_content` AI tool. Fills the content values for
@@ -274,17 +286,25 @@ export const addModuleToolInput = z
274
286
  // the exact two-step build_page was created to kill. Only enforced
275
287
  // when the caller authored explicit fields; the raw-HTML path goes
276
288
  // through moduleize, which enforces defaults itself.
289
+ // A `list` field bound to a shared list the module fixes (`list`), or
290
+ // fed by a source, already has its content: requiring a default there
291
+ // refused the very footer-bound-to-the-menu call the manage-menu skill
292
+ // prescribes (PR #1121 e2e-livedit run 40297574140).
293
+ const carriesContent = (f: ModuleField): boolean =>
294
+ ("default" in f && f.default !== undefined) ||
295
+ (f.kind === "list" && (f.bind === "source" || (f.bind === "shared" && f.list !== undefined)));
277
296
  if (
278
297
  input.fields !== undefined &&
279
298
  input.fields.length > 0 &&
280
299
  input.values === undefined &&
281
- !input.fields.some((f) => "default" in f && f.default !== undefined)
300
+ !input.fields.some(carriesContent)
282
301
  ) {
283
302
  ctx.addIssue({
284
303
  code: z.ZodIssueCode.custom,
285
304
  message:
286
305
  "A minted module needs its initial content IN THIS CALL: pass `values` " +
287
306
  "({fieldName: value, …}) for this placement, or give the fields `default`s. " +
307
+ '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
308
  "Without either, the module renders empty placeholders until a second call.",
289
309
  });
290
310
  }
@@ -355,7 +375,6 @@ export const AI_TOOLS = [
355
375
  "delete_pages_many",
356
376
  // one remove-module tool routed by target (page|layout).
357
377
  "remove_module_from",
358
- "set_structured_set",
359
378
  "update_theme",
360
379
  "set_template_layout",
361
380
  "create_layout",
@@ -451,9 +470,14 @@ export const CHAT_MAX_ATTACHMENTS = 4;
451
470
  * `context` come from a right-click / text selection in the live-edit
452
471
  * preview (#916): what the operator selected and the text of the block
453
472
  * around it, so the AI knows exactly what was meant. `blockName` /
454
- * `position` / `field` / `index` (#917) name the placement field the text
473
+ * `position` / `field` (#917) — plus `item` / `key` for an entry of a
474
+ * local list field (epic #1107) — name the placement field the text
455
475
  * comes from when the preview marked it, so the AI can address it in
456
476
  * `set_page_module_content` without inspecting the page first.
477
+ * `listItem` is the `data-caelo-list-item` of the list entry the element
478
+ * sits in (epic #1107, `docs/lists.md` §13): the chip then names that
479
+ * entry — of a shared list, or of a module's own list — not just the
480
+ * module around it.
457
481
  */
458
482
  export const chatChipSchema = z
459
483
  .object({
@@ -465,7 +489,12 @@ export const chatChipSchema = z
465
489
  blockName: z.string().min(1).max(80).optional(),
466
490
  position: z.number().int().nonnegative().optional(),
467
491
  field: z.string().min(1).max(200).optional(),
468
- index: z.number().int().nonnegative().optional(),
492
+ item: z.string().uuid().optional(),
493
+ key: z
494
+ .string()
495
+ .regex(/^[a-z][a-z0-9_]{0,63}$/)
496
+ .optional(),
497
+ listItem: z.string().max(200).regex(LIST_ITEM_ATTR_RE).optional(),
469
498
  })
470
499
  .strict();
471
500
  export type ChatChip = z.infer<typeof chatChipSchema>;
@@ -555,11 +584,8 @@ export const chatPublishInput = z
555
584
  .array(
556
585
  z
557
586
  .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.
587
+ // v0.9.0 — "layout" joined the enum so the Stage picker can
588
+ // include/exclude it.
563
589
  kind: z.enum([
564
590
  "module",
565
591
  "template",
@@ -567,7 +593,10 @@ export const chatPublishInput = z
567
593
  "pageLayout",
568
594
  "pageModuleContent",
569
595
  "layout",
570
- "structuredSet",
596
+ // Epic #1107 — shared lists, list types, page types.
597
+ "listType",
598
+ "list",
599
+ "pageType",
571
600
  // v0.11.0 (#45) — themes primitive joins the publish/Stage
572
601
  // surface so chat-branched theme edits replay into live
573
602
  // on chat.publish.
@@ -650,6 +679,8 @@ export const createTemplateToolInput = z
650
679
  html: sourceTextArg(z.string().min(1).max(2_000_000)),
651
680
  css: sourceTextArg(z.string().max(2_000_000)).default(""),
652
681
  layoutId: z.string().uuid().optional(),
682
+ /** Epic #1107 — the page type (slug) this template serves; omit for the site default. */
683
+ pageType: pageTypeSlugSchema.optional(),
653
684
  // Optional block-set metadata, symmetric with create_layout. Omit to
654
685
  // auto-derive blocks from <caelo-slot> tags; pass to set displayName /
655
686
  // position (each name must match a slot). Enforced op-side in
@@ -682,25 +713,9 @@ export const removeModuleFromToolInput = z
682
713
  .strict();
683
714
  export type RemoveModuleFromToolInput = z.infer<typeof removeModuleFromToolInput>;
684
715
 
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.
716
+ // v0.11.0 (#45) — theme edits are `set_theme_tokens` (loose-name patch) /
717
+ // `propose_create_theme` (gated mint) / `propose_activate_theme` (gated
718
+ // flip) — see packages/admin-core/src/ai/tools/{update-theme-tokens,get-theme,…}.ts.
704
719
 
705
720
  export const setTemplateLayoutToolInput = z
706
721
  .object({
@@ -1037,7 +1052,6 @@ export type BulkOptimizeSeoToolInput = z.infer<typeof bulkOptimizeSeoToolInput>;
1037
1052
  export type EditModuleToolInput = z.infer<typeof editModuleToolInput>;
1038
1053
  export type SiteMemoryProposeToolInput = z.infer<typeof siteMemoryProposeToolInput>;
1039
1054
  export type CreateTemplateToolInput = z.infer<typeof createTemplateToolInput>;
1040
- export type SetStructuredSetToolInput = z.infer<typeof setStructuredSetToolInput>;
1041
1055
  // v0.10.22 — `UpdateThemeToolInput` removed alongside `update_theme` tool.
1042
1056
  export type ChatCreateSessionInput = z.infer<typeof chatCreateSessionInput>;
1043
1057
  export type ChatSendMessageInput = z.infer<typeof chatSendMessageInput>;
@@ -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,
@@ -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
@@ -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
+ "name",
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) {
@@ -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", () => {
@@ -120,8 +121,9 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
120
121
  expect(msg).toContain("numbered-scalar fanout");
121
122
  expect(msg).toContain('"label", "label2", "label3"');
122
123
  // AI-actionable: the error names every list-kind fix.
123
- expect(msg).toContain("text-list");
124
- expect(msg).toContain("link-list");
124
+ expect(msg).toContain('kind "list"');
125
+ expect(msg).toContain('listType "texts"');
126
+ expect(msg).toContain('"links"');
125
127
  expect(msg).toContain("module-list");
126
128
  expect(msg).toContain("§1A");
127
129
  }
@@ -153,11 +155,83 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
153
155
  });
154
156
 
155
157
  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 });
158
+ const linkList = (name: string) => ({ name, kind: "list", listType: "links", label: name });
157
159
  expect(mod([linkList("links1"), linkList("links2"), linkList("links3")]).success).toBe(true);
158
160
  });
159
161
  });
160
162
 
163
+ // Epic #1107 — the `list` field kind (docs/lists.md §7): the static
164
+ // schema checks the binding's shape; fitting the type is the op's job.
165
+ describe("moduleFields list kind", () => {
166
+ const mod = (fields: unknown) =>
167
+ moduleCreateSchema.safeParse({ slug: "m", displayName: "M", html: "<div></div>", fields });
168
+ const LIST = "caelo:list/00000000-0000-4000-8000-000000000101";
169
+ const messages = (r: ReturnType<typeof mod>) =>
170
+ r.success ? [] : r.error.issues.map((i) => i.message);
171
+
172
+ it("accepts every binding", () => {
173
+ const ok = mod([
174
+ {
175
+ name: "bullets",
176
+ label: "B",
177
+ kind: "list",
178
+ listType: "texts",
179
+ default: [{ node: "text", values: { text: "x" } }],
180
+ },
181
+ { name: "menu", label: "M", kind: "list", listType: "menu", bind: "shared", list: LIST },
182
+ {
183
+ name: "footer",
184
+ label: "F",
185
+ kind: "list",
186
+ listType: "links",
187
+ bind: "shared",
188
+ default: LIST,
189
+ },
190
+ {
191
+ name: "latest",
192
+ label: "L",
193
+ kind: "list",
194
+ listType: "links",
195
+ bind: "source",
196
+ source: {
197
+ kind: "query",
198
+ editable: ["limit"],
199
+ defaults: { page_type: "news", order: "published_desc", limit: 3 },
200
+ },
201
+ },
202
+ {
203
+ name: "crumbs",
204
+ label: "C",
205
+ kind: "list",
206
+ listType: "links",
207
+ bind: "source",
208
+ source: { kind: "system", name: "breadcrumbs" },
209
+ },
210
+ ]);
211
+ expect(ok.success).toBe(true);
212
+ });
213
+
214
+ it("refuses bindings whose parts do not fit together, naming the fix", () => {
215
+ const r = mod([
216
+ { name: "a", label: "A", kind: "list", listType: "menu", bind: "source" },
217
+ { name: "b", label: "B", kind: "list", listType: "menu", list: LIST },
218
+ { name: "c", label: "C", kind: "list", listType: "menu", bind: "shared", default: [] },
219
+ { name: "d", label: "D", kind: "list", listType: "texts", default: LIST },
220
+ ]);
221
+ expect(messages(r)).toEqual([
222
+ 'list field "a": bind "source" needs a source (query, system or plugin).',
223
+ 'list field "b": list is only allowed with bind "shared" — a local list stores its entries in the placement.',
224
+ 'list field "c": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.',
225
+ 'list field "d": a local default is an array of entries, not a list reference.',
226
+ ]);
227
+ });
228
+
229
+ it("refuses the removed text-list / link-list kinds", () => {
230
+ expect(mod([{ name: "t", label: "T", kind: "text-list" }]).success).toBe(false);
231
+ expect(mod([{ name: "l", label: "L", kind: "link-list" }]).success).toBe(false);
232
+ });
233
+ });
234
+
161
235
  describe("pageCreateSchema (no raw HTML invariant)", () => {
162
236
  it("accepts a structured page payload", () => {
163
237
  const r = pageCreateSchema.safeParse({
@@ -250,3 +324,37 @@ describe("templateBlocksSetSchema", () => {
250
324
  expect(r.success).toBe(true);
251
325
  });
252
326
  });
327
+
328
+ describe("corrupted code payloads are refused at the boundary (PR #1121 e2e)", () => {
329
+ const base = { slug: "footer", displayName: "Footer", html: "<footer>x</footer>" };
330
+ it("refuses JSON-encoded html and css with the fix in the message", () => {
331
+ const html = moduleCreateSchema.safeParse({
332
+ ...base,
333
+ html: JSON.stringify('<footer class="x">{{#links}}{{label}}{{/links}}</footer>'),
334
+ });
335
+ expect(html.success).toBe(false);
336
+ expect(html.error?.issues[0]?.message).toContain("JSON-encoded");
337
+ const css = moduleCreateSchema.safeParse({
338
+ ...base,
339
+ css: JSON.stringify(".x { color: red; }"),
340
+ });
341
+ expect(css.success).toBe(false);
342
+ });
343
+ it("refuses HTML-escaped template markup", () => {
344
+ const r = templateCreateSchema.safeParse({
345
+ slug: "page",
346
+ displayName: "Page",
347
+ html: '&lt;div&gt;&lt;caelo-slot name="content"&gt;&lt;/caelo-slot&gt;&lt;/div&gt;',
348
+ });
349
+ expect(r.success).toBe(false);
350
+ expect(r.error?.issues[0]?.message).toContain("HTML-escaped");
351
+ });
352
+ it("keeps legitimate markup: entities inside real tags, a quote-led text node", () => {
353
+ expect(
354
+ moduleCreateSchema.safeParse({ ...base, html: "<pre>&lt;div&gt; is a tag</pre>" }).success,
355
+ ).toBe(true);
356
+ expect(moduleCreateSchema.safeParse({ ...base, html: '"Quoted" <em>text</em>' }).success).toBe(
357
+ true,
358
+ );
359
+ });
360
+ });