@caelo-cms/shared 0.11.0 → 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 (92) hide show
  1. package/dist/ai-tools.d.ts +30 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +41 -141
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +5 -5
  6. package/dist/build-page.js +9 -9
  7. package/dist/build-page.js.map +1 -1
  8. package/dist/content.d.ts +13 -2
  9. package/dist/content.d.ts.map +1 -1
  10. package/dist/content.js +45 -12
  11. package/dist/content.js.map +1 -1
  12. package/dist/css-scope-scan.js +1 -1
  13. package/dist/escaped-line-breaks.d.ts +44 -0
  14. package/dist/escaped-line-breaks.d.ts.map +1 -0
  15. package/dist/escaped-line-breaks.js +203 -0
  16. package/dist/escaped-line-breaks.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/interactions.js +5 -5
  22. package/dist/interactions.js.map +1 -1
  23. package/dist/lists/default-types.d.ts +12 -0
  24. package/dist/lists/default-types.d.ts.map +1 -1
  25. package/dist/lists/default-types.js +36 -0
  26. package/dist/lists/default-types.js.map +1 -1
  27. package/dist/lists/fields.d.ts.map +1 -1
  28. package/dist/lists/fields.js +1 -0
  29. package/dist/lists/fields.js.map +1 -1
  30. package/dist/lists/render-data.d.ts +9 -0
  31. package/dist/lists/render-data.d.ts.map +1 -1
  32. package/dist/lists/render-deps.d.ts +4 -3
  33. package/dist/lists/render-deps.d.ts.map +1 -1
  34. package/dist/lists/render-deps.js +5 -3
  35. package/dist/lists/render-deps.js.map +1 -1
  36. package/dist/lists/resolve.d.ts +13 -0
  37. package/dist/lists/resolve.d.ts.map +1 -1
  38. package/dist/lists/resolve.js +95 -3
  39. package/dist/lists/resolve.js.map +1 -1
  40. package/dist/nested-module-render.d.ts +13 -0
  41. package/dist/nested-module-render.d.ts.map +1 -1
  42. package/dist/nested-module-render.js +34 -0
  43. package/dist/nested-module-render.js.map +1 -1
  44. package/dist/redirect-plan.d.ts +13 -0
  45. package/dist/redirect-plan.d.ts.map +1 -1
  46. package/dist/redirect-plan.js +22 -0
  47. package/dist/redirect-plan.js.map +1 -1
  48. package/dist/seo.d.ts +21 -0
  49. package/dist/seo.d.ts.map +1 -1
  50. package/dist/seo.js +32 -0
  51. package/dist/seo.js.map +1 -1
  52. package/dist/source-text-transport.d.ts +28 -2
  53. package/dist/source-text-transport.d.ts.map +1 -1
  54. package/dist/source-text-transport.js +12 -4
  55. package/dist/source-text-transport.js.map +1 -1
  56. package/dist/template-sections.d.ts.map +1 -1
  57. package/dist/template-sections.js +17 -4
  58. package/dist/template-sections.js.map +1 -1
  59. package/dist/version.d.ts +2 -2
  60. package/dist/version.js +1 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/redos-hardening.test.ts +1 -0
  63. package/src/ai-tools-add-module-modes.test.ts +1 -1
  64. package/src/ai-tools.test.ts +7 -20
  65. package/src/ai-tools.ts +49 -153
  66. package/src/bug-intake.test.ts +1 -1
  67. package/src/build-page.ts +9 -9
  68. package/src/content.test.ts +26 -1
  69. package/src/content.ts +47 -13
  70. package/src/css-scope-scan.ts +1 -1
  71. package/src/escaped-line-breaks.ts +221 -0
  72. package/src/index.ts +1 -0
  73. package/src/interactions.ts +5 -5
  74. package/src/link-refs.test.ts +1 -0
  75. package/src/lists/default-types.ts +39 -0
  76. package/src/lists/fields.ts +1 -0
  77. package/src/lists/list-fixtures.test.ts +9 -3
  78. package/src/lists/render-data.ts +9 -0
  79. package/src/lists/render-deps.ts +6 -3
  80. package/src/lists/resolve.test.ts +182 -5
  81. package/src/lists/resolve.ts +101 -3
  82. package/src/nested-module-render.test.ts +39 -0
  83. package/src/nested-module-render.ts +35 -0
  84. package/src/redirect-plan.test.ts +25 -0
  85. package/src/redirect-plan.ts +24 -0
  86. package/src/seo.test.ts +58 -0
  87. package/src/seo.ts +49 -0
  88. package/src/source-text-transport.test.ts +98 -3
  89. package/src/source-text-transport.ts +15 -4
  90. package/src/template-engine-lists.test.ts +55 -0
  91. package/src/template-sections.ts +18 -4
  92. package/src/version.ts +1 -1
