@caelo-cms/shared 0.10.72 → 0.11.1
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 +103 -142
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +79 -167
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +115 -35
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +31 -12
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +214 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +219 -84
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +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/escaped-line-breaks.d.ts +44 -0
- package/dist/escaped-line-breaks.d.ts.map +1 -0
- package/dist/escaped-line-breaks.js +203 -0
- package/dist/escaped-line-breaks.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +6 -6
- 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 +22 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +127 -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 +259 -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 +123 -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 +69 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +93 -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 +119 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +970 -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 +38 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +156 -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/redirect-plan.d.ts +13 -0
- package/dist/redirect-plan.d.ts.map +1 -1
- package/dist/redirect-plan.js +22 -0
- package/dist/redirect-plan.js.map +1 -1
- package/dist/seo.d.ts +21 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +32 -0
- package/dist/seo.js.map +1 -1
- package/dist/source-text-transport.d.ts +43 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +79 -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 +436 -0
- package/dist/template-sections.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 +51 -16
- package/src/ai-tools-add-module-modes.test.ts +51 -1
- package/src/ai-tools.test.ts +7 -10
- package/src/ai-tools.ts +90 -180
- package/src/bug-intake.test.ts +1 -1
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +34 -11
- package/src/content.test.ts +137 -4
- package/src/content.ts +230 -85
- package/src/css-scope-scan.ts +1 -1
- 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/escaped-line-breaks.ts +221 -0
- package/src/index.ts +4 -1
- package/src/interactions.ts +6 -6
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +67 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +141 -0
- package/src/lists/fields.ts +286 -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 +308 -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 +134 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +147 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +472 -0
- package/src/lists/resolve.ts +1246 -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 +147 -74
- package/src/nested-module-render.ts +120 -48
- package/src/preview-compose.test.ts +55 -19
- package/src/preview-compose.ts +59 -154
- package/src/redirect-plan.test.ts +25 -0
- package/src/redirect-plan.ts +24 -0
- package/src/seo.test.ts +58 -0
- package/src/seo.ts +49 -0
- package/src/source-text-transport.test.ts +158 -0
- package/src/source-text-transport.ts +81 -0
- package/src/template-engine-lists.test.ts +608 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +514 -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
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", () => {
|
|
@@ -90,7 +91,32 @@ describe("moduleCreateSchema", () => {
|
|
|
90
91
|
expect(moduleCreateSchema.safeParse({ ...base, js: 'xml += "]]>";' }).success).toBe(true);
|
|
91
92
|
});
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
// e2e-livedit run 40532297620 — html / css whose line breaks arrived as
|
|
95
|
+
// literal `\n` sequences rendered `\n` text and broke the CSS. AI tool
|
|
96
|
+
// arguments are decoded first (`decodeEscapedLineBreaks`); every other
|
|
97
|
+
// writer is refused with the fix.
|
|
98
|
+
it("rejects html and css whose line breaks are escaped, with the fix", () => {
|
|
99
|
+
const base = { slug: "site-footer", displayName: "Site footer", html: "<footer></footer>" };
|
|
100
|
+
const html = moduleCreateSchema.safeParse({
|
|
101
|
+
...base,
|
|
102
|
+
html: '<footer class="f">\\n <nav>\\n <a href="/">Home</a>\\n </nav>\\n</footer>',
|
|
103
|
+
});
|
|
104
|
+
expect(html.success).toBe(false);
|
|
105
|
+
if (!html.success) {
|
|
106
|
+
expect(html.error.issues.map((i) => i.message).join(" ")).toContain("real line breaks");
|
|
107
|
+
}
|
|
108
|
+
const css = moduleUpdateSchema.safeParse({
|
|
109
|
+
moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
|
|
110
|
+
css: ".f {\\n padding: 2rem;\\n}\\n.f a {\\n color: inherit;\\n}",
|
|
111
|
+
});
|
|
112
|
+
expect(css.success).toBe(false);
|
|
113
|
+
// One escaped newline inside a CSS string on a single line is not the pattern.
|
|
114
|
+
expect(
|
|
115
|
+
moduleCreateSchema.safeParse({ ...base, css: '.f::after { content: "\\n " }' }).success,
|
|
116
|
+
).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects CDATA on the update path too (update_modules_many / edit_content funnel)", () => {
|
|
94
120
|
const r = moduleUpdateSchema.safeParse({
|
|
95
121
|
moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
|
|
96
122
|
css: "/*<![CDATA[*/ .x{} /*]]>*/",
|
|
@@ -120,8 +146,9 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
|
|
|
120
146
|
expect(msg).toContain("numbered-scalar fanout");
|
|
121
147
|
expect(msg).toContain('"label", "label2", "label3"');
|
|
122
148
|
// AI-actionable: the error names every list-kind fix.
|
|
123
|
-
expect(msg).toContain("
|
|
124
|
-
expect(msg).toContain("
|
|
149
|
+
expect(msg).toContain('kind "list"');
|
|
150
|
+
expect(msg).toContain('listType "texts"');
|
|
151
|
+
expect(msg).toContain('"links"');
|
|
125
152
|
expect(msg).toContain("module-list");
|
|
126
153
|
expect(msg).toContain("§1A");
|
|
127
154
|
}
|
|
@@ -153,11 +180,83 @@ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
|
|
|
153
180
|
});
|
|
154
181
|
|
|
155
182
|
it("scopes to scalar kinds — numbered list fields are a modelling question, not fanout", () => {
|
|
156
|
-
const linkList = (name: string) => ({ name, kind: "
|
|
183
|
+
const linkList = (name: string) => ({ name, kind: "list", listType: "links", label: name });
|
|
157
184
|
expect(mod([linkList("links1"), linkList("links2"), linkList("links3")]).success).toBe(true);
|
|
158
185
|
});
|
|
159
186
|
});
|
|
160
187
|
|
|
188
|
+
// Epic #1107 — the `list` field kind (docs/lists.md §7): the static
|
|
189
|
+
// schema checks the binding's shape; fitting the type is the op's job.
|
|
190
|
+
describe("moduleFields list kind", () => {
|
|
191
|
+
const mod = (fields: unknown) =>
|
|
192
|
+
moduleCreateSchema.safeParse({ slug: "m", displayName: "M", html: "<div></div>", fields });
|
|
193
|
+
const LIST = "caelo:list/00000000-0000-4000-8000-000000000101";
|
|
194
|
+
const messages = (r: ReturnType<typeof mod>) =>
|
|
195
|
+
r.success ? [] : r.error.issues.map((i) => i.message);
|
|
196
|
+
|
|
197
|
+
it("accepts every binding", () => {
|
|
198
|
+
const ok = mod([
|
|
199
|
+
{
|
|
200
|
+
name: "bullets",
|
|
201
|
+
label: "B",
|
|
202
|
+
kind: "list",
|
|
203
|
+
listType: "texts",
|
|
204
|
+
default: [{ node: "text", values: { text: "x" } }],
|
|
205
|
+
},
|
|
206
|
+
{ name: "menu", label: "M", kind: "list", listType: "menu", bind: "shared", list: LIST },
|
|
207
|
+
{
|
|
208
|
+
name: "footer",
|
|
209
|
+
label: "F",
|
|
210
|
+
kind: "list",
|
|
211
|
+
listType: "links",
|
|
212
|
+
bind: "shared",
|
|
213
|
+
default: LIST,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "latest",
|
|
217
|
+
label: "L",
|
|
218
|
+
kind: "list",
|
|
219
|
+
listType: "links",
|
|
220
|
+
bind: "source",
|
|
221
|
+
source: {
|
|
222
|
+
kind: "query",
|
|
223
|
+
editable: ["limit"],
|
|
224
|
+
defaults: { page_type: "news", order: "published_desc", limit: 3 },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "crumbs",
|
|
229
|
+
label: "C",
|
|
230
|
+
kind: "list",
|
|
231
|
+
listType: "links",
|
|
232
|
+
bind: "source",
|
|
233
|
+
source: { kind: "system", name: "breadcrumbs" },
|
|
234
|
+
},
|
|
235
|
+
]);
|
|
236
|
+
expect(ok.success).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("refuses bindings whose parts do not fit together, naming the fix", () => {
|
|
240
|
+
const r = mod([
|
|
241
|
+
{ name: "a", label: "A", kind: "list", listType: "menu", bind: "source" },
|
|
242
|
+
{ name: "b", label: "B", kind: "list", listType: "menu", list: LIST },
|
|
243
|
+
{ name: "c", label: "C", kind: "list", listType: "menu", bind: "shared", default: [] },
|
|
244
|
+
{ name: "d", label: "D", kind: "list", listType: "texts", default: LIST },
|
|
245
|
+
]);
|
|
246
|
+
expect(messages(r)).toEqual([
|
|
247
|
+
'list field "a": bind "source" needs a source (query, system or plugin).',
|
|
248
|
+
'list field "b": list is only allowed with bind "shared" — a local list stores its entries in the placement.',
|
|
249
|
+
'list field "c": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.',
|
|
250
|
+
'list field "d": a local default is an array of entries, not a list reference.',
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("refuses the removed text-list / link-list kinds", () => {
|
|
255
|
+
expect(mod([{ name: "t", label: "T", kind: "text-list" }]).success).toBe(false);
|
|
256
|
+
expect(mod([{ name: "l", label: "L", kind: "link-list" }]).success).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
161
260
|
describe("pageCreateSchema (no raw HTML invariant)", () => {
|
|
162
261
|
it("accepts a structured page payload", () => {
|
|
163
262
|
const r = pageCreateSchema.safeParse({
|
|
@@ -250,3 +349,37 @@ describe("templateBlocksSetSchema", () => {
|
|
|
250
349
|
expect(r.success).toBe(true);
|
|
251
350
|
});
|
|
252
351
|
});
|
|
352
|
+
|
|
353
|
+
describe("corrupted code payloads are refused at the boundary (PR #1121 e2e)", () => {
|
|
354
|
+
const base = { slug: "footer", displayName: "Footer", html: "<footer>x</footer>" };
|
|
355
|
+
it("refuses JSON-encoded html and css with the fix in the message", () => {
|
|
356
|
+
const html = moduleCreateSchema.safeParse({
|
|
357
|
+
...base,
|
|
358
|
+
html: JSON.stringify('<footer class="x">{{#links}}{{label}}{{/links}}</footer>'),
|
|
359
|
+
});
|
|
360
|
+
expect(html.success).toBe(false);
|
|
361
|
+
expect(html.error?.issues[0]?.message).toContain("JSON-encoded");
|
|
362
|
+
const css = moduleCreateSchema.safeParse({
|
|
363
|
+
...base,
|
|
364
|
+
css: JSON.stringify(".x { color: red; }"),
|
|
365
|
+
});
|
|
366
|
+
expect(css.success).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
it("refuses HTML-escaped template markup", () => {
|
|
369
|
+
const r = templateCreateSchema.safeParse({
|
|
370
|
+
slug: "page",
|
|
371
|
+
displayName: "Page",
|
|
372
|
+
html: '<div><caelo-slot name="content"></caelo-slot></div>',
|
|
373
|
+
});
|
|
374
|
+
expect(r.success).toBe(false);
|
|
375
|
+
expect(r.error?.issues[0]?.message).toContain("HTML-escaped");
|
|
376
|
+
});
|
|
377
|
+
it("keeps legitimate markup: entities inside real tags, a quote-led text node", () => {
|
|
378
|
+
expect(
|
|
379
|
+
moduleCreateSchema.safeParse({ ...base, html: "<pre><div> is a tag</pre>" }).success,
|
|
380
|
+
).toBe(true);
|
|
381
|
+
expect(moduleCreateSchema.safeParse({ ...base, html: '"Quoted" <em>text</em>' }).success).toBe(
|
|
382
|
+
true,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
});
|
package/src/content.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { z } from "zod";
|
|
14
|
+
import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
|
|
15
|
+
import { listItemInputSchema } from "./lists/items.js";
|
|
16
|
+
import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
|
|
17
|
+
import { LIST_REF_RE } from "./lists/refs.js";
|
|
14
18
|
|
|
15
19
|
/** Lowercase slug, hyphenated, 1–64 chars, no leading/trailing hyphen. */
|
|
16
20
|
export const slugSchema = z
|
|
@@ -93,7 +97,69 @@ export const MODULE_JS_MAX = 256 * 1024;
|
|
|
93
97
|
export const TEMPLATE_HTML_MAX = 512 * 1024;
|
|
94
98
|
export const TEMPLATE_CSS_MAX = 128 * 1024;
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Code payloads a model sometimes corrupts before they reach us, both seen
|
|
102
|
+
* in PR #1121's real-AI runs and stored verbatim before this guard:
|
|
103
|
+
* - JSON-encoded: the whole value wrapped in quotes with `\"` escapes
|
|
104
|
+
* (`"<footer class=\"x\">…"`) — the page showed literal quotes;
|
|
105
|
+
* - HTML-escaped markup: `<div><caelo-slot …` with no real tag
|
|
106
|
+
* — a template without a single slot, so it had no blocks at all.
|
|
107
|
+
* Refusing at the write boundary with the fix turns a broken render the
|
|
108
|
+
* AI only notices from a screenshot into one corrected call (CLAUDE.md
|
|
109
|
+
* §2, §11; same reasoning as the CDATA guard below). AI tool arguments
|
|
110
|
+
* never reach this in that shape: `sourceTextArg` decodes both encodings
|
|
111
|
+
* first (`source-text-transport.ts`); this guards every other writer.
|
|
112
|
+
*/
|
|
113
|
+
export function jsonEncodedCodeMessage(field: string, value: string): string | null {
|
|
114
|
+
const t = value.trim();
|
|
115
|
+
if (t.length < 2 || !t.startsWith('"') || !t.endsWith('"')) return null;
|
|
116
|
+
try {
|
|
117
|
+
if (typeof JSON.parse(t) !== "string") return null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return `${field} is JSON-encoded (wrapped in quotes with \\" escapes) — send the raw ${field} itself, not a JSON string of it.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** See {@link jsonEncodedCodeMessage}: markup sent HTML-escaped. */
|
|
125
|
+
export function htmlEscapedMarkupMessage(field: string, value: string): string | null {
|
|
126
|
+
if (value.includes("<") || !/<\/?[a-zA-Z]/.test(value)) return null;
|
|
127
|
+
return `${field} is HTML-escaped (<…> instead of <…>) — send the markup itself, unescaped.`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* See {@link jsonEncodedCodeMessage}: source whose line breaks arrived as
|
|
132
|
+
* literal `\n` sequences (`hasEscapedLineBreaks`) — the page shows the
|
|
133
|
+
* `\n` text and the CSS rules after it break. AI tool arguments are decoded
|
|
134
|
+
* first (`decodeEscapedLineBreaks`); this guards every other writer. Module
|
|
135
|
+
* js is checked by parsing it (admin-core `module-js-syntax.ts`), which
|
|
136
|
+
* catches this and every other syntax error.
|
|
137
|
+
*/
|
|
138
|
+
export function escapedLineBreaksMessage(field: "html" | "css", value: string): string | null {
|
|
139
|
+
if (!hasEscapedLineBreaks(value, field)) return null;
|
|
140
|
+
return `${field} has its line breaks escaped (literal \\n sequences, no real line break) — send the ${field} with real line breaks.`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const noCorruptMarkup = (field: "html") => (value: string, ctx: z.RefinementCtx) => {
|
|
144
|
+
const message =
|
|
145
|
+
jsonEncodedCodeMessage(field, value) ??
|
|
146
|
+
htmlEscapedMarkupMessage(field, value) ??
|
|
147
|
+
escapedLineBreaksMessage(field, value);
|
|
148
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
149
|
+
};
|
|
150
|
+
const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
|
|
151
|
+
const message = jsonEncodedCodeMessage(field, value);
|
|
152
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
153
|
+
};
|
|
154
|
+
const noCorruptCss = (value: string, ctx: z.RefinementCtx) => {
|
|
155
|
+
const message = jsonEncodedCodeMessage("css", value) ?? escapedLineBreaksMessage("css", value);
|
|
156
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const moduleHtml = z
|
|
160
|
+
.string()
|
|
161
|
+
.max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`)
|
|
162
|
+
.superRefine(noCorruptMarkup("html"));
|
|
97
163
|
|
|
98
164
|
/**
|
|
99
165
|
* issue #432 root-cause guard — reject XML CDATA wrappers in module css/js.
|
|
@@ -122,11 +188,13 @@ const cdataMessage = (field: "css" | "js"): string =>
|
|
|
122
188
|
const moduleCss = z
|
|
123
189
|
.string()
|
|
124
190
|
.max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
|
|
125
|
-
.refine(noCdata, cdataMessage("css"))
|
|
191
|
+
.refine(noCdata, cdataMessage("css"))
|
|
192
|
+
.superRefine(noCorruptCss);
|
|
126
193
|
const moduleJs = z
|
|
127
194
|
.string()
|
|
128
195
|
.max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
|
|
129
|
-
.refine(noCdata, cdataMessage("js"))
|
|
196
|
+
.refine(noCdata, cdataMessage("js"))
|
|
197
|
+
.superRefine(noJsonEncodedCode("js"));
|
|
130
198
|
|
|
131
199
|
/**
|
|
132
200
|
* v0.4.0 — module field schema. Each field declares one substitution slot in
|
|
@@ -161,29 +229,29 @@ export const MODULE_FIELD_PRIMITIVE_KINDS = [
|
|
|
161
229
|
] as const;
|
|
162
230
|
|
|
163
231
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* Use `text-list` for "list of strings" — menu labels, tag chips,
|
|
170
|
-
* bullet points where each item is just text. Inner template
|
|
171
|
-
* references `{{.}}` (Mustache convention) or `{{item}}` to mean
|
|
172
|
-
* the current element.
|
|
232
|
+
* Epic #1107 — the data-list field kind (`docs/lists.md` §7). One kind
|
|
233
|
+
* for every repeated piece of data: feature bullets, a footer link group,
|
|
234
|
+
* a three-level menu, a mega dropdown, a "latest news" block. The entry
|
|
235
|
+
* shape comes from a LIST TYPE (`listType`, a slug); the entries come from
|
|
236
|
+
* the field's binding:
|
|
173
237
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
238
|
+
* - `local` (default) — entries stored in the placement's value
|
|
239
|
+
* (`ListItem[]`), only this module shows them;
|
|
240
|
+
* - `shared` — a `caelo:list/<id>` list, fixed on the field (`list`) or
|
|
241
|
+
* chosen per placement; navigation and anything needed in
|
|
242
|
+
* more than one place;
|
|
243
|
+
* - `source` — entries generated per page from `source` (a page query,
|
|
244
|
+
* a system source like breadcrumbs, a plugin source).
|
|
177
245
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
246
|
+
* The template iterates `{{#field}}…{{/field}}` the same way for every
|
|
247
|
+
* binding. `module-list` stays: it composes modules, lists carry data.
|
|
248
|
+
* Replaces the `text-list` / `link-list` kinds.
|
|
181
249
|
*/
|
|
182
|
-
export const MODULE_FIELD_LIST_KINDS = ["
|
|
250
|
+
export const MODULE_FIELD_LIST_KINDS = ["list"] as const;
|
|
183
251
|
|
|
184
252
|
export const MODULE_FIELD_NESTED_KINDS = ["module", "module-list"] as const;
|
|
185
253
|
|
|
186
|
-
/**
|
|
254
|
+
/** Every module field kind. */
|
|
187
255
|
export const MODULE_FIELD_KINDS = [
|
|
188
256
|
...MODULE_FIELD_PRIMITIVE_KINDS,
|
|
189
257
|
...MODULE_FIELD_LIST_KINDS,
|
|
@@ -200,10 +268,9 @@ export type ModuleFieldKind = (typeof MODULE_FIELD_KINDS)[number];
|
|
|
200
268
|
|
|
201
269
|
/**
|
|
202
270
|
* Field kinds the shared template engine iterates via the
|
|
203
|
-
* `{{#name}}…{{/name}}` Mustache-section operator
|
|
204
|
-
* list
|
|
205
|
-
*
|
|
206
|
-
* keys off it (`packages/shared/src/template-engine.ts`).
|
|
271
|
+
* `{{#name}}…{{/name}}` Mustache-section operator (`list` and
|
|
272
|
+
* `module-list`); the engine's section-dispatch keys off it
|
|
273
|
+
* (`packages/shared/src/template-engine.ts`).
|
|
207
274
|
*/
|
|
208
275
|
export const MODULE_FIELD_SECTION_KINDS = [...MODULE_FIELD_LIST_KINDS, "module-list"] as const;
|
|
209
276
|
|
|
@@ -230,7 +297,7 @@ const moduleFieldModuleSchema = z
|
|
|
230
297
|
* stable `modules.type` class, e.g. `button` — NOT the unique
|
|
231
298
|
* `modules.slug` like `button-mpqxq3ch`). When absent, any module is
|
|
232
299
|
* permitted. The op-layer validator enforces the whitelist against
|
|
233
|
-
* the referenced module's `type` at `
|
|
300
|
+
* the referenced module's `type` at `content_instances.set_values`
|
|
234
301
|
* time. v0.12.3 (issue #106) renamed this from `allowedModuleSlugs`:
|
|
235
302
|
* an exact-slug whitelist could never match an AI-minted module
|
|
236
303
|
* because every minted slug carries a uniqueness suffix.
|
|
@@ -255,43 +322,84 @@ const moduleFieldModuleListSchema = z
|
|
|
255
322
|
.strict();
|
|
256
323
|
|
|
257
324
|
/**
|
|
258
|
-
*
|
|
259
|
-
* the
|
|
260
|
-
* is
|
|
261
|
-
*
|
|
325
|
+
* Epic #1107 — a `list` field (`docs/lists.md` §7). The static schema
|
|
326
|
+
* checks the SHAPE: the binding's parts fit together and a local default
|
|
327
|
+
* is well-formed entries. Whether those entries fit the named list type
|
|
328
|
+
* (nodes, fields, depth) needs the type, which is stored data — the op
|
|
329
|
+
* layer validates that with `validateListItems` when the module is
|
|
330
|
+
* written, and the renderer reports `list-type-missing:<slug>` for a type
|
|
331
|
+
* that is gone.
|
|
262
332
|
*/
|
|
263
|
-
const
|
|
333
|
+
const moduleFieldListSchema = z
|
|
264
334
|
.object({
|
|
265
335
|
name: moduleFieldName,
|
|
266
|
-
kind: z.literal("
|
|
336
|
+
kind: z.literal("list"),
|
|
267
337
|
label: moduleFieldLabel,
|
|
338
|
+
/** Slug of the list type that defines the entries. */
|
|
339
|
+
listType: slugSchema,
|
|
340
|
+
bind: z.enum(["local", "shared", "source"]).optional(),
|
|
341
|
+
/** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
|
|
342
|
+
list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
|
|
343
|
+
/** `bind: "source"` — where generated entries come from. */
|
|
344
|
+
source: sourceDefSchema.optional(),
|
|
268
345
|
min: z.number().int().nonnegative().optional(),
|
|
269
|
-
max: z.number().int().positive().max(
|
|
270
|
-
|
|
346
|
+
max: z.number().int().positive().max(LIST_MAX_ITEMS).optional(),
|
|
347
|
+
/** `local`: entries; `shared`: `caelo:list/<id>`. */
|
|
348
|
+
default: z
|
|
349
|
+
.union([
|
|
350
|
+
z.array(listItemInputSchema),
|
|
351
|
+
z.string().regex(LIST_REF_RE, "a shared default must be caelo:list/<id>"),
|
|
352
|
+
])
|
|
353
|
+
.optional(),
|
|
271
354
|
})
|
|
272
355
|
.strict();
|
|
273
356
|
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
.
|
|
282
|
-
name:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
.
|
|
357
|
+
/** Binding consistency of one `list` field; every problem, AI-actionable. */
|
|
358
|
+
function listFieldIssues(f: z.infer<typeof moduleFieldListSchema>): string[] {
|
|
359
|
+
const bind = f.bind ?? "local";
|
|
360
|
+
const issues: string[] = [];
|
|
361
|
+
if (bind === "source" && f.source === undefined) {
|
|
362
|
+
issues.push(`list field "${f.name}": bind "source" needs a source (query, system or plugin).`);
|
|
363
|
+
}
|
|
364
|
+
if (bind !== "source" && f.source !== undefined) {
|
|
365
|
+
issues.push(`list field "${f.name}": source is only allowed with bind "source".`);
|
|
366
|
+
}
|
|
367
|
+
if (bind === "local" && f.list !== undefined) {
|
|
368
|
+
issues.push(
|
|
369
|
+
`list field "${f.name}": list is only allowed with bind "shared" — a local list stores its entries in the placement.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (bind === "source" && f.list !== undefined && f.source?.kind !== "system") {
|
|
373
|
+
issues.push(
|
|
374
|
+
`list field "${f.name}": list on a source field names the vocabulary of a page_terms source only.`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (f.default !== undefined) {
|
|
378
|
+
if (bind === "local" && typeof f.default === "string") {
|
|
379
|
+
issues.push(
|
|
380
|
+
`list field "${f.name}": a local default is an array of entries, not a list reference.`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (bind === "shared" && Array.isArray(f.default)) {
|
|
384
|
+
issues.push(
|
|
385
|
+
`list field "${f.name}": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (bind === "source") {
|
|
389
|
+
issues.push(
|
|
390
|
+
`list field "${f.name}": a source field has no default; its entries are generated.`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (f.min !== undefined && f.max !== undefined && f.min > f.max) {
|
|
395
|
+
issues.push(`list field "${f.name}": min is greater than max.`);
|
|
396
|
+
}
|
|
397
|
+
return issues;
|
|
398
|
+
}
|
|
290
399
|
|
|
291
400
|
export const moduleFieldSchema = z.discriminatedUnion("kind", [
|
|
292
401
|
moduleFieldPrimitiveSchema,
|
|
293
|
-
|
|
294
|
-
moduleFieldLinkListSchema,
|
|
402
|
+
moduleFieldListSchema,
|
|
295
403
|
moduleFieldModuleSchema,
|
|
296
404
|
moduleFieldModuleListSchema,
|
|
297
405
|
]);
|
|
@@ -368,6 +476,11 @@ const moduleFieldsArray = z
|
|
|
368
476
|
return;
|
|
369
477
|
}
|
|
370
478
|
seen.add(f.name);
|
|
479
|
+
if (f.kind === "list") {
|
|
480
|
+
for (const message of listFieldIssues(f)) {
|
|
481
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
371
484
|
}
|
|
372
485
|
for (const run of findNumberedScalarFanoutRuns(arr)) {
|
|
373
486
|
ctx.addIssue({
|
|
@@ -375,8 +488,9 @@ const moduleFieldsArray = z
|
|
|
375
488
|
message:
|
|
376
489
|
`numbered-scalar fanout: ${run.map((n) => `"${n}"`).join(", ")} repeat one role as ` +
|
|
377
490
|
"numbered scalar fields — repeating content is ONE list field, never numbered scalars " +
|
|
378
|
-
'(CLAUDE.md §1A). Replace the run with a single field: kind "
|
|
379
|
-
'strings
|
|
491
|
+
'(CLAUDE.md §1A). Replace the run with a single field: kind "list" with a list type ' +
|
|
492
|
+
'(listType "texts" for plain strings — the template iterates {{#f}}{{text}}{{/f}}; ' +
|
|
493
|
+
'"links" for label + link — {{#f}}<a href="{{link}}">{{label}}</a>{{/f}}), or ' +
|
|
380
494
|
'"module-list" (rich per-item structure: mint a card sub-module and reference it per ' +
|
|
381
495
|
"item — in build_page, author the card detached with `ref` and pass " +
|
|
382
496
|
'[{"$ref": "<ref>"}, …] as the field value). A two-field split like col1/col2 is fine; ' +
|
|
@@ -398,7 +512,7 @@ export type ModuleKind = (typeof MODULE_KINDS)[number];
|
|
|
398
512
|
/**
|
|
399
513
|
* v0.12.0 — description is `.default("")` at the Zod boundary so the
|
|
400
514
|
* 82+ legacy callers in tests/seed scripts keep working unchanged.
|
|
401
|
-
* AI tool descriptions (
|
|
515
|
+
* AI tool descriptions (add_module / build_page / update_modules_many) require the AI
|
|
402
516
|
* to supply a real description explicitly — see CLAUDE.md §1A.
|
|
403
517
|
*/
|
|
404
518
|
const moduleDescription = z.string().max(1000);
|
|
@@ -440,31 +554,29 @@ export const moduleUpdateSchema = z
|
|
|
440
554
|
})
|
|
441
555
|
.strict();
|
|
442
556
|
|
|
443
|
-
/**
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"home",
|
|
451
|
-
"landing",
|
|
452
|
-
"product",
|
|
453
|
-
"blog",
|
|
454
|
-
"doc",
|
|
455
|
-
"content",
|
|
456
|
-
"utility",
|
|
457
|
-
] as const;
|
|
458
|
-
export const templateKindSchema = z.enum(TEMPLATE_KINDS);
|
|
459
|
-
export type TemplateKind = (typeof TEMPLATE_KINDS)[number];
|
|
557
|
+
/** A page type slug (epic #1107, docs/lists.md §5). */
|
|
558
|
+
export const pageTypeSlugSchema = z
|
|
559
|
+
.string()
|
|
560
|
+
.regex(
|
|
561
|
+
/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
|
|
562
|
+
"page type slug: lowercase letters, digits, hyphens",
|
|
563
|
+
);
|
|
460
564
|
|
|
461
565
|
export const templateCreateSchema = z
|
|
462
566
|
.object({
|
|
463
567
|
slug: slugSchema,
|
|
464
568
|
displayName: displayNameSchema,
|
|
465
|
-
/**
|
|
466
|
-
|
|
467
|
-
|
|
569
|
+
/**
|
|
570
|
+
* Epic #1107 — the page type (slug) this template serves; the template
|
|
571
|
+
* is appended to that type's `templates`. Omit for the site's default
|
|
572
|
+
* page type (`site_defaults.default_page_type_id`, stored data — not a
|
|
573
|
+
* render-time fallback).
|
|
574
|
+
*/
|
|
575
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
576
|
+
html: z
|
|
577
|
+
.string()
|
|
578
|
+
.max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`)
|
|
579
|
+
.superRefine(noCorruptMarkup("html")),
|
|
468
580
|
css: z.string().max(TEMPLATE_CSS_MAX, `css exceeds ${TEMPLATE_CSS_MAX} bytes`).default(""),
|
|
469
581
|
/**
|
|
470
582
|
* P6.7.6 — every template binds to one layout. When omitted, the
|
|
@@ -505,9 +617,9 @@ export const templateUpdateSchema = z
|
|
|
505
617
|
.object({
|
|
506
618
|
templateId: z.string().uuid(),
|
|
507
619
|
displayName: displayNameSchema.optional(),
|
|
508
|
-
/**
|
|
509
|
-
|
|
510
|
-
html: z.string().max(TEMPLATE_HTML_MAX).optional(),
|
|
620
|
+
/** Epic #1107 — also allow this template for the page type (slug); appended to its `templates`. */
|
|
621
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
622
|
+
html: z.string().max(TEMPLATE_HTML_MAX).superRefine(noCorruptMarkup("html")).optional(),
|
|
511
623
|
css: z.string().max(TEMPLATE_CSS_MAX).optional(),
|
|
512
624
|
/** P6.7.6 — re-point the template to a different layout. */
|
|
513
625
|
layoutId: z.string().uuid().optional(),
|
|
@@ -536,6 +648,15 @@ export const templateBlocksSetSchema = z
|
|
|
536
648
|
|
|
537
649
|
export const pageStatusSchema = z.enum(["draft", "published"]);
|
|
538
650
|
|
|
651
|
+
/** `pages.short_title` (#1142): a crumb-sized public label, never blank. */
|
|
652
|
+
export const pageShortTitleSchema = z
|
|
653
|
+
.string()
|
|
654
|
+
.trim()
|
|
655
|
+
.min(1, "the short title is the page's breadcrumb label and cannot be blank")
|
|
656
|
+
// Same bound as the title it is backfilled and defaulted from, so every
|
|
657
|
+
// stored page stays editable; keeping it crumb-sized is guidance.
|
|
658
|
+
.max(256);
|
|
659
|
+
|
|
539
660
|
/**
|
|
540
661
|
* Page payloads are `.strict()` — passing an `html` field is rejected by Zod
|
|
541
662
|
* before the handler runs. This is the §3.1 "no raw HTML on pages" invariant
|
|
@@ -545,9 +666,13 @@ export const pageStatusSchema = z.enum(["draft", "published"]);
|
|
|
545
666
|
export const pageCreateSchema = z
|
|
546
667
|
.object({
|
|
547
668
|
slug: slugSchema,
|
|
548
|
-
/** P6.7.5 — internal editor label. Defaults to title if omitted. */
|
|
549
|
-
name: z.string().min(1).max(256).optional(),
|
|
550
669
|
title: z.string().min(1).max(256),
|
|
670
|
+
/**
|
|
671
|
+
* #1142 — the page's public label in breadcrumbs, navigation and the
|
|
672
|
+
* page picker ("Pricing" for "Pricing for teams of every size").
|
|
673
|
+
* Omitted = the create-time resolver stores `title` (stored data).
|
|
674
|
+
*/
|
|
675
|
+
shortTitle: pageShortTitleSchema.optional(),
|
|
551
676
|
/**
|
|
552
677
|
* P6.7.6 — optional. When omitted, the handler resolves to
|
|
553
678
|
* `site_defaults.default_template_id` at create time. Stored data,
|
|
@@ -568,6 +693,16 @@ export const pageCreateSchema = z
|
|
|
568
693
|
* becomes "<parent's path>/<slug>". Omit (or null) for a top-level page.
|
|
569
694
|
*/
|
|
570
695
|
parentPageId: z.string().uuid().nullable().optional(),
|
|
696
|
+
/**
|
|
697
|
+
* Epic #1107 — what the page IS (page type slug). Omit to take the type
|
|
698
|
+
* whose `templates` contain the template; with neither `pageType` nor
|
|
699
|
+
* `templateId`, the site's default page type and its default template.
|
|
700
|
+
*/
|
|
701
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
702
|
+
/** Property values, validated against the page type. */
|
|
703
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
704
|
+
/** Terms (`caelo:term/<listId>/<itemId>`) from the page type's vocabularies. */
|
|
705
|
+
terms: z.array(z.string().max(120)).max(100).optional(),
|
|
571
706
|
})
|
|
572
707
|
.strict();
|
|
573
708
|
|
|
@@ -602,9 +737,9 @@ export const pageUpdateSchema = z
|
|
|
602
737
|
* Routes that load the page first should always pass it back.
|
|
603
738
|
*/
|
|
604
739
|
expectedVersion: z.number().int().nonnegative().optional(),
|
|
605
|
-
/** P6.7.5 — three independently-editable identifiers. */
|
|
606
|
-
name: z.string().min(1).max(256).optional(),
|
|
607
740
|
title: z.string().min(1).max(256).optional(),
|
|
741
|
+
/** #1142 — the breadcrumb / navigation label; see {@link pageCreateSchema}. */
|
|
742
|
+
shortTitle: pageShortTitleSchema.optional(),
|
|
608
743
|
slug: slugSchema.optional(),
|
|
609
744
|
templateId: z.string().uuid().optional(),
|
|
610
745
|
status: pageStatusSchema.optional(),
|
|
@@ -620,6 +755,16 @@ export const pageUpdateSchema = z
|
|
|
620
755
|
* every inbound link, which is never a safe default (CLAUDE.md §2).
|
|
621
756
|
*/
|
|
622
757
|
redirectFromOld: z.enum(["auto", "skip"]).optional(),
|
|
758
|
+
/**
|
|
759
|
+
* Epic #1107 — switch the page type (slug). The template must be one of
|
|
760
|
+
* the type's templates (pass `templateId` too when it is not), and the
|
|
761
|
+
* page's properties and terms must fit the new type.
|
|
762
|
+
*/
|
|
763
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
764
|
+
/** Merged into the page's properties; `null` removes a property. */
|
|
765
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
766
|
+
/** Replaces the page's terms (`caelo:term/<listId>/<itemId>`). */
|
|
767
|
+
terms: z.array(z.string().max(120)).max(100).optional(),
|
|
623
768
|
})
|
|
624
769
|
.strict();
|
|
625
770
|
|
|
@@ -669,10 +814,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
|
|
|
669
814
|
|
|
670
815
|
/**
|
|
671
816
|
* v0.12.0 — why this row exists as a shared/reusable instance.
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
817
|
+
* Shown by the AI's list_content_instances tool so the AI can decide
|
|
818
|
+
* *reuse the synced row* vs *fork to unsynced* vs *mint new* without
|
|
819
|
+
* asking the operator. Required by the AI's
|
|
820
|
+
* create_content_instances tool description; legacy callers may pass
|
|
676
821
|
* null (the migration-0093 unsynced rows have purpose=NULL).
|
|
677
822
|
* See CLAUDE.md §1A.
|
|
678
823
|
*/
|
package/src/css-scope-scan.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* that is not tied to the module bleeds into every other module on the
|
|
8
8
|
* page: an `h1 {}` in the hero restyles the pricing table's heading, a
|
|
9
9
|
* `.card {}` in one module restyles another module's cards. The
|
|
10
|
-
* convention (
|
|
10
|
+
* convention (update_modules_many description, design-quality skill) is to nest
|
|
11
11
|
* module CSS under the module's own root class; this scanner is the
|
|
12
12
|
* mechanical write-time check that rides the authoring tool's result,
|
|
13
13
|
* so the AI fixes the selector in the same turn. A warning, not a gate:
|