@caelo-cms/shared 0.10.21 → 0.10.23

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 (241) hide show
  1. package/dist/ai-tools.d.ts +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P6.6 closing pass — Zod schemas for the auth-side forms (setup,
5
+ * login). Lives in @caelo-cms/shared so the SvelteKit route's inline
6
+ * client-side validation helper (`bindZodForm`) and the server-side
7
+ * `users.create_first_owner` / `auth.login` handlers can both
8
+ * consume the same source-of-truth.
9
+ *
10
+ * Kept separate from `content.ts` because auth shape is independent
11
+ * of content-layer evolution; bumping a min-password requirement here
12
+ * shouldn't churn the page schemas.
13
+ */
14
+
15
+ import { z } from "zod";
16
+
17
+ export const setupFormSchema = z
18
+ .object({
19
+ displayName: z.string().min(1, "required").max(128),
20
+ email: z.string().email("must be a valid email").max(254),
21
+ // Client-side floor mirrors the server strength policy's length rule
22
+ // (validatePasswordStrength / MIN_PASSWORD_LENGTH); the server also checks
23
+ // common-list, sequences and personal-info and returns the real reason.
24
+ password: z.string().min(10, "at least 10 characters").max(256),
25
+ })
26
+ .strict();
27
+
28
+ export const loginFormSchema = z
29
+ .object({
30
+ email: z.string().email("must be a valid email").max(254),
31
+ password: z.string().min(1, "required").max(256),
32
+ })
33
+ .strict();
34
+
35
+ export type SetupFormInput = z.infer<typeof setupFormSchema>;
36
+ export type LoginFormInput = z.infer<typeof loginFormSchema>;
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #151 (re-scoped, epic #149) — the INVISIBLE technical baseline.
5
+ *
6
+ * Deliberately carries ZERO visible design opinion: no type scale, no
7
+ * colors, no element styling — every visible default is compiled per
8
+ * site from that site's chosen design (#164), because a global look
9
+ * would homogenize Caelo sites (the operator explicitly rejected that).
10
+ *
11
+ * What remains is the technical floor every hand-built page and every
12
+ * Genesis draft silently assumes, and whose absence produces the
13
+ * "subtly broken" rendering class from the epic review:
14
+ *
15
+ * - border-box sizing (the universal expectation since ~2013);
16
+ * - no default body margin (the 8px UA gutter breaks full-bleed
17
+ * heroes on every design);
18
+ * - media elements can't overflow their container (mobile-first
19
+ * drafts assume it);
20
+ * - form controls inherit the page's font instead of UA chrome.
21
+ *
22
+ * Injected as `<style data-source="base">` between the theme vars and
23
+ * the aggregated module CSS, so any module rule overrides it trivially.
24
+ */
25
+
26
+ export const BASE_TECHNICAL_CSS =
27
+ "*,*::before,*::after{box-sizing:border-box}" +
28
+ "body{margin:0}" +
29
+ "img,picture,video,canvas,svg{display:block;max-width:100%}" +
30
+ "input,button,textarea,select{font:inherit}";
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #299 — input-schema tests for the bulk build path. The contract
5
+ * under test: invalid entries fail LOUD with the failing element's index
6
+ * in the Zod path (so the dispatcher's error names `modules[i]` /
7
+ * `instances[i]`), and the mode gates (page: pageId XOR slug+title;
8
+ * module: moduleId XOR displayName+html) reject ambiguous calls.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import {
13
+ buildPageContentSchema,
14
+ buildPageInputSchema,
15
+ contentInstancesCreateManySchema,
16
+ pageModuleContentSetManySchema,
17
+ } from "./build-page.js";
18
+
19
+ // Zod 4's .uuid() enforces RFC 4122 version/variant bits — use real
20
+ // v4-shaped constants, not sequential zero-padded strings.
21
+ const UUID = "11111111-1111-4111-8111-111111111111";
22
+ const UUID2 = "22222222-2222-4222-8222-222222222222";
23
+
24
+ const mintModule = {
25
+ blockName: "content",
26
+ displayName: "Hero",
27
+ html: "<section><h1>{{hero_title}}</h1></section>",
28
+ description: "Homepage hero",
29
+ kind: "hero",
30
+ fields: [{ name: "hero_title", kind: "text", label: "Hero title" }],
31
+ } as const;
32
+
33
+ describe("buildPageInputSchema — page target modes", () => {
34
+ it("accepts create mode (slug + title) with defaults applied downstream", () => {
35
+ const r = buildPageInputSchema.safeParse({
36
+ page: { slug: "pricing", title: "Pricing" },
37
+ modules: [mintModule],
38
+ });
39
+ expect(r.success).toBe(true);
40
+ });
41
+
42
+ it("accepts existing mode (pageId only)", () => {
43
+ const r = buildPageInputSchema.safeParse({
44
+ page: { pageId: UUID },
45
+ modules: [{ blockName: "content", moduleId: UUID2 }],
46
+ });
47
+ expect(r.success).toBe(true);
48
+ });
49
+
50
+ it("rejects pageId mixed with create keys, naming the offending keys", () => {
51
+ const r = buildPageInputSchema.safeParse({
52
+ page: { pageId: UUID, slug: "pricing", title: "Pricing" },
53
+ modules: [mintModule],
54
+ });
55
+ expect(r.success).toBe(false);
56
+ if (r.success) return;
57
+ const issue = r.error.issues[0]!;
58
+ expect(issue.message).toContain("pageId targets an EXISTING page");
59
+ expect(issue.message).toContain("slug");
60
+ expect(issue.message).toContain("title");
61
+ });
62
+
63
+ it("rejects a page target with neither pageId nor slug+title", () => {
64
+ const r = buildPageInputSchema.safeParse({
65
+ page: { slug: "pricing" },
66
+ modules: [mintModule],
67
+ });
68
+ expect(r.success).toBe(false);
69
+ if (r.success) return;
70
+ expect(r.error.issues[0]!.message).toContain("`slug` + `title`");
71
+ });
72
+ });
73
+
74
+ describe("buildPageInputSchema — module entry modes name the failing index", () => {
75
+ it("TOLERATES a moduleId entry carrying authoring keys (incl. structural) — placement-only, handler surfaces the ignored-authoring info", () => {
76
+ // §1A/§11 — a placement that CAN succeed must never fail over an extra
77
+ // field. moduleId + html + displayName is a valid placement; the handler
78
+ // (not the schema) reports which carried fields were not applied.
79
+ const r = buildPageInputSchema.safeParse({
80
+ page: { slug: "pricing", title: "Pricing" },
81
+ modules: [
82
+ mintModule,
83
+ { blockName: "content", moduleId: UUID2, html: "<p>x</p>", displayName: "Dup" },
84
+ ],
85
+ });
86
+ expect(r.success).toBe(true);
87
+ });
88
+
89
+ it("TOLERATES a moduleId entry carrying only metadata (displayName) — the handler ignores it", () => {
90
+ const r = buildPageInputSchema.safeParse({
91
+ page: { slug: "pricing", title: "Pricing" },
92
+ modules: [{ blockName: "content", moduleId: UUID2, displayName: "Site Header" }],
93
+ });
94
+ expect(r.success).toBe(true);
95
+ });
96
+
97
+ it("rejects an entry with neither moduleId nor displayName+html, at its index", () => {
98
+ const r = buildPageInputSchema.safeParse({
99
+ page: { slug: "pricing", title: "Pricing" },
100
+ modules: [mintModule, mintModule, { blockName: "content" }],
101
+ });
102
+ expect(r.success).toBe(false);
103
+ if (r.success) return;
104
+ const issue = r.error.issues.find((i) => i.message.includes("Pass either `moduleId`"));
105
+ expect(issue).toBeDefined();
106
+ expect(issue!.path[0]).toBe("modules");
107
+ expect(issue!.path[1]).toBe(2);
108
+ });
109
+
110
+ it("list-shaped field kinds are representable (CLAUDE.md §1A — no numbered scalars)", () => {
111
+ const r = buildPageInputSchema.safeParse({
112
+ page: { slug: "p", title: "P" },
113
+ modules: [
114
+ {
115
+ blockName: "content",
116
+ displayName: "Nav",
117
+ html: "<nav>{{#nav_links}}<a href='{{href}}'>{{label}}</a>{{/nav_links}}</nav>",
118
+ fields: [
119
+ { name: "nav_links", kind: "link-list", label: "Nav links" },
120
+ { name: "tags", kind: "text-list", label: "Tags", min: 0, max: 12 },
121
+ ],
122
+ },
123
+ ],
124
+ });
125
+ expect(r.success).toBe(true);
126
+ });
127
+
128
+ it("caps the batch at 40 modules", () => {
129
+ const r = buildPageInputSchema.safeParse({
130
+ page: { slug: "p", title: "P" },
131
+ modules: Array.from({ length: 41 }, () => mintModule),
132
+ });
133
+ expect(r.success).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("buildPageContentSchema — the three sources", () => {
138
+ it("inline requires values", () => {
139
+ expect(buildPageContentSchema.safeParse({ source: "inline" }).success).toBe(false);
140
+ expect(
141
+ buildPageContentSchema.safeParse({ source: "inline", values: { hero_title: "x" } }).success,
142
+ ).toBe(true);
143
+ });
144
+
145
+ it("shared requires purpose and defaults syncMode to synced", () => {
146
+ const missing = buildPageContentSchema.safeParse({ source: "shared", values: {} });
147
+ expect(missing.success).toBe(false);
148
+ const r = buildPageContentSchema.safeParse({
149
+ source: "shared",
150
+ purpose: "Footer CTA shared across product pages",
151
+ values: { cta_label: "Go" },
152
+ });
153
+ expect(r.success).toBe(true);
154
+ if (!r.success || r.data.source !== "shared") return;
155
+ expect(r.data.syncMode).toBe("synced");
156
+ });
157
+
158
+ it("existing requires contentInstanceId and defaults syncMode to synced", () => {
159
+ const r = buildPageContentSchema.safeParse({ source: "existing", contentInstanceId: UUID });
160
+ expect(r.success).toBe(true);
161
+ if (!r.success || r.data.source !== "existing") return;
162
+ expect(r.data.syncMode).toBe("synced");
163
+ });
164
+
165
+ it("rejects cross-variant keys (purpose on inline)", () => {
166
+ const r = buildPageContentSchema.safeParse({
167
+ source: "inline",
168
+ values: {},
169
+ purpose: "nope",
170
+ });
171
+ expect(r.success).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe("contentInstancesCreateManySchema", () => {
176
+ it("accepts a batch of singular-shaped items", () => {
177
+ const r = contentInstancesCreateManySchema.safeParse({
178
+ instances: [
179
+ { moduleId: UUID, values: { a: 1 } },
180
+ { moduleId: UUID2, purpose: "shared cta", values: {} },
181
+ ],
182
+ });
183
+ expect(r.success).toBe(true);
184
+ });
185
+
186
+ it("names the failing item index in the Zod path", () => {
187
+ const r = contentInstancesCreateManySchema.safeParse({
188
+ instances: [{ moduleId: UUID }, { moduleId: "not-a-uuid" }],
189
+ });
190
+ expect(r.success).toBe(false);
191
+ if (r.success) return;
192
+ const issue = r.error.issues[0]!;
193
+ expect(issue.path[0]).toBe("instances");
194
+ expect(issue.path[1]).toBe(1);
195
+ expect(issue.path[2]).toBe("moduleId");
196
+ });
197
+
198
+ it("rejects an empty batch", () => {
199
+ expect(contentInstancesCreateManySchema.safeParse({ instances: [] }).success).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("pageModuleContentSetManySchema", () => {
204
+ it("accepts multi-page batches", () => {
205
+ const r = pageModuleContentSetManySchema.safeParse({
206
+ items: [
207
+ { pageId: UUID, blockName: "content", position: 0, contentValues: { t: "x" } },
208
+ { pageId: UUID2, blockName: "content", position: 3, contentValues: {} },
209
+ ],
210
+ });
211
+ expect(r.success).toBe(true);
212
+ });
213
+
214
+ it("names the failing item index + field in the Zod path", () => {
215
+ const r = pageModuleContentSetManySchema.safeParse({
216
+ items: [
217
+ { pageId: UUID, blockName: "content", position: 0, contentValues: {} },
218
+ { pageId: UUID, blockName: "content", position: -1, contentValues: {} },
219
+ ],
220
+ });
221
+ expect(r.success).toBe(false);
222
+ if (r.success) return;
223
+ const issue = r.error.issues[0]!;
224
+ expect(issue.path[0]).toBe("items");
225
+ expect(issue.path[1]).toBe(1);
226
+ expect(issue.path[2]).toBe("position");
227
+ });
228
+ });
@@ -0,0 +1,319 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #299 — bulk build-path schemas (CLAUDE.md §11 bulk-first).
5
+ *
6
+ * Run #15 telemetry showed the AI assembling ~14 pages through ~100
7
+ * singular round-trips (36× add_module_to_page, 29× set_page_module_content,
8
+ * 9× create_content_instance, 8× create_page) at 110K–556K input tokens
9
+ * per call. These schemas back the three ops that collapse that chain:
10
+ *
11
+ * - `pages.build_page` — ONE call: page (new or existing)
12
+ * + ordered modules + content instances + placements, one transaction.
13
+ * - `content_instances.create_many` — batch instance minting.
14
+ * - `page_module_content.set_many` — batch content fill on existing
15
+ * placements.
16
+ *
17
+ * All three are all-or-nothing: any validation error aborts the whole
18
+ * call with a message naming the failing element index (and field where
19
+ * one is involved), so partial failure is impossible (§11).
20
+ */
21
+
22
+ import { z } from "zod";
23
+ import {
24
+ contentInstanceCreateSchema,
25
+ localeSchema,
26
+ MODULE_CSS_MAX,
27
+ MODULE_HTML_MAX,
28
+ MODULE_JS_MAX,
29
+ moduleFieldSchema,
30
+ moduleKindSchema,
31
+ pageStatusSchema,
32
+ slugSchema,
33
+ syncModeSchema,
34
+ } from "./content.js";
35
+
36
+ /** Content values keyed by the module's declared field names. */
37
+ const contentValuesSchema = z.record(z.string(), z.unknown());
38
+
39
+ /**
40
+ * Per-module content payload — a discriminated union mirroring the three
41
+ * existing content paths so build_page adds NO new semantics, only batching:
42
+ *
43
+ * - `inline` → mint a private (unsynced) content_instance carrying
44
+ * `values`, exactly what `set_page_module_content` produces for a
45
+ * fresh placement.
46
+ * - `shared` → mint a reusable content_instance (purpose required —
47
+ * same decision-support contract as `create_content_instance`) and
48
+ * bind this placement to it, `synced` by default.
49
+ * - `existing` → bind an already-minted content_instance (reuse-first
50
+ * per CLAUDE.md §1A) — the batched form of `set_placement_content`.
51
+ *
52
+ * Omitting `content` mints an empty unsynced instance, matching what
53
+ * `pages.set_modules` does for a net-new placement today.
54
+ */
55
+ export const buildPageContentSchema = z.discriminatedUnion("source", [
56
+ z
57
+ .object({
58
+ source: z.literal("inline"),
59
+ values: contentValuesSchema,
60
+ })
61
+ .strict(),
62
+ z
63
+ .object({
64
+ source: z.literal("shared"),
65
+ values: contentValuesSchema.default({}),
66
+ /** Why this row exists as a shared instance — see CLAUDE.md §1A. */
67
+ purpose: z.string().min(1).max(1000),
68
+ slug: slugSchema.optional(),
69
+ displayName: z.string().min(1).max(128).optional(),
70
+ syncMode: syncModeSchema.default("synced"),
71
+ })
72
+ .strict(),
73
+ z
74
+ .object({
75
+ source: z.literal("existing"),
76
+ contentInstanceId: z.string().uuid(),
77
+ syncMode: syncModeSchema.default("synced"),
78
+ })
79
+ .strict(),
80
+ ]);
81
+ export type BuildPageContent = z.infer<typeof buildPageContentSchema>;
82
+
83
+ /**
84
+ * One module entry in a build_page call. Two modes per entry, identical
85
+ * to `add_module_to_page` (issue #159):
86
+ *
87
+ * - **Mint mode** — `displayName` + `html` (+ `fields`, `description`,
88
+ * `kind`, `type`, `css`, `js`). The op creates the module through the
89
+ * same `modules.create` path (extractor fallback, type derivation,
90
+ * snapshot) the singular tool uses.
91
+ * - **Place mode** — `moduleId` of an existing module. Placement-only:
92
+ * extra authoring keys are TOLERATED (§1A/§11 — a placement that can
93
+ * succeed must not fail over an extra field) but NOT applied; the op
94
+ * surfaces an info naming any carried field whose value differs from the
95
+ * module's stored one, pointing at edit_module (§2 — no silent drop).
96
+ *
97
+ * The element-level superRefine reports issues WITH the element's array
98
+ * index in the Zod path, so a mode error fails as `modules[3]: …`.
99
+ */
100
+ export const buildPageModuleSchema = z
101
+ .object({
102
+ /**
103
+ * Template block to place into — must exist on the page's template.
104
+ * OMIT for a DETACHED entry: the module + its content_instance are
105
+ * created but NOT placed on the page — used for nested-only modules
106
+ * that later entries embed via `{"$ref": "<ref>"}` in a
107
+ * module / module-list field value. A detached entry requires `ref`.
108
+ */
109
+ blockName: slugSchema.optional(),
110
+ /**
111
+ * Local handle other entries in the SAME call can reference: a
112
+ * module/module-list field value of `{"$ref": "<ref>"}` resolves to
113
+ * this entry's `{moduleId, contentInstanceId}`. Entries resolve in
114
+ * array order, so referenced entries must come FIRST.
115
+ */
116
+ ref: z
117
+ .string()
118
+ .regex(/^[a-z][a-z0-9_-]{0,31}$/, "ref must be a short lowercase handle")
119
+ .optional(),
120
+ /**
121
+ * Place mode: an existing module from `## Modules` (UUID), or a
122
+ * module minted EARLIER IN THIS CALL via `{"$ref": "<handle>"}` —
123
+ * the second-placement case (e.g. three feature cards reusing one
124
+ * card module). Live-edit run A showed the model writing
125
+ * `moduleId: "$feat1"` unprompted; the union makes that intent
126
+ * expressible instead of a validation dead-end.
127
+ */
128
+ moduleId: z
129
+ .union([
130
+ z
131
+ .string()
132
+ .uuid(
133
+ 'moduleId must be a UUID from ## Modules — to re-place a module minted earlier in THIS call, pass {"$ref": "<its ref>"} instead',
134
+ ),
135
+ z.object({ $ref: z.string() }).strict(),
136
+ ])
137
+ .optional(),
138
+ /** Mint mode: authoring surface, mirrors add_module_to_page. */
139
+ displayName: z.string().min(1).max(128).optional(),
140
+ description: z.string().max(1000).optional(),
141
+ kind: moduleKindSchema.optional(),
142
+ type: slugSchema.optional(),
143
+ html: z.string().min(1).max(MODULE_HTML_MAX).optional(),
144
+ css: z.string().max(MODULE_CSS_MAX).optional(),
145
+ js: z.string().max(MODULE_JS_MAX).optional(),
146
+ fields: z.array(moduleFieldSchema).max(64).optional(),
147
+ /** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
148
+ bindThemeLiterals: z.boolean().optional(),
149
+ content: buildPageContentSchema.optional(),
150
+ })
151
+ .strict()
152
+ .superRefine((entry, ctx) => {
153
+ if (entry.blockName === undefined && entry.ref === undefined) {
154
+ ctx.addIssue({
155
+ code: z.ZodIssueCode.custom,
156
+ message:
157
+ "An entry without `blockName` is a DETACHED (nested-only) module and requires `ref` " +
158
+ 'so a later entry can embed it via {"$ref": "<ref>"} — otherwise it would be unreachable. ' +
159
+ "Pass `blockName` to place it on the page instead.",
160
+ });
161
+ }
162
+ // `moduleId` is PLACEMENT-ONLY: a placement that CAN succeed must never
163
+ // fail over an extra field (§1A/§11), so we do NOT reject a moduleId entry
164
+ // that also carries authoring fields. But those fields are NOT applied here
165
+ // (build_page places; it does not re-author a shared module). To avoid a
166
+ // SILENT dropped change (§2), the HANDLER surfaces an INFO in the result
167
+ // naming any ignored authoring field whose value DIFFERS from the module's
168
+ // stored one, and points at edit_module. The schema can't do that check (no
169
+ // DB), so it just allows the entry through.
170
+ if (entry.moduleId !== undefined) return;
171
+ if (entry.displayName === undefined || entry.html === undefined) {
172
+ ctx.addIssue({
173
+ code: z.ZodIssueCode.custom,
174
+ message:
175
+ "Pass either `moduleId` (place an existing module from ## Modules) " +
176
+ "or `displayName` + `html` (mint a new module).",
177
+ });
178
+ }
179
+ });
180
+ export type BuildPageModule = z.infer<typeof buildPageModuleSchema>;
181
+
182
+ /**
183
+ * Target page — exactly one of two shapes:
184
+ *
185
+ * - `{ pageId }` → build onto an existing page
186
+ * (modules are APPENDED to the named blocks in listed order).
187
+ * - `{ slug, title, … }` → create the page first (same
188
+ * resolution rules as `pages.create`: templateId optional when
189
+ * site_defaults carries a default).
190
+ */
191
+ export const buildPageTargetSchema = z
192
+ .object({
193
+ pageId: z.string().uuid().optional(),
194
+ slug: slugSchema.optional(),
195
+ title: z.string().min(1).max(256).optional(),
196
+ name: z.string().min(1).max(256).optional(),
197
+ locale: localeSchema.optional(),
198
+ templateId: z.string().uuid().optional(),
199
+ status: pageStatusSchema.optional(),
200
+ /**
201
+ * Migration linkage (issue #278 flow): the `import_pages` row this
202
+ * page rebuilds. The op stamps `import_pages.accepted_page_id` with
203
+ * the built page's id so fidelity / inventory / media-migration reads
204
+ * resolve via that pointer regardless of slug. It ALSO makes the
205
+ * build idempotent — a second build_page for the same importPageId
206
+ * REBUILDS the already-linked page instead of minting a duplicate.
207
+ * Compatible with either target shape (create OR existing pageId).
208
+ */
209
+ importPageId: z.string().uuid().optional(),
210
+ })
211
+ .strict()
212
+ .superRefine((page, ctx) => {
213
+ const createKeys = (
214
+ ["slug", "title", "name", "locale", "templateId", "status"] as const
215
+ ).filter((k) => page[k] !== undefined);
216
+ if (page.pageId !== undefined) {
217
+ if (createKeys.length > 0) {
218
+ ctx.addIssue({
219
+ code: z.ZodIssueCode.custom,
220
+ message:
221
+ `pageId targets an EXISTING page — drop ${createKeys.join(", ")}. ` +
222
+ "To create a new page instead, omit pageId and pass slug + title.",
223
+ });
224
+ }
225
+ return;
226
+ }
227
+ if (page.slug === undefined || page.title === undefined) {
228
+ ctx.addIssue({
229
+ code: z.ZodIssueCode.custom,
230
+ message:
231
+ "Pass either `pageId` (build onto an existing page) or `slug` + `title` (create the page in the same call).",
232
+ });
233
+ }
234
+ });
235
+ export type BuildPageTarget = z.infer<typeof buildPageTargetSchema>;
236
+
237
+ /** `pages.build_page` op input. */
238
+ export const buildPageInputSchema = z
239
+ .object({
240
+ page: buildPageTargetSchema,
241
+ // min 0: build_page is the SINGLE page-creation tool. An empty modules
242
+ // array creates an intentionally empty page shell (the case that used to
243
+ // need the now-removed create_page); a populated array builds the whole
244
+ // page in one transaction (the common case).
245
+ modules: z.array(buildPageModuleSchema).max(40),
246
+ })
247
+ .strict()
248
+ .superRefine((input, ctx) => {
249
+ // `ref` handles must be unique — a duplicate would silently shadow
250
+ // the earlier entry when a later {"$ref"} resolves.
251
+ const seen = new Set<string>();
252
+ for (const [i, entry] of input.modules.entries()) {
253
+ if (entry.ref === undefined) continue;
254
+ if (seen.has(entry.ref)) {
255
+ ctx.addIssue({
256
+ code: z.ZodIssueCode.custom,
257
+ path: ["modules", i, "ref"],
258
+ message: `duplicate ref "${entry.ref}" — each ref handle must be unique within the call`,
259
+ });
260
+ }
261
+ seen.add(entry.ref);
262
+ }
263
+ });
264
+ export type BuildPageInput = z.infer<typeof buildPageInputSchema>;
265
+
266
+ /** `pages.build_page` op output — one row per placed module, in input order. */
267
+ export const buildPagePlacementResultSchema = z.object({
268
+ blockName: z.string(),
269
+ position: z.number().int().nonnegative(),
270
+ moduleId: z.string(),
271
+ contentInstanceId: z.string(),
272
+ syncMode: syncModeSchema,
273
+ /** True when this call minted the module (mint mode). */
274
+ minted: z.boolean(),
275
+ });
276
+ export type BuildPagePlacementResult = z.infer<typeof buildPagePlacementResultSchema>;
277
+
278
+ // ─── content_instances.create_many ───────────────────────────────────
279
+
280
+ /**
281
+ * Batch form of `content_instances.create`. Each item is the exact
282
+ * singular input; the handler runs the singular path per item inside
283
+ * ONE transaction (all-or-nothing — a failure at index i rolls back
284
+ * items 0..i-1 and the error names `instances[i]`).
285
+ */
286
+ export const contentInstancesCreateManySchema = z
287
+ .object({
288
+ instances: z.array(contentInstanceCreateSchema).min(1).max(100),
289
+ })
290
+ .strict();
291
+ export type ContentInstancesCreateManyInput = z.infer<typeof contentInstancesCreateManySchema>;
292
+
293
+ // ─── page_module_content.set_many ────────────────────────────────────
294
+
295
+ /**
296
+ * Batch form of `page_module_content.set` — a content-only pass over N
297
+ * existing placements (typically one page's worth, but cross-page items
298
+ * are fine). All-or-nothing in one transaction; failures name `items[i]`
299
+ * plus the placement coordinates and, for value-shape errors, the field.
300
+ */
301
+ export const pageModuleContentSetManySchema = z
302
+ .object({
303
+ items: z
304
+ .array(
305
+ z
306
+ .object({
307
+ pageId: z.string().uuid(),
308
+ blockName: z.string().min(1).max(80),
309
+ position: z.number().int().nonnegative(),
310
+ /** Keyed by module field name. Fully replaces existing values. */
311
+ contentValues: contentValuesSchema,
312
+ })
313
+ .strict(),
314
+ )
315
+ .min(1)
316
+ .max(100),
317
+ })
318
+ .strict();
319
+ export type PageModuleContentSetManyInput = z.infer<typeof pageModuleContentSetManySchema>;
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P16 hardening — track consecutive cap-lookup failures so a flaky DB
5
+ * query doesn't silently disable AI cost enforcement.
6
+ *
7
+ * The plugin-host's `ctx.ai.complete` and chat-runner's daily-budget
8
+ * pre-flight both consult `ai_calls.aggregate_per_plugin` /
9
+ * `ai_budgets.status` before dispatching to the provider. Today a thrown
10
+ * error there is swallowed (so a working plugin doesn't break on a DB
11
+ * hiccup). That's the right default — but unbounded silent retries =
12
+ * "no enforcement at all under sustained DB pressure".
13
+ *
14
+ * The counter trips a per-key fail-closed mode after `LOOKUP_FAIL_THRESHOLD`
15
+ * consecutive misses; the next call is blocked with a structured error,
16
+ * the trip is reset on the first successful lookup. A success after one
17
+ * miss does NOT trip — only a sustained failure does.
18
+ */
19
+
20
+ const LOOKUP_FAIL_THRESHOLD = 3;
21
+ const counters = new Map<string, number>();
22
+
23
+ /** Total fail-closed trips since process start — surfaced in /security/costs. */
24
+ let totalTrips = 0;
25
+
26
+ export function recordCapLookupSuccess(key: string): void {
27
+ counters.delete(key);
28
+ }
29
+
30
+ /**
31
+ * Record a cap-lookup failure. Returns `true` when the threshold has been
32
+ * crossed and the caller MUST fail closed instead of swallowing the error.
33
+ */
34
+ export function recordCapLookupFailure(key: string): boolean {
35
+ const next = (counters.get(key) ?? 0) + 1;
36
+ counters.set(key, next);
37
+ if (next === LOOKUP_FAIL_THRESHOLD) {
38
+ totalTrips++;
39
+ console.warn(
40
+ JSON.stringify({
41
+ level: "warn",
42
+ msg: "ai-cap-lookup fail-closed tripped",
43
+ key,
44
+ consecutiveFailures: next,
45
+ }),
46
+ );
47
+ }
48
+ return next >= LOOKUP_FAIL_THRESHOLD;
49
+ }
50
+
51
+ /** Snapshot for the cost dashboard. */
52
+ export function getCapLookupHealth(): {
53
+ trippedKeys: Array<{ key: string; consecutiveFailures: number }>;
54
+ totalTrips: number;
55
+ } {
56
+ const tripped: Array<{ key: string; consecutiveFailures: number }> = [];
57
+ for (const [key, count] of counters.entries()) {
58
+ if (count >= LOOKUP_FAIL_THRESHOLD) tripped.push({ key, consecutiveFailures: count });
59
+ }
60
+ return { trippedKeys: tripped, totalTrips };
61
+ }
62
+
63
+ /** Test-only reset. */
64
+ export function resetCapLookupCounters(): void {
65
+ counters.clear();
66
+ totalTrips = 0;
67
+ }