package/src/ai-tools.ts CHANGED
@@ -17,15 +17,14 @@ import {
17
17
  contentInstanceDeleteSchema,
18
18
  contentInstanceUpdateSchema,
19
19
  forkPlacementContentSchema,
20
- MODULE_CSS_MAX,
21
- MODULE_HTML_MAX,
22
- MODULE_JS_MAX,
23
20
  type ModuleField,
24
21
  moduleFieldSchema,
22
+ pageShortTitleSchema,
25
23
  pageTypeSlugSchema,
26
24
  setPlacementContentSchema,
27
25
  slugSchema,
28
26
  } from "./content.js";
27
+ import type { SourceLanguage } from "./escaped-line-breaks.js";
29
28
  import { LIST_ITEM_ATTR_RE } from "./lists/item-attr.js";
30
29
  import { pluginPreviewSelectionSchema } from "./plugin-preview.js";
31
30
  import { redirectTargetPathSchema } from "./redirect-target.js";
@@ -71,9 +70,10 @@ function stripSurroundingQuotes(v: unknown): unknown {
71
70
  * is handled in one place instead of refused per tool.
72
71
  *
73
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`).
74
74
  */
75
- export function sourceTextArg<T extends z.ZodType<string>>(schema: T) {
76
- return z.preprocess(decodeSourceTextTransport, schema);
75
+ export function sourceTextArg<T extends z.ZodType<string>>(schema: T, language: SourceLanguage) {
76
+ return z.preprocess((value) => decodeSourceTextTransport(value, language), schema);
77
77
  }
78
78
 
79
79
  export const positionInputSchema = z.preprocess(
@@ -87,63 +87,6 @@ export const positionInputSchema = z.preprocess(
87
87
  ]),
88
88
  );
89
89
 
90
- export const editModuleToolInput = z
91
- .object({
92
- moduleId: z.string().uuid(),
93
- displayName: z.string().min(1).max(128).optional(),
94
- /**
95
- * v0.12.0 — rewrite the module's purpose. Optional; passing it
96
- * updates the `list_modules` catalog your future self will read.
97
- */
98
- description: z.string().max(1000).optional(),
99
- /** v0.12.0 — re-classify the module's role tag. */
100
- kind: z.enum(["chrome", "hero", "content", "cta", "utility"]).optional(),
101
- /**
102
- * v0.12.3 (issue #106) — re-classify the module's stable `type` (the
103
- * class a parent's `allowedModuleTypes` whitelist matches against,
104
- * e.g. `button`). Rarely needed; usually derived from displayName at
105
- * create time. Set it to make this module satisfy a parent's allowlist.
106
- */
107
- type: slugSchema.optional(),
108
- html: sourceTextArg(z.string().max(MODULE_HTML_MAX)).optional(),
109
- css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
110
- js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
111
- /**
112
- * v0.4.0 — module field schema. Each field declares one substitution
113
- * slot referenced in the module HTML as `{{name}}`. Page placements
114
- * fill these via `set_page_module_content`. Optional on edits — pass
115
- * to replace the declared schema.
116
- */
117
- fields: z.array(moduleFieldSchema).max(64).optional(),
118
- /** issue #164 slice 2 — see addModuleToolInput.bindThemeLiterals. */
119
- bindThemeLiterals: z.boolean().optional(),
120
- })
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
- });
130
-
131
- /**
132
- * v0.4.0 — `set_page_module_content` AI tool. Fills the content values for
133
- * a module placement on a specific page. Page-bound + branch-isolated per
134
- * chat until publish (unlike `edit_module` which is global + immediate).
135
- */
136
- export const setPageModuleContentToolInput = z
137
- .object({
138
- pageId: z.string().uuid(),
139
- blockName: z.string().min(1).max(64),
140
- position: z.number().int().nonnegative(),
141
- /** Map of module field name → value. Fully replaces existing values. */
142
- contentValues: z.record(z.string(), z.unknown()),
143
- })
144
- .strict();
145
- export type SetPageModuleContentToolInput = z.infer<typeof setPageModuleContentToolInput>;
146
-
147
90
  export const siteMemoryProposeToolInput = z
148
91
  .object({
149
92
  slot: z.enum(["purpose", "brand-voice", "tone", "banned-phrases", "instructions", "glossary"]),
@@ -218,9 +161,9 @@ export const addModuleToolInput = z
218
161
  description: z.string().max(1000).optional(),
219
162
  kind: z.enum(["chrome", "hero", "content", "cta", "utility"]).optional(),
220
163
  type: slugSchema.optional(),
221
- html: sourceTextArg(z.string().min(1).max(50_000)).optional(),
222
- css: sourceTextArg(z.string().max(50_000)).optional(),
223
- 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(),
224
167
  fields: z.array(moduleFieldSchema).max(64).optional(),
225
168
  bindThemeLiterals: z.boolean().optional(),
226
169
  /**
@@ -233,8 +176,8 @@ export const addModuleToolInput = z
233
176
  * stores the values as the minted module's `fields[].default` —
234
177
  * which requires explicit `fields` (moduleize field names are
235
178
  * unknowable up front) and is rejected for `moduleId` reuse (a
236
- * shared module renders its stored defaults; edit_module changes
237
- * them).
179
+ * shared module renders its stored defaults; update_modules_many
180
+ * changes them).
238
181
  */
239
182
  values: z.record(z.string(), z.unknown()).optional(),
240
183
  })
@@ -245,7 +188,7 @@ export const addModuleToolInput = z
245
188
  ctx.addIssue({
246
189
  code: z.ZodIssueCode.custom,
247
190
  message:
248
- "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.",
249
192
  });
250
193
  } else if (input.fields === undefined || input.fields.length === 0) {
251
194
  ctx.addIssue({
@@ -269,7 +212,7 @@ export const addModuleToolInput = z
269
212
  // (§1A/§11), so we do NOT reject a moduleId call that also carries authoring
270
213
  // fields. Those fields are not applied here; the tool HANDLER surfaces an
271
214
  // INFO for any ignored field whose value DIFFERS from the module's stored
272
- // 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).
273
216
  if (input.moduleId !== undefined) return;
274
217
  if (input.displayName === undefined || input.html === undefined) {
275
218
  ctx.addIssue({
@@ -361,44 +304,6 @@ export const deleteContentInstanceToolInput = contentInstanceDeleteSchema;
361
304
  export const setPlacementContentToolInput = setPlacementContentSchema;
362
305
  export const forkPlacementContentToolInput = forkPlacementContentSchema;
363
306
 
364
- export const AI_TOOLS = [
365
- "edit_module",
366
- "site_memory_propose",
367
- "add_module",
368
- "create_page",
369
- // audit #3 — page metadata (name/title/slug/template/status) is one tool for
370
- // 1..200 pages: `update_pages_many`. The former rename_page / set_page_title
371
- // / change_page_slug were single-field wrappers over the same pages.update.
372
- "update_pages_many",
373
- // audit #4 — one delete tool for 1..200 pages, disposition per page.
374
- // The former singular delete_page folded in (n=1 is a one-item array).
375
- "delete_pages_many",
376
- // one remove-module tool routed by target (page|layout).
377
- "remove_module_from",
378
- "update_theme",
379
- "set_template_layout",
380
- "create_layout",
381
- "set_site_defaults",
382
- "duplicate_page",
383
- "repoint_page_template",
384
- "move_module",
385
- "reorder_module",
386
- "set_nav_menu",
387
- "add_plugin_to_page",
388
- // v0.12.0 — content_instances + placement binding
389
- "list_content_instances",
390
- "get_content_instance",
391
- "create_content_instance",
392
- "set_content_instance_values",
393
- "delete_content_instance",
394
- "set_placement_content",
395
- "fork_placement_content",
396
- // issue #299 — bulk-first build path (§11)
397
- "build_page",
398
- "create_content_instances",
399
- "set_page_module_content_many",
400
- ] as const;
401
- export type AiToolName = (typeof AI_TOOLS)[number];
402
307
  export type AddPluginToPageToolInput = z.infer<typeof addPluginToPageToolInput>;
403
308
  export type ListContentInstancesToolInput = z.infer<typeof listContentInstancesToolInput>;
404
309
  export type GetContentInstanceToolInput = z.infer<typeof getContentInstanceToolInput>;
@@ -473,7 +378,7 @@ export const CHAT_MAX_ATTACHMENTS = 4;
473
378
  * `position` / `field` (#917) — plus `item` / `key` for an entry of a
474
379
  * local list field (epic #1107) — name the placement field the text
475
380
  * comes from when the preview marked it, so the AI can address it in
476
- * `set_page_module_content` without inspecting the page first.
381
+ * `set_page_module_content_many` without inspecting the page first.
477
382
  * `listItem` is the `data-caelo-list-item` of the list entry the element
478
383
  * sits in (epic #1107, `docs/lists.md` §13): the chip then names that
479
384
  * entry — of a shared list, or of a module's own list — not just the
@@ -676,8 +581,8 @@ export const createTemplateToolInput = z
676
581
  .object({
677
582
  slug: slugInputSchema,
678
583
  displayName: z.string().min(1).max(256),
679
- html: sourceTextArg(z.string().min(1).max(2_000_000)),
680
- 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(""),
681
586
  layoutId: z.string().uuid().optional(),
682
587
  /** Epic #1107 — the page type (slug) this template serves; omit for the site default. */
683
588
  pageType: pageTypeSlugSchema.optional(),
@@ -728,8 +633,8 @@ export const createLayoutToolInput = z
728
633
  .object({
729
634
  slug: slugInputSchema,
730
635
  displayName: z.string().min(1).max(200),
731
- html: sourceTextArg(z.string().min(1).max(50_000)),
732
- 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(),
733
638
  blocks: z
734
639
  .array(
735
640
  z
@@ -765,8 +670,9 @@ export const duplicatePageToolInput = z
765
670
  .object({
766
671
  sourcePageId: z.string().uuid(),
767
672
  newSlug: slugInputSchema,
768
- newName: z.string().min(1).max(256).optional(),
769
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(),
770
676
  targetTemplateId: z.string().uuid().optional(),
771
677
  /** Where the copy sits: a parent page id, or null for the top level.
772
678
  * Omitted = next to the source (same parent). */
@@ -794,32 +700,40 @@ export const repointPageTemplateToolInput = z
794
700
  })
795
701
  .strict();
796
702
 
797
- export const moveModuleToolInput = z
798
- .object({
799
- pageId: z.string().uuid(),
800
- moduleId: z.string().uuid(),
801
- toBlockName: z.string().min(1).max(80),
802
- /** "top" | "bottom" | a 0-based index inside the destination block. */
803
- position: positionInputSchema,
804
- })
805
- .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
+ );
806
718
 
807
- 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
808
725
  .object({
809
726
  pageId: z.string().uuid(),
810
727
  moduleId: z.string().uuid(),
811
- /**
812
- * `up` / `down` shift one slot. An integer is an absolute 0-based
813
- * target position within the same block.
814
- */
815
- 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,
816
730
  })
817
731
  .strict();
818
732
 
819
733
  /**
820
734
  * P7 — `find_media`. Searches the media library by alt-text /
821
735
  * filename / mime. Each row carries the media UUID (what
822
- * `set_theme_asset` / `set_media_alt` require) plus a pre-resolved
736
+ * `set_theme_asset` / `set_media_alt_many` require) plus a pre-resolved
823
737
  * variant URL. The AI's only media-inventory surface — the `## Media`
824
738
  * system-prompt block was removed by the context diet (#300), so every
825
739
  * asset lookup ("an image of a sunlit office") goes through this tool.
@@ -864,7 +778,7 @@ export const regenerateMediaVariantsToolInput = z
864
778
  export type RegenerateMediaVariantsToolInput = z.infer<typeof regenerateMediaVariantsToolInput>;
865
779
 
866
780
  /**
867
- * 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
868
782
  * without a human round-trip (e.g. when the editor uploaded an image
869
783
  * with the default alt and the AI reads its content). Updates only
870
784
  * the alt field on `media_assets`.
@@ -894,7 +808,7 @@ export const setNotFoundPageToolInput = z.object({ pageId: z.string().uuid().nul
894
808
  export type SetNotFoundPageToolInput = z.infer<typeof setNotFoundPageToolInput>;
895
809
 
896
810
  /**
897
- * 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
898
812
  * kind + origin detail) and licence when known. All fields but the
899
813
  * assetId are optional; the op COALESCEs so omitted fields stay
900
814
  * unchanged.
@@ -910,7 +824,7 @@ export const setMediaSourceToolInput = z
910
824
  export type SetMediaSourceToolInput = z.infer<typeof setMediaSourceToolInput>;
911
825
 
912
826
  /**
913
- * 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
914
828
  * sidecar. AI calls this only on explicit user intent
915
829
  * ("set the home meta description to ..."). Doesn't bump fingerprints
916
830
  * (autofilled_at / optimized_at).
@@ -931,7 +845,7 @@ export const setPageSeoToolInput = z
931
845
  export type SetPageSeoToolInput = z.infer<typeof setPageSeoToolInput>;
932
846
 
933
847
  /**
934
- * P8 — `autofill_page_seo` (and, per item, `autofill_page_seo_many`).
848
+ * P8 — one item of `autofill_page_seo_many`.
935
849
  * Fill-once. Refuses when the page's SEO was already autofilled. The AI
936
850
  * calls it before a page's first Stage; staging and production builds
937
851
  * refuse pages without a meta description (deploy/missing-seo.ts).
@@ -945,22 +859,6 @@ export const autofillPageSeoToolInput = z
945
859
  .strict();
946
860
  export type AutofillPageSeoToolInput = z.infer<typeof autofillPageSeoToolInput>;
947
861
 
948
- /**
949
- * P8 — `optimize_page_seo`. Explicit re-optimization. Always allowed.
950
- * Takes optional context (keyword analysis / intent shifts / branding
951
- * changes). The AI can call this across N pages in one chat turn —
952
- * the resulting changes batch into one Publish-pill confirm.
953
- */
954
- export const optimizePageSeoToolInput = z
955
- .object({
956
- pageId: z.string().uuid(),
957
- metaDescription: z.string().min(1).max(320),
958
- ogImageAssetId: z.string().uuid().nullable().optional(),
959
- context: z.string().max(4000).optional(),
960
- })
961
- .strict();
962
- export type OptimizePageSeoToolInput = z.infer<typeof optimizePageSeoToolInput>;
963
-
964
862
  /**
965
863
  * P8 AI-first review pass — bulk variants. Per CLAUDE.md §11, bulk
966
864
  * tools save round-trips when the AI knows it has N changes to make
@@ -980,7 +878,7 @@ export type FindRedirectsToolInput = z.infer<typeof findRedirectsToolInput>;
980
878
 
981
879
  export const bulkCreateRedirectsToolInput = z
982
880
  .object({
983
- redirects: z
881
+ items: z
984
882
  .array(
985
883
  z
986
884
  .object({
@@ -1032,7 +930,7 @@ export type BulkDeleteRedirectsToolInput = z.infer<typeof bulkDeleteRedirectsToo
1032
930
 
1033
931
  export const bulkOptimizeSeoToolInput = z
1034
932
  .object({
1035
- updates: z
933
+ items: z
1036
934
  .array(
1037
935
  z
1038
936
  .object({
@@ -1049,7 +947,6 @@ export const bulkOptimizeSeoToolInput = z
1049
947
  .strict();
1050
948
  export type BulkOptimizeSeoToolInput = z.infer<typeof bulkOptimizeSeoToolInput>;
1051
949
 
1052
- export type EditModuleToolInput = z.infer<typeof editModuleToolInput>;
1053
950
  export type SiteMemoryProposeToolInput = z.infer<typeof siteMemoryProposeToolInput>;
1054
951
  export type CreateTemplateToolInput = z.infer<typeof createTemplateToolInput>;
1055
952
  // v0.10.22 — `UpdateThemeToolInput` removed alongside `update_theme` tool.
@@ -1067,7 +964,6 @@ export type SetSiteDefaultsToolInput = z.infer<typeof setSiteDefaultsToolInput>;
1067
964
  export type DuplicatePageToolInput = z.infer<typeof duplicatePageToolInput>;
1068
965
  export type RepointPageTemplateToolInput = z.infer<typeof repointPageTemplateToolInput>;
1069
966
  export type MoveModuleToolInput = z.infer<typeof moveModuleToolInput>;
1070
- export type ReorderModuleToolInput = z.infer<typeof reorderModuleToolInput>;
1071
967
  // v0.10.22 — `SetNavMenuToolInput` removed alongside `set_nav_menu` tool.
1072
968
 
1073
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
  );
package/src/build-page.ts CHANGED
@@ -43,10 +43,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
43
43
  * existing content paths so build_page adds NO new semantics, only batching:
44
44
  *
45
45
  * - `inline` → mint a private (unsynced) content_instance carrying
46
- * `values`, exactly what `set_page_module_content` produces for a
46
+ * `values`, exactly what `set_page_module_content_many` produces for a
47
47
  * fresh placement.
48
48
  * - `shared` → mint a reusable content_instance (purpose required —
49
- * same decision-support contract as `create_content_instance`) and
49
+ * same decision-support contract as `create_content_instances`) and
50
50
  * bind this placement to it, `synced` by default.
51
51
  * - `existing` → bind an already-minted content_instance (reuse-first
52
52
  * per CLAUDE.md §1A) — the batched form of `set_placement_content`.
@@ -94,7 +94,7 @@ export type BuildPageContent = z.infer<typeof buildPageContentSchema>;
94
94
  * extra authoring keys are TOLERATED (§1A/§11 — a placement that can
95
95
  * succeed must not fail over an extra field) but NOT applied; the op
96
96
  * surfaces an info naming any carried field whose value differs from the
97
- * 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).
98
98
  *
99
99
  * The element-level superRefine reports issues WITH the element's array
100
100
  * index in the Zod path, so a mode error fails as `modules[3]: …`.
@@ -159,9 +159,9 @@ export const buildPageModuleSchema = z
159
159
  description: z.string().max(1000).optional(),
160
160
  kind: moduleKindSchema.optional(),
161
161
  type: slugSchema.optional(),
162
- html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX)).optional(),
163
- css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
164
- 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(),
165
165
  fields: z.array(moduleFieldSchema).max(64).optional(),
166
166
  /** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
167
167
  bindThemeLiterals: z.boolean().optional(),
@@ -192,7 +192,7 @@ export const buildPageModuleSchema = z
192
192
  // (build_page places; it does not re-author a shared module). To avoid a
193
193
  // SILENT dropped change (§2), the HANDLER surfaces an INFO in the result
194
194
  // naming any ignored authoring field whose value DIFFERS from the module's
195
- // 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
196
196
  // DB), so it just allows the entry through.
197
197
  if (entry.moduleId !== undefined) return;
198
198
  if (entry.displayName === undefined || entry.html === undefined) {
@@ -220,7 +220,7 @@ export const buildPageTargetSchema = z
220
220
  pageId: z.string().uuid().optional(),
221
221
  slug: slugSchema.optional(),
222
222
  title: z.string().min(1).max(256).optional(),
223
- name: z.string().min(1).max(256).optional(),
223
+ shortTitle: pageCreateSchema.shape.shortTitle,
224
224
  templateId: z.string().uuid().optional(),
225
225
  status: pageStatusSchema.optional(),
226
226
  /**
@@ -268,7 +268,7 @@ export const buildPageTargetSchema = z
268
268
  [
269
269
  "slug",
270
270
  "title",
271
- "name",
271
+ "shortTitle",
272
272
  "templateId",
273
273
  "status",
274
274
  "parentPageId",
@@ -91,7 +91,32 @@ describe("moduleCreateSchema", () => {
91
91
  expect(moduleCreateSchema.safeParse({ ...base, js: 'xml += "]]>";' }).success).toBe(true);
92
92
  });
93
93
 
94
- it("rejects CDATA on the update path too (edit_module / edit_content funnel)", () => {
94
+ // e2e-livedit run 40532297620 — html / css whose line breaks arrived as
95
+ // literal `\n` sequences rendered `\n` text and broke the CSS. AI tool
96
+ // arguments are decoded first (`decodeEscapedLineBreaks`); every other
97
+ // writer is refused with the fix.
98
+ it("rejects html and css whose line breaks are escaped, with the fix", () => {
99
+ const base = { slug: "site-footer", displayName: "Site footer", html: "<footer></footer>" };
100
+ const html = moduleCreateSchema.safeParse({
101
+ ...base,
102
+ html: '<footer class="f">\\n <nav>\\n <a href="/">Home</a>\\n </nav>\\n</footer>',
103
+ });
104
+ expect(html.success).toBe(false);
105
+ if (!html.success) {
106
+ expect(html.error.issues.map((i) => i.message).join(" ")).toContain("real line breaks");
107
+ }
108
+ const css = moduleUpdateSchema.safeParse({
109
+ moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
110
+ css: ".f {\\n padding: 2rem;\\n}\\n.f a {\\n color: inherit;\\n}",
111
+ });
112
+ expect(css.success).toBe(false);
113
+ // One escaped newline inside a CSS string on a single line is not the pattern.
114
+ expect(
115
+ moduleCreateSchema.safeParse({ ...base, css: '.f::after { content: "\\n " }' }).success,
116
+ ).toBe(true);
117
+ });
118
+
119
+ it("rejects CDATA on the update path too (update_modules_many / edit_content funnel)", () => {
95
120
  const r = moduleUpdateSchema.safeParse({
96
121
  moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
97
122
  css: "/*<![CDATA[*/ .x{} /*]]>*/",
package/src/content.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { z } from "zod";
14
+ import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
14
15
  import { listItemInputSchema } from "./lists/items.js";
15
16
  import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
16
17
  import { LIST_REF_RE } from "./lists/refs.js";
@@ -126,14 +127,34 @@ export function htmlEscapedMarkupMessage(field: string, value: string): string |
126
127
  return `${field} is HTML-escaped (&lt;…&gt; instead of <…>) — send the markup itself, unescaped.`;
127
128
  }
128
129
 
129
- const noCorruptMarkup = (field: string) => (value: string, ctx: z.RefinementCtx) => {
130
- const message = jsonEncodedCodeMessage(field, value) ?? htmlEscapedMarkupMessage(field, value);
130
+ /**
131
+ * See {@link jsonEncodedCodeMessage}: source whose line breaks arrived as
132
+ * literal `\n` sequences (`hasEscapedLineBreaks`) — the page shows the
133
+ * `\n` text and the CSS rules after it break. AI tool arguments are decoded
134
+ * first (`decodeEscapedLineBreaks`); this guards every other writer. Module
135
+ * js is checked by parsing it (admin-core `module-js-syntax.ts`), which
136
+ * catches this and every other syntax error.
137
+ */
138
+ export function escapedLineBreaksMessage(field: "html" | "css", value: string): string | null {
139
+ if (!hasEscapedLineBreaks(value, field)) return null;
140
+ return `${field} has its line breaks escaped (literal \\n sequences, no real line break) — send the ${field} with real line breaks.`;
141
+ }
142
+
143
+ const noCorruptMarkup = (field: "html") => (value: string, ctx: z.RefinementCtx) => {
144
+ const message =
145
+ jsonEncodedCodeMessage(field, value) ??
146
+ htmlEscapedMarkupMessage(field, value) ??
147
+ escapedLineBreaksMessage(field, value);
131
148
  if (message) ctx.addIssue({ code: "custom", message });
132
149
  };
133
150
  const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
134
151
  const message = jsonEncodedCodeMessage(field, value);
135
152
  if (message) ctx.addIssue({ code: "custom", message });
136
153
  };
154
+ const noCorruptCss = (value: string, ctx: z.RefinementCtx) => {
155
+ const message = jsonEncodedCodeMessage("css", value) ?? escapedLineBreaksMessage("css", value);
156
+ if (message) ctx.addIssue({ code: "custom", message });
157
+ };
137
158
 
138
159
  const moduleHtml = z
139
160
  .string()
@@ -168,7 +189,7 @@ const moduleCss = z
168
189
  .string()
169
190
  .max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
170
191
  .refine(noCdata, cdataMessage("css"))
171
- .superRefine(noJsonEncodedCode("css"));
192
+ .superRefine(noCorruptCss);
172
193
  const moduleJs = z
173
194
  .string()
174
195
  .max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
@@ -276,7 +297,7 @@ const moduleFieldModuleSchema = z
276
297
  * stable `modules.type` class, e.g. `button` — NOT the unique
277
298
  * `modules.slug` like `button-mpqxq3ch`). When absent, any module is
