@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
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Zod input schemas for the P4 snapshot ops. Lives in @caelo-cms/shared so that
5
+ * route actions and the ops themselves import from a single source — same
6
+ * pattern as packages/shared/src/content.ts for P3.
7
+ *
8
+ * Every schema is `.strict()` so unknown keys are rejected at the Validator
9
+ * before the handler runs. Snapshot state JSONB is *not* validated by Zod
10
+ * here — it is emitted by trusted ops, never user-supplied (see CMS plan
11
+ * §"Validator rules").
12
+ */
13
+
14
+ import { z } from "zod";
15
+
16
+ export const snapshotsListInput = z
17
+ .object({
18
+ /** ISO timestamp; only snapshots strictly before this are returned. */
19
+ before: z.string().datetime().optional(),
20
+ limit: z.number().int().min(1).max(200).default(50),
21
+ /** When set, only snapshots that touched this module are returned. */
22
+ forModuleId: z.string().uuid().optional(),
23
+ /** When set, only snapshots that touched this page (metadata or layout). */
24
+ forPageId: z.string().uuid().optional(),
25
+ /** When set, only snapshots that touched this template (incl. block changes). */
26
+ forTemplateId: z.string().uuid().optional(),
27
+ /** When set, only snapshots whose op_kind matches one of these values. */
28
+ opKinds: z.array(z.string()).optional(),
29
+ /** When true, archived snapshots are also returned. Defaults to false. */
30
+ includeArchived: z.boolean().default(false),
31
+ /** v0.6.0 — only snapshots tagged with this chat's branch are returned.
32
+ * Used by `revert_chat_changes` to enumerate everything one chat did
33
+ * without scanning the full timeline. */
34
+ forChatBranchId: z.string().uuid().optional(),
35
+ })
36
+ .strict();
37
+
38
+ export const archiveOlderThanInput = z
39
+ .object({
40
+ /** ISO timestamp; snapshots with `created_at < before` get archived_at set. */
41
+ before: z.string().datetime(),
42
+ /** Hard cap to keep one call from updating the whole table by mistake. */
43
+ limit: z.number().int().min(1).max(10_000).default(1000),
44
+ })
45
+ .strict();
46
+ export type ArchiveOlderThanInput = z.infer<typeof archiveOlderThanInput>;
47
+
48
+ export const snapshotGetInput = z.object({ snapshotId: z.string().uuid() }).strict();
49
+
50
+ export const moduleImpactInput = z.object({ moduleId: z.string().uuid() }).strict();
51
+
52
+ export const revertSiteInput = z.object({ snapshotId: z.string().uuid() }).strict();
53
+
54
+ export const revertModuleInput = z
55
+ .object({
56
+ moduleId: z.string().uuid(),
57
+ snapshotId: z.string().uuid(),
58
+ })
59
+ .strict();
60
+
61
+ export const revertTemplateInput = z
62
+ .object({
63
+ templateId: z.string().uuid(),
64
+ snapshotId: z.string().uuid(),
65
+ })
66
+ .strict();
67
+
68
+ export const revertPageInput = z
69
+ .object({
70
+ pageId: z.string().uuid(),
71
+ snapshotId: z.string().uuid(),
72
+ })
73
+ .strict();
74
+
75
+ export type SnapshotsListInput = z.infer<typeof snapshotsListInput>;
76
+ export type SnapshotGetInput = z.infer<typeof snapshotGetInput>;
77
+ export type ModuleImpactInput = z.infer<typeof moduleImpactInput>;
78
+ export type RevertSiteInput = z.infer<typeof revertSiteInput>;
79
+ export type RevertModuleInput = z.infer<typeof revertModuleInput>;
80
+ export type RevertTemplateInput = z.infer<typeof revertTemplateInput>;
81
+ export type RevertPageInput = z.infer<typeof revertPageInput>;
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { stripCdataGuards } from "./strip-cdata.js";
5
+
6
+ describe("stripCdataGuards", () => {
7
+ it("leaves clean HTML untouched (fast path)", () => {
8
+ const html = '<nav><a href="/docs">Docs</a></nav>';
9
+ expect(stripCdataGuards(html)).toBe(html);
10
+ });
11
+
12
+ it("removes a bare `<![CDATA[` / `]]>` pair, preserving inner content", () => {
13
+ const html = "<style><![CDATA[.a{color:red}]]></style>";
14
+ expect(stripCdataGuards(html)).toBe("<style>.a{color:red}</style>");
15
+ });
16
+
17
+ it("removes the stray `]]>` that leaks into chrome slots (the bug signature)", () => {
18
+ const html = "<header>Site name]]></header>";
19
+ expect(stripCdataGuards(html)).toBe("<header>Site name</header>");
20
+ });
21
+
22
+ it("unwraps the block-comment guard form", () => {
23
+ const html = "<script>/*<![CDATA[*/ doStuff(); /*]]>*/</script>";
24
+ expect(stripCdataGuards(html)).toBe("<script> doStuff(); </script>");
25
+ });
26
+
27
+ it("unwraps the line-comment guard form", () => {
28
+ const html = "<script>\n//<![CDATA[\n doStuff();\n//]]>\n</script>";
29
+ expect(stripCdataGuards(html)).toBe("<script>\n\n doStuff();\n\n</script>");
30
+ });
31
+
32
+ it("handles multiple guards in one document", () => {
33
+ const html = "<style><![CDATA[.a{}]]></style><script>//<![CDATA[\nx()\n//]]>\n</script>";
34
+ expect(stripCdataGuards(html)).toBe("<style>.a{}</style><script>\nx()\n\n</script>");
35
+ });
36
+
37
+ it("does not touch a legitimate `CDATA` substring in text", () => {
38
+ const html = "<p>The CDATA section in XML is explained here.</p>";
39
+ expect(stripCdataGuards(html)).toBe(html);
40
+ });
41
+ });
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * CDATA-guard normalization for module HTML.
5
+ *
6
+ * The AI sometimes authors chrome/layout module HTML with XHTML-style
7
+ * CDATA guards wrapping inline `<style>`/`<script>` content — e.g.
8
+ *
9
+ * <script>
10
+ * //<![CDATA[
11
+ * doStuff();
12
+ * //]]>
13
+ * </script>
14
+ *
15
+ * or the block-comment / bare forms (`/*<![CDATA[* /`, `<![CDATA[`).
16
+ * These are legal in XHTML but MEANINGLESS in HTML5: the compose
17
+ * pipeline is byte-preserving by design (see `preview-scanner.ts`), so
18
+ * the guards survive verbatim into the rendered page. Chrome/layout
19
+ * modules render straight from their stored HTML (no content_instance
20
+ * indirection), so a bare `<![CDATA[` in the body is parsed by the
21
+ * browser as a bogus comment up to the first `>`, leaving a stray,
22
+ * visible `]]>` in the header/footer/nav.
23
+ *
24
+ * We normalize at the module-HTML write boundary (canonical) and again
25
+ * defensively at render, so both freshly-authored and already-stored
26
+ * modules stay clean. Only the DELIMITERS are removed — the wrapped
27
+ * CSS/JS/markup content is preserved.
28
+ *
29
+ * This is normalization of malformed authored markup, not a data
30
+ * fallback (CLAUDE.md §2): there is no "missing" data to point at, and
31
+ * the inner content is kept intact.
32
+ */
33
+
34
+ /**
35
+ * Matches every CDATA-guard delimiter form the model emits. The
36
+ * comment-wrapped variants come first so they're consumed as a unit
37
+ * before the bare `<![CDATA[` / `]]>` alternatives can match a subset.
38
+ */
39
+ const CDATA_GUARDS =
40
+ /\/\*\s*<!\[CDATA\[\s*\*\/|\/\*\s*\]\]>\s*\*\/|\/\/[ \t]*<!\[CDATA\[|\/\/[ \t]*\]\]>|<!\[CDATA\[|\]\]>/g;
41
+
42
+ /**
43
+ * Strip XHTML-style CDATA-section guard delimiters from module HTML,
44
+ * preserving the content they wrapped. Returns the input unchanged when
45
+ * no guard is present (fast path for the overwhelming clean-HTML case).
46
+ */
47
+ export function stripCdataGuards(html: string): string {
48
+ if (!html.includes("CDATA") && !html.includes("]]>")) return html;
49
+ return html.replace(CDATA_GUARDS, "");
50
+ }
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P6.7.5 — typed schemas for the `structured_sets` table.
5
+ *
6
+ * One database table holds N kinds of named lists (nav-menus, taxonomies,
7
+ * theme tokens, tag lists, footer link blocks, ...). The `kind`
8
+ * discriminator drives both the Zod validator at the Query API layer
9
+ * and the renderer that turns the items into HTML at preview / deploy.
10
+ *
11
+ * Adding a new kind: add a Zod schema below, add it to the
12
+ * `structuredSetItem` discriminated union, add a renderer in
13
+ * `packages/shared/src/structured-set-renderer.ts`. No DB migration
14
+ * required — the table already accepts arbitrary `kind` text.
15
+ *
16
+ * Item shapes are deliberately **structured data**, not HTML strings.
17
+ * That's what makes `change_page_slug` able to retarget every menu
18
+ * link automatically: walk the JSON, swap `href` matches.
19
+ */
20
+
21
+ import { z } from "zod";
22
+
23
+ /** Recursive nav menu — supports arbitrary submenu nesting. */
24
+ export const navMenuItem: z.ZodType<{
25
+ label: string;
26
+ href: string;
27
+ target?: "_self" | "_blank";
28
+ children?: { label: string; href: string }[];
29
+ /** When set, the renderer delegates this slot to the named ad plugin. */
30
+ adSlotId?: string;
31
+ }> = z.lazy(() =>
32
+ z
33
+ .object({
34
+ label: z.string().min(1).max(120),
35
+ href: z.string().min(1).max(500),
36
+ target: z.enum(["_self", "_blank"]).optional(),
37
+ children: z.array(navMenuItem).optional(),
38
+ adSlotId: z.string().min(1).max(100).optional(),
39
+ })
40
+ .strict(),
41
+ );
42
+
43
+ /** Tree-shaped category list. Used by typed-content / tag pages (P12A). */
44
+ export const taxonomyItem = z
45
+ .object({
46
+ slug: z.string().min(1).max(120),
47
+ displayName: z.string().min(1).max(200),
48
+ parentSlug: z.string().min(1).max(120).optional(),
49
+ description: z.string().max(2000).optional(),
50
+ })
51
+ .strict();
52
+
53
+ /** Flat tag — used by post taxonomies, content filtering. */
54
+ export const tagItem = z
55
+ .object({
56
+ slug: z
57
+ .string()
58
+ .min(1)
59
+ .max(120)
60
+ .regex(/^[a-z0-9-]+$/, "lowercase letters/digits/hyphens"),
61
+ displayName: z.string().min(1).max(200),
62
+ color: z
63
+ .string()
64
+ .regex(/^#[0-9a-fA-F]{3,8}$/, "hex color")
65
+ .optional(),
66
+ })
67
+ .strict();
68
+
69
+ /** Footer "legal" / related-resources block. */
70
+ export const linkListItem = z
71
+ .object({
72
+ label: z.string().min(1).max(200),
73
+ href: z.string().min(1).max(500),
74
+ description: z.string().max(500).optional(),
75
+ })
76
+ .strict();
77
+
78
+ /**
79
+ * P9 review-pass — `language-selector` kind. Items are user-supplied
80
+ * overrides (e.g. force a specific display label per locale); empty
81
+ * array means "auto-populate from the locale registry at render time."
82
+ * The renderer (apps/static-generator + preview op) reads the locale
83
+ * registry + the current page's slug + resolveLocaleUrl to emit
84
+ * `<a hreflang lang href>` rows. Closes CMS_REQUIREMENTS §7.8.
85
+ */
86
+ export const languageSelectorOverride = z
87
+ .object({
88
+ /** BCP-47 locale code; must match a row in the locales registry. */
89
+ locale: z.string().min(2).max(10),
90
+ /** Override the locale's display_name when rendering this entry. */
91
+ label: z.string().min(1).max(120).optional(),
92
+ /** Hide this locale from the rendered selector even if it has a published page. */
93
+ hidden: z.boolean().optional(),
94
+ })
95
+ .strict();
96
+
97
+ /** Discriminated kind → items array. The Query API op picks the
98
+ * validator at runtime by reading `kind` first.
99
+ *
100
+ * v0.11.0 (#45) — `theme` is no longer a structured-set kind. The
101
+ * theme primitive moved to its own `themes` table with DTCG-shaped
102
+ * jsonb tokens; see `themes.ts` + `theme-render.ts`. */
103
+ export const structuredSetKind = z.enum([
104
+ "nav-menu",
105
+ "taxonomy",
106
+ "tags",
107
+ "link-list",
108
+ "language-selector",
109
+ ]);
110
+ export type StructuredSetKind = z.infer<typeof structuredSetKind>;
111
+
112
+ /** Validate `items` against the right schema for `kind`. Throws a
113
+ * ZodError on mismatch — callers wrap and return `Err(ValidationFailed)`. */
114
+ export function validateStructuredSetItems(kind: StructuredSetKind, items: unknown): unknown[] {
115
+ if (!Array.isArray(items)) {
116
+ throw new z.ZodError([
117
+ {
118
+ code: "custom",
119
+ path: ["items"],
120
+ message: "items must be an array",
121
+ },
122
+ ]);
123
+ }
124
+ switch (kind) {
125
+ case "nav-menu":
126
+ return items.map((it) => navMenuItem.parse(it));
127
+ case "taxonomy":
128
+ return items.map((it) => taxonomyItem.parse(it));
129
+ case "tags":
130
+ return items.map((it) => tagItem.parse(it));
131
+ case "link-list":
132
+ return items.map((it) => linkListItem.parse(it));
133
+ case "language-selector":
134
+ return items.map((it) => languageSelectorOverride.parse(it));
135
+ }
136
+ }
137
+
138
+ export type NavMenuItem = z.infer<typeof navMenuItem>;
139
+ export type TaxonomyItem = z.infer<typeof taxonomyItem>;
140
+ export type TagItem = z.infer<typeof tagItem>;
141
+ export type LinkListItem = z.infer<typeof linkListItem>;
142
+ export type LanguageSelectorOverride = z.infer<typeof languageSelectorOverride>;
143
+
144
+ /**
145
+ * Render a `<nav class="caelo-language-selector">` with one `<a>`
146
+ * per locale that has a published variant of the current page. Pure
147
+ * function — caller threads the page's published-locale list and a
148
+ * URL resolver. Used by `composePagePreview` (admin) and the static
149
+ * generator (deploy) so byte-for-byte output parity holds.
150
+ *
151
+ * Per CMS_REQUIREMENTS §7.8: locales with no published variant for the
152
+ * current page are excluded.
153
+ */
154
+ export function renderLanguageSelector(args: {
155
+ /** Each locale that has a published variant of the current page. */
156
+ readonly availableLocales: ReadonlyArray<{
157
+ code: string;
158
+ displayName: string;
159
+ href: string;
160
+ isCurrent: boolean;
161
+ }>;
162
+ /** Owner-supplied overrides (relabel a locale, hide one). */
163
+ readonly overrides?: ReadonlyArray<LanguageSelectorOverride>;
164
+ }): string {
165
+ const overrideByLocale = new Map<string, LanguageSelectorOverride>(
166
+ (args.overrides ?? []).map((o) => [o.locale, o]),
167
+ );
168
+ const enc = (s: string): string =>
169
+ s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
170
+ const items = args.availableLocales
171
+ .filter((l) => !overrideByLocale.get(l.code)?.hidden)
172
+ .map((l) => {
173
+ const override = overrideByLocale.get(l.code);
174
+ const label = override?.label ?? l.displayName;
175
+ const aria = l.isCurrent ? ' aria-current="true"' : "";
176
+ return `<a hreflang="${enc(l.code)}" lang="${enc(l.code)}" href="${enc(l.href)}"${aria}>${enc(label)}</a>`;
177
+ });
178
+ if (items.length === 0) return "";
179
+ return `<nav class="caelo-language-selector" aria-label="Language">${items.join("")}</nav>`;
180
+ }
@@ -0,0 +1,262 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { parseSubagentResult, subagentSpec, validateSubagentResultValue } from "./subagents.js";
5
+
6
+ describe("subagentSpec", () => {
7
+ it("accepts a minimal spec with role + task", () => {
8
+ const r = subagentSpec.safeParse({ role: "qa", task: "QA the page" });
9
+ expect(r.success).toBe(true);
10
+ if (!r.success) return;
11
+ expect(r.data.expectedReturnShape).toBe("verdict");
12
+ // issue #304 — no schema default: an omitted cap means "derive from
13
+ // the armed run budget" in the spawn orchestrator, and a default
14
+ // here (the old 50M µ¢) would make omission indistinguishable from
15
+ // an explicit choice.
16
+ expect(r.data.maxCostMicrocents).toBeUndefined();
17
+ // Default 300s — build children (Genesis draft, migration rebuild) need
18
+ // minutes; the old 60s aborted them mid-work (0 drafts saved).
19
+ expect(r.data.timeoutMs).toBe(300_000);
20
+ });
21
+
22
+ it("rejects empty role + task", () => {
23
+ expect(subagentSpec.safeParse({ role: "", task: "x" }).success).toBe(false);
24
+ expect(subagentSpec.safeParse({ role: "x", task: "" }).success).toBe(false);
25
+ });
26
+
27
+ it("rejects unknown fields (.strict)", () => {
28
+ expect(subagentSpec.safeParse({ role: "x", task: "y", randomField: "z" }).success).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("validateSubagentResultValue — double-encoded payload (submit_result bug)", () => {
33
+ // Bug: a provider double-encodes the verdict as a JSON STRING
34
+ // (`{"result":"{\"pass\":true,...}"}`); with `result` untyped in the
35
+ // submit_result inputSchema the encoding-repair layer couldn't decode
36
+ // it, so a well-formed verdict reached the validator as a string and
37
+ // was rejected as "string (scalar)". The validator now decodes a
38
+ // JSON-looking string for structured shapes as defense-in-depth.
39
+ it("verdict — accepts a JSON-string-encoded object", () => {
40
+ const encoded = JSON.stringify({ pass: true, issues: [], suggestions: ["add CTA"] });
41
+ const r = validateSubagentResultValue(encoded, "verdict");
42
+ expect(r.ok).toBe(true);
43
+ if (!r.ok) return;
44
+ expect(r.shape).toBe("verdict");
45
+ expect(r.value.pass).toBe(true);
46
+ expect(r.value.suggestions).toEqual(["add CTA"]);
47
+ });
48
+
49
+ it("verdict — still accepts a real object (unchanged path)", () => {
50
+ const r = validateSubagentResultValue({ pass: false, issues: ["x"] }, "verdict");
51
+ expect(r.ok).toBe(true);
52
+ });
53
+
54
+ it("rebuild — accepts a JSON-string-encoded object", () => {
55
+ const encoded = JSON.stringify({ pages: [{ slug: "/home", status: "rebuilt" }] });
56
+ const r = validateSubagentResultValue(encoded, "rebuild");
57
+ expect(r.ok).toBe(true);
58
+ });
59
+
60
+ it("verdict — a genuine non-JSON string still fails with the scalar hint", () => {
61
+ const r = validateSubagentResultValue("looks good to me", "verdict");
62
+ expect(r.ok).toBe(false);
63
+ if (r.ok) return;
64
+ expect(r.error).toContain("scalar");
65
+ });
66
+
67
+ it("freeform — a bare prose string is NOT treated as JSON", () => {
68
+ const r = validateSubagentResultValue("plain prose", "freeform");
69
+ expect(r.ok).toBe(true);
70
+ if (!r.ok) return;
71
+ expect(r.value.text).toBe("plain prose");
72
+ });
73
+ });
74
+
75
+ describe("parseSubagentResult — verdict", () => {
76
+ it("extracts JSON from a code fence", () => {
77
+ const text = `\`\`\`json\n${JSON.stringify({ pass: true, issues: [], suggestions: ["add CTA"] })}\n\`\`\``;
78
+ const r = parseSubagentResult(text, "verdict");
79
+ expect(r.ok).toBe(true);
80
+ if (!r.ok) return;
81
+ expect(r.shape).toBe("verdict");
82
+ expect(r.value.pass).toBe(true);
83
+ expect(r.value.suggestions).toEqual(["add CTA"]);
84
+ });
85
+
86
+ it("extracts JSON from prose-wrapped output", () => {
87
+ const text =
88
+ 'Here is my verdict:\n{"pass": false, "issues": ["missing disclaimer"], "suggestions": []}\nHope that helps.';
89
+ const r = parseSubagentResult(text, "verdict");
90
+ expect(r.ok).toBe(true);
91
+ if (!r.ok) return;
92
+ expect(r.value.pass).toBe(false);
93
+ expect(r.value.issues).toEqual(["missing disclaimer"]);
94
+ });
95
+
96
+ it("returns error on shape mismatch", () => {
97
+ const text = JSON.stringify({ pass: "yes", issues: [] });
98
+ const r = parseSubagentResult(text, "verdict");
99
+ expect(r.ok).toBe(false);
100
+ if (r.ok) return;
101
+ expect(r.error).toContain("pass");
102
+ });
103
+
104
+ it("returns error on non-JSON garbage", () => {
105
+ const r = parseSubagentResult("not json at all", "verdict");
106
+ expect(r.ok).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe("parseSubagentResult — tree", () => {
111
+ it("validates tree shape", () => {
112
+ const text = JSON.stringify({
113
+ tree: [{ label: "Root", children: [] }],
114
+ rationale: "minimal",
115
+ });
116
+ const r = parseSubagentResult(text, "tree");
117
+ expect(r.ok).toBe(true);
118
+ if (!r.ok) return;
119
+ expect(r.shape).toBe("tree");
120
+ });
121
+ });
122
+
123
+ describe("parseSubagentResult — rebuild (issue #264)", () => {
124
+ it("validates a full migration-batch summary", () => {
125
+ const text = JSON.stringify({
126
+ pages: [
127
+ {
128
+ pageId: "11111111-1111-4111-8111-222222222222",
129
+ slug: "pricing",
130
+ status: "rebuilt",
131
+ notes: "table modernised; footnote folded into caption",
132
+ },
133
+ { slug: "pricing-archive", status: "skipped", notes: "operator marked obsolete" },
134
+ ],
135
+ contentNotes: ["source hero carried a rotating quote widget; kept the first quote only"],
136
+ skipped: [{ item: "legacy price calculator embed", reason: "third-party script, no data" }],
137
+ summary: "2-page batch: 1 rebuilt, 1 skipped.",
138
+ });
139
+ const r = parseSubagentResult(text, "rebuild");
140
+ expect(r.ok).toBe(true);
141
+ if (!r.ok) return;
142
+ expect(r.shape).toBe("rebuild");
143
+ if (r.shape !== "rebuild") return;
144
+ expect(r.value.pages).toHaveLength(2);
145
+ expect(r.value.pages[0]?.status).toBe("rebuilt");
146
+ expect(r.value.skipped[0]?.reason).toContain("third-party");
147
+ });
148
+
149
+ it("defaults contentNotes/skipped/summary when omitted", () => {
150
+ const text = `\`\`\`json\n${JSON.stringify({ pages: [{ slug: "home", status: "rebuilt" }] })}\n\`\`\``;
151
+ const r = parseSubagentResult(text, "rebuild");
152
+ expect(r.ok).toBe(true);
153
+ if (!r.ok) return;
154
+ if (r.shape !== "rebuild") return;
155
+ expect(r.value.contentNotes).toEqual([]);
156
+ expect(r.value.skipped).toEqual([]);
157
+ expect(r.value.summary).toBe("");
158
+ });
159
+
160
+ it("rejects an empty pages array — a rebuild subagent must account for its batch", () => {
161
+ const r = parseSubagentResult(JSON.stringify({ pages: [] }), "rebuild");
162
+ expect(r.ok).toBe(false);
163
+ });
164
+
165
+ it("names the expected shape + observed keys on mismatch", () => {
166
+ const r = parseSubagentResult(JSON.stringify({ pass: true, issues: [] }), "rebuild");
167
+ expect(r.ok).toBe(false);
168
+ if (r.ok) return;
169
+ expect(r.error).toContain("rebuild shape mismatch");
170
+ expect(r.error).toContain("pass");
171
+ });
172
+
173
+ it("rejects an out-of-enum page status", () => {
174
+ const r = parseSubagentResult(
175
+ JSON.stringify({ pages: [{ slug: "home", status: "done" }] }),
176
+ "rebuild",
177
+ );
178
+ expect(r.ok).toBe(false);
179
+ });
180
+ });
181
+
182
+ describe("subagentSpec — rebuild shape accepted", () => {
183
+ it("accepts expectedReturnShape rebuild with migration-scale caps", () => {
184
+ const r = subagentSpec.safeParse({
185
+ role: "rebuild:blog",
186
+ task: "REBUILD TASK — rebuild the blog cluster",
187
+ expectedReturnShape: "rebuild",
188
+ timeoutMs: 600_000,
189
+ maxCostMicrocents: 100_000_000,
190
+ });
191
+ expect(r.success).toBe(true);
192
+ if (!r.success) return;
193
+ expect(r.data.expectedReturnShape).toBe("rebuild");
194
+ });
195
+ });
196
+
197
+ describe("parseSubagentResult — freeform", () => {
198
+ it("accepts {text: ...} JSON", () => {
199
+ const r = parseSubagentResult(JSON.stringify({ text: "summary here" }), "freeform");
200
+ expect(r.ok).toBe(true);
201
+ if (!r.ok) return;
202
+ expect(r.value.text).toBe("summary here");
203
+ });
204
+
205
+ it("falls back to raw text when JSON parse fails", () => {
206
+ const r = parseSubagentResult("plain text response from the subagent.", "freeform");
207
+ expect(r.ok).toBe(true);
208
+ if (!r.ok) return;
209
+ expect(r.value.text).toBe("plain text response from the subagent.");
210
+ });
211
+
212
+ it("returns error on empty text", () => {
213
+ expect(parseSubagentResult(" ", "freeform").ok).toBe(false);
214
+ });
215
+ });
216
+
217
+ describe("subagentSpec — model tier (issue #306)", () => {
218
+ it("defaults tier to inherit (single-model behaviour unchanged when omitted)", () => {
219
+ const r = subagentSpec.safeParse({ role: "qa", task: "QA the page" });
220
+ expect(r.success).toBe(true);
221
+ if (!r.success) return;
222
+ expect(r.data.tier).toBe("inherit");
223
+ });
224
+
225
+ it("accepts mid and small, rejects unknown tiers", () => {
226
+ for (const tier of ["mid", "small", "inherit"]) {
227
+ expect(subagentSpec.safeParse({ role: "x", task: "y", tier }).success).toBe(true);
228
+ }
229
+ expect(subagentSpec.safeParse({ role: "x", task: "y", tier: "large" }).success).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe("parseSubagentResult — needs_escalation (issue #306)", () => {
234
+ it("accepts a needs_escalation page WITH a reason in notes", () => {
235
+ const r = parseSubagentResult(
236
+ JSON.stringify({
237
+ pages: [
238
+ { slug: "home", status: "rebuilt" },
239
+ { slug: "pricing", status: "needs_escalation", notes: "no matching table module" },
240
+ ],
241
+ }),
242
+ "rebuild",
243
+ );
244
+ expect(r.ok).toBe(true);
245
+ if (!r.ok) return;
246
+ if (r.shape !== "rebuild") return;
247
+ expect(r.value.pages[1]?.status).toBe("needs_escalation");
248
+ expect(r.value.pages[1]?.notes).toBe("no matching table module");
249
+ });
250
+
251
+ it("REJECTS needs_escalation without notes — a blind escalation cannot be briefed", () => {
252
+ for (const notes of [undefined, "", " "]) {
253
+ const r = parseSubagentResult(
254
+ JSON.stringify({ pages: [{ slug: "pricing", status: "needs_escalation", notes }] }),
255
+ "rebuild",
256
+ );
257
+ expect(r.ok).toBe(false);
258
+ if (r.ok) continue;
259
+ expect(r.error).toContain("needs_escalation");
260
+ }
261
+ });
262
+ });