@caelo-cms/shared 0.10.71 → 0.11.0
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 +125 -50
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +50 -38
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +110 -30
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +26 -7
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +203 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +178 -76
- package/dist/content.js.map +1 -1
- package/dist/css-var-aliases.d.ts +20 -0
- package/dist/css-var-aliases.d.ts.map +1 -0
- package/dist/css-var-aliases.js +95 -0
- package/dist/css-var-aliases.js.map +1 -0
- package/dist/css-var-scan.d.ts +0 -16
- package/dist/css-var-scan.d.ts.map +1 -1
- package/dist/css-var-scan.js +24 -0
- package/dist/css-var-scan.js.map +1 -1
- package/dist/edit-markers.d.ts +41 -5
- package/dist/edit-markers.d.ts.map +1 -1
- package/dist/edit-markers.js +47 -50
- package/dist/edit-markers.js.map +1 -1
- package/dist/id-safe-truncate.d.ts +15 -0
- package/dist/id-safe-truncate.d.ts.map +1 -0
- package/dist/id-safe-truncate.js +47 -0
- package/dist/id-safe-truncate.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +2 -2
- package/dist/interactions.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +7 -1
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/link-refs.d.ts +1 -1
- package/dist/link-refs.js +3 -3
- package/dist/lists/address.d.ts +34 -0
- package/dist/lists/address.d.ts.map +1 -0
- package/dist/lists/address.js +43 -0
- package/dist/lists/address.js.map +1 -0
- package/dist/lists/default-types.d.ts +10 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +91 -0
- package/dist/lists/default-types.js.map +1 -0
- package/dist/lists/fields.d.ts +152 -0
- package/dist/lists/fields.d.ts.map +1 -0
- package/dist/lists/fields.js +258 -0
- package/dist/lists/fields.js.map +1 -0
- package/dist/lists/index.d.ts +14 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +15 -0
- package/dist/lists/index.js.map +1 -0
- package/dist/lists/item-attr.d.ts +26 -0
- package/dist/lists/item-attr.d.ts.map +1 -0
- package/dist/lists/item-attr.js +31 -0
- package/dist/lists/item-attr.js.map +1 -0
- package/dist/lists/items.d.ts +105 -0
- package/dist/lists/items.d.ts.map +1 -0
- package/dist/lists/items.js +304 -0
- package/dist/lists/items.js.map +1 -0
- package/dist/lists/list-type.d.ts +401 -0
- package/dist/lists/list-type.d.ts.map +1 -0
- package/dist/lists/list-type.js +275 -0
- package/dist/lists/list-type.js.map +1 -0
- package/dist/lists/model.d.ts +14 -0
- package/dist/lists/model.d.ts.map +1 -0
- package/dist/lists/model.js +15 -0
- package/dist/lists/model.js.map +1 -0
- package/dist/lists/page-type.d.ts +115 -0
- package/dist/lists/page-type.d.ts.map +1 -0
- package/dist/lists/page-type.js +66 -0
- package/dist/lists/page-type.js.map +1 -0
- package/dist/lists/refs.d.ts +13 -0
- package/dist/lists/refs.d.ts.map +1 -0
- package/dist/lists/refs.js +18 -0
- package/dist/lists/refs.js.map +1 -0
- package/dist/lists/render-data.d.ts +114 -0
- package/dist/lists/render-data.d.ts.map +1 -0
- package/dist/lists/render-data.js +3 -0
- package/dist/lists/render-data.js.map +1 -0
- package/dist/lists/render-deps.d.ts +68 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +91 -0
- package/dist/lists/render-deps.js.map +1 -0
- package/dist/lists/resolve-values.d.ts +63 -0
- package/dist/lists/resolve-values.d.ts.map +1 -0
- package/dist/lists/resolve-values.js +181 -0
- package/dist/lists/resolve-values.js.map +1 -0
- package/dist/lists/resolve.d.ts +106 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +878 -0
- package/dist/lists/resolve.js.map +1 -0
- package/dist/lists/scope.d.ts +81 -0
- package/dist/lists/scope.d.ts.map +1 -0
- package/dist/lists/scope.js +3 -0
- package/dist/lists/scope.js.map +1 -0
- package/dist/markup-structure.d.ts +8 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +2 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +25 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +122 -31
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts +24 -21
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +34 -118
- package/dist/preview-compose.js.map +1 -1
- package/dist/preview-scanner.d.ts +5 -2
- package/dist/preview-scanner.d.ts.map +1 -1
- package/dist/preview-scanner.js +5 -3
- package/dist/preview-scanner.js.map +1 -1
- package/dist/source-text-transport.d.ts +17 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +71 -0
- package/dist/source-text-transport.js.map +1 -0
- package/dist/template-engine.d.ts +44 -31
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +75 -174
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts +69 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/dist/template-sections.js +423 -0
- package/dist/template-sections.js.map +1 -0
- package/dist/uuid-diagnose.d.ts +9 -0
- package/dist/uuid-diagnose.d.ts.map +1 -0
- package/dist/uuid-diagnose.js +47 -0
- package/dist/uuid-diagnose.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/src/__tests__/redos-hardening.test.ts +50 -16
- package/src/ai-tools-add-module-modes.test.ts +50 -0
- package/src/ai-tools.test.ts +10 -0
- package/src/ai-tools.ts +53 -39
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +29 -6
- package/src/content.test.ts +111 -3
- package/src/content.ts +188 -77
- package/src/css-var-aliases.test.ts +61 -0
- package/src/css-var-aliases.ts +109 -0
- package/src/css-var-scan.ts +9 -0
- package/src/edit-markers.test.ts +37 -21
- package/src/edit-markers.ts +52 -20
- package/src/id-safe-truncate.test.ts +35 -0
- package/src/id-safe-truncate.ts +46 -0
- package/src/index.ts +5 -1
- package/src/interactions.ts +2 -2
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +66 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +102 -0
- package/src/lists/fields.ts +285 -0
- package/src/lists/index.ts +16 -0
- package/src/lists/item-attr.test.ts +36 -0
- package/src/lists/item-attr.ts +49 -0
- package/src/lists/items.ts +402 -0
- package/src/lists/list-fixtures.test.ts +302 -0
- package/src/lists/list-type.ts +306 -0
- package/src/lists/lists.test.ts +328 -0
- package/src/lists/model.ts +16 -0
- package/src/lists/page-type.ts +71 -0
- package/src/lists/refs.ts +21 -0
- package/src/lists/render-data.ts +125 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +144 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +295 -0
- package/src/lists/resolve.ts +1148 -0
- package/src/lists/scope.ts +69 -0
- package/src/lists-compose-parity.test.ts +262 -0
- package/src/markup-structure.ts +10 -0
- package/src/nested-module-render.test.ts +111 -77
- package/src/nested-module-render.ts +85 -48
- package/src/preview-compose.test.ts +74 -19
- package/src/preview-compose.ts +59 -154
- package/src/preview-scanner.test.ts +8 -4
- package/src/preview-scanner.ts +5 -3
- package/src/source-text-transport.test.ts +63 -0
- package/src/source-text-transport.ts +70 -0
- package/src/template-engine-lists.test.ts +553 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +500 -0
- package/src/uuid-diagnose.test.ts +28 -0
- package/src/uuid-diagnose.ts +50 -0
- package/src/version.ts +1 -1
- package/dist/structured-sets.d.ts +0 -70
- package/dist/structured-sets.d.ts.map +0 -1
- package/dist/structured-sets.js +0 -92
- package/dist/structured-sets.js.map +0 -1
- package/src/structured-sets.ts +0 -114
- package/src/template-engine-data-lists.test.ts +0 -90
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { describe, expect, it } from "bun:test";
|
|
14
14
|
import { fontFamilySlug, parseFontsCss } from "../fonts.js";
|
|
15
|
+
import { DEFAULT_LIST_TYPES } from "../lists/default-types.js";
|
|
16
|
+
import { createListRenderContext } from "../lists/resolve.js";
|
|
15
17
|
import { renderTemplate } from "../template-engine.js";
|
|
16
18
|
import { stripCssComments } from "../theme-importers/css-comments.js";
|
|
17
19
|
import { trimSlashes, trimTrailingSlashes } from "../url.js";
|
|
@@ -59,36 +61,68 @@ describe("stripCssComments (S4)", () => {
|
|
|
59
61
|
});
|
|
60
62
|
});
|
|
61
63
|
|
|
62
|
-
describe("renderTemplate
|
|
64
|
+
describe("renderTemplate sections (S4, epic #1107 tokenizer)", () => {
|
|
65
|
+
const lists = createListRenderContext({
|
|
66
|
+
listTypes: new Map(DEFAULT_LIST_TYPES.map((t) => [t.slug, { id: t.slug, def: t }])),
|
|
67
|
+
lists: new Map(),
|
|
68
|
+
pageTypes: new Map(),
|
|
69
|
+
pages: new Map(),
|
|
70
|
+
media: new Map(),
|
|
71
|
+
siteLanguage: "en",
|
|
72
|
+
}).forPage(null);
|
|
73
|
+
const navField = { name: "nav_items", kind: "list" as const, listType: "links" };
|
|
74
|
+
const entry = (n: number, label: string, href: string) => ({
|
|
75
|
+
id: `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`,
|
|
76
|
+
node: "link",
|
|
77
|
+
values: { label, link: { href } },
|
|
78
|
+
});
|
|
79
|
+
|
|
63
80
|
it("scans a large closed section body linearly (no quadratic backtracking)", () => {
|
|
64
|
-
// A closed section with a 100k-char literal body
|
|
65
|
-
//
|
|
66
|
-
// the polynomial shape this guards against.
|
|
81
|
+
// A closed section with a 100k-char literal body: the section pass
|
|
82
|
+
// tokenizes once and pairs tags with a stack, no regex over the body.
|
|
67
83
|
const big = "x".repeat(100_000);
|
|
68
|
-
const html = `<nav>{{#nav_items}}${big}<a href="{{
|
|
84
|
+
const html = `<nav>{{#nav_items}}${big}<a href="{{link}}">{{label}}</a>{{/nav_items}}</nav>`;
|
|
69
85
|
expect(
|
|
70
86
|
underBudget(() =>
|
|
71
87
|
renderTemplate({
|
|
72
88
|
html,
|
|
73
|
-
fields: [
|
|
74
|
-
contentValues: { nav_items: [
|
|
89
|
+
fields: [navField],
|
|
90
|
+
contentValues: { nav_items: [entry(1, "Docs", "/docs")] },
|
|
91
|
+
lists,
|
|
75
92
|
}),
|
|
76
93
|
),
|
|
77
94
|
).toBeLessThan(BUDGET_MS);
|
|
78
95
|
});
|
|
79
96
|
|
|
97
|
+
it("thousands of unclosed sections cost one pass, not one scan each", () => {
|
|
98
|
+
// Unclosed opens stay unpaired (the later Mustache pass reports them);
|
|
99
|
+
// pairing must not rescan the rest of the document per open.
|
|
100
|
+
const html = "{{#a}}".repeat(5_000) + "x".repeat(50_000);
|
|
101
|
+
expect(
|
|
102
|
+
underBudget(() => {
|
|
103
|
+
try {
|
|
104
|
+
renderTemplate({
|
|
105
|
+
html,
|
|
106
|
+
fields: [{ name: "a", kind: "text" }],
|
|
107
|
+
contentValues: { a: "1" },
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
// Mustache refuses the unclosed sections; only the cost matters here.
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
).toBeLessThan(BUDGET_MS);
|
|
114
|
+
});
|
|
115
|
+
|
|
80
116
|
it("still iterates a closed section the same way", () => {
|
|
81
117
|
const out = renderTemplate({
|
|
82
|
-
html: '<nav>{{#nav_items}}<a href="{{
|
|
83
|
-
fields: [
|
|
84
|
-
contentValues: {
|
|
85
|
-
|
|
86
|
-
{ label: "Docs", href: "/docs" },
|
|
87
|
-
{ label: "Blog", href: "/blog" },
|
|
88
|
-
],
|
|
89
|
-
},
|
|
118
|
+
html: '<nav>{{#nav_items}}<a href="{{link}}">{{label}}</a>{{/nav_items}}</nav>',
|
|
119
|
+
fields: [navField],
|
|
120
|
+
contentValues: { nav_items: [entry(1, "Docs", "/docs"), entry(2, "Blog", "/blog")] },
|
|
121
|
+
lists,
|
|
90
122
|
});
|
|
91
|
-
expect(out.html
|
|
123
|
+
expect(out.html.replace(/ data-caelo-list-item="[^"]*"/g, "")).toBe(
|
|
124
|
+
'<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>',
|
|
125
|
+
);
|
|
92
126
|
});
|
|
93
127
|
});
|
|
94
128
|
|
|
@@ -104,3 +104,53 @@ describe("addModuleToolInput modes (issue #159)", () => {
|
|
|
104
104
|
expect(r.success).toBe(false);
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("addModuleToolInput — list fields carry their own content (PR #1121 e2e)", () => {
|
|
109
|
+
const LIST = "caelo:list/22222222-2222-4222-8222-222222222222";
|
|
110
|
+
const footer = (fields: unknown[]) =>
|
|
111
|
+
addModuleToolInput.safeParse({
|
|
112
|
+
...BASE,
|
|
113
|
+
target: "layout" as const,
|
|
114
|
+
targetRef: "site-default",
|
|
115
|
+
blockName: "footer",
|
|
116
|
+
displayName: "Footer",
|
|
117
|
+
html: '<nav>{{#footer_links}}<a href="{{link}}">{{label}}</a>{{/footer_links}}</nav>',
|
|
118
|
+
fields,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("accepts a footer bound to a shared list without values or defaults", () => {
|
|
122
|
+
const r = footer([
|
|
123
|
+
{
|
|
124
|
+
name: "footer_links",
|
|
125
|
+
kind: "list",
|
|
126
|
+
label: "Links",
|
|
127
|
+
listType: "menu",
|
|
128
|
+
bind: "shared",
|
|
129
|
+
list: LIST,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
expect(r.success).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("accepts a list fed by a source", () => {
|
|
136
|
+
const r = footer([
|
|
137
|
+
{
|
|
138
|
+
name: "footer_links",
|
|
139
|
+
kind: "list",
|
|
140
|
+
label: "Crumbs",
|
|
141
|
+
listType: "links",
|
|
142
|
+
bind: "source",
|
|
143
|
+
source: { kind: "system", name: "breadcrumbs" },
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
expect(r.success).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("still refuses a shared list field with no list and no value, naming the list route", () => {
|
|
150
|
+
const r = footer([
|
|
151
|
+
{ name: "footer_links", kind: "list", label: "Links", listType: "menu", bind: "shared" },
|
|
152
|
+
]);
|
|
153
|
+
expect(r.success).toBe(false);
|
|
154
|
+
expect(r.error?.issues[0]?.message).toContain("upsert_lists_many");
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/ai-tools.test.ts
CHANGED
|
@@ -33,6 +33,16 @@ describe("editModuleToolInput", () => {
|
|
|
33
33
|
});
|
|
34
34
|
expect(r.success).toBe(false);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// e2e-livedit run 40351690739 — `{moduleId}` alone reached the op.
|
|
38
|
+
it("refuses an edit that changes nothing, naming what to pass", () => {
|
|
39
|
+
for (const input of [{ moduleId: UUID }, { moduleId: UUID, bindThemeLiterals: false }]) {
|
|
40
|
+
const r = editModuleToolInput.safeParse(input);
|
|
41
|
+
expect(r.success).toBe(false);
|
|
42
|
+
if (!r.success) expect(r.error.issues[0]?.message).toContain("edit_module changes nothing");
|
|
43
|
+
}
|
|
44
|
+
expect(editModuleToolInput.safeParse({ moduleId: UUID, kind: "hero" }).success).toBe(true);
|
|
45
|
+
});
|
|
36
46
|
});
|
|
37
47
|
|
|
38
48
|
describe("siteMemoryProposeToolInput", () => {
|
package/src/ai-tools.ts
CHANGED
|
@@ -20,13 +20,16 @@ import {
|
|
|
20
20
|
MODULE_CSS_MAX,
|
|
21
21
|
MODULE_HTML_MAX,
|
|
22
22
|
MODULE_JS_MAX,
|
|
23
|
+
type ModuleField,
|
|
23
24
|
moduleFieldSchema,
|
|
25
|
+
pageTypeSlugSchema,
|
|
24
26
|
setPlacementContentSchema,
|
|
25
27
|
slugSchema,
|
|
26
28
|
} from "./content.js";
|
|
29
|
+
import { LIST_ITEM_ATTR_RE } from "./lists/item-attr.js";
|
|
27
30
|
import { pluginPreviewSelectionSchema } from "./plugin-preview.js";
|
|
28
31
|
import { redirectTargetPathSchema } from "./redirect-target.js";
|
|
29
|
-
import {
|
|
32
|
+
import { decodeSourceTextTransport } from "./source-text-transport.js";
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
* v0.6.2 — `position` argument shared across the three `add_module_to_*`
|
|
@@ -61,15 +64,16 @@ function stripSurroundingQuotes(v: unknown): unknown {
|
|
|
61
64
|
}
|
|
62
65
|
/**
|
|
63
66
|
* An AI tool argument holding source text (html / css / js): the given
|
|
64
|
-
* string schema, with
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
67
|
+
* string schema, with whole-value transport encodings decoded first — a
|
|
68
|
+
* CDATA envelope, a JSON string literal, HTML-escaped markup (see
|
|
69
|
+
* `decodeSourceTextTransport`). Every AI authoring surface for module,
|
|
70
|
+
* template and layout source uses it, so one encoding habit of the model
|
|
71
|
+
* is handled in one place instead of refused per tool.
|
|
68
72
|
*
|
|
69
73
|
* @param schema - the string schema (length caps, min) the decoded value must satisfy.
|
|
70
74
|
*/
|
|
71
75
|
export function sourceTextArg<T extends z.ZodType<string>>(schema: T) {
|
|
72
|
-
return z.preprocess(
|
|
76
|
+
return z.preprocess(decodeSourceTextTransport, schema);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export const positionInputSchema = z.preprocess(
|
|
@@ -89,7 +93,7 @@ export const editModuleToolInput = z
|
|
|
89
93
|
displayName: z.string().min(1).max(128).optional(),
|
|
90
94
|
/**
|
|
91
95
|
* v0.12.0 — rewrite the module's purpose. Optional; passing it
|
|
92
|
-
* updates the
|
|
96
|
+
* updates the `list_modules` catalog your future self will read.
|
|
93
97
|
*/
|
|
94
98
|
description: z.string().max(1000).optional(),
|
|
95
99
|
/** v0.12.0 — re-classify the module's role tag. */
|
|
@@ -114,7 +118,15 @@ export const editModuleToolInput = z
|
|
|
114
118
|
/** issue #164 slice 2 — see addModuleToolInput.bindThemeLiterals. */
|
|
115
119
|
bindThemeLiterals: z.boolean().optional(),
|
|
116
120
|
})
|
|
117
|
-
.strict()
|
|
121
|
+
.strict()
|
|
122
|
+
// e2e-livedit run 40351690739: a call with only a (placeholder)
|
|
123
|
+
// moduleId reached the op and came back as a bare "module not found".
|
|
124
|
+
// An edit that changes nothing is refused before any lookup, with the
|
|
125
|
+
// fields that make it one.
|
|
126
|
+
.refine((v) => Object.keys(v).some((k) => k !== "moduleId" && k !== "bindThemeLiterals"), {
|
|
127
|
+
message:
|
|
128
|
+
"edit_module changes nothing: pass what to change (html, css, js, fields, displayName, description, kind or type). list_modules shows the modules; add_module creates a new one.",
|
|
129
|
+
});
|
|
118
130
|
|
|
119
131
|
/**
|
|
120
132
|
* v0.4.0 — `set_page_module_content` AI tool. Fills the content values for
|
|
@@ -263,7 +275,7 @@ export const addModuleToolInput = z
|
|
|
263
275
|
ctx.addIssue({
|
|
264
276
|
code: z.ZodIssueCode.custom,
|
|
265
277
|
message:
|
|
266
|
-
"Pass either `moduleId` (place an existing module from
|
|
278
|
+
"Pass either `moduleId` (place an existing module from list_modules) " +
|
|
267
279
|
"or `displayName` + `html` (mint a new module).",
|
|
268
280
|
});
|
|
269
281
|
}
|
|
@@ -274,17 +286,25 @@ export const addModuleToolInput = z
|
|
|
274
286
|
// the exact two-step build_page was created to kill. Only enforced
|
|
275
287
|
// when the caller authored explicit fields; the raw-HTML path goes
|
|
276
288
|
// through moduleize, which enforces defaults itself.
|
|
289
|
+
// A `list` field bound to a shared list the module fixes (`list`), or
|
|
290
|
+
// fed by a source, already has its content: requiring a default there
|
|
291
|
+
// refused the very footer-bound-to-the-menu call the manage-menu skill
|
|
292
|
+
// prescribes (PR #1121 e2e-livedit run 40297574140).
|
|
293
|
+
const carriesContent = (f: ModuleField): boolean =>
|
|
294
|
+
("default" in f && f.default !== undefined) ||
|
|
295
|
+
(f.kind === "list" && (f.bind === "source" || (f.bind === "shared" && f.list !== undefined)));
|
|
277
296
|
if (
|
|
278
297
|
input.fields !== undefined &&
|
|
279
298
|
input.fields.length > 0 &&
|
|
280
299
|
input.values === undefined &&
|
|
281
|
-
!input.fields.some(
|
|
300
|
+
!input.fields.some(carriesContent)
|
|
282
301
|
) {
|
|
283
302
|
ctx.addIssue({
|
|
284
303
|
code: z.ZodIssueCode.custom,
|
|
285
304
|
message:
|
|
286
305
|
"A minted module needs its initial content IN THIS CALL: pass `values` " +
|
|
287
306
|
"({fieldName: value, …}) for this placement, or give the fields `default`s. " +
|
|
307
|
+
'A list field bound to a site list carries its content through `list` ({kind:"list", bind:"shared", list:"caelo:list/<id>"} — create the list first with upsert_lists_many). ' +
|
|
288
308
|
"Without either, the module renders empty placeholders until a second call.",
|
|
289
309
|
});
|
|
290
310
|
}
|
|
@@ -355,7 +375,6 @@ export const AI_TOOLS = [
|
|
|
355
375
|
"delete_pages_many",
|
|
356
376
|
// one remove-module tool routed by target (page|layout).
|
|
357
377
|
"remove_module_from",
|
|
358
|
-
"set_structured_set",
|
|
359
378
|
"update_theme",
|
|
360
379
|
"set_template_layout",
|
|
361
380
|
"create_layout",
|
|
@@ -451,9 +470,14 @@ export const CHAT_MAX_ATTACHMENTS = 4;
|
|
|
451
470
|
* `context` come from a right-click / text selection in the live-edit
|
|
452
471
|
* preview (#916): what the operator selected and the text of the block
|
|
453
472
|
* around it, so the AI knows exactly what was meant. `blockName` /
|
|
454
|
-
* `position` / `field`
|
|
473
|
+
* `position` / `field` (#917) — plus `item` / `key` for an entry of a
|
|
474
|
+
* local list field (epic #1107) — name the placement field the text
|
|
455
475
|
* comes from when the preview marked it, so the AI can address it in
|
|
456
476
|
* `set_page_module_content` without inspecting the page first.
|
|
477
|
+
* `listItem` is the `data-caelo-list-item` of the list entry the element
|
|
478
|
+
* sits in (epic #1107, `docs/lists.md` §13): the chip then names that
|
|
479
|
+
* entry — of a shared list, or of a module's own list — not just the
|
|
480
|
+
* module around it.
|
|
457
481
|
*/
|
|
458
482
|
export const chatChipSchema = z
|
|
459
483
|
.object({
|
|
@@ -465,7 +489,12 @@ export const chatChipSchema = z
|
|
|
465
489
|
blockName: z.string().min(1).max(80).optional(),
|
|
466
490
|
position: z.number().int().nonnegative().optional(),
|
|
467
491
|
field: z.string().min(1).max(200).optional(),
|
|
468
|
-
|
|
492
|
+
item: z.string().uuid().optional(),
|
|
493
|
+
key: z
|
|
494
|
+
.string()
|
|
495
|
+
.regex(/^[a-z][a-z0-9_]{0,63}$/)
|
|
496
|
+
.optional(),
|
|
497
|
+
listItem: z.string().max(200).regex(LIST_ITEM_ATTR_RE).optional(),
|
|
469
498
|
})
|
|
470
499
|
.strict();
|
|
471
500
|
export type ChatChip = z.infer<typeof chatChipSchema>;
|
|
@@ -555,11 +584,8 @@ export const chatPublishInput = z
|
|
|
555
584
|
.array(
|
|
556
585
|
z
|
|
557
586
|
.object({
|
|
558
|
-
// v0.9.0 —
|
|
559
|
-
//
|
|
560
|
-
// helper accepted "structuredSet" via the underlying SQL
|
|
561
|
-
// query but the input schema rejected it — minor gap closed
|
|
562
|
-
// alongside the branched-create work.
|
|
587
|
+
// v0.9.0 — "layout" joined the enum so the Stage picker can
|
|
588
|
+
// include/exclude it.
|
|
563
589
|
kind: z.enum([
|
|
564
590
|
"module",
|
|
565
591
|
"template",
|
|
@@ -567,7 +593,10 @@ export const chatPublishInput = z
|
|
|
567
593
|
"pageLayout",
|
|
568
594
|
"pageModuleContent",
|
|
569
595
|
"layout",
|
|
570
|
-
|
|
596
|
+
// Epic #1107 — shared lists, list types, page types.
|
|
597
|
+
"listType",
|
|
598
|
+
"list",
|
|
599
|
+
"pageType",
|
|
571
600
|
// v0.11.0 (#45) — themes primitive joins the publish/Stage
|
|
572
601
|
// surface so chat-branched theme edits replay into live
|
|
573
602
|
// on chat.publish.
|
|
@@ -650,6 +679,8 @@ export const createTemplateToolInput = z
|
|
|
650
679
|
html: sourceTextArg(z.string().min(1).max(2_000_000)),
|
|
651
680
|
css: sourceTextArg(z.string().max(2_000_000)).default(""),
|
|
652
681
|
layoutId: z.string().uuid().optional(),
|
|
682
|
+
/** Epic #1107 — the page type (slug) this template serves; omit for the site default. */
|
|
683
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
653
684
|
// Optional block-set metadata, symmetric with create_layout. Omit to
|
|
654
685
|
// auto-derive blocks from <caelo-slot> tags; pass to set displayName /
|
|
655
686
|
// position (each name must match a slot). Enforced op-side in
|
|
@@ -682,25 +713,9 @@ export const removeModuleFromToolInput = z
|
|
|
682
713
|
.strict();
|
|
683
714
|
export type RemoveModuleFromToolInput = z.infer<typeof removeModuleFromToolInput>;
|
|
684
715
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// from any AI tool — the kind-specific wrappers covered only
|
|
689
|
-
// nav-menu + theme).
|
|
690
|
-
kind: z.enum(["nav-menu", "taxonomy", "tags", "link-list"]),
|
|
691
|
-
slug: slugInputSchema,
|
|
692
|
-
displayName: z.string().min(1).max(200),
|
|
693
|
-
items: z.array(z.unknown()),
|
|
694
|
-
})
|
|
695
|
-
.strict();
|
|
696
|
-
|
|
697
|
-
// v0.10.22 — `updateThemeToolInput` removed. The kind-specific `update_theme`
|
|
698
|
-
// wrapper was replaced by the unified `set_structured_set` surface.
|
|
699
|
-
// v0.11.0 (#45) — theme moved out of structured_sets entirely into its
|
|
700
|
-
// own `themes` primitive (DTCG-shaped jsonb). Theme edits are now
|
|
701
|
-
// `set_theme_tokens` (loose-name patch) / `propose_create_theme`
|
|
702
|
-
// (gated mint) / `propose_activate_theme` (gated flip) — see
|
|
703
|
-
// packages/admin-core/src/ai/tools/{update-theme-tokens,get-theme,…}.ts.
|
|
716
|
+
// v0.11.0 (#45) — theme edits are `set_theme_tokens` (loose-name patch) /
|
|
717
|
+
// `propose_create_theme` (gated mint) / `propose_activate_theme` (gated
|
|
718
|
+
// flip) — see packages/admin-core/src/ai/tools/{update-theme-tokens,get-theme,…}.ts.
|
|
704
719
|
|
|
705
720
|
export const setTemplateLayoutToolInput = z
|
|
706
721
|
.object({
|
|
@@ -1037,7 +1052,6 @@ export type BulkOptimizeSeoToolInput = z.infer<typeof bulkOptimizeSeoToolInput>;
|
|
|
1037
1052
|
export type EditModuleToolInput = z.infer<typeof editModuleToolInput>;
|
|
1038
1053
|
export type SiteMemoryProposeToolInput = z.infer<typeof siteMemoryProposeToolInput>;
|
|
1039
1054
|
export type CreateTemplateToolInput = z.infer<typeof createTemplateToolInput>;
|
|
1040
|
-
export type SetStructuredSetToolInput = z.infer<typeof setStructuredSetToolInput>;
|
|
1041
1055
|
// v0.10.22 — `UpdateThemeToolInput` removed alongside `update_theme` tool.
|
|
1042
1056
|
export type ChatCreateSessionInput = z.infer<typeof chatCreateSessionInput>;
|
|
1043
1057
|
export type ChatSendMessageInput = z.infer<typeof chatSendMessageInput>;
|
package/src/build-page.test.ts
CHANGED
|
@@ -39,6 +39,14 @@ describe("buildPageInputSchema — page target modes", () => {
|
|
|
39
39
|
expect(r.success).toBe(true);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it("accepts parentPageId: null as top level, like pages.create (PR #1121 e2e)", () => {
|
|
43
|
+
const r = buildPageInputSchema.safeParse({
|
|
44
|
+
page: { slug: "pricing", title: "Pricing", parentPageId: null },
|
|
45
|
+
modules: [mintModule],
|
|
46
|
+
});
|
|
47
|
+
expect(r.success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
it("accepts existing mode (pageId only)", () => {
|
|
43
51
|
const r = buildPageInputSchema.safeParse({
|
|
44
52
|
page: { pageId: UUID },
|
|
@@ -140,10 +148,10 @@ describe("buildPageInputSchema — module entry modes name the failing index", (
|
|
|
140
148
|
{
|
|
141
149
|
blockName: "content",
|
|
142
150
|
displayName: "Nav",
|
|
143
|
-
html: "<nav>{{#nav_links}}<a href='{{
|
|
151
|
+
html: "<nav>{{#nav_links}}<a href='{{link}}'>{{label}}</a>{{/nav_links}}</nav>",
|
|
144
152
|
fields: [
|
|
145
|
-
{ name: "nav_links", kind: "
|
|
146
|
-
{ name: "tags", kind: "
|
|
153
|
+
{ name: "nav_links", kind: "list", listType: "links", label: "Nav links" },
|
|
154
|
+
{ name: "tags", kind: "list", listType: "texts", label: "Tags", min: 0, max: 12 },
|
|
147
155
|
],
|
|
148
156
|
},
|
|
149
157
|
],
|
|
@@ -190,6 +198,29 @@ describe("buildPageInputSchema — module entry modes name the failing index", (
|
|
|
190
198
|
});
|
|
191
199
|
});
|
|
192
200
|
|
|
201
|
+
describe("buildPageInputSchema — ref handles", () => {
|
|
202
|
+
// e2e-livedit run 40336739359 (PR #1143): the AI named three cards
|
|
203
|
+
// `cardA` / `cardB` / `cardC` and the lowercase-only rule refused the
|
|
204
|
+
// whole call for a handle that never leaves it.
|
|
205
|
+
it("accepts mixed-case handles and refuses ones that do not start with a letter", () => {
|
|
206
|
+
const { blockName: _b, ...detached } = mintModule;
|
|
207
|
+
for (const ref of ["cardA", "card_b", "feat-1"]) {
|
|
208
|
+
const r = buildPageInputSchema.safeParse({
|
|
209
|
+
page: { pageId: UUID },
|
|
210
|
+
modules: [{ ...detached, ref }],
|
|
211
|
+
});
|
|
212
|
+
expect(r.success).toBe(true);
|
|
213
|
+
}
|
|
214
|
+
for (const ref of ["1card", "$card", "card a"]) {
|
|
215
|
+
const r = buildPageInputSchema.safeParse({
|
|
216
|
+
page: { pageId: UUID },
|
|
217
|
+
modules: [{ ...detached, ref }],
|
|
218
|
+
});
|
|
219
|
+
expect(r.success).toBe(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
193
224
|
describe("buildPageContentSchema — the three sources", () => {
|
|
194
225
|
it("inline requires values", () => {
|
|
195
226
|
expect(buildPageContentSchema.safeParse({ source: "inline" }).success).toBe(false);
|
package/src/build-page.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
MODULE_JS_MAX,
|
|
29
29
|
moduleFieldSchema,
|
|
30
30
|
moduleKindSchema,
|
|
31
|
+
pageCreateSchema,
|
|
31
32
|
pageStatusSchema,
|
|
32
33
|
slugSchema,
|
|
33
34
|
syncModeSchema,
|
|
@@ -130,10 +131,13 @@ export const buildPageModuleSchema = z
|
|
|
130
131
|
*/
|
|
131
132
|
ref: z
|
|
132
133
|
.string()
|
|
133
|
-
.regex(
|
|
134
|
+
.regex(
|
|
135
|
+
/^[A-Za-z][A-Za-z0-9_-]{0,31}$/,
|
|
136
|
+
"ref must be a short handle: a letter, then letters, digits, _ or -",
|
|
137
|
+
)
|
|
134
138
|
.optional(),
|
|
135
139
|
/**
|
|
136
|
-
* Place mode: an existing module from
|
|
140
|
+
* Place mode: an existing module from `list_modules` (UUID), or a
|
|
137
141
|
* module minted EARLIER IN THIS CALL via `{"$ref": "<handle>"}` —
|
|
138
142
|
* the second-placement case (e.g. three feature cards reusing one
|
|
139
143
|
* card module). Live-edit run A showed the model writing
|
|
@@ -145,7 +149,7 @@ export const buildPageModuleSchema = z
|
|
|
145
149
|
z
|
|
146
150
|
.string()
|
|
147
151
|
.uuid(
|
|
148
|
-
'moduleId must be a UUID from
|
|
152
|
+
'moduleId must be a UUID from list_modules — to re-place a module minted earlier in THIS call, pass {"$ref": "<its ref>"} instead',
|
|
149
153
|
),
|
|
150
154
|
z.object({ $ref: z.string() }).strict(),
|
|
151
155
|
])
|
|
@@ -195,7 +199,7 @@ export const buildPageModuleSchema = z
|
|
|
195
199
|
ctx.addIssue({
|
|
196
200
|
code: z.ZodIssueCode.custom,
|
|
197
201
|
message:
|
|
198
|
-
"Pass either `moduleId` (place an existing module from
|
|
202
|
+
"Pass either `moduleId` (place an existing module from list_modules) " +
|
|
199
203
|
"or `displayName` + `html` (mint a new module).",
|
|
200
204
|
});
|
|
201
205
|
}
|
|
@@ -224,7 +228,16 @@ export const buildPageTargetSchema = z
|
|
|
224
228
|
* "<parent's path>/<slug>". Create mode only (an existing page moves
|
|
225
229
|
* with `pages.move_many`).
|
|
226
230
|
*/
|
|
227
|
-
|
|
231
|
+
// null = top level, same as pages.create (sibling tools must agree — #106 class).
|
|
232
|
+
parentPageId: z.string().uuid().nullable().optional(),
|
|
233
|
+
/**
|
|
234
|
+
* Epic #1107 — what the new page IS (page type slug), its properties
|
|
235
|
+
* and terms; same semantics as `pages.create` (create mode only — an
|
|
236
|
+
* existing page changes type with `pages.update`).
|
|
237
|
+
*/
|
|
238
|
+
pageType: pageCreateSchema.shape.pageType,
|
|
239
|
+
properties: pageCreateSchema.shape.properties,
|
|
240
|
+
terms: pageCreateSchema.shape.terms,
|
|
228
241
|
/**
|
|
229
242
|
* Migration linkage (issue #278 flow): the `import_pages` row this
|
|
230
243
|
* page rebuilds. The op stamps `import_pages.accepted_page_id` with
|
|
@@ -252,7 +265,17 @@ export const buildPageTargetSchema = z
|
|
|
252
265
|
.strict()
|
|
253
266
|
.superRefine((page, ctx) => {
|
|
254
267
|
const createKeys = (
|
|
255
|
-
[
|
|
268
|
+
[
|
|
269
|
+
"slug",
|
|
270
|
+
"title",
|
|
271
|
+
"name",
|
|
272
|
+
"templateId",
|
|
273
|
+
"status",
|
|
274
|
+
"parentPageId",
|
|
275
|
+
"pageType",
|
|
276
|
+
"properties",
|
|
277
|
+
"terms",
|
|
278
|
+
] as const
|
|
256
279
|
).filter((k) => page[k] !== undefined);
|
|
257
280
|
if (page.pageId !== undefined) {
|
|
258
281
|
if (createKeys.length > 0) {
|
package/src/content.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
pageUpdateSchema,
|
|
11
11
|
slugSchema,
|
|
12
12
|
templateBlocksSetSchema,
|
|
13
|
+
templateCreateSchema,
|
|
13
14
|
} from "./content.js";
|
|
14
15
|
|
|
15
16
|
describe("slugSchema", () => {
|
|
@@ -120,8 +121,9 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
|
|
|
120
121
|
expect(msg).toContain("numbered-scalar fanout");
|
|
121
122
|
expect(msg).toContain('"label", "label2", "label3"');
|
|
122
123
|
// AI-actionable: the error names every list-kind fix.
|
|
123
|
-
expect(msg).toContain("
|
|
124
|
-
expect(msg).toContain("
|
|
124
|
+
expect(msg).toContain('kind "list"');
|
|
125
|
+
expect(msg).toContain('listType "texts"');
|
|
126
|
+
expect(msg).toContain('"links"');
|
|
125
127
|
expect(msg).toContain("module-list");
|
|
126
128
|
expect(msg).toContain("§1A");
|
|
127
129
|
}
|
|
@@ -153,11 +155,83 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
|
|
|
153
155
|
});
|
|
154
156
|
|
|
155
157
|
it("scopes to scalar kinds — numbered list fields are a modelling question, not fanout", () => {
|
|
156
|
-
const linkList = (name: string) => ({ name, kind: "
|
|
158
|
+
const linkList = (name: string) => ({ name, kind: "list", listType: "links", label: name });
|
|
157
159
|
expect(mod([linkList("links1"), linkList("links2"), linkList("links3")]).success).toBe(true);
|
|
158
160
|
});
|
|
159
161
|
});
|
|
160
162
|
|
|
163
|
+
// Epic #1107 — the `list` field kind (docs/lists.md §7): the static
|
|
164
|
+
// schema checks the binding's shape; fitting the type is the op's job.
|
|
165
|
+
describe("moduleFields list kind", () => {
|
|
166
|
+
const mod = (fields: unknown) =>
|
|
167
|
+
moduleCreateSchema.safeParse({ slug: "m", displayName: "M", html: "<div></div>", fields });
|
|
168
|
+
const LIST = "caelo:list/00000000-0000-4000-8000-000000000101";
|
|
169
|
+
const messages = (r: ReturnType<typeof mod>) =>
|
|
170
|
+
r.success ? [] : r.error.issues.map((i) => i.message);
|
|
171
|
+
|
|
172
|
+
it("accepts every binding", () => {
|
|
173
|
+
const ok = mod([
|
|
174
|
+
{
|
|
175
|
+
name: "bullets",
|
|
176
|
+
label: "B",
|
|
177
|
+
kind: "list",
|
|
178
|
+
listType: "texts",
|
|
179
|
+
default: [{ node: "text", values: { text: "x" } }],
|
|
180
|
+
},
|
|
181
|
+
{ name: "menu", label: "M", kind: "list", listType: "menu", bind: "shared", list: LIST },
|
|
182
|
+
{
|
|
183
|
+
name: "footer",
|
|
184
|
+
label: "F",
|
|
185
|
+
kind: "list",
|
|
186
|
+
listType: "links",
|
|
187
|
+
bind: "shared",
|
|
188
|
+
default: LIST,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "latest",
|
|
192
|
+
label: "L",
|
|
193
|
+
kind: "list",
|
|
194
|
+
listType: "links",
|
|
195
|
+
bind: "source",
|
|
196
|
+
source: {
|
|
197
|
+
kind: "query",
|
|
198
|
+
editable: ["limit"],
|
|
199
|
+
defaults: { page_type: "news", order: "published_desc", limit: 3 },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "crumbs",
|
|
204
|
+
label: "C",
|
|
205
|
+
kind: "list",
|
|
206
|
+
listType: "links",
|
|
207
|
+
bind: "source",
|
|
208
|
+
source: { kind: "system", name: "breadcrumbs" },
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
expect(ok.success).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("refuses bindings whose parts do not fit together, naming the fix", () => {
|
|
215
|
+
const r = mod([
|
|
216
|
+
{ name: "a", label: "A", kind: "list", listType: "menu", bind: "source" },
|
|
217
|
+
{ name: "b", label: "B", kind: "list", listType: "menu", list: LIST },
|
|
218
|
+
{ name: "c", label: "C", kind: "list", listType: "menu", bind: "shared", default: [] },
|
|
219
|
+
{ name: "d", label: "D", kind: "list", listType: "texts", default: LIST },
|
|
220
|
+
]);
|
|
221
|
+
expect(messages(r)).toEqual([
|
|
222
|
+
'list field "a": bind "source" needs a source (query, system or plugin).',
|
|
223
|
+
'list field "b": list is only allowed with bind "shared" — a local list stores its entries in the placement.',
|
|
224
|
+
'list field "c": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.',
|
|
225
|
+
'list field "d": a local default is an array of entries, not a list reference.',
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("refuses the removed text-list / link-list kinds", () => {
|
|
230
|
+
expect(mod([{ name: "t", label: "T", kind: "text-list" }]).success).toBe(false);
|
|
231
|
+
expect(mod([{ name: "l", label: "L", kind: "link-list" }]).success).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
161
235
|
describe("pageCreateSchema (no raw HTML invariant)", () => {
|
|
162
236
|
it("accepts a structured page payload", () => {
|
|
163
237
|
const r = pageCreateSchema.safeParse({
|
|
@@ -250,3 +324,37 @@ describe("templateBlocksSetSchema", () => {
|
|
|
250
324
|
expect(r.success).toBe(true);
|
|
251
325
|
});
|
|
252
326
|
});
|
|
327
|
+
|
|
328
|
+
describe("corrupted code payloads are refused at the boundary (PR #1121 e2e)", () => {
|
|
329
|
+
const base = { slug: "footer", displayName: "Footer", html: "<footer>x</footer>" };
|
|
330
|
+
it("refuses JSON-encoded html and css with the fix in the message", () => {
|
|
331
|
+
const html = moduleCreateSchema.safeParse({
|
|
332
|
+
...base,
|
|
333
|
+
html: JSON.stringify('<footer class="x">{{#links}}{{label}}{{/links}}</footer>'),
|
|
334
|
+
});
|
|
335
|
+
expect(html.success).toBe(false);
|
|
336
|
+
expect(html.error?.issues[0]?.message).toContain("JSON-encoded");
|
|
337
|
+
const css = moduleCreateSchema.safeParse({
|
|
338
|
+
...base,
|
|
339
|
+
css: JSON.stringify(".x { color: red; }"),
|
|
340
|
+
});
|
|
341
|
+
expect(css.success).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
it("refuses HTML-escaped template markup", () => {
|
|
344
|
+
const r = templateCreateSchema.safeParse({
|
|
345
|
+
slug: "page",
|
|
346
|
+
displayName: "Page",
|
|
347
|
+
html: '<div><caelo-slot name="content"></caelo-slot></div>',
|
|
348
|
+
});
|
|
349
|
+
expect(r.success).toBe(false);
|
|
350
|
+
expect(r.error?.issues[0]?.message).toContain("HTML-escaped");
|
|
351
|
+
});
|
|
352
|
+
it("keeps legitimate markup: entities inside real tags, a quote-led text node", () => {
|
|
353
|
+
expect(
|
|
354
|
+
moduleCreateSchema.safeParse({ ...base, html: "<pre><div> is a tag</pre>" }).success,
|
|
355
|
+
).toBe(true);
|
|
356
|
+
expect(moduleCreateSchema.safeParse({ ...base, html: '"Quoted" <em>text</em>' }).success).toBe(
|
|
357
|
+
true,
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
});
|