278
299
  * permitted. The op-layer validator enforces the whitelist against
279
- * the referenced module's `type` at `set_content_instance_values`
300
+ * the referenced module's `type` at `content_instances.set_values`
280
301
  * time. v0.12.3 (issue #106) renamed this from `allowedModuleSlugs`:
281
302
  * an exact-slug whitelist could never match an AI-minted module
282
303
  * because every minted slug carries a uniqueness suffix.
@@ -491,7 +512,7 @@ export type ModuleKind = (typeof MODULE_KINDS)[number];
491
512
  /**
492
513
  * v0.12.0 — description is `.default("")` at the Zod boundary so the
493
514
  * 82+ legacy callers in tests/seed scripts keep working unchanged.
494
- * AI tool descriptions (create_module / edit_module) require the AI
515
+ * AI tool descriptions (add_module / build_page / update_modules_many) require the AI
495
516
  * to supply a real description explicitly — see CLAUDE.md §1A.
496
517
  */
497
518
  const moduleDescription = z.string().max(1000);
@@ -627,6 +648,15 @@ export const templateBlocksSetSchema = z
627
648
 
628
649
  export const pageStatusSchema = z.enum(["draft", "published"]);
629
650
 
651
+ /** `pages.short_title` (#1142): a crumb-sized public label, never blank. */
652
+ export const pageShortTitleSchema = z
653
+ .string()
654
+ .trim()
655
+ .min(1, "the short title is the page's breadcrumb label and cannot be blank")
656
+ // Same bound as the title it is backfilled and defaulted from, so every
657
+ // stored page stays editable; keeping it crumb-sized is guidance.
658
+ .max(256);
659
+
630
660
  /**
631
661
  * Page payloads are `.strict()` — passing an `html` field is rejected by Zod
632
662
  * before the handler runs. This is the §3.1 "no raw HTML on pages" invariant
@@ -636,9 +666,13 @@ export const pageStatusSchema = z.enum(["draft", "published"]);
636
666
  export const pageCreateSchema = z
637
667
  .object({
638
668
  slug: slugSchema,
639
- /** P6.7.5 — internal editor label. Defaults to title if omitted. */
640
- name: z.string().min(1).max(256).optional(),
641
669
  title: z.string().min(1).max(256),
670
+ /**
671
+ * #1142 — the page's public label in breadcrumbs, navigation and the
672
+ * page picker ("Pricing" for "Pricing for teams of every size").
673
+ * Omitted = the create-time resolver stores `title` (stored data).
674
+ */
675
+ shortTitle: pageShortTitleSchema.optional(),
642
676
  /**
643
677
  * P6.7.6 — optional. When omitted, the handler resolves to
644
678
  * `site_defaults.default_template_id` at create time. Stored data,
@@ -703,9 +737,9 @@ export const pageUpdateSchema = z
703
737
  * Routes that load the page first should always pass it back.
704
738
  */
705
739
  expectedVersion: z.number().int().nonnegative().optional(),
706
- /** P6.7.5 — three independently-editable identifiers. */
707
- name: z.string().min(1).max(256).optional(),
708
740
  title: z.string().min(1).max(256).optional(),
741
+ /** #1142 — the breadcrumb / navigation label; see {@link pageCreateSchema}. */
742
+ shortTitle: pageShortTitleSchema.optional(),
709
743
  slug: slugSchema.optional(),
710
744
  templateId: z.string().uuid().optional(),
711
745
  status: pageStatusSchema.optional(),
@@ -780,10 +814,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
780
814
 
781
815
  /**
782
816
  * v0.12.0 — why this row exists as a shared/reusable instance.
783
- * Surfaced in the `## Content Library` system-prompt block so the AI
784
- * can decide *reuse the synced row* vs *fork to unsynced* vs *mint
785
- * new* without a tool round-trip. Required by the AI's
786
- * create_content_instance tool description; legacy callers may pass
817
+ * Shown by the AI's list_content_instances tool so the AI can decide
818
+ * *reuse the synced row* vs *fork to unsynced* vs *mint new* without
819
+ * asking the operator. Required by the AI's
820
+ * create_content_instances tool description; legacy callers may pass
787
821
  * null (the migration-0093 unsynced rows have purpose=NULL).
788
822
  * See CLAUDE.md §1A.
789
823
  */
@@ -7,7 +7,7 @@
7
7
  * that is not tied to the module bleeds into every other module on the
8
8
  * page: an `h1 {}` in the hero restyles the pricing table's heading, a
9
9
  * `.card {}` in one module restyles another module's cards. The
10
- * convention (edit_module description, design-quality skill) is to nest
10
+ * convention (update_modules_many description, design-quality skill) is to nest
11
11
  * module CSS under the module's own root class; this scanner is the
12
12
  * mechanical write-time check that rides the authoring tool's result,
13
13
  * so the AI fixes the selector in the same turn. A warning, not a gate: