@caelo-cms/shared 0.10.71 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/dist/ai-tools.d.ts +125 -50
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +50 -38
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +110 -30
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +26 -7
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +203 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +178 -76
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-var-aliases.d.ts +20 -0
  14. package/dist/css-var-aliases.d.ts.map +1 -0
  15. package/dist/css-var-aliases.js +95 -0
  16. package/dist/css-var-aliases.js.map +1 -0
  17. package/dist/css-var-scan.d.ts +0 -16
  18. package/dist/css-var-scan.d.ts.map +1 -1
  19. package/dist/css-var-scan.js +24 -0
  20. package/dist/css-var-scan.js.map +1 -1
  21. package/dist/edit-markers.d.ts +41 -5
  22. package/dist/edit-markers.d.ts.map +1 -1
  23. package/dist/edit-markers.js +47 -50
  24. package/dist/edit-markers.js.map +1 -1
  25. package/dist/id-safe-truncate.d.ts +15 -0
  26. package/dist/id-safe-truncate.d.ts.map +1 -0
  27. package/dist/id-safe-truncate.js +47 -0
  28. package/dist/id-safe-truncate.js.map +1 -0
  29. package/dist/index.d.ts +5 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/interactions.js +2 -2
  34. package/dist/interactions.js.map +1 -1
  35. package/dist/link-canonicalise.d.ts.map +1 -1
  36. package/dist/link-canonicalise.js +7 -1
  37. package/dist/link-canonicalise.js.map +1 -1
  38. package/dist/link-refs.d.ts +1 -1
  39. package/dist/link-refs.js +3 -3
  40. package/dist/lists/address.d.ts +34 -0
  41. package/dist/lists/address.d.ts.map +1 -0
  42. package/dist/lists/address.js +43 -0
  43. package/dist/lists/address.js.map +1 -0
  44. package/dist/lists/default-types.d.ts +10 -0
  45. package/dist/lists/default-types.d.ts.map +1 -0
  46. package/dist/lists/default-types.js +91 -0
  47. package/dist/lists/default-types.js.map +1 -0
  48. package/dist/lists/fields.d.ts +152 -0
  49. package/dist/lists/fields.d.ts.map +1 -0
  50. package/dist/lists/fields.js +258 -0
  51. package/dist/lists/fields.js.map +1 -0
  52. package/dist/lists/index.d.ts +14 -0
  53. package/dist/lists/index.d.ts.map +1 -0
  54. package/dist/lists/index.js +15 -0
  55. package/dist/lists/index.js.map +1 -0
  56. package/dist/lists/item-attr.d.ts +26 -0
  57. package/dist/lists/item-attr.d.ts.map +1 -0
  58. package/dist/lists/item-attr.js +31 -0
  59. package/dist/lists/item-attr.js.map +1 -0
  60. package/dist/lists/items.d.ts +105 -0
  61. package/dist/lists/items.d.ts.map +1 -0
  62. package/dist/lists/items.js +304 -0
  63. package/dist/lists/items.js.map +1 -0
  64. package/dist/lists/list-type.d.ts +401 -0
  65. package/dist/lists/list-type.d.ts.map +1 -0
  66. package/dist/lists/list-type.js +275 -0
  67. package/dist/lists/list-type.js.map +1 -0
  68. package/dist/lists/model.d.ts +14 -0
  69. package/dist/lists/model.d.ts.map +1 -0
  70. package/dist/lists/model.js +15 -0
  71. package/dist/lists/model.js.map +1 -0
  72. package/dist/lists/page-type.d.ts +115 -0
  73. package/dist/lists/page-type.d.ts.map +1 -0
  74. package/dist/lists/page-type.js +66 -0
  75. package/dist/lists/page-type.js.map +1 -0
  76. package/dist/lists/refs.d.ts +13 -0
  77. package/dist/lists/refs.d.ts.map +1 -0
  78. package/dist/lists/refs.js +18 -0
  79. package/dist/lists/refs.js.map +1 -0
  80. package/dist/lists/render-data.d.ts +114 -0
  81. package/dist/lists/render-data.d.ts.map +1 -0
  82. package/dist/lists/render-data.js +3 -0
  83. package/dist/lists/render-data.js.map +1 -0
  84. package/dist/lists/render-deps.d.ts +68 -0
  85. package/dist/lists/render-deps.d.ts.map +1 -0
  86. package/dist/lists/render-deps.js +91 -0
  87. package/dist/lists/render-deps.js.map +1 -0
  88. package/dist/lists/resolve-values.d.ts +63 -0
  89. package/dist/lists/resolve-values.d.ts.map +1 -0
  90. package/dist/lists/resolve-values.js +181 -0
  91. package/dist/lists/resolve-values.js.map +1 -0
  92. package/dist/lists/resolve.d.ts +106 -0
  93. package/dist/lists/resolve.d.ts.map +1 -0
  94. package/dist/lists/resolve.js +878 -0
  95. package/dist/lists/resolve.js.map +1 -0
  96. package/dist/lists/scope.d.ts +81 -0
  97. package/dist/lists/scope.d.ts.map +1 -0
  98. package/dist/lists/scope.js +3 -0
  99. package/dist/lists/scope.js.map +1 -0
  100. package/dist/markup-structure.d.ts +8 -0
  101. package/dist/markup-structure.d.ts.map +1 -1
  102. package/dist/markup-structure.js +2 -0
  103. package/dist/markup-structure.js.map +1 -1
  104. package/dist/media.d.ts +1 -1
  105. package/dist/nested-module-render.d.ts +25 -20
  106. package/dist/nested-module-render.d.ts.map +1 -1
  107. package/dist/nested-module-render.js +122 -31
  108. package/dist/nested-module-render.js.map +1 -1
  109. package/dist/page-log.d.ts +1 -1
  110. package/dist/preview-compose.d.ts +24 -21
  111. package/dist/preview-compose.d.ts.map +1 -1
  112. package/dist/preview-compose.js +34 -118
  113. package/dist/preview-compose.js.map +1 -1
  114. package/dist/preview-scanner.d.ts +5 -2
  115. package/dist/preview-scanner.d.ts.map +1 -1
  116. package/dist/preview-scanner.js +5 -3
  117. package/dist/preview-scanner.js.map +1 -1
  118. package/dist/source-text-transport.d.ts +17 -0
  119. package/dist/source-text-transport.d.ts.map +1 -0
  120. package/dist/source-text-transport.js +71 -0
  121. package/dist/source-text-transport.js.map +1 -0
  122. package/dist/template-engine.d.ts +44 -31
  123. package/dist/template-engine.d.ts.map +1 -1
  124. package/dist/template-engine.js +75 -174
  125. package/dist/template-engine.js.map +1 -1
  126. package/dist/template-sections.d.ts +69 -0
  127. package/dist/template-sections.d.ts.map +1 -0
  128. package/dist/template-sections.js +423 -0
  129. package/dist/template-sections.js.map +1 -0
  130. package/dist/uuid-diagnose.d.ts +9 -0
  131. package/dist/uuid-diagnose.d.ts.map +1 -0
  132. package/dist/uuid-diagnose.js +47 -0
  133. package/dist/uuid-diagnose.js.map +1 -0
  134. package/dist/version.d.ts +2 -2
  135. package/dist/version.d.ts.map +1 -1
  136. package/dist/version.js +1 -1
  137. package/dist/version.js.map +1 -1
  138. package/package.json +8 -1
  139. package/src/__tests__/redos-hardening.test.ts +50 -16
  140. package/src/ai-tools-add-module-modes.test.ts +50 -0
  141. package/src/ai-tools.test.ts +10 -0
  142. package/src/ai-tools.ts +53 -39
  143. package/src/build-page.test.ts +34 -3
  144. package/src/build-page.ts +29 -6
  145. package/src/content.test.ts +111 -3
  146. package/src/content.ts +188 -77
  147. package/src/css-var-aliases.test.ts +61 -0
  148. package/src/css-var-aliases.ts +109 -0
  149. package/src/css-var-scan.ts +9 -0
  150. package/src/edit-markers.test.ts +37 -21
  151. package/src/edit-markers.ts +52 -20
  152. package/src/id-safe-truncate.test.ts +35 -0
  153. package/src/id-safe-truncate.ts +46 -0
  154. package/src/index.ts +5 -1
  155. package/src/interactions.ts +2 -2
  156. package/src/link-canonicalise.test.ts +36 -6
  157. package/src/link-canonicalise.ts +6 -1
  158. package/src/link-refs.test.ts +66 -42
  159. package/src/link-refs.ts +3 -3
  160. package/src/lists/address.ts +75 -0
  161. package/src/lists/default-types.ts +102 -0
  162. package/src/lists/fields.ts +285 -0
  163. package/src/lists/index.ts +16 -0
  164. package/src/lists/item-attr.test.ts +36 -0
  165. package/src/lists/item-attr.ts +49 -0
  166. package/src/lists/items.ts +402 -0
  167. package/src/lists/list-fixtures.test.ts +302 -0
  168. package/src/lists/list-type.ts +306 -0
  169. package/src/lists/lists.test.ts +328 -0
  170. package/src/lists/model.ts +16 -0
  171. package/src/lists/page-type.ts +71 -0
  172. package/src/lists/refs.ts +21 -0
  173. package/src/lists/render-data.ts +125 -0
  174. package/src/lists/render-deps.test.ts +132 -0
  175. package/src/lists/render-deps.ts +144 -0
  176. package/src/lists/resolve-values.ts +224 -0
  177. package/src/lists/resolve.test.ts +295 -0
  178. package/src/lists/resolve.ts +1148 -0
  179. package/src/lists/scope.ts +69 -0
  180. package/src/lists-compose-parity.test.ts +262 -0
  181. package/src/markup-structure.ts +10 -0
  182. package/src/nested-module-render.test.ts +111 -77
  183. package/src/nested-module-render.ts +85 -48
  184. package/src/preview-compose.test.ts +74 -19
  185. package/src/preview-compose.ts +59 -154
  186. package/src/preview-scanner.test.ts +8 -4
  187. package/src/preview-scanner.ts +5 -3
  188. package/src/source-text-transport.test.ts +63 -0
  189. package/src/source-text-transport.ts +70 -0
  190. package/src/template-engine-lists.test.ts +553 -0
  191. package/src/template-engine.test.ts +62 -80
  192. package/src/template-engine.ts +102 -245
  193. package/src/template-sections.ts +500 -0
  194. package/src/uuid-diagnose.test.ts +28 -0
  195. package/src/uuid-diagnose.ts +50 -0
  196. package/src/version.ts +1 -1
  197. package/dist/structured-sets.d.ts +0 -70
  198. package/dist/structured-sets.d.ts.map +0 -1
  199. package/dist/structured-sets.js +0 -92
  200. package/dist/structured-sets.js.map +0 -1
  201. package/src/structured-sets.ts +0 -114
  202. package/src/template-engine-data-lists.test.ts +0 -90
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { describe, expect, it } from "bun:test";
14
14
  import { fontFamilySlug, parseFontsCss } from "../fonts.js";
