@caelo-cms/shared 0.10.22 → 0.10.24
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.
- package/dist/ai-tools.d.ts +289 -273
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +342 -323
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +35 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +40 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-draft-shell.d.ts +21 -0
- package/dist/design-draft-shell.d.ts.map +1 -0
- package/dist/design-draft-shell.js +81 -0
- package/dist/design-draft-shell.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +102 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +145 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +29 -36
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +53 -128
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +85 -12
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +157 -67
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +11 -16
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +7 -23
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -52
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +6 -68
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1105 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +42 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +158 -0
- package/src/i18n.test.ts +58 -0
- package/src/i18n.ts +91 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +637 -0
- package/src/preview-compose.ts +656 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +194 -0
- package/src/seo.ts +233 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +114 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/version.ts +66 -0
- package/dist/translation.d.ts +0 -127
- package/dist/translation.d.ts.map +0 -1
- package/dist/translation.js +0 -208
- package/dist/translation.js.map +0 -1
|
@@ -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,114 @@
|
|
|
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
|
+
/** Discriminated kind → items array. The Query API op picks the
|
|
79
|
+
* validator at runtime by reading `kind` first.
|
|
80
|
+
*
|
|
81
|
+
* v0.11.0 (#45) — `theme` is no longer a structured-set kind. The
|
|
82
|
+
* theme primitive moved to its own `themes` table with DTCG-shaped
|
|
83
|
+
* jsonb tokens; see `themes.ts` + `theme-render.ts`. */
|
|
84
|
+
export const structuredSetKind = z.enum(["nav-menu", "taxonomy", "tags", "link-list"]);
|
|
85
|
+
export type StructuredSetKind = z.infer<typeof structuredSetKind>;
|
|
86
|
+
|
|
87
|
+
/** Validate `items` against the right schema for `kind`. Throws a
|
|
88
|
+
* ZodError on mismatch — callers wrap and return `Err(ValidationFailed)`. */
|
|
89
|
+
export function validateStructuredSetItems(kind: StructuredSetKind, items: unknown): unknown[] {
|
|
90
|
+
if (!Array.isArray(items)) {
|
|
91
|
+
throw new z.ZodError([
|
|
92
|
+
{
|
|
93
|
+
code: "custom",
|
|
94
|
+
path: ["items"],
|
|
95
|
+
message: "items must be an array",
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
switch (kind) {
|
|
100
|
+
case "nav-menu":
|
|
101
|
+
return items.map((it) => navMenuItem.parse(it));
|
|
102
|
+
case "taxonomy":
|
|
103
|
+
return items.map((it) => taxonomyItem.parse(it));
|
|
104
|
+
case "tags":
|
|
105
|
+
return items.map((it) => tagItem.parse(it));
|
|
106
|
+
case "link-list":
|
|
107
|
+
return items.map((it) => linkListItem.parse(it));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type NavMenuItem = z.infer<typeof navMenuItem>;
|
|
112
|
+
export type TaxonomyItem = z.infer<typeof taxonomyItem>;
|
|
113
|
+
export type TagItem = z.infer<typeof tagItem>;
|
|
114
|
+
export type LinkListItem = z.infer<typeof linkListItem>;
|
|
@@ -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
|
+
});
|