@caelo-cms/shared 0.10.72 → 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 +48 -36
- 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 +23 -4
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +201 -100
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +176 -74
- 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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/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/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 +51 -37
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +26 -3
- package/src/content.test.ts +111 -3
- package/src/content.ts +186 -75
- 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/index.ts +3 -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 +55 -19
- package/src/preview-compose.ts +59 -154
- 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/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.ts
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { z } from "zod";
|
|
14
|
+
import { listItemInputSchema } from "./lists/items.js";
|
|
15
|
+
import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
|
|
16
|
+
import { LIST_REF_RE } from "./lists/refs.js";
|
|
14
17
|
|
|
15
18
|
/** Lowercase slug, hyphenated, 1–64 chars, no leading/trailing hyphen. */
|
|
16
19
|
export const slugSchema = z
|
|
@@ -93,7 +96,49 @@ export const MODULE_JS_MAX = 256 * 1024;
|
|
|
93
96
|
export const TEMPLATE_HTML_MAX = 512 * 1024;
|
|
94
97
|
export const TEMPLATE_CSS_MAX = 128 * 1024;
|
|
95
98
|
|
|
96
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Code payloads a model sometimes corrupts before they reach us, both seen
|
|
101
|
+
* in PR #1121's real-AI runs and stored verbatim before this guard:
|
|
102
|
+
* - JSON-encoded: the whole value wrapped in quotes with `\"` escapes
|
|
103
|
+
* (`"<footer class=\"x\">…"`) — the page showed literal quotes;
|
|
104
|
+
* - HTML-escaped markup: `<div><caelo-slot …` with no real tag
|
|
105
|
+
* — a template without a single slot, so it had no blocks at all.
|
|
106
|
+
* Refusing at the write boundary with the fix turns a broken render the
|
|
107
|
+
* AI only notices from a screenshot into one corrected call (CLAUDE.md
|
|
108
|
+
* §2, §11; same reasoning as the CDATA guard below). AI tool arguments
|
|
109
|
+
* never reach this in that shape: `sourceTextArg` decodes both encodings
|
|
110
|
+
* first (`source-text-transport.ts`); this guards every other writer.
|
|
111
|
+
*/
|
|
112
|
+
export function jsonEncodedCodeMessage(field: string, value: string): string | null {
|
|
113
|
+
const t = value.trim();
|
|
114
|
+
if (t.length < 2 || !t.startsWith('"') || !t.endsWith('"')) return null;
|
|
115
|
+
try {
|
|
116
|
+
if (typeof JSON.parse(t) !== "string") return null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return `${field} is JSON-encoded (wrapped in quotes with \\" escapes) — send the raw ${field} itself, not a JSON string of it.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** See {@link jsonEncodedCodeMessage}: markup sent HTML-escaped. */
|
|
124
|
+
export function htmlEscapedMarkupMessage(field: string, value: string): string | null {
|
|
125
|
+
if (value.includes("<") || !/<\/?[a-zA-Z]/.test(value)) return null;
|
|
126
|
+
return `${field} is HTML-escaped (<…> instead of <…>) — send the markup itself, unescaped.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const noCorruptMarkup = (field: string) => (value: string, ctx: z.RefinementCtx) => {
|
|
130
|
+
const message = jsonEncodedCodeMessage(field, value) ?? htmlEscapedMarkupMessage(field, value);
|
|
131
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
132
|
+
};
|
|
133
|
+
const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
|
|
134
|
+
const message = jsonEncodedCodeMessage(field, value);
|
|
135
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const moduleHtml = z
|
|
139
|
+
.string()
|
|
140
|
+
.max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`)
|
|
141
|
+
.superRefine(noCorruptMarkup("html"));
|
|
97
142
|
|
|
98
143
|
/**
|
|
99
144
|
* issue #432 root-cause guard — reject XML CDATA wrappers in module css/js.
|
|
@@ -122,11 +167,13 @@ const cdataMessage = (field: "css" | "js"): string =>
|
|
|
122
167
|
const moduleCss = z
|
|
123
168
|
.string()
|
|
124
169
|
.max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
|
|
125
|
-
.refine(noCdata, cdataMessage("css"))
|
|
170
|
+
.refine(noCdata, cdataMessage("css"))
|
|
171
|
+
.superRefine(noJsonEncodedCode("css"));
|
|
126
172
|
const moduleJs = z
|
|
127
173
|
.string()
|
|
128
174
|
.max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
|
|
129
|
-
.refine(noCdata, cdataMessage("js"))
|
|
175
|
+
.refine(noCdata, cdataMessage("js"))
|
|
176
|
+
.superRefine(noJsonEncodedCode("js"));
|
|
130
177
|
|
|
131
178
|
/**
|
|
132
179
|
* v0.4.0 — module field schema. Each field declares one substitution slot in
|
|
@@ -161,29 +208,29 @@ export const MODULE_FIELD_PRIMITIVE_KINDS = [
|
|
|
161
208
|
] as const;
|
|
162
209
|
|
|
163
210
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
211
|
+
* Epic #1107 — the data-list field kind (`docs/lists.md` §7). One kind
|
|
212
|
+
* for every repeated piece of data: feature bullets, a footer link group,
|
|
213
|
+
* a three-level menu, a mega dropdown, a "latest news" block. The entry
|
|
214
|
+
* shape comes from a LIST TYPE (`listType`, a slug); the entries come from
|
|
215
|
+
* the field's binding:
|
|
168
216
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
217
|
+
* - `local` (default) — entries stored in the placement's value
|
|
218
|
+
* (`ListItem[]`), only this module shows them;
|
|
219
|
+
* - `shared` — a `caelo:list/<id>` list, fixed on the field (`list`) or
|
|
220
|
+
* chosen per placement; navigation and anything needed in
|
|
221
|
+
* more than one place;
|
|
222
|
+
* - `source` — entries generated per page from `source` (a page query,
|
|
223
|
+
* a system source like breadcrumbs, a plugin source).
|
|
173
224
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* For lists with richer per-item structure (cards with image + title
|
|
179
|
-
* + body + CTA), use `module-list` pointing at a sub-module — that's
|
|
180
|
-
* what nested modules are for.
|
|
225
|
+
* The template iterates `{{#field}}…{{/field}}` the same way for every
|
|
226
|
+
* binding. `module-list` stays: it composes modules, lists carry data.
|
|
227
|
+
* Replaces the `text-list` / `link-list` kinds.
|
|
181
228
|
*/
|
|
182
|
-
export const MODULE_FIELD_LIST_KINDS = ["
|
|
229
|
+
export const MODULE_FIELD_LIST_KINDS = ["list"] as const;
|
|
183
230
|
|
|
184
231
|
export const MODULE_FIELD_NESTED_KINDS = ["module", "module-list"] as const;
|
|
185
232
|
|
|
186
|
-
/**
|
|
233
|
+
/** Every module field kind. */
|
|
187
234
|
export const MODULE_FIELD_KINDS = [
|
|
188
235
|
...MODULE_FIELD_PRIMITIVE_KINDS,
|
|
189
236
|
...MODULE_FIELD_LIST_KINDS,
|
|
@@ -200,10 +247,9 @@ export type ModuleFieldKind = (typeof MODULE_FIELD_KINDS)[number];
|
|
|
200
247
|
|
|
201
248
|
/**
|
|
202
249
|
* 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`).
|
|
250
|
+
* `{{#name}}…{{/name}}` Mustache-section operator (`list` and
|
|
251
|
+
* `module-list`); the engine's section-dispatch keys off it
|
|
252
|
+
* (`packages/shared/src/template-engine.ts`).
|
|
207
253
|
*/
|
|
208
254
|
export const MODULE_FIELD_SECTION_KINDS = [...MODULE_FIELD_LIST_KINDS, "module-list"] as const;
|
|
209
255
|
|
|
@@ -255,43 +301,84 @@ const moduleFieldModuleListSchema = z
|
|
|
255
301
|
.strict();
|
|
256
302
|
|
|
257
303
|
/**
|
|
258
|
-
*
|
|
259
|
-
* the
|
|
260
|
-
* is
|
|
261
|
-
*
|
|
304
|
+
* Epic #1107 — a `list` field (`docs/lists.md` §7). The static schema
|
|
305
|
+
* checks the SHAPE: the binding's parts fit together and a local default
|
|
306
|
+
* is well-formed entries. Whether those entries fit the named list type
|
|
307
|
+
* (nodes, fields, depth) needs the type, which is stored data — the op
|
|
308
|
+
* layer validates that with `validateListItems` when the module is
|
|
309
|
+
* written, and the renderer reports `list-type-missing:<slug>` for a type
|
|
310
|
+
* that is gone.
|
|
262
311
|
*/
|
|
263
|
-
const
|
|
312
|
+
const moduleFieldListSchema = z
|
|
264
313
|
.object({
|
|
265
314
|
name: moduleFieldName,
|
|
266
|
-
kind: z.literal("
|
|
315
|
+
kind: z.literal("list"),
|
|
267
316
|
label: moduleFieldLabel,
|
|
317
|
+
/** Slug of the list type that defines the entries. */
|
|
318
|
+
listType: slugSchema,
|
|
319
|
+
bind: z.enum(["local", "shared", "source"]).optional(),
|
|
320
|
+
/** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
|
|
321
|
+
list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
|
|
322
|
+
/** `bind: "source"` — where generated entries come from. */
|
|
323
|
+
source: sourceDefSchema.optional(),
|
|
268
324
|
min: z.number().int().nonnegative().optional(),
|
|
269
|
-
max: z.number().int().positive().max(
|
|
270
|
-
|
|
325
|
+
max: z.number().int().positive().max(LIST_MAX_ITEMS).optional(),
|
|
326
|
+
/** `local`: entries; `shared`: `caelo:list/<id>`. */
|
|
327
|
+
default: z
|
|
328
|
+
.union([
|
|
329
|
+
z.array(listItemInputSchema),
|
|
330
|
+
z.string().regex(LIST_REF_RE, "a shared default must be caelo:list/<id>"),
|
|
331
|
+
])
|
|
332
|
+
.optional(),
|
|
271
333
|
})
|
|
272
334
|
.strict();
|
|
273
335
|
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
.
|
|
282
|
-
name:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
.
|
|
336
|
+
/** Binding consistency of one `list` field; every problem, AI-actionable. */
|
|
337
|
+
function listFieldIssues(f: z.infer<typeof moduleFieldListSchema>): string[] {
|
|
338
|
+
const bind = f.bind ?? "local";
|
|
339
|
+
const issues: string[] = [];
|
|
340
|
+
if (bind === "source" && f.source === undefined) {
|
|
341
|
+
issues.push(`list field "${f.name}": bind "source" needs a source (query, system or plugin).`);
|
|
342
|
+
}
|
|
343
|
+
if (bind !== "source" && f.source !== undefined) {
|
|
344
|
+
issues.push(`list field "${f.name}": source is only allowed with bind "source".`);
|
|
345
|
+
}
|
|
346
|
+
if (bind === "local" && f.list !== undefined) {
|
|
347
|
+
issues.push(
|
|
348
|
+
`list field "${f.name}": list is only allowed with bind "shared" — a local list stores its entries in the placement.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (bind === "source" && f.list !== undefined && f.source?.kind !== "system") {
|
|
352
|
+
issues.push(
|
|
353
|
+
`list field "${f.name}": list on a source field names the vocabulary of a page_terms source only.`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (f.default !== undefined) {
|
|
357
|
+
if (bind === "local" && typeof f.default === "string") {
|
|
358
|
+
issues.push(
|
|
359
|
+
`list field "${f.name}": a local default is an array of entries, not a list reference.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (bind === "shared" && Array.isArray(f.default)) {
|
|
363
|
+
issues.push(
|
|
364
|
+
`list field "${f.name}": a shared default is caelo:list/<id> — create the list with upsert_lists_many first.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (bind === "source") {
|
|
368
|
+
issues.push(
|
|
369
|
+
`list field "${f.name}": a source field has no default; its entries are generated.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (f.min !== undefined && f.max !== undefined && f.min > f.max) {
|
|
374
|
+
issues.push(`list field "${f.name}": min is greater than max.`);
|
|
375
|
+
}
|
|
376
|
+
return issues;
|
|
377
|
+
}
|
|
290
378
|
|
|
291
379
|
export const moduleFieldSchema = z.discriminatedUnion("kind", [
|
|
292
380
|
moduleFieldPrimitiveSchema,
|
|
293
|
-
|
|
294
|
-
moduleFieldLinkListSchema,
|
|
381
|
+
moduleFieldListSchema,
|
|
295
382
|
moduleFieldModuleSchema,
|
|
296
383
|
moduleFieldModuleListSchema,
|
|
297
384
|
]);
|
|
@@ -368,6 +455,11 @@ const moduleFieldsArray = z
|
|
|
368
455
|
return;
|
|
369
456
|
}
|
|
370
457
|
seen.add(f.name);
|
|
458
|
+
if (f.kind === "list") {
|
|
459
|
+
for (const message of listFieldIssues(f)) {
|
|
460
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
371
463
|
}
|
|
372
464
|
for (const run of findNumberedScalarFanoutRuns(arr)) {
|
|
373
465
|
ctx.addIssue({
|
|
@@ -375,8 +467,9 @@ const moduleFieldsArray = z
|
|
|
375
467
|
message:
|
|
376
468
|
`numbered-scalar fanout: ${run.map((n) => `"${n}"`).join(", ")} repeat one role as ` +
|
|
377
469
|
"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
|
|
470
|
+
'(CLAUDE.md §1A). Replace the run with a single field: kind "list" with a list type ' +
|
|
471
|
+
'(listType "texts" for plain strings — the template iterates {{#f}}{{text}}{{/f}}; ' +
|
|
472
|
+
'"links" for label + link — {{#f}}<a href="{{link}}">{{label}}</a>{{/f}}), or ' +
|
|
380
473
|
'"module-list" (rich per-item structure: mint a card sub-module and reference it per ' +
|
|
381
474
|
"item — in build_page, author the card detached with `ref` and pass " +
|
|
382
475
|
'[{"$ref": "<ref>"}, …] as the field value). A two-field split like col1/col2 is fine; ' +
|
|
@@ -440,31 +533,29 @@ export const moduleUpdateSchema = z
|
|
|
440
533
|
})
|
|
441
534
|
.strict();
|
|
442
535
|
|
|
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];
|
|
536
|
+
/** A page type slug (epic #1107, docs/lists.md §5). */
|
|
537
|
+
export const pageTypeSlugSchema = z
|
|
538
|
+
.string()
|
|
539
|
+
.regex(
|
|
540
|
+
/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
|
|
541
|
+
"page type slug: lowercase letters, digits, hyphens",
|
|
542
|
+
);
|
|
460
543
|
|
|
461
544
|
export const templateCreateSchema = z
|
|
462
545
|
.object({
|
|
463
546
|
slug: slugSchema,
|
|
464
547
|
displayName: displayNameSchema,
|
|
465
|
-
/**
|
|
466
|
-
|
|
467
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Epic #1107 — the page type (slug) this template serves; the template
|
|
550
|
+
* is appended to that type's `templates`. Omit for the site's default
|
|
551
|
+
* page type (`site_defaults.default_page_type_id`, stored data — not a
|
|
552
|
+
* render-time fallback).
|
|
553
|
+
*/
|
|
554
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
555
|
+
html: z
|
|
556
|
+
.string()
|
|
557
|
+
.max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`)
|
|
558
|
+
.superRefine(noCorruptMarkup("html")),
|
|
468
559
|
css: z.string().max(TEMPLATE_CSS_MAX, `css exceeds ${TEMPLATE_CSS_MAX} bytes`).default(""),
|
|
469
560
|
/**
|
|
470
561
|
* P6.7.6 — every template binds to one layout. When omitted, the
|
|
@@ -505,9 +596,9 @@ export const templateUpdateSchema = z
|
|
|
505
596
|
.object({
|
|
506
597
|
templateId: z.string().uuid(),
|
|
507
598
|
displayName: displayNameSchema.optional(),
|
|
508
|
-
/**
|
|
509
|
-
|
|
510
|
-
html: z.string().max(TEMPLATE_HTML_MAX).optional(),
|
|
599
|
+
/** Epic #1107 — also allow this template for the page type (slug); appended to its `templates`. */
|
|
600
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
601
|
+
html: z.string().max(TEMPLATE_HTML_MAX).superRefine(noCorruptMarkup("html")).optional(),
|
|
511
602
|
css: z.string().max(TEMPLATE_CSS_MAX).optional(),
|
|
512
603
|
/** P6.7.6 — re-point the template to a different layout. */
|
|
513
604
|
layoutId: z.string().uuid().optional(),
|
|
@@ -568,6 +659,16 @@ export const pageCreateSchema = z
|
|
|
568
659
|
* becomes "<parent's path>/<slug>". Omit (or null) for a top-level page.
|
|
569
660
|
*/
|
|
570
661
|
parentPageId: z.string().uuid().nullable().optional(),
|
|
662
|
+
/**
|
|
663
|
+
* Epic #1107 — what the page IS (page type slug). Omit to take the type
|
|
664
|
+
* whose `templates` contain the template; with neither `pageType` nor
|
|
665
|
+
* `templateId`, the site's default page type and its default template.
|
|
666
|
+
*/
|
|
667
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
668
|
+
/** Property values, validated against the page type. */
|
|
669
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
670
|
+
/** Terms (`caelo:term/<listId>/<itemId>`) from the page type's vocabularies. */
|
|
671
|
+
terms: z.array(z.string().max(120)).max(100).optional(),
|
|
571
672
|
})
|
|
572
673
|
.strict();
|
|
573
674
|
|
|
@@ -620,6 +721,16 @@ export const pageUpdateSchema = z
|
|
|
620
721
|
* every inbound link, which is never a safe default (CLAUDE.md §2).
|
|
621
722
|
*/
|
|
622
723
|
redirectFromOld: z.enum(["auto", "skip"]).optional(),
|
|
724
|
+
/**
|
|
725
|
+
* Epic #1107 — switch the page type (slug). The template must be one of
|
|
726
|
+
* the type's templates (pass `templateId` too when it is not), and the
|
|
727
|
+
* page's properties and terms must fit the new type.
|
|
728
|
+
*/
|
|
729
|
+
pageType: pageTypeSlugSchema.optional(),
|
|
730
|
+
/** Merged into the page's properties; `null` removes a property. */
|
|
731
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
732
|
+
/** Replaces the page's terms (`caelo:term/<listId>/<itemId>`). */
|
|
733
|
+
terms: z.array(z.string().max(120)).max(100).optional(),
|
|
623
734
|
})
|
|
624
735
|
.strict();
|
|
625
736
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
formatCssVarRenameReport,
|
|
6
|
+
renameCssVarAliases,
|
|
7
|
+
themeVarForAlias,
|
|
8
|
+
} from "./css-var-aliases.js";
|
|
9
|
+
import { scanCssVars } from "./css-var-scan.js";
|
|
10
|
+
|
|
11
|
+
const THEME = [
|
|
12
|
+
"--color-background",
|
|
13
|
+
"--color-foreground",
|
|
14
|
+
"--color-muted",
|
|
15
|
+
"--color-muted-foreground",
|
|
16
|
+
"--color-border",
|
|
17
|
+
"--color-accent",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("renameCssVarAliases", () => {
|
|
21
|
+
// The header CSS of e2e-livedit run 40351690739.
|
|
22
|
+
const css =
|
|
23
|
+
".site-header{border-bottom:1px solid var(--color-border, #e5e5e5);background:var(--color-surface, #fff);}\n" +
|
|
24
|
+
".site-header__brand{color:var(--color-text, #111);}\n" +
|
|
25
|
+
".site-header__panel{background:var(--color-surface, #fff);}\n" +
|
|
26
|
+
".site-header__col h3{color:var(--color-text-muted,#888);}";
|
|
27
|
+
|
|
28
|
+
it("renames common role names to the theme's names, every occurrence", () => {
|
|
29
|
+
const r = renameCssVarAliases(css, THEME, new Set());
|
|
30
|
+
expect(r.css).not.toContain("--color-surface");
|
|
31
|
+
expect(r.css).not.toContain("var(--color-text,");
|
|
32
|
+
expect(r.css).toContain("background:var(--color-background, #fff)");
|
|
33
|
+
expect(r.css).toContain("color:var(--color-foreground, #111)");
|
|
34
|
+
expect(r.css).toContain("color:var(--color-muted-foreground,#888)");
|
|
35
|
+
expect(r.renames).toEqual([
|
|
36
|
+
{ from: "--color-surface", to: "--color-background", count: 2 },
|
|
37
|
+
{ from: "--color-text", to: "--color-foreground", count: 1 },
|
|
38
|
+
{ from: "--color-text-muted", to: "--color-muted-foreground", count: 1 },
|
|
39
|
+
]);
|
|
40
|
+
expect(formatCssVarRenameReport(r.renames)).toContain("--color-surface→--color-background×2");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("leaves known names, the author's own properties and unmapped names alone", () => {
|
|
44
|
+
const own = ":root{--surface:#fafafa}.a{background:var(--surface);color:var(--brand-ink)}";
|
|
45
|
+
const r = renameCssVarAliases(own, THEME, new Set(["--surface"]));
|
|
46
|
+
expect(r.css).toBe(own);
|
|
47
|
+
expect(r.renames).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renames only when the theme emits the target", () => {
|
|
51
|
+
expect(themeVarForAlias("--color-surface-raised", new Set(THEME))).toBeNull();
|
|
52
|
+
expect(themeVarForAlias("--text", new Set(THEME))).toBe("--color-foreground");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("scanCssVars suggestions", () => {
|
|
57
|
+
it("suggests the theme's role name, not the nearest spelling", () => {
|
|
58
|
+
const [u] = scanCssVars({ css: ".a{color:var(--color-text, #111)}", knownVars: THEME });
|
|
59
|
+
expect(u?.suggestion).toBe("--color-foreground");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common names for theme roles, mapped to the names Caelo's theme emits.
|
|
5
|
+
*
|
|
6
|
+
* Module CSS is authored against conventions the model knows from other
|
|
7
|
+
* design systems: `--color-text`, `--color-surface`, `--color-text-muted`.
|
|
8
|
+
* Caelo's theme calls those roles `--color-foreground`, `--color-background`
|
|
9
|
+
* and `--color-muted-foreground`. The write-time scan used to answer with an
|
|
10
|
+
* edit-distance guess (`--color-text` → "did you mean `--color-accent`?"),
|
|
11
|
+
* so the AI loaded the theme and renamed every reference with edit_content
|
|
12
|
+
* — two to three extra tool calls per module, and a failed one when the
|
|
13
|
+
* same declaration appeared twice (e2e-livedit run 40351690739). A name
|
|
14
|
+
* that unambiguously means a role the theme has is renamed before the
|
|
15
|
+
* write instead, and the tool result says so, the same way theme-literal
|
|
16
|
+
* binding rewrites a literal that equals a token (issue #164). Only names
|
|
17
|
+
* in this table are touched, and only when the theme emits the target.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Role name (without `--` and an optional `color-` prefix) → theme role. */
|
|
21
|
+
const ROLE_ALIASES: Readonly<Record<string, string>> = {
|
|
22
|
+
text: "foreground",
|
|
23
|
+
"text-primary": "foreground",
|
|
24
|
+
"text-color": "foreground",
|
|
25
|
+
"body-text": "foreground",
|
|
26
|
+
fg: "foreground",
|
|
27
|
+
heading: "foreground",
|
|
28
|
+
"text-muted": "muted-foreground",
|
|
29
|
+
"text-secondary": "muted-foreground",
|
|
30
|
+
"text-subtle": "muted-foreground",
|
|
31
|
+
"muted-text": "muted-foreground",
|
|
32
|
+
"fg-muted": "muted-foreground",
|
|
33
|
+
surface: "background",
|
|
34
|
+
bg: "background",
|
|
35
|
+
"page-bg": "background",
|
|
36
|
+
"body-bg": "background",
|
|
37
|
+
"surface-muted": "muted",
|
|
38
|
+
"surface-alt": "muted",
|
|
39
|
+
"bg-muted": "muted",
|
|
40
|
+
"bg-subtle": "muted",
|
|
41
|
+
"surface-raised": "card",
|
|
42
|
+
"card-bg": "card",
|
|
43
|
+
"border-color": "border",
|
|
44
|
+
divider: "border",
|
|
45
|
+
line: "border",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The theme var an unknown name stands for, or null. */
|
|
49
|
+
export function themeVarForAlias(name: string, knownVars: ReadonlySet<string>): string | null {
|
|
50
|
+
const bare = name.replace(/^--/, "").replace(/^color-/, "");
|
|
51
|
+
const role = ROLE_ALIASES[bare];
|
|
52
|
+
if (role === undefined) return null;
|
|
53
|
+
const target = `--color-${role}`;
|
|
54
|
+
return knownVars.has(target) ? target : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `var(` + the referenced name, when the name ends there. */
|
|
58
|
+
const VAR_NAME_RE = /(var\(\s*)(--[a-zA-Z0-9_-]+)(?![a-zA-Z0-9_-])/g;
|
|
59
|
+
|
|
60
|
+
/** One renamed reference. */
|
|
61
|
+
export interface CssVarRename {
|
|
62
|
+
readonly from: string;
|
|
63
|
+
readonly to: string;
|
|
64
|
+
readonly count: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Rename `var(--alias…)` references to the theme's name for that role.
|
|
69
|
+
* A name the CSS itself defines (`--surface: …`) is the author's own
|
|
70
|
+
* property and stays.
|
|
71
|
+
*/
|
|
72
|
+
export function renameCssVarAliases(
|
|
73
|
+
css: string,
|
|
74
|
+
knownVars: readonly string[],
|
|
75
|
+
definedInCss: ReadonlySet<string>,
|
|
76
|
+
): { css: string; renames: CssVarRename[] } {
|
|
77
|
+
const known = new Set(knownVars);
|
|
78
|
+
const targets = new Map<string, string>();
|
|
79
|
+
const counts = new Map<string, number>();
|
|
80
|
+
// One pass over the references, no pattern built from a name.
|
|
81
|
+
const out = css.replace(VAR_NAME_RE, (match, open: string, name: string) => {
|
|
82
|
+
if (known.has(name) || definedInCss.has(name)) return match;
|
|
83
|
+
let to = targets.get(name);
|
|
84
|
+
if (to === undefined) {
|
|
85
|
+
const resolved = themeVarForAlias(name, known);
|
|
86
|
+
if (resolved === null) return match;
|
|
87
|
+
to = resolved;
|
|
88
|
+
targets.set(name, to);
|
|
89
|
+
}
|
|
90
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
91
|
+
return `${open}${to}`;
|
|
92
|
+
});
|
|
93
|
+
const renames: CssVarRename[] = [...targets].map(([from, to]) => ({
|
|
94
|
+
from,
|
|
95
|
+
to,
|
|
96
|
+
count: counts.get(from) ?? 0,
|
|
97
|
+
}));
|
|
98
|
+
return { css: out, renames };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Tool-result sentence for the renames, or null. */
|
|
102
|
+
export function formatCssVarRenameReport(renames: readonly CssVarRename[]): string | null {
|
|
103
|
+
if (renames.length === 0) return null;
|
|
104
|
+
return ` 🔗 renamed to the theme's names: ${renames
|
|
105
|
+
.map((r) => `${r.from}→${r.to}${r.count > 1 ? `×${r.count}` : ""}`)
|
|
106
|
+
.join(
|
|
107
|
+
", ",
|
|
108
|
+
)} — the STORED css uses the theme's names, so anchor later edit_content calls on those.`;
|
|
109
|
+
}
|
package/src/css-var-scan.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* that is legitimate authoring, not drift.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { themeVarForAlias } from "./css-var-aliases.js";
|
|
21
|
+
|
|
20
22
|
/** One `var(--x)` reference found in CSS. */
|
|
21
23
|
export interface CssVarReference {
|
|
22
24
|
readonly name: string;
|
|
@@ -97,6 +99,13 @@ export function scanCssVars(input: CssVarScanInput): UnknownCssVar[] {
|
|
|
97
99
|
for (const ref of extractCssVarReferences(input.css)) {
|
|
98
100
|
if (known.has(ref.name) || reported.has(ref.name)) continue;
|
|
99
101
|
reported.add(ref.name);
|
|
102
|
+
// A common role name (`--color-text`) suggests the theme's name for
|
|
103
|
+
// that role, not the nearest spelling (which was `--color-accent`).
|
|
104
|
+
const alias = themeVarForAlias(ref.name, known);
|
|
105
|
+
if (alias !== null) {
|
|
106
|
+
unknown.push({ name: ref.name, hasFallback: ref.hasFallback, suggestion: alias });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
100
109
|
let best: string | null = null;
|
|
101
110
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
102
111
|
for (const candidate of known) {
|
package/src/edit-markers.test.ts
CHANGED
|
@@ -2,19 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
4
|
import { finalizeEditMarkers, wrapEditableValues } from "./edit-markers.js";
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_LIST_TYPES } from "./lists/default-types.js";
|
|
6
|
+
import { fixtureData } from "./lists/list-fixtures.test.js";
|
|
7
|
+
import { createListRenderContext } from "./lists/resolve.js";
|
|
8
|
+
import { renderTemplate, type TemplateField } from "./template-engine.js";
|
|
6
9
|
|
|
7
|
-
const FIELDS = [
|
|
10
|
+
const FIELDS: TemplateField[] = [
|
|
8
11
|
{ name: "title", kind: "text" },
|
|
9
|
-
{ name: "tags", kind: "
|
|
10
|
-
{ name: "links", kind: "
|
|
12
|
+
{ name: "tags", kind: "list", listType: "texts" },
|
|
13
|
+
{ name: "links", kind: "list", listType: "links" },
|
|
11
14
|
{ name: "photo", kind: "image" },
|
|
12
|
-
]
|
|
15
|
+
];
|
|
16
|
+
const listTypes = (slug: string) => DEFAULT_LIST_TYPES.find((t) => t.slug === slug);
|
|
17
|
+
const lists = createListRenderContext(fixtureData()).forPage(null);
|
|
18
|
+
|
|
19
|
+
const T1 = "00000000-0000-4000-9000-000000000001";
|
|
20
|
+
const T2 = "00000000-0000-4000-9000-000000000002";
|
|
21
|
+
const L1 = "00000000-0000-4000-9000-000000000003";
|
|
22
|
+
const tags = (...t: string[]) =>
|
|
23
|
+
t.map((text, i) => ({ id: [T1, T2][i], node: "text", values: { text } }));
|
|
24
|
+
const links = (label: string, href: string) => [
|
|
25
|
+
{ id: L1, node: "link", values: { label, link: { href } } },
|
|
26
|
+
];
|
|
27
|
+
/** A marker path as the comment encodes it (`-` → %2D). */
|
|
28
|
+
const enc = (p: string) => encodeURIComponent(p).replace(/-/g, "%2D");
|
|
13
29
|
|
|
14
30
|
function render(html: string, values: Record<string, unknown>, sharedPages = 0): string {
|
|
15
|
-
const wrapped = wrapEditableValues(values, FIELDS);
|
|
16
|
-
const rendered = renderTemplate({ html, fields:
|
|
17
|
-
return finalizeEditMarkers(rendered, "content#2", sharedPages)
|
|
31
|
+
const wrapped = wrapEditableValues(values, FIELDS, listTypes);
|
|
32
|
+
const rendered = renderTemplate({ html, fields: FIELDS, contentValues: wrapped, lists }).html;
|
|
33
|
+
return finalizeEditMarkers(rendered, "content#2", sharedPages).replace(
|
|
34
|
+
/ data-caelo-list-item="[^"]*"/g,
|
|
35
|
+
"",
|
|
36
|
+
);
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
const marked = (fields: string, shared = 0) =>
|
|
@@ -34,17 +53,14 @@ describe("edit markers (#917)", () => {
|
|
|
34
53
|
expect(render("<h1>{{title}}</h1>", { title: "Hi" }, 4)).toContain(open("title", 4));
|
|
35
54
|
});
|
|
36
55
|
|
|
37
|
-
it("marks
|
|
56
|
+
it("marks local list entries' text and link targets by entry id", () => {
|
|
38
57
|
const html = render(
|
|
39
|
-
'<ul>{{#tags}}<li>{{
|
|
40
|
-
{
|
|
41
|
-
tags: ["a", "b"],
|
|
42
|
-
links: [{ label: "Docs", href: "/docs" }],
|
|
43
|
-
},
|
|
58
|
+
'<ul>{{#tags}}<li>{{text}}</li>{{/tags}}</ul><nav>{{#links}}<a href="{{link}}">{{label}}</a>{{/links}}</nav>',
|
|
59
|
+
{ tags: tags("a", "b"), links: links("Docs", "/docs") },
|
|
44
60
|
);
|
|
45
|
-
expect(html).toContain(`<li>${open(
|
|
61
|
+
expect(html).toContain(`<li>${open(enc(`tags.${T2}.text`))}b${close}</li>`);
|
|
46
62
|
expect(html).toContain(
|
|
47
|
-
`<a href="/docs"${marked(
|
|
63
|
+
`<a href="/docs"${marked(`href=${enc(`links.${L1}.link.href`)}`)}>${open(enc(`links.${L1}.label`))}Docs${close}</a>`,
|
|
48
64
|
);
|
|
49
65
|
});
|
|
50
66
|
|
|
@@ -61,12 +77,12 @@ describe("edit markers (#917)", () => {
|
|
|
61
77
|
|
|
62
78
|
it("marks link targets, images and alt text on their tag (#918)", () => {
|
|
63
79
|
const html = render(
|
|
64
|
-
'{{#links}}<a href="{{
|
|
65
|
-
{ links:
|
|
80
|
+
'{{#links}}<a href="{{link}}">{{label}}</a>{{/links}}<img src="{{photo}}" alt="{{title}}"/>',
|
|
81
|
+
{ links: links("Docs", "/docs"), photo: "/m/cup.webp", title: "A cup" },
|
|
66
82
|
2,
|
|
67
83
|
);
|
|
68
84
|
expect(html).toContain(
|
|
69
|
-
`<a href="/docs"${marked(
|
|
85
|
+
`<a href="/docs"${marked(`href=${enc(`links.${L1}.link.href`)}`, 2)}>${open(enc(`links.${L1}.label`), 2)}Docs${close}</a>`,
|
|
70
86
|
);
|
|
71
87
|
expect(html).toContain(
|
|
72
88
|
`<img src="/m/cup.webp" alt="A cup"${marked("src=photo alt=title", 2)}/>`,
|
|
@@ -80,9 +96,9 @@ describe("edit markers (#917)", () => {
|
|
|
80
96
|
});
|
|
81
97
|
|
|
82
98
|
it("does not mark values that carry markup, empty values, or non-text kinds", () => {
|
|
83
|
-
const html = render("<h1>{{title}}</h1><p>{{#tags}}{{
|
|
99
|
+
const html = render("<h1>{{title}}</h1><p>{{#tags}}{{text}}{{/tags}}</p>", {
|
|
84
100
|
title: "<b>Bold</b>",
|
|
85
|
-
tags:
|
|
101
|
+
tags: tags(" "),
|
|
86
102
|
});
|
|
87
103
|
expect(html).toBe("<h1><b>Bold</b></h1><p> </p>");
|
|
88
104
|
expect(wrapEditableValues({ n: 3 }, [{ name: "n", kind: "number" }])).toEqual({ n: 3 });
|