15
+ import { DEFAULT_LIST_TYPES } from "../lists/default-types.js";
16
+ import { createListRenderContext } from "../lists/resolve.js";
15
17
  import { renderTemplate } from "../template-engine.js";
16
18
  import { stripCssComments } from "../theme-importers/css-comments.js";
17
19
  import { trimSlashes, trimTrailingSlashes } from "../url.js";
@@ -59,36 +61,68 @@ describe("stripCssComments (S4)", () => {
59
61
  });
60
62
  });
61
63
 
62
- describe("renderTemplate SECTION_RE (S4)", () => {
64
+ describe("renderTemplate sections (S4, epic #1107 tokenizer)", () => {
65
+ const lists = createListRenderContext({
66
+ listTypes: new Map(DEFAULT_LIST_TYPES.map((t) => [t.slug, { id: t.slug, def: t }])),
67
+ lists: new Map(),
68
+ pageTypes: new Map(),
69
+ pages: new Map(),
70
+ media: new Map(),
71
+ siteLanguage: "en",
72
+ }).forPage(null);
73
+ const navField = { name: "nav_items", kind: "list" as const, listType: "links" };
74
+ const entry = (n: number, label: string, href: string) => ({
75
+ id: `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`,
76
+ node: "link",
77
+ values: { label, link: { href } },
78
+ });
79
+
63
80
  it("scans a large closed section body linearly (no quadratic backtracking)", () => {
64
- // A closed section with a 100k-char literal body. The tempered-dot scans
65
- // it in one linear pass; the old lazy [\s\S]*? + backreference close was
66
- // the polynomial shape this guards against.
81
+ // A closed section with a 100k-char literal body: the section pass
82
+ // tokenizes once and pairs tags with a stack, no regex over the body.
67
83
  const big = "x".repeat(100_000);
68
- const html = `<nav>{{#nav_items}}${big}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>`;
84
+ const html = `<nav>{{#nav_items}}${big}<a href="{{link}}">{{label}}</a>{{/nav_items}}</nav>`;
69
85
  expect(
70
86
  underBudget(() =>
71
87
  renderTemplate({
72
88
  html,
73
- fields: [{ name: "nav_items", kind: "link-list" }],
74
- contentValues: { nav_items: [{ label: "Docs", href: "/docs" }] },
89
+ fields: [navField],
90
+ contentValues: { nav_items: [entry(1, "Docs", "/docs")] },
91
+ lists,
75
92
  }),
76
93
  ),
77
94
  ).toBeLessThan(BUDGET_MS);
78
95
  });
79
96
 
97
+ it("thousands of unclosed sections cost one pass, not one scan each", () => {
98
+ // Unclosed opens stay unpaired (the later Mustache pass reports them);
99
+ // pairing must not rescan the rest of the document per open.
100
+ const html = "{{#a}}".repeat(5_000) + "x".repeat(50_000);
101
+ expect(
102
+ underBudget(() => {
103
+ try {
104
+ renderTemplate({
105
+ html,
106
+ fields: [{ name: "a", kind: "text" }],
107
+ contentValues: { a: "1" },
108
+ });
109
+ } catch {
110
+ // Mustache refuses the unclosed sections; only the cost matters here.
111
+ }
112
+ }),
113
+ ).toBeLessThan(BUDGET_MS);
114
+ });
115
+
80
116
  it("still iterates a closed section the same way", () => {
81
117
  const out = renderTemplate({
82
- html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
83
- fields: [{ name: "nav_items", kind: "link-list" }],
84
- contentValues: {
85
- nav_items: [
86
- { label: "Docs", href: "/docs" },
87
- { label: "Blog", href: "/blog" },
88
- ],
89
- },
118
+ html: '<nav>{{#nav_items}}<a href="{{link}}">{{label}}</a>{{/nav_items}}</nav>',
119
+ fields: [navField],
120
+ contentValues: { nav_items: [entry(1, "Docs", "/docs"), entry(2, "Blog", "/blog")] },
121
+ lists,
90
122
  });
91
- expect(out.html).toBe('<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>');
123
+ expect(out.html.replace(/ data-caelo-list-item="[^"]*"/g, "")).toBe(
124
+ '<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>',
125
+ );
92
126
  });
93
127
  });
94
128
 
@@ -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(
@@ -89,7 +93,7 @@ export const editModuleToolInput = z
89
93
  displayName: z.string().min(1).max(128).optional(),
90
94
  /**
91
95
  * v0.12.0 — rewrite the module's purpose. Optional; passing it
92
- * updates the `## Modules` block your future self will read.
96
+ * updates the `list_modules` catalog your future self will read.
93
97
  */
94
98
  description: z.string().max(1000).optional(),
95
99
  /** v0.12.0 — re-classify the module's role tag. */
@@ -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
@@ -263,7 +275,7 @@ export const addModuleToolInput = z
263
275
  ctx.addIssue({
264
276
  code: z.ZodIssueCode.custom,
265
277
  message:
266
- "Pass either `moduleId` (place an existing module from ## Modules) " +
278
+ "Pass either `moduleId` (place an existing module from list_modules) " +
267
279
  "or `displayName` + `html` (mint a new module).",
268
280
  });
269
281
  }
@@ -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,10 +131,13 @@ 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
- * Place mode: an existing module from `## Modules` (UUID), or a
140
+ * Place mode: an existing module from `list_modules` (UUID), or a
137
141
  * module minted EARLIER IN THIS CALL via `{"$ref": "<handle>"}` —
138
142
  * the second-placement case (e.g. three feature cards reusing one
139
143
  * card module). Live-edit run A showed the model writing
@@ -145,7 +149,7 @@ export const buildPageModuleSchema = z
145
149
  z
146
150
  .string()
147
151
  .uuid(
148
- 'moduleId must be a UUID from ## Modules — to re-place a module minted earlier in THIS call, pass {"$ref": "<its ref>"} instead',
152
+ 'moduleId must be a UUID from list_modules — to re-place a module minted earlier in THIS call, pass {"$ref": "<its ref>"} instead',
149
153
  ),
150
154
  z.object({ $ref: z.string() }).strict(),
151
155
  ])
@@ -195,7 +199,7 @@ export const buildPageModuleSchema = z
195
199
  ctx.addIssue({
196
200
  code: z.ZodIssueCode.custom,
197
201
  message:
198
- "Pass either `moduleId` (place an existing module from ## Modules) " +
202
+ "Pass either `moduleId` (place an existing module from list_modules) " +
199
203
  "or `displayName` + `html` (mint a new module).",
200
204
  });
201
205
  }
@@ -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
+ });