@caelo-cms/shared 0.10.22 → 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 +290 -214
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +341 -264
  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
package/src/content.ts ADDED
@@ -0,0 +1,620 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Zod schemas for the Phase 3 content layer. Lives in @caelo-cms/shared so the
5
+ * Validator (Query API ops) and the SvelteKit form actions can both import
6
+ * from a single source.
7
+ *
8
+ * The Page schemas are `.strict()` — Zod rejects any extra key, which is how
9
+ * the §3.1 "no raw HTML on pages" invariant is enforced *in code*: a payload
10
+ * trying to set `html` on a page fails Zod parse before any handler runs.
11
+ */
12
+
13
+ import { z } from "zod";
14
+
15
+ /** Lowercase slug, hyphenated, 1–64 chars, no leading/trailing hyphen. */
16
+ export const slugSchema = z
17
+ .string()
18
+ .regex(
19
+ /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
20
+ "slug must be lowercase letters, digits, or hyphens (1–64 chars, no leading/trailing hyphen)",
21
+ );
22
+
23
+ /**
24
+ * v0.12.3 (issue #106) — derive a module's stable `type` from its
25
+ * displayName. The `type` is the semantic class of a module (`button`,
26
+ * `hero`, `pricing-card`) shared by every instance of that class — it is
27
+ * what a parent module's `allowedModuleTypes` whitelist matches against.
28
+ *
29
+ * This is the slug *base* WITHOUT the `-<timestamp>` uniqueness suffix
30
+ * that `slugify()` appends: a module's `slug` is its unique row identity
31
+ * (`button-mpqxq3ch`), its `type` is the reusable class (`button`). The
32
+ * tool-side `slugify` composes as `deriveModuleType(name) + "-" + suffix`
33
+ * so the two can never diverge.
34
+ *
35
+ * Mirrors `slugify`'s normalization (lowercase, non-alphanumerics → single
36
+ * hyphen, trim hyphens, 40-char cap, `"module"` stem fallback) so a
37
+ * round-trip is predictable.
38
+ */
39
+ export function deriveModuleType(displayName: string): string {
40
+ const base = displayName
41
+ .toLowerCase()
42
+ // Collapse every run of non-alphanumerics to a SINGLE hyphen first, so the
43
+ // string can never contain consecutive hyphens after this point.
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ // Cap BEFORE trimming the edge hyphen — trimming first then slicing could
46
+ // truncate mid-token and leave a trailing hyphen (e.g. the 40th char is a
47
+ // hyphen), which would make `slug = type + "-" + suffix` double-hyphenate.
48
+ .slice(0, 40)
49
+ // Trim a single leading/trailing hyphen. `^-|-$` (not `^-+|-+$`) is both
50
+ // sufficient — the collapse above guarantees no `--` runs — and free of the
51
+ // polynomial backtracking CodeQL flags on `-+$` over uncontrolled input.
52
+ .replace(/^-|-$/g, "");
53
+ return base.length > 0 ? base : "module";
54
+ }
55
+
56
+ /**
57
+ * v0.12.3 (issue #106) — mint a unique module slug from a displayName:
58
+ * the stable `type` base (`deriveModuleType`) + a base36 timestamp
59
+ * suffix. Because the slug is always `type + "-" + suffix`, a module's
60
+ * `type` is guaranteed to be a prefix of its `slug` — the two can never
61
+ * drift. Shared by every module-minting AI tool so the rule lives in one
62
+ * place.
63
+ *
64
+ * @param suffix - override the uniqueness suffix (defaults to a base36
65
+ * timestamp). Pass a stable value in tests for determinism.
66
+ */
67
+ export function slugifyModuleName(
68
+ displayName: string,
69
+ suffix: string = Date.now().toString(36),
70
+ ): string {
71
+ return `${deriveModuleType(displayName)}-${suffix}`;
72
+ }
73
+
74
+ /**
75
+ * v0.12.3 (issue #106) — slug for a module minted per page-section
76
+ * (`build_page` / legacy compose). Includes the section index so two sections
77
+ * sharing a displayName don't collide on the unique slug constraint.
78
+ */
79
+ export function slugifyModuleSection(
80
+ displayName: string,
81
+ idx: number,
82
+ suffix: string = Date.now().toString(36),
83
+ ): string {
84
+ return `${deriveModuleType(displayName)}-${idx}-${suffix}`;
85
+ }
86
+
87
+ /** BCP-47 shape: 2-letter language with optional 2-letter region. P9 widens this. */
88
+ export const localeSchema = z
89
+ .string()
90
+ .regex(/^[a-z]{2}(-[A-Z]{2})?$/, "locale must be 'xx' or 'xx-YY' (BCP-47 shape)");
91
+
92
+ const displayNameSchema = z.string().min(1).max(128);
93
+
94
+ /** Caps are arbitrary but cheap — paste-of-binary mistakes get caught. */
95
+ export const MODULE_HTML_MAX = 256 * 1024;
96
+ export const MODULE_CSS_MAX = 128 * 1024;
97
+ export const MODULE_JS_MAX = 256 * 1024;
98
+ export const TEMPLATE_HTML_MAX = 512 * 1024;
99
+ export const TEMPLATE_CSS_MAX = 128 * 1024;
100
+
101
+ const moduleHtml = z.string().max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`);
102
+ const moduleCss = z.string().max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`);
103
+ const moduleJs = z.string().max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`);
104
+
105
+ /**
106
+ * v0.4.0 — module field schema. Each field declares one substitution slot in
107
+ * the module's templated HTML (`{{fieldName}}`) and the kind of content it
108
+ * holds. Field values live on each page placement (v0.4.0: in
109
+ * `page_module_content.content_values`; v0.12.0 onward: in
110
+ * `content_instances.values`).
111
+ *
112
+ * v0.12.0 — extended to nine kinds. Primitive kinds (text/richtext/url/
113
+ * image/number/boolean/link) substitute via `{{fieldName}}` placeholders.
114
+ * The two new nested kinds reference another module by id + content_instance:
115
+ *
116
+ * - `module` — single nested module reference. HTML slot syntax: `{{>fieldName}}`.
117
+ * Value shape: `{ moduleId, contentInstanceId }`.
118
+ * - `module-list` — ordered array of nested module references. HTML slot
119
+ * syntax: `{{#fieldName}}…inner…{{/fieldName}}` — `inner` renders once
120
+ * per element. Value shape: `Array<{ moduleId, contentInstanceId }>`.
121
+ *
122
+ * Schema is a discriminated union by `kind` so the validator rejects, e.g., a
123
+ * `text` kind that carries `allowedModuleTypes` (which only nested kinds use)
124
+ * or a `module-list` that carries a `default` (nested kinds populate from
125
+ * referenced content_instances, not from defaults).
126
+ */
127
+ export const MODULE_FIELD_PRIMITIVE_KINDS = [
128
+ "text",
129
+ "richtext",
130
+ "url",
131
+ "image",
132
+ "number",
133
+ "boolean",
134
+ "link",
135
+ ] as const;
136
+
137
+ /**
138
+ * v0.12.0 — list-of-primitive field kinds. The grammar mirrors
139
+ * `module-list`'s `{{#field}}{{/field}}` iteration but the elements
140
+ * are primitives (or a fixed {label,href} pair for link-list), not
141
+ * `{moduleId, contentInstanceId}` refs.
142
+ *
143
+ * Use `text-list` for "list of strings" — menu labels, tag chips,
144
+ * bullet points where each item is just text. Inner template
145
+ * references `{{.}}` (Mustache convention) or `{{item}}` to mean
146
+ * the current element.
147
+ *
148
+ * Use `link-list` for "list of links" — primary nav, footer columns,
149
+ * sidebar menus. Each element is `{href, label}`. Inner template
150
+ * uses `{{href}}` + `{{label}}` per iteration.
151
+ *
152
+ * For lists with richer per-item structure (cards with image + title
153
+ * + body + CTA), use `module-list` pointing at a sub-module — that's
154
+ * what nested modules are for.
155
+ */
156
+ export const MODULE_FIELD_LIST_KINDS = ["text-list", "link-list"] as const;
157
+
158
+ export const MODULE_FIELD_NESTED_KINDS = ["module", "module-list"] as const;
159
+
160
+ /** All eleven v0.12.0 kinds. */
161
+ export const MODULE_FIELD_KINDS = [
162
+ ...MODULE_FIELD_PRIMITIVE_KINDS,
163
+ ...MODULE_FIELD_LIST_KINDS,
164
+ ...MODULE_FIELD_NESTED_KINDS,
165
+ ] as const;
166
+
167
+ /**
168
+ * Canonical union of every field kind — used by callers that need to
169
+ * type a single `kind` field (e.g. the shared template engine's
170
+ * `TemplateField.kind`, the static-gen field parser). Importing this
171
+ * instead of `string` catches typos like `'tetx-list'` at compile time.
172
+ */
173
+ export type ModuleFieldKind = (typeof MODULE_FIELD_KINDS)[number];
174
+
175
+ /**
176
+ * Field kinds the shared template engine iterates via the
177
+ * `{{#name}}…{{/name}}` Mustache-section operator. Adding a new
178
+ * list-shaped kind to MODULE_FIELD_LIST_KINDS / MODULE_FIELD_NESTED_KINDS
179
+ * automatically extends this set; the engine's section-dispatch branch
180
+ * keys off it (`packages/shared/src/template-engine.ts`).
181
+ */
182
+ export const MODULE_FIELD_SECTION_KINDS = [...MODULE_FIELD_LIST_KINDS, "module-list"] as const;
183
+
184
+ const moduleFieldName = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/, "name must be snake_case");
185
+ const moduleFieldLabel = z.string().min(1).max(128);
186
+
187
+ const moduleFieldPrimitiveSchema = z
188
+ .object({
189
+ name: moduleFieldName,
190
+ kind: z.enum(MODULE_FIELD_PRIMITIVE_KINDS),
191
+ label: moduleFieldLabel,
192
+ /** Default value used when a placement's content_instance has no override. */
193
+ default: z.unknown().optional(),
194
+ })
195
+ .strict();
196
+
197
+ const moduleFieldModuleSchema = z
198
+ .object({
199
+ name: moduleFieldName,
200
+ kind: z.literal("module"),
201
+ label: moduleFieldLabel,
202
+ /**
203
+ * Optional whitelist of module *types* that may fill this slot (the
204
+ * stable `modules.type` class, e.g. `button` — NOT the unique
205
+ * `modules.slug` like `button-mpqxq3ch`). When absent, any module is
206
+ * permitted. The op-layer validator enforces the whitelist against
207
+ * the referenced module's `type` at `set_content_instance_values`
208
+ * time. v0.12.3 (issue #106) renamed this from `allowedModuleSlugs`:
209
+ * an exact-slug whitelist could never match an AI-minted module
210
+ * because every minted slug carries a uniqueness suffix.
211
+ */
212
+ allowedModuleTypes: z.array(slugSchema).max(32).optional(),
213
+ })
214
+ .strict();
215
+
216
+ const moduleFieldModuleListSchema = z
217
+ .object({
218
+ name: moduleFieldName,
219
+ kind: z.literal("module-list"),
220
+ label: moduleFieldLabel,
221
+ /** v0.12.3 (issue #106) — whitelist of stable module `type`s (not
222
+ * unique slugs). See `moduleFieldModuleSchema.allowedModuleTypes`. */
223
+ allowedModuleTypes: z.array(slugSchema).max(32).optional(),
224
+ /** Minimum count of elements. Validator enforces at write time. */
225
+ min: z.number().int().nonnegative().optional(),
226
+ /** Maximum count of elements. Validator enforces at write time. */
227
+ max: z.number().int().positive().max(256).optional(),
228
+ })
229
+ .strict();
230
+
231
+ /**
232
+ * v0.12.0 — list-of-strings field. Inner template uses `{{.}}` (or
233
+ * the alias `{{item}}`) to reference the current element. Default
234
+ * is an optional array of strings rendered when the placement's
235
+ * content_instance has no override.
236
+ */
237
+ const moduleFieldTextListSchema = z
238
+ .object({
239
+ name: moduleFieldName,
240
+ kind: z.literal("text-list"),
241
+ label: moduleFieldLabel,
242
+ min: z.number().int().nonnegative().optional(),
243
+ max: z.number().int().positive().max(256).optional(),
244
+ default: z.array(z.string()).optional(),
245
+ })
246
+ .strict();
247
+
248
+ /**
249
+ * v0.12.0 — list-of-{label,href} field. Inner template uses
250
+ * `{{label}}` + `{{href}}` per iteration. Targets the common
251
+ * primary-nav / footer-column / sidebar-menu pattern without
252
+ * forcing a sub-module per link.
253
+ */
254
+ const moduleFieldLinkListSchema = z
255
+ .object({
256
+ name: moduleFieldName,
257
+ kind: z.literal("link-list"),
258
+ label: moduleFieldLabel,
259
+ min: z.number().int().nonnegative().optional(),
260
+ max: z.number().int().positive().max(256).optional(),
261
+ default: z.array(z.object({ label: z.string(), href: z.string() }).strict()).optional(),
262
+ })
263
+ .strict();
264
+
265
+ export const moduleFieldSchema = z.discriminatedUnion("kind", [
266
+ moduleFieldPrimitiveSchema,
267
+ moduleFieldTextListSchema,
268
+ moduleFieldLinkListSchema,
269
+ moduleFieldModuleSchema,
270
+ moduleFieldModuleListSchema,
271
+ ]);
272
+ export type ModuleField = z.infer<typeof moduleFieldSchema>;
273
+
274
+ /**
275
+ * The shape of a single nested-module reference inside `content_instances.values`
276
+ * when the field's kind is `module` or — repeated inside an array — `module-list`.
277
+ */
278
+ export const moduleRefSchema = z
279
+ .object({
280
+ moduleId: z.string().uuid(),
281
+ contentInstanceId: z.string().uuid(),
282
+ })
283
+ .strict();
284
+ export type ModuleRef = z.infer<typeof moduleRefSchema>;
285
+
286
+ const moduleFieldsArray = z
287
+ .array(moduleFieldSchema)
288
+ .max(64, "modules may declare at most 64 fields")
289
+ .superRefine((arr, ctx) => {
290
+ const seen = new Set<string>();
291
+ for (const f of arr) {
292
+ if (seen.has(f.name)) {
293
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate field name: ${f.name}` });
294
+ return;
295
+ }
296
+ seen.add(f.name);
297
+ }
298
+ });
299
+
300
+ /**
301
+ * v0.12.0 — coarse role tag for the AI's `## Modules` catalog block.
302
+ * Constrained by `modules_kind_check` in migration 0095. Adding a
303
+ * new kind means: (1) bump the SQL CHECK, (2) update this schema,
304
+ * (3) update formatModulesBlock so the AI knows when to use it.
305
+ */
306
+ export const MODULE_KINDS = ["chrome", "hero", "content", "cta", "utility"] as const;
307
+ export const moduleKindSchema = z.enum(MODULE_KINDS);
308
+ export type ModuleKind = (typeof MODULE_KINDS)[number];
309
+
310
+ /**
311
+ * v0.12.0 — description is `.default("")` at the Zod boundary so the
312
+ * 82+ legacy callers in tests/seed scripts keep working unchanged.
313
+ * AI tool descriptions (create_module / edit_module) require the AI
314
+ * to supply a real description explicitly — see CLAUDE.md §1A.
315
+ */
316
+ const moduleDescription = z.string().max(1000);
317
+
318
+ export const moduleCreateSchema = z
319
+ .object({
320
+ slug: slugSchema,
321
+ displayName: displayNameSchema,
322
+ description: moduleDescription.default(""),
323
+ kind: moduleKindSchema.default("content"),
324
+ /**
325
+ * v0.12.3 (issue #106) — stable semantic class shared by every
326
+ * instance of this module (`button`, `pricing-card`). What a parent
327
+ * module's `allowedModuleTypes` whitelist matches against. Optional:
328
+ * when omitted, `modules.create` derives it from `displayName` via
329
+ * `deriveModuleType()`. Pass it explicitly to reuse an existing
330
+ * class (e.g. a second `button` variant should share `type: "button"`).
331
+ */
332
+ type: slugSchema.optional(),
333
+ html: moduleHtml,
334
+ css: moduleCss.default(""),
335
+ js: moduleJs.default(""),
336
+ fields: moduleFieldsArray.default([]),
337
+ })
338
+ .strict();
339
+
340
+ export const moduleUpdateSchema = z
341
+ .object({
342
+ moduleId: z.string().uuid(),
343
+ displayName: displayNameSchema.optional(),
344
+ description: moduleDescription.optional(),
345
+ kind: moduleKindSchema.optional(),
346
+ /** v0.12.3 (issue #106) — re-classify a module's stable `type`. */
347
+ type: slugSchema.optional(),
348
+ html: moduleHtml.optional(),
349
+ css: moduleCss.optional(),
350
+ js: moduleJs.optional(),
351
+ fields: moduleFieldsArray.optional(),
352
+ })
353
+ .strict();
354
+
355
+ /**
356
+ * v0.12.0 — page-type tag pages inherit from their template.
357
+ * Constrained by templates_kind_check in migration 0096. Surfaced
358
+ * in the AI's `## Pages` block so the AI sees three modules-on-
359
+ * product-pages as a pattern. See CLAUDE.md §1A.
360
+ */
361
+ export const TEMPLATE_KINDS = [
362
+ "home",
363
+ "landing",
364
+ "product",
365
+ "blog",
366
+ "doc",
367
+ "content",
368
+ "utility",
369
+ ] as const;
370
+ export const templateKindSchema = z.enum(TEMPLATE_KINDS);
371
+ export type TemplateKind = (typeof TEMPLATE_KINDS)[number];
372
+
373
+ export const templateCreateSchema = z
374
+ .object({
375
+ slug: slugSchema,
376
+ displayName: displayNameSchema,
377
+ /** v0.12.0 — what kind of page binds to this template. */
378
+ kind: templateKindSchema.default("content"),
379
+ html: z.string().max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`),
380
+ css: z.string().max(TEMPLATE_CSS_MAX, `css exceeds ${TEMPLATE_CSS_MAX} bytes`).default(""),
381
+ /**
382
+ * P6.7.6 — every template binds to one layout. When omitted, the
383
+ * handler resolves to `site_defaults.default_layout_id` at create
384
+ * time. (Stored data, not a render-time fallback — see CLAUDE.md §2.)
385
+ */
386
+ layoutId: z.string().uuid().optional(),
387
+ /**
388
+ * Optional block-set metadata — SYMMETRIC with `create_layout` and
389
+ * `propose_update_template`, which both take `blocks`. Omit it and the
390
+ * handler auto-derives one block per `<caelo-slot name="X">` in `html`
391
+ * (displayName = name, position = order of appearance) — the common case.
392
+ * Pass it to give blocks a nicer `displayName` or an explicit `position`;
393
+ * every entry's `name` MUST match a `<caelo-slot>` in `html` (a block with
394
+ * no slot renders nothing), else the create fails loudly (CLAUDE.md §2).
395
+ */
396
+ blocks: z
397
+ .array(
398
+ z.object({
399
+ name: z.string().min(1).max(80),
400
+ displayName: z.string().min(1).max(200),
401
+ position: z.number().int().min(0).max(1000),
402
+ }),
403
+ )
404
+ .optional(),
405
+ })
406
+ .strict();
407
+
408
+ export const templateBlockSchema = z
409
+ .object({
410
+ name: slugSchema,
411
+ displayName: displayNameSchema,
412
+ position: z.number().int().nonnegative(),
413
+ })
414
+ .strict();
415
+
416
+ export const templateUpdateSchema = z
417
+ .object({
418
+ templateId: z.string().uuid(),
419
+ displayName: displayNameSchema.optional(),
420
+ /** v0.12.0 — re-classify the template's page-type kind. */
421
+ kind: templateKindSchema.optional(),
422
+ html: z.string().max(TEMPLATE_HTML_MAX).optional(),
423
+ css: z.string().max(TEMPLATE_CSS_MAX).optional(),
424
+ /** P6.7.6 — re-point the template to a different layout. */
425
+ layoutId: z.string().uuid().optional(),
426
+ /**
427
+ * v0.2.65 — Optional block-set replacement. When present, the
428
+ * update atomically applies the provided block list to
429
+ * `template_blocks` (DELETE-then-INSERT, same path as
430
+ * `template_blocks.set`). Critical for AI-driven flows: the AI's
431
+ * `propose_update_template` previously only wrote the html string
432
+ * and never touched the block table, so an approved proposal that
433
+ * added `<!-- block:content -->` markup left the page unable to
434
+ * find a "content" block. Allowing blocks here lets one
435
+ * propose+execute round add both the markup AND the block
436
+ * definition atomically.
437
+ */
438
+ blocks: z.array(templateBlockSchema).optional(),
439
+ })
440
+ .strict();
441
+
442
+ export const templateBlocksSetSchema = z
443
+ .object({
444
+ templateId: z.string().uuid(),
445
+ blocks: z.array(templateBlockSchema),
446
+ })
447
+ .strict();
448
+
449
+ export const pageStatusSchema = z.enum(["draft", "published"]);
450
+
451
+ /**
452
+ * Page payloads are `.strict()` — passing an `html` field is rejected by Zod
453
+ * before the handler runs. This is the §3.1 "no raw HTML on pages" invariant
454
+ * at the Validator boundary, complementing the schema-level guarantee that
455
+ * the `pages` table has no `html` column.
456
+ */
457
+ export const pageCreateSchema = z
458
+ .object({
459
+ slug: slugSchema,
460
+ locale: localeSchema.default("en"),
461
+ /** P6.7.5 — internal editor label. Defaults to title if omitted. */
462
+ name: z.string().min(1).max(256).optional(),
463
+ title: z.string().min(1).max(256),
464
+ /**
465
+ * P6.7.6 — optional. When omitted, the handler resolves to
466
+ * `site_defaults.default_template_id` at create time. Stored data,
467
+ * not a render-time fallback (CLAUDE.md §2 no-fallbacks).
468
+ */
469
+ templateId: z.string().uuid().optional(),
470
+ /**
471
+ * Optional — omit to let `pages.create` pick a context-aware default:
472
+ * `published` on a bootstrap site (0 live published pages), else
473
+ * `draft`. A bootstrap homepage MUST ship or the first Stage has
474
+ * nothing to serve ("0 published pages for env='staging'"). Pass an
475
+ * explicit value to override. (Was `.default("draft")`, which silently
476
+ * left the first page unpublished.)
477
+ */
478
+ status: pageStatusSchema.optional(),
479
+ })
480
+ .strict();
481
+
482
+ export const pageUpdateSchema = z
483
+ .object({
484
+ pageId: z.string().uuid(),
485
+ /**
486
+ * Optional optimistic-concurrency token. When present, the op rejects
487
+ * with HandlerError("Conflict") if the row's version no longer matches.
488
+ * Routes that load the page first should always pass it back.
489
+ */
490
+ expectedVersion: z.number().int().nonnegative().optional(),
491
+ /** P6.7.5 — three independently-editable identifiers. */
492
+ name: z.string().min(1).max(256).optional(),
493
+ title: z.string().min(1).max(256).optional(),
494
+ slug: slugSchema.optional(),
495
+ templateId: z.string().uuid().optional(),
496
+ status: pageStatusSchema.optional(),
497
+ /**
498
+ * Only meaningful together with `slug`. A slug change rewrites the page's
499
+ * public URL, so the op creates a 301 from the old path by default and
500
+ * rewrites every nav-menu / link-list / module-body link that pointed at
501
+ * it — all inside the same transaction as the slug write.
502
+ *
503
+ * `'skip'` suppresses ONLY the redirect (the link rewrites still run) and
504
+ * is for the rare case where the operator explicitly says the old URL
505
+ * should 404. Defaults to `'auto'`: silently dropping the 301 would strand
506
+ * every inbound link, which is never a safe default (CLAUDE.md §2).
507
+ */
508
+ redirectFromOld: z.enum(["auto", "skip"]).optional(),
509
+ })
510
+ .strict();
511
+
512
+ export const pageSetModulesSchema = z
513
+ .object({
514
+ pageId: z.string().uuid(),
515
+ expectedVersion: z.number().int().nonnegative().optional(),
516
+ blocks: z.array(
517
+ z
518
+ .object({
519
+ blockName: slugSchema,
520
+ moduleIds: z.array(z.string().uuid()),
521
+ })
522
+ .strict(),
523
+ ),
524
+ })
525
+ .strict();
526
+
527
+ export type ModuleCreateInput = z.infer<typeof moduleCreateSchema>;
528
+ export type ModuleUpdateInput = z.infer<typeof moduleUpdateSchema>;
529
+ export type TemplateCreateInput = z.infer<typeof templateCreateSchema>;
530
+ export type TemplateUpdateInput = z.infer<typeof templateUpdateSchema>;
531
+ export type TemplateBlocksSetInput = z.infer<typeof templateBlocksSetSchema>;
532
+ export type PageCreateInput = z.infer<typeof pageCreateSchema>;
533
+ export type PageUpdateInput = z.infer<typeof pageUpdateSchema>;
534
+ export type PageSetModulesInput = z.infer<typeof pageSetModulesSchema>;
535
+
536
+ // ─── v0.12.0 — content_instances ops ─────────────────────────────────
537
+
538
+ /**
539
+ * v0.12.0 — content sync mode on a placement.
540
+ *
541
+ * `synced` — editing the placement's content_instance propagates to
542
+ * every other placement bound to the same row.
543
+ * `unsynced` — placement holds a private row. Default.
544
+ */
545
+ export const syncModeSchema = z.enum(["synced", "unsynced"]);
546
+ export type SyncMode = z.infer<typeof syncModeSchema>;
547
+
548
+ /**
549
+ * `values` jsonb shape — keyed by module field name. Values are arbitrary
550
+ * jsonb. For nested-module field kinds, the value satisfies `moduleRefSchema`
551
+ * (single) or `moduleRefSchema[]` (list); the renderer + write-side validator
552
+ * enforces shape against the module's declared `fields[]`.
553
+ */
554
+ const contentValuesSchema = z.record(z.string(), z.unknown());
555
+
556
+ /**
557
+ * v0.12.0 — why this row exists as a shared/reusable instance.
558
+ * Surfaced in the `## Content Library` system-prompt block so the AI
559
+ * can decide *reuse the synced row* vs *fork to unsynced* vs *mint
560
+ * new* without a tool round-trip. Required by the AI's
561
+ * create_content_instance tool description; legacy callers may pass
562
+ * null (the migration-0093 unsynced rows have purpose=NULL).
563
+ * See CLAUDE.md §1A.
564
+ */
565
+ const contentInstancePurpose = z.string().max(1000);
566
+
567
+ export const contentInstanceCreateSchema = z
568
+ .object({
569
+ moduleId: z.string().uuid(),
570
+ slug: slugSchema.optional(),
571
+ displayName: z.string().min(1).max(128).optional(),
572
+ purpose: contentInstancePurpose.optional(),
573
+ values: contentValuesSchema.default({}),
574
+ })
575
+ .strict();
576
+
577
+ export const contentInstanceUpdateSchema = z
578
+ .object({
579
+ id: z.string().uuid(),
580
+ /** Fully replaces existing values. */
581
+ values: contentValuesSchema,
582
+ /** Optional optimistic-concurrency token; mirrors pages.update. */
583
+ expectedVersion: z.number().int().nonnegative().optional(),
584
+ /** Optional rename in the same write; mirrors how pages.update accepts metadata edits. */
585
+ slug: slugSchema.nullable().optional(),
586
+ displayName: z.string().min(1).max(128).nullable().optional(),
587
+ /** v0.12.0 — rewrite the purpose (or clear via null). */
588
+ purpose: contentInstancePurpose.nullable().optional(),
589
+ })
590
+ .strict();
591
+
592
+ export const contentInstanceDeleteSchema = z
593
+ .object({
594
+ id: z.string().uuid(),
595
+ })
596
+ .strict();
597
+
598
+ export const setPlacementContentSchema = z
599
+ .object({
600
+ pageId: z.string().uuid(),
601
+ blockName: z.string().min(1).max(80),
602
+ position: z.number().int().nonnegative(),
603
+ contentInstanceId: z.string().uuid(),
604
+ syncMode: syncModeSchema,
605
+ })
606
+ .strict();
607
+
608
+ export const forkPlacementContentSchema = z
609
+ .object({
610
+ pageId: z.string().uuid(),
611
+ blockName: z.string().min(1).max(80),
612
+ position: z.number().int().nonnegative(),
613
+ })
614
+ .strict();
615
+
616
+ export type ContentInstanceCreateInput = z.infer<typeof contentInstanceCreateSchema>;
617
+ export type ContentInstanceUpdateInput = z.infer<typeof contentInstanceUpdateSchema>;
618
+ export type ContentInstanceDeleteInput = z.infer<typeof contentInstanceDeleteSchema>;
619
+ export type SetPlacementContentInput = z.infer<typeof setPlacementContentSchema>;
620
+ export type ForkPlacementContentInput = z.infer<typeof forkPlacementContentSchema>;
package/src/context.ts ADDED
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Execution identity threaded through every Query API call. The Database Adapter
5
+ * reads this to set `SET LOCAL caelo.actor_id / actor_kind / plugin_id` on the
6
+ * transaction so RLS policies can scope rows.
7
+ *
8
+ * `actorId` being NULL must mean "no identity, deny by default" — the adapter
9
+ * sets the session var to an empty string and every RLS policy's `NULLIF(...,'')::uuid`
10
+ * yields NULL, which matches nothing.
11
+ */
12
+
13
+ export type ActorKind = "human" | "ai" | "plugin" | "system";
14
+
15
+ export interface ExecutionContext {
16
+ readonly actorId: string;
17
+ readonly actorKind: ActorKind;
18
+ /** Present only when the caller is a plugin. */
19
+ readonly pluginId?: string;
20
+ /** Opaque id for audit trace / log correlation. */
21
+ readonly requestId: string;
22
+ /**
23
+ * P5: when set, all snapshot rows emitted by ops in this transaction
24
+ * carry this chat_branch_id. Reads that opt into branch-aware mode
25
+ * resolve the chat's branch state when present, else fall back to main.
26
+ */
27
+ readonly chatBranchId?: string;
28
+ /**
29
+ * P5: when set, snapshots emitted during this op group under the same
30
+ * chat_task_id so the timeline UX (P10A's task-grouped collapsing) can
31
+ * fold consecutive AI actions into one entry.
32
+ */
33
+ readonly chatTaskId?: string;
34
+ /**
35
+ * P10.5: parent attribution for subagent invocations. When the
36
+ * `spawn_subagent` tool calls runChatTurn for the child, it carries
37
+ * these fields so the existing ai_calls + audit_events writers
38
+ * persist them to the new schema columns. Same code paths; just
39
+ * more attribution data threaded through.
40
+ */
41
+ readonly parentChatSessionId?: string;
42
+ readonly parentAiCallId?: string;
43
+ }