@caelo-cms/shared 0.11.0 → 0.11.2
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 +32 -144
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +46 -139
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +46 -3
- package/dist/bug-intake.d.ts.map +1 -1
- package/dist/bug-intake.js +52 -3
- package/dist/bug-intake.js.map +1 -1
- package/dist/build-page.d.ts +5 -5
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +14 -9
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +14 -2
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +73 -15
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +1 -1
- package/dist/css-var-scan.js +3 -3
- package/dist/css-var-scan.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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +5 -5
- package/dist/interactions.js.map +1 -1
- package/dist/lists/default-types.d.ts +12 -0
- package/dist/lists/default-types.d.ts.map +1 -1
- package/dist/lists/default-types.js +36 -0
- package/dist/lists/default-types.js.map +1 -1
- package/dist/lists/fields.d.ts.map +1 -1
- package/dist/lists/fields.js +1 -0
- package/dist/lists/fields.js.map +1 -1
- package/dist/lists/render-data.d.ts +9 -0
- package/dist/lists/render-data.d.ts.map +1 -1
- package/dist/lists/render-deps.d.ts +4 -3
- package/dist/lists/render-deps.d.ts.map +1 -1
- package/dist/lists/render-deps.js +5 -3
- package/dist/lists/render-deps.js.map +1 -1
- package/dist/lists/resolve.d.ts +13 -0
- package/dist/lists/resolve.d.ts.map +1 -1
- package/dist/lists/resolve.js +95 -3
- package/dist/lists/resolve.js.map +1 -1
- package/dist/live-events.d.ts +44 -0
- package/dist/live-events.d.ts.map +1 -0
- package/dist/live-events.js +128 -0
- package/dist/live-events.js.map +1 -0
- package/dist/markup-structure.d.ts +20 -2
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +69 -3
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +15 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js.map +1 -1
- package/dist/module-id-tag.d.ts +4 -1
- package/dist/module-id-tag.d.ts.map +1 -1
- package/dist/module-id-tag.js +11 -2
- package/dist/module-id-tag.js.map +1 -1
- package/dist/nested-module-render.d.ts +13 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +34 -0
- package/dist/nested-module-render.js.map +1 -1
- package/dist/preview-compose.d.ts +18 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +26 -7
- 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 +28 -2
- package/dist/source-text-transport.d.ts.map +1 -1
- package/dist/source-text-transport.js +12 -4
- package/dist/source-text-transport.js.map +1 -1
- package/dist/template-sections.d.ts.map +1 -1
- package/dist/template-sections.js +17 -4
- package/dist/template-sections.js.map +1 -1
- package/dist/theme-render.js +11 -26
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +12 -9
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/redos-hardening.test.ts +1 -0
- package/src/ai-tools-add-module-modes.test.ts +1 -1
- package/src/ai-tools.test.ts +7 -20
- package/src/ai-tools.ts +57 -151
- package/src/bug-intake.test.ts +48 -1
- package/src/bug-intake.ts +58 -3
- package/src/build-page.ts +14 -9
- package/src/content.test.ts +26 -1
- package/src/content.ts +84 -16
- package/src/css-scope-scan.ts +1 -1
- package/src/css-var-scan.ts +3 -3
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +2 -0
- package/src/interactions.ts +5 -5
- package/src/link-refs.test.ts +1 -0
- package/src/lists/default-types.ts +39 -0
- package/src/lists/fields.ts +1 -0
- package/src/lists/list-fixtures.test.ts +9 -3
- package/src/lists/render-data.ts +9 -0
- package/src/lists/render-deps.ts +6 -3
- package/src/lists/resolve.test.ts +182 -5
- package/src/lists/resolve.ts +101 -3
- package/src/live-events.ts +138 -0
- package/src/markup-structure.test.ts +47 -0
- package/src/markup-structure.ts +74 -3
- package/src/media.ts +12 -0
- package/src/module-id-tag.test.ts +38 -0
- package/src/module-id-tag.ts +11 -2
- package/src/nested-module-render.test.ts +39 -0
- package/src/nested-module-render.ts +35 -0
- package/src/preview-compose-deferrals.test.ts +44 -0
- package/src/preview-compose.ts +47 -10
- 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 +98 -3
- package/src/source-text-transport.ts +15 -4
- package/src/template-engine-lists.test.ts +55 -0
- package/src/template-sections.ts +18 -4
- package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
- package/src/theme-render-sanitize.test.ts +8 -0
- package/src/theme-render.ts +11 -26
- package/src/themes.ts +12 -7
- package/src/version.ts +1 -1
package/src/build-page.ts
CHANGED
|
@@ -43,10 +43,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
|
|
|
43
43
|
* existing content paths so build_page adds NO new semantics, only batching:
|
|
44
44
|
*
|
|
45
45
|
* - `inline` → mint a private (unsynced) content_instance carrying
|
|
46
|
-
* `values`, exactly what `
|
|
46
|
+
* `values`, exactly what `set_page_module_content_many` produces for a
|
|
47
47
|
* fresh placement.
|
|
48
48
|
* - `shared` → mint a reusable content_instance (purpose required —
|
|
49
|
-
* same decision-support contract as `
|
|
49
|
+
* same decision-support contract as `create_content_instances`) and
|
|
50
50
|
* bind this placement to it, `synced` by default.
|
|
51
51
|
* - `existing` → bind an already-minted content_instance (reuse-first
|
|
52
52
|
* per CLAUDE.md §1A) — the batched form of `set_placement_content`.
|
|
@@ -94,7 +94,7 @@ export type BuildPageContent = z.infer<typeof buildPageContentSchema>;
|
|
|
94
94
|
* extra authoring keys are TOLERATED (§1A/§11 — a placement that can
|
|
95
95
|
* succeed must not fail over an extra field) but NOT applied; the op
|
|
96
96
|
* surfaces an info naming any carried field whose value differs from the
|
|
97
|
-
* module's stored one, pointing at
|
|
97
|
+
* module's stored one, pointing at update_modules_many (§2 — no silent drop).
|
|
98
98
|
*
|
|
99
99
|
* The element-level superRefine reports issues WITH the element's array
|
|
100
100
|
* index in the Zod path, so a mode error fails as `modules[3]: …`.
|
|
@@ -159,9 +159,9 @@ export const buildPageModuleSchema = z
|
|
|
159
159
|
description: z.string().max(1000).optional(),
|
|
160
160
|
kind: moduleKindSchema.optional(),
|
|
161
161
|
type: slugSchema.optional(),
|
|
162
|
-
html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX)).optional(),
|
|
163
|
-
css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
|
|
164
|
-
js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
|
|
162
|
+
html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX), "html").optional(),
|
|
163
|
+
css: sourceTextArg(z.string().max(MODULE_CSS_MAX), "css").optional(),
|
|
164
|
+
js: sourceTextArg(z.string().max(MODULE_JS_MAX), "js").optional(),
|
|
165
165
|
fields: z.array(moduleFieldSchema).max(64).optional(),
|
|
166
166
|
/** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
|
|
167
167
|
bindThemeLiterals: z.boolean().optional(),
|
|
@@ -192,7 +192,7 @@ export const buildPageModuleSchema = z
|
|
|
192
192
|
// (build_page places; it does not re-author a shared module). To avoid a
|
|
193
193
|
// SILENT dropped change (§2), the HANDLER surfaces an INFO in the result
|
|
194
194
|
// naming any ignored authoring field whose value DIFFERS from the module's
|
|
195
|
-
// stored one, and points at
|
|
195
|
+
// stored one, and points at update_modules_many. The schema can't do that check (no
|
|
196
196
|
// DB), so it just allows the entry through.
|
|
197
197
|
if (entry.moduleId !== undefined) return;
|
|
198
198
|
if (entry.displayName === undefined || entry.html === undefined) {
|
|
@@ -220,7 +220,7 @@ export const buildPageTargetSchema = z
|
|
|
220
220
|
pageId: z.string().uuid().optional(),
|
|
221
221
|
slug: slugSchema.optional(),
|
|
222
222
|
title: z.string().min(1).max(256).optional(),
|
|
223
|
-
|
|
223
|
+
shortTitle: pageCreateSchema.shape.shortTitle,
|
|
224
224
|
templateId: z.string().uuid().optional(),
|
|
225
225
|
status: pageStatusSchema.optional(),
|
|
226
226
|
/**
|
|
@@ -268,7 +268,7 @@ export const buildPageTargetSchema = z
|
|
|
268
268
|
[
|
|
269
269
|
"slug",
|
|
270
270
|
"title",
|
|
271
|
-
"
|
|
271
|
+
"shortTitle",
|
|
272
272
|
"templateId",
|
|
273
273
|
"status",
|
|
274
274
|
"parentPageId",
|
|
@@ -283,6 +283,11 @@ export const buildPageTargetSchema = z
|
|
|
283
283
|
code: z.ZodIssueCode.custom,
|
|
284
284
|
message:
|
|
285
285
|
`pageId targets an EXISTING page — drop ${createKeys.join(", ")}. ` +
|
|
286
|
+
// #1152 — status is a create-time field; publishing an existing
|
|
287
|
+
// page has its own tool.
|
|
288
|
+
(createKeys.includes("status")
|
|
289
|
+
? "`status` only applies to a page this call creates; publish an existing page with set_pages_status_many. "
|
|
290
|
+
: "") +
|
|
286
291
|
"To create a new page instead, omit pageId and pass slug + title.",
|
|
287
292
|
});
|
|
288
293
|
}
|
package/src/content.test.ts
CHANGED
|
@@ -91,7 +91,32 @@ describe("moduleCreateSchema", () => {
|
|
|
91
91
|
expect(moduleCreateSchema.safeParse({ ...base, js: 'xml += "]]>";' }).success).toBe(true);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
|
|
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)", () => {
|
|
95
120
|
const r = moduleUpdateSchema.safeParse({
|
|
96
121
|
moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
|
|
97
122
|
css: "/*<![CDATA[*/ .x{} /*]]>*/",
|
package/src/content.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { z } from "zod";
|
|
14
|
+
import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
|
|
15
|
+
import { DEFAULT_LIST_TYPES, SITE_TEXTS_LIST_TYPE } from "./lists/default-types.js";
|
|
14
16
|
import { listItemInputSchema } from "./lists/items.js";
|
|
15
17
|
import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
|
|
16
18
|
import { LIST_REF_RE } from "./lists/refs.js";
|
|
@@ -126,14 +128,34 @@ export function htmlEscapedMarkupMessage(field: string, value: string): string |
|
|
|
126
128
|
return `${field} is HTML-escaped (<…> instead of <…>) — send the markup itself, unescaped.`;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
/**
|
|
132
|
+
* See {@link jsonEncodedCodeMessage}: source whose line breaks arrived as
|
|
133
|
+
* literal `\n` sequences (`hasEscapedLineBreaks`) — the page shows the
|
|
134
|
+
* `\n` text and the CSS rules after it break. AI tool arguments are decoded
|
|
135
|
+
* first (`decodeEscapedLineBreaks`); this guards every other writer. Module
|
|
136
|
+
* js is checked by parsing it (admin-core `module-js-syntax.ts`), which
|
|
137
|
+
* catches this and every other syntax error.
|
|
138
|
+
*/
|
|
139
|
+
export function escapedLineBreaksMessage(field: "html" | "css", value: string): string | null {
|
|
140
|
+
if (!hasEscapedLineBreaks(value, field)) return null;
|
|
141
|
+
return `${field} has its line breaks escaped (literal \\n sequences, no real line break) — send the ${field} with real line breaks.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const noCorruptMarkup = (field: "html") => (value: string, ctx: z.RefinementCtx) => {
|
|
145
|
+
const message =
|
|
146
|
+
jsonEncodedCodeMessage(field, value) ??
|
|
147
|
+
htmlEscapedMarkupMessage(field, value) ??
|
|
148
|
+
escapedLineBreaksMessage(field, value);
|
|
131
149
|
if (message) ctx.addIssue({ code: "custom", message });
|
|
132
150
|
};
|
|
133
151
|
const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
|
|
134
152
|
const message = jsonEncodedCodeMessage(field, value);
|
|
135
153
|
if (message) ctx.addIssue({ code: "custom", message });
|
|
136
154
|
};
|
|
155
|
+
const noCorruptCss = (value: string, ctx: z.RefinementCtx) => {
|
|
156
|
+
const message = jsonEncodedCodeMessage("css", value) ?? escapedLineBreaksMessage("css", value);
|
|
157
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
158
|
+
};
|
|
137
159
|
|
|
138
160
|
const moduleHtml = z
|
|
139
161
|
.string()
|
|
@@ -168,7 +190,7 @@ const moduleCss = z
|
|
|
168
190
|
.string()
|
|
169
191
|
.max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
|
|
170
192
|
.refine(noCdata, cdataMessage("css"))
|
|
171
|
-
.superRefine(
|
|
193
|
+
.superRefine(noCorruptCss);
|
|
172
194
|
const moduleJs = z
|
|
173
195
|
.string()
|
|
174
196
|
.max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
|
|
@@ -276,7 +298,7 @@ const moduleFieldModuleSchema = z
|
|
|
276
298
|
* stable `modules.type` class, e.g. `button` — NOT the unique
|
|
277
299
|
* `modules.slug` like `button-mpqxq3ch`). When absent, any module is
|
|
278
300
|
* permitted. The op-layer validator enforces the whitelist against
|
|
279
|
-
* the referenced module's `type` at `
|
|
301
|
+
* the referenced module's `type` at `content_instances.set_values`
|
|
280
302
|
* time. v0.12.3 (issue #106) renamed this from `allowedModuleSlugs`:
|
|
281
303
|
* an exact-slug whitelist could never match an AI-minted module
|
|
282
304
|
* because every minted slug carries a uniqueness suffix.
|
|
@@ -309,13 +331,31 @@ const moduleFieldModuleListSchema = z
|
|
|
309
331
|
* written, and the renderer reports `list-type-missing:<slug>` for a type
|
|
310
332
|
* that is gone.
|
|
311
333
|
*/
|
|
334
|
+
/** The shipped list types, quoted, for the missing-listType message. */
|
|
335
|
+
const SHIPPED_LIST_TYPE_SLUGS = [...DEFAULT_LIST_TYPES.map((t) => t.slug), SITE_TEXTS_LIST_TYPE]
|
|
336
|
+
.map((slug) => `"${slug}"`)
|
|
337
|
+
.join(", ");
|
|
338
|
+
|
|
312
339
|
const moduleFieldListSchema = z
|
|
313
340
|
.object({
|
|
314
341
|
name: moduleFieldName,
|
|
315
342
|
kind: z.literal("list"),
|
|
316
343
|
label: moduleFieldLabel,
|
|
317
|
-
/**
|
|
318
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Slug of the list type that defines the entries. REQUIRED — a missing
|
|
346
|
+
* one says which types exist (#1190), not just "expected string".
|
|
347
|
+
*/
|
|
348
|
+
listType: z
|
|
349
|
+
.string({
|
|
350
|
+
error: (iss) =>
|
|
351
|
+
iss.input === undefined
|
|
352
|
+
? `a list field needs listType: ${SHIPPED_LIST_TYPE_SLUGS} or a custom type's slug (list_list_types shows them)`
|
|
353
|
+
: undefined,
|
|
354
|
+
})
|
|
355
|
+
.regex(
|
|
356
|
+
/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
|
|
357
|
+
"slug must be lowercase letters, digits, or hyphens (1–64 chars, no leading/trailing hyphen)",
|
|
358
|
+
),
|
|
319
359
|
bind: z.enum(["local", "shared", "source"]).optional(),
|
|
320
360
|
/** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
|
|
321
361
|
list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
|
|
@@ -491,7 +531,7 @@ export type ModuleKind = (typeof MODULE_KINDS)[number];
|
|
|
491
531
|
/**
|
|
492
532
|
* v0.12.0 — description is `.default("")` at the Zod boundary so the
|
|
493
533
|
* 82+ legacy callers in tests/seed scripts keep working unchanged.
|
|
494
|
-
* AI tool descriptions (
|
|
534
|
+
* AI tool descriptions (add_module / build_page / update_modules_many) require the AI
|
|
495
535
|
* to supply a real description explicitly — see CLAUDE.md §1A.
|
|
496
536
|
*/
|
|
497
537
|
const moduleDescription = z.string().max(1000);
|
|
@@ -627,6 +667,15 @@ export const templateBlocksSetSchema = z
|
|
|
627
667
|
|
|
628
668
|
export const pageStatusSchema = z.enum(["draft", "published"]);
|
|
629
669
|
|
|
670
|
+
/** `pages.short_title` (#1142): a crumb-sized public label, never blank. */
|
|
671
|
+
export const pageShortTitleSchema = z
|
|
672
|
+
.string()
|
|
673
|
+
.trim()
|
|
674
|
+
.min(1, "the short title is the page's breadcrumb label and cannot be blank")
|
|
675
|
+
// Same bound as the title it is backfilled and defaulted from, so every
|
|
676
|
+
// stored page stays editable; keeping it crumb-sized is guidance.
|
|
677
|
+
.max(256);
|
|
678
|
+
|
|
630
679
|
/**
|
|
631
680
|
* Page payloads are `.strict()` — passing an `html` field is rejected by Zod
|
|
632
681
|
* before the handler runs. This is the §3.1 "no raw HTML on pages" invariant
|
|
@@ -636,9 +685,13 @@ export const pageStatusSchema = z.enum(["draft", "published"]);
|
|
|
636
685
|
export const pageCreateSchema = z
|
|
637
686
|
.object({
|
|
638
687
|
slug: slugSchema,
|
|
639
|
-
/** P6.7.5 — internal editor label. Defaults to title if omitted. */
|
|
640
|
-
name: z.string().min(1).max(256).optional(),
|
|
641
688
|
title: z.string().min(1).max(256),
|
|
689
|
+
/**
|
|
690
|
+
* #1142 — the page's public label in breadcrumbs, navigation and the
|
|
691
|
+
* page picker ("Pricing" for "Pricing for teams of every size").
|
|
692
|
+
* Omitted = the create-time resolver stores `title` (stored data).
|
|
693
|
+
*/
|
|
694
|
+
shortTitle: pageShortTitleSchema.optional(),
|
|
642
695
|
/**
|
|
643
696
|
* P6.7.6 — optional. When omitted, the handler resolves to
|
|
644
697
|
* `site_defaults.default_template_id` at create time. Stored data,
|
|
@@ -703,9 +756,9 @@ export const pageUpdateSchema = z
|
|
|
703
756
|
* Routes that load the page first should always pass it back.
|
|
704
757
|
*/
|
|
705
758
|
expectedVersion: z.number().int().nonnegative().optional(),
|
|
706
|
-
/** P6.7.5 — three independently-editable identifiers. */
|
|
707
|
-
name: z.string().min(1).max(256).optional(),
|
|
708
759
|
title: z.string().min(1).max(256).optional(),
|
|
760
|
+
/** #1142 — the breadcrumb / navigation label; see {@link pageCreateSchema}. */
|
|
761
|
+
shortTitle: pageShortTitleSchema.optional(),
|
|
709
762
|
slug: slugSchema.optional(),
|
|
710
763
|
templateId: z.string().uuid().optional(),
|
|
711
764
|
status: pageStatusSchema.optional(),
|
|
@@ -743,8 +796,23 @@ export const pageSetModulesSchema = z
|
|
|
743
796
|
.object({
|
|
744
797
|
blockName: slugSchema,
|
|
745
798
|
moduleIds: z.array(z.string().uuid()),
|
|
799
|
+
/**
|
|
800
|
+
* #1195 — the placement each entry of `moduleIds` IS, by its
|
|
801
|
+
* content instance (same index; null = a new placement). Given,
|
|
802
|
+
* a placement keeps its content wherever it moves — across
|
|
803
|
+
* blocks too — and two placements of one module never trade
|
|
804
|
+
* content. Omitted, bindings carry over per (block, module) in
|
|
805
|
+
* order, which cannot tell repeated placements apart.
|
|
806
|
+
*/
|
|
807
|
+
contentInstanceIds: z.array(z.string().uuid().nullable()).optional(),
|
|
746
808
|
})
|
|
747
|
-
.strict()
|
|
809
|
+
.strict()
|
|
810
|
+
.refine(
|
|
811
|
+
(b) =>
|
|
812
|
+
b.contentInstanceIds === undefined ||
|
|
813
|
+
b.contentInstanceIds.length === b.moduleIds.length,
|
|
814
|
+
{ message: "contentInstanceIds needs one entry (or null) per moduleIds entry" },
|
|
815
|
+
),
|
|
748
816
|
),
|
|
749
817
|
})
|
|
750
818
|
.strict();
|
|
@@ -780,10 +848,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
|
|
|
780
848
|
|
|
781
849
|
/**
|
|
782
850
|
* v0.12.0 — why this row exists as a shared/reusable instance.
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
851
|
+
* Shown by the AI's list_content_instances tool so the AI can decide
|
|
852
|
+
* *reuse the synced row* vs *fork to unsynced* vs *mint new* without
|
|
853
|
+
* asking the operator. Required by the AI's
|
|
854
|
+
* create_content_instances tool description; legacy callers may pass
|
|
787
855
|
* null (the migration-0093 unsynced rows have purpose=NULL).
|
|
788
856
|
* See CLAUDE.md §1A.
|
|
789
857
|
*/
|
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:
|
package/src/css-var-scan.ts
CHANGED
|
@@ -143,11 +143,11 @@ export function formatUnknownCssVarWarning(unknown: readonly UnknownCssVar[]): s
|
|
|
143
143
|
const hint = u.suggestion !== null ? ` (did you mean \`${u.suggestion}\`?)` : "";
|
|
144
144
|
const fallback = u.hasFallback
|
|
145
145
|
? " — its hardcoded fallback is what visitors will see, detached from the theme"
|
|
146
|
-
: " —
|
|
146
|
+
: " — nothing defines it, so the property won't apply";
|
|
147
147
|
return `\`${u.name}\`${hint}${fallback}`;
|
|
148
148
|
});
|
|
149
149
|
return (
|
|
150
|
-
`⚠️ Unknown CSS vars (the active theme
|
|
151
|
-
"Use the exact names
|
|
150
|
+
`⚠️ Unknown CSS vars (neither the active theme nor any layout or template CSS defines them): ${parts.join("; ")}. ` +
|
|
151
|
+
"Use the exact token names (`get_theme`), or define the custom property in this CSS if it's intentional."
|
|
152
152
|
);
|
|
153
153
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Source text whose line breaks arrived string-escaped as a whole.
|
|
5
|
+
*
|
|
6
|
+
* e2e-livedit run 40532297620 (release gate v0.11.1): `add_module`'s html,
|
|
7
|
+
* css and js came in with every line break as the two characters `\` `n`
|
|
8
|
+
* (`(function () {\n document…`). Stored as sent, the footer showed `\n`
|
|
9
|
+
* text, its CSS broke, and the page's shared module `<script>` threw
|
|
10
|
+
* `SyntaxError: Invalid or unexpected token` — a backslash outside a string
|
|
11
|
+
* is not JavaScript. Like the other whole-value transport encodings
|
|
12
|
+
* (`source-text-transport.ts`) the AI tool boundary decodes it, once.
|
|
13
|
+
*
|
|
14
|
+
* The same two characters are legitimate inside a string literal (`"a\nb"`
|
|
15
|
+
* in JS or a CSS `content`), a regex, an HTML attribute value, or the body
|
|
16
|
+
* of a raw-text element. A value is only decoded when EVERY escaped line
|
|
17
|
+
* break sits where a real line break could have stood — between tokens of
|
|
18
|
+
* code, or in markup text between tags. One escape inside a literal makes
|
|
19
|
+
* the value ambiguous and it is left exactly as sent: decoding it would
|
|
20
|
+
* turn valid source into broken source (a raw newline inside a JS string
|
|
21
|
+
* is a syntax error). The op validators still refuse what is left broken
|
|
22
|
+
* (module js is parsed in admin-core `module-js-syntax.ts`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Which grammar a source value is written in; decides what counts as a literal. */
|
|
26
|
+
export type SourceLanguage = "html" | "css" | "js";
|
|
27
|
+
|
|
28
|
+
/** `\n` or `\r\n` escape starting at `i` (a single backslash, not an escaped one). */
|
|
29
|
+
function escapedLineBreakLength(value: string, i: number): number {
|
|
30
|
+
if (value[i] !== "\\") return 0;
|
|
31
|
+
if (value[i + 1] === "n") return 2;
|
|
32
|
+
if (value[i + 1] === "r" && value[i + 2] === "\\" && value[i + 3] === "n") return 4;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Characters after which a `/` in JS starts a regex literal rather than a division. */
|
|
37
|
+
const REGEX_PRECEDERS = new Set([..."(,=:[!&|?{};+-*%<>~^"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Index just past a quoted literal starting at `start` (its quote char), or
|
|
41
|
+
* -1 when an escaped line break sits inside it.
|
|
42
|
+
*/
|
|
43
|
+
function skipQuoted(value: string, start: number): number {
|
|
44
|
+
const quote = value[start];
|
|
45
|
+
let j = start + 1;
|
|
46
|
+
while (j < value.length) {
|
|
47
|
+
const ch = value[j];
|
|
48
|
+
if (ch === "\\") {
|
|
49
|
+
if (value[j + 1] === "n" || value[j + 1] === "r") return -1;
|
|
50
|
+
j += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === quote) return j + 1;
|
|
54
|
+
j += 1;
|
|
55
|
+
}
|
|
56
|
+
return j;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when an escaped line break sits inside a JS string, template, or regex literal. */
|
|
60
|
+
function jsLiteralHoldsEscape(value: string): boolean {
|
|
61
|
+
let prev = "";
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < value.length) {
|
|
64
|
+
const ch = value[i] as string;
|
|
65
|
+
const lineBreak = escapedLineBreakLength(value, i);
|
|
66
|
+
if (lineBreak > 0) {
|
|
67
|
+
prev = "";
|
|
68
|
+
i += lineBreak;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ch === "\\") {
|
|
72
|
+
i += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
76
|
+
const end = skipQuoted(value, i);
|
|
77
|
+
if (end < 0) return true;
|
|
78
|
+
prev = ch;
|
|
79
|
+
i = end;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === "/" && value[i + 1] === "/") {
|
|
83
|
+
// A line comment runs to the next (escaped) line break.
|
|
84
|
+
i += 2;
|
|
85
|
+
while (i < value.length && escapedLineBreakLength(value, i) === 0) i += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === "/" && value[i + 1] === "*") {
|
|
89
|
+
const close = value.indexOf("*/", i + 2);
|
|
90
|
+
i = close < 0 ? value.length : close + 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "/" && (prev === "" || REGEX_PRECEDERS.has(prev))) {
|
|
94
|
+
let j = i + 1;
|
|
95
|
+
let inClass = false;
|
|
96
|
+
while (j < value.length) {
|
|
97
|
+
const r = value[j];
|
|
98
|
+
if (r === "\\") {
|
|
99
|
+
if (value[j + 1] === "n" || value[j + 1] === "r") return true;
|
|
100
|
+
j += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (r === "[") inClass = true;
|
|
104
|
+
else if (r === "]") inClass = false;
|
|
105
|
+
else if (r === "/" && !inClass) break;
|
|
106
|
+
j += 1;
|
|
107
|
+
}
|
|
108
|
+
prev = "/";
|
|
109
|
+
i = j + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (ch !== " " && ch !== "\t") prev = ch;
|
|
113
|
+
i += 1;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when an escaped line break sits inside a CSS string. */
|
|
119
|
+
function cssLiteralHoldsEscape(value: string): boolean {
|
|
120
|
+
let i = 0;
|
|
121
|
+
while (i < value.length) {
|
|
122
|
+
const ch = value[i];
|
|
123
|
+
if (ch === "/" && value[i + 1] === "*") {
|
|
124
|
+
const close = value.indexOf("*/", i + 2);
|
|
125
|
+
i = close < 0 ? value.length : close + 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === '"' || ch === "'") {
|
|
129
|
+
const end = skipQuoted(value, i);
|
|
130
|
+
if (end < 0) return true;
|
|
131
|
+
i = end;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
i += ch === "\\" ? 2 : 1;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Elements whose body is not markup text: an escape there belongs to that body. */
|
|
140
|
+
const RAW_TEXT_ELEMENTS = new Set(["script", "style", "pre", "textarea"]);
|
|
141
|
+
|
|
142
|
+
/** True when an escaped line break sits inside an attribute value, a comment, or a raw-text element body. */
|
|
143
|
+
function htmlLiteralHoldsEscape(value: string): boolean {
|
|
144
|
+
const lower = value.toLowerCase();
|
|
145
|
+
let i = 0;
|
|
146
|
+
while (i < value.length) {
|
|
147
|
+
if (value[i] !== "<") {
|
|
148
|
+
i += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (value.startsWith("<!--", i)) {
|
|
152
|
+
const close = value.indexOf("-->", i + 4);
|
|
153
|
+
const end = close < 0 ? value.length : close + 3;
|
|
154
|
+
if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
|
|
155
|
+
i = end;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const name = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(value.slice(i, i + 64))?.[1]?.toLowerCase();
|
|
159
|
+
let j = i + 1;
|
|
160
|
+
while (j < value.length && value[j] !== ">") {
|
|
161
|
+
if (value[j] === '"' || value[j] === "'") {
|
|
162
|
+
const end = skipQuoted(value, j);
|
|
163
|
+
if (end < 0) return true;
|
|
164
|
+
j = end;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
j += 1;
|
|
168
|
+
}
|
|
169
|
+
i = j + 1;
|
|
170
|
+
if (name && RAW_TEXT_ELEMENTS.has(name)) {
|
|
171
|
+
const close = lower.indexOf(`</${name}`, i);
|
|
172
|
+
const end = close < 0 ? value.length : close;
|
|
173
|
+
if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
|
|
174
|
+
i = end;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const LITERAL_HOLDS_ESCAPE: Readonly<Record<SourceLanguage, (value: string) => boolean>> = {
|
|
181
|
+
html: htmlLiteralHoldsEscape,
|
|
182
|
+
css: cssLiteralHoldsEscape,
|
|
183
|
+
js: jsLiteralHoldsEscape,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** An escaped line break (`\n`, `\r\n`) not preceded by another backslash. */
|
|
187
|
+
const ESCAPED_LINE_BREAK = /(?<!\\)(?:\\r)?\\n/g;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* True when a source value's line breaks were string-escaped as a whole:
|
|
191
|
+
* no real line break at all, at least two escaped ones, one of them
|
|
192
|
+
* followed by indentation (the signature of pretty-printed source), and
|
|
193
|
+
* none inside a literal of `language` (see the file comment).
|
|
194
|
+
*
|
|
195
|
+
* @param value - the source text as sent.
|
|
196
|
+
* @param language - the grammar the value is written in.
|
|
197
|
+
*/
|
|
198
|
+
export function hasEscapedLineBreaks(value: string, language: SourceLanguage): boolean {
|
|
199
|
+
if (/[\r\n]/.test(value)) return false;
|
|
200
|
+
const hits = value.match(ESCAPED_LINE_BREAK)?.length ?? 0;
|
|
201
|
+
if (hits < 2) return false;
|
|
202
|
+
if (!/(?<!\\)\\n(?:[ \t]|\\t)/.test(value)) return false;
|
|
203
|
+
return !LITERAL_HOLDS_ESCAPE[language](value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The value with every escaped line break turned back into a real one (and
|
|
208
|
+
* the escaped tabs indenting the next line into real tabs) when
|
|
209
|
+
* `hasEscapedLineBreaks` holds; the input unchanged otherwise. Nothing else
|
|
210
|
+
* is unescaped: quotes and backslashes arrived as they were meant.
|
|
211
|
+
*
|
|
212
|
+
* @param value - the tool argument (non-strings pass through).
|
|
213
|
+
* @param language - the grammar the value is written in.
|
|
214
|
+
*/
|
|
215
|
+
export function decodeEscapedLineBreaks(value: unknown, language: SourceLanguage): unknown {
|
|
216
|
+
if (typeof value !== "string" || !hasEscapedLineBreaks(value, language)) return value;
|
|
217
|
+
return value.replace(
|
|
218
|
+
/(?<!\\)(?:\\r)?\\n((?:\\t)*)/g,
|
|
219
|
+
(_m, tabs: string) => `\n${"\t".repeat(tabs.length / 2)}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export * from "./database-url.js";
|
|
|
20
20
|
export * from "./design-draft-shell.js";
|
|
21
21
|
export * from "./document-language.js";
|
|
22
22
|
export * from "./edit-markers.js";
|
|
23
|
+
export * from "./escaped-line-breaks.js";
|
|
23
24
|
export * from "./font-assets.js";
|
|
24
25
|
export * from "./font-axis-policy.js";
|
|
25
26
|
export * from "./font-usage.js";
|
|
@@ -34,6 +35,7 @@ export * from "./interactions.js";
|
|
|
34
35
|
export * from "./link-canonicalise.js";
|
|
35
36
|
export * from "./link-refs.js";
|
|
36
37
|
export * from "./lists/index.js";
|
|
38
|
+
export * from "./live-events.js";
|
|
37
39
|
export {
|
|
38
40
|
type LogContext,
|
|
39
41
|
type Logger,
|
package/src/interactions.ts
CHANGED
|
@@ -195,9 +195,9 @@ export function interactionAssets(used: ReadonlySet<InteractionId>): {
|
|
|
195
195
|
* guidance cannot drift from the snippets above.
|
|
196
196
|
*/
|
|
197
197
|
export const INTERACTION_MARKUP_CONTRACT =
|
|
198
|
-
|
|
199
|
-
'`accordion` (FAQ, collapsible
|
|
200
|
-
'`tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div
|
|
201
|
-
'`carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</span></button></section
|
|
202
|
-
'`mobile-nav` (menu collapsing behind a toggle on small screens): `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false"><button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span></button><ul>{{#menu}}<li>…</li>{{/menu}}</ul></nav>` around the module\'s menu `list` field
|
|
198
|
+
"`data-caelo-ui` kit — use it instead of JS for these (accessible, shipped once per page, style freely in module CSS):\n" +
|
|
199
|
+
'- `accordion` (FAQ, collapsible): `<div data-caelo-ui="accordion" data-single>` with `<details id="..."><summary>Question</summary><div>Answer</div></details>` items; `data-single` keeps one open; a link to `#<details id>` opens it.\n' +
|
|
200
|
+
'- `tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div>`; ids unique on the page.\n' +
|
|
201
|
+
'- `carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</span></button></section>`; icon buttons keep visible content aria-hidden, text buttons need no aria-label. One slide per view; set the slides\' `flex-basis` in module CSS to show more.\n' +
|
|
202
|
+
'- `mobile-nav` (menu collapsing behind a toggle on small screens): `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false"><button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span></button><ul>{{#menu}}<li>…</li>{{/menu}}</ul></nav>` around the module\'s menu `list` field.\n' +
|
|
203
203
|
"Write module JS only for behaviour the kit does not cover.";
|
package/src/link-refs.test.ts
CHANGED
|
@@ -39,6 +39,7 @@ const lists = createListRenderContext({
|
|
|
39
39
|
pages: new Map(),
|
|
40
40
|
media: new Map(),
|
|
41
41
|
siteLanguage: "en",
|
|
42
|
+
homePageId: null,
|
|
42
43
|
}).forPage(null);
|
|
43
44
|
const uid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
|
|
44
45
|
const links = (...ls: [string, string][]) =>
|
|
@@ -100,3 +100,42 @@ export const DEFAULT_LIST_TYPES: readonly ListTypeDef[] = [
|
|
|
100
100
|
},
|
|
101
101
|
},
|
|
102
102
|
];
|
|
103
|
+
|
|
104
|
+
/** Slug of the list type whose entries are the site texts (`{{site_text.<key>}}`, #1142). */
|
|
105
|
+
export const SITE_TEXTS_LIST_TYPE = "site-texts";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The site texts type: fixed interface strings of the website that are no
|
|
109
|
+
* page content ("Start" in a trail, the breadcrumb `aria-label`, "Read
|
|
110
|
+
* more"). Module HTML reads one by key with `{{site_text.<key>}}`, so the
|
|
111
|
+
* string is translatable (shared-list overlays) instead of frozen into
|
|
112
|
+
* the module. Seeded by migration 0358 together with the "Site texts"
|
|
113
|
+
* list; installs that ran 0352 never had it, so it is not part of
|
|
114
|
+
* {@link DEFAULT_LIST_TYPES} (whose seed is pinned to 0352).
|
|
115
|
+
*/
|
|
116
|
+
export const SITE_TEXTS_TYPE: ListTypeDef = {
|
|
117
|
+
slug: SITE_TEXTS_LIST_TYPE,
|
|
118
|
+
name: "Site texts",
|
|
119
|
+
purpose:
|
|
120
|
+
"Fixed interface texts of the website, read by key in module HTML with {{site_text.<key>}}: the breadcrumb aria-label, button and link texts that repeat across modules. Not page content.",
|
|
121
|
+
role: "content",
|
|
122
|
+
root: { allow: ["text"] },
|
|
123
|
+
nodes: {
|
|
124
|
+
text: {
|
|
125
|
+
label: "Site text",
|
|
126
|
+
fields: [
|
|
127
|
+
// The lookup key is code, not copy: a translated key would stop
|
|
128
|
+
// `{{site_text.<key>}}` from finding the entry in that language.
|
|
129
|
+
{
|
|
130
|
+
name: "key",
|
|
131
|
+
label: "Key",
|
|
132
|
+
type: "text",
|
|
133
|
+
required: true,
|
|
134
|
+
maxLength: 64,
|
|
135
|
+
ext: { "international-site": { translate: false } },
|
|
136
|
+
},
|
|
137
|
+
{ name: "text", label: "Text", type: "text", required: true },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|