@caelo-cms/shared 0.10.71 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tools.d.ts +125 -50
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +50 -38
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +110 -30
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +26 -7
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +203 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +178 -76
- package/dist/content.js.map +1 -1
- package/dist/css-var-aliases.d.ts +20 -0
- package/dist/css-var-aliases.d.ts.map +1 -0
- package/dist/css-var-aliases.js +95 -0
- package/dist/css-var-aliases.js.map +1 -0
- package/dist/css-var-scan.d.ts +0 -16
- package/dist/css-var-scan.d.ts.map +1 -1
- package/dist/css-var-scan.js +24 -0
- package/dist/css-var-scan.js.map +1 -1
- package/dist/edit-markers.d.ts +41 -5
- package/dist/edit-markers.d.ts.map +1 -1
- package/dist/edit-markers.js +47 -50
- package/dist/edit-markers.js.map +1 -1
- package/dist/id-safe-truncate.d.ts +15 -0
- package/dist/id-safe-truncate.d.ts.map +1 -0
- package/dist/id-safe-truncate.js +47 -0
- package/dist/id-safe-truncate.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +2 -2
- package/dist/interactions.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +7 -1
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/link-refs.d.ts +1 -1
- package/dist/link-refs.js +3 -3
- package/dist/lists/address.d.ts +34 -0
- package/dist/lists/address.d.ts.map +1 -0
- package/dist/lists/address.js +43 -0
- package/dist/lists/address.js.map +1 -0
- package/dist/lists/default-types.d.ts +10 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +91 -0
- package/dist/lists/default-types.js.map +1 -0
- package/dist/lists/fields.d.ts +152 -0
- package/dist/lists/fields.d.ts.map +1 -0
- package/dist/lists/fields.js +258 -0
- package/dist/lists/fields.js.map +1 -0
- package/dist/lists/index.d.ts +14 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +15 -0
- package/dist/lists/index.js.map +1 -0
- package/dist/lists/item-attr.d.ts +26 -0
- package/dist/lists/item-attr.d.ts.map +1 -0
- package/dist/lists/item-attr.js +31 -0
- package/dist/lists/item-attr.js.map +1 -0
- package/dist/lists/items.d.ts +105 -0
- package/dist/lists/items.d.ts.map +1 -0
- package/dist/lists/items.js +304 -0
- package/dist/lists/items.js.map +1 -0
- package/dist/lists/list-type.d.ts +401 -0
- package/dist/lists/list-type.d.ts.map +1 -0
- package/dist/lists/list-type.js +275 -0
- package/dist/lists/list-type.js.map +1 -0
- package/dist/lists/model.d.ts +14 -0
- package/dist/lists/model.d.ts.map +1 -0
- package/dist/lists/model.js +15 -0
- package/dist/lists/model.js.map +1 -0
- package/dist/lists/page-type.d.ts +115 -0
- package/dist/lists/page-type.d.ts.map +1 -0
- package/dist/lists/page-type.js +66 -0
- package/dist/lists/page-type.js.map +1 -0
- package/dist/lists/refs.d.ts +13 -0
- package/dist/lists/refs.d.ts.map +1 -0
- package/dist/lists/refs.js +18 -0
- package/dist/lists/refs.js.map +1 -0
- package/dist/lists/render-data.d.ts +114 -0
- package/dist/lists/render-data.d.ts.map +1 -0
- package/dist/lists/render-data.js +3 -0
- package/dist/lists/render-data.js.map +1 -0
- package/dist/lists/render-deps.d.ts +68 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +91 -0
- package/dist/lists/render-deps.js.map +1 -0
- package/dist/lists/resolve-values.d.ts +63 -0
- package/dist/lists/resolve-values.d.ts.map +1 -0
- package/dist/lists/resolve-values.js +181 -0
- package/dist/lists/resolve-values.js.map +1 -0
- package/dist/lists/resolve.d.ts +106 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +878 -0
- package/dist/lists/resolve.js.map +1 -0
- package/dist/lists/scope.d.ts +81 -0
- package/dist/lists/scope.d.ts.map +1 -0
- package/dist/lists/scope.js +3 -0
- package/dist/lists/scope.js.map +1 -0
- package/dist/markup-structure.d.ts +8 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +2 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +25 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +122 -31
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts +24 -21
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +34 -118
- package/dist/preview-compose.js.map +1 -1
- package/dist/preview-scanner.d.ts +5 -2
- package/dist/preview-scanner.d.ts.map +1 -1
- package/dist/preview-scanner.js +5 -3
- package/dist/preview-scanner.js.map +1 -1
- package/dist/source-text-transport.d.ts +17 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +71 -0
- package/dist/source-text-transport.js.map +1 -0
- package/dist/template-engine.d.ts +44 -31
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +75 -174
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts +69 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/dist/template-sections.js +423 -0
- package/dist/template-sections.js.map +1 -0
- package/dist/uuid-diagnose.d.ts +9 -0
- package/dist/uuid-diagnose.d.ts.map +1 -0
- package/dist/uuid-diagnose.js +47 -0
- package/dist/uuid-diagnose.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/src/__tests__/redos-hardening.test.ts +50 -16
- package/src/ai-tools-add-module-modes.test.ts +50 -0
- package/src/ai-tools.test.ts +10 -0
- package/src/ai-tools.ts +53 -39
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +29 -6
- package/src/content.test.ts +111 -3
- package/src/content.ts +188 -77
- package/src/css-var-aliases.test.ts +61 -0
- package/src/css-var-aliases.ts +109 -0
- package/src/css-var-scan.ts +9 -0
- package/src/edit-markers.test.ts +37 -21
- package/src/edit-markers.ts +52 -20
- package/src/id-safe-truncate.test.ts +35 -0
- package/src/id-safe-truncate.ts +46 -0
- package/src/index.ts +5 -1
- package/src/interactions.ts +2 -2
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +66 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +102 -0
- package/src/lists/fields.ts +285 -0
- package/src/lists/index.ts +16 -0
- package/src/lists/item-attr.test.ts +36 -0
- package/src/lists/item-attr.ts +49 -0
- package/src/lists/items.ts +402 -0
- package/src/lists/list-fixtures.test.ts +302 -0
- package/src/lists/list-type.ts +306 -0
- package/src/lists/lists.test.ts +328 -0
- package/src/lists/model.ts +16 -0
- package/src/lists/page-type.ts +71 -0
- package/src/lists/refs.ts +21 -0
- package/src/lists/render-data.ts +125 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +144 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +295 -0
- package/src/lists/resolve.ts +1148 -0
- package/src/lists/scope.ts +69 -0
- package/src/lists-compose-parity.test.ts +262 -0
- package/src/markup-structure.ts +10 -0
- package/src/nested-module-render.test.ts +111 -77
- package/src/nested-module-render.ts +85 -48
- package/src/preview-compose.test.ts +74 -19
- package/src/preview-compose.ts +59 -154
- package/src/preview-scanner.test.ts +8 -4
- package/src/preview-scanner.ts +5 -3
- package/src/source-text-transport.test.ts +63 -0
- package/src/source-text-transport.ts +70 -0
- package/src/template-engine-lists.test.ts +553 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +500 -0
- package/src/uuid-diagnose.test.ts +28 -0
- package/src/uuid-diagnose.ts +50 -0
- package/src/version.ts +1 -1
- package/dist/structured-sets.d.ts +0 -70
- package/dist/structured-sets.d.ts.map +0 -1
- package/dist/structured-sets.js +0 -92
- package/dist/structured-sets.js.map +0 -1
- package/src/structured-sets.ts +0 -114
- package/src/template-engine-data-lists.test.ts +0 -90
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 });
|
package/src/edit-markers.ts
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* be edited as plain text without destroying that markup.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import type { ListTypeDef } from "./lists/list-type.js";
|
|
33
|
+
|
|
32
34
|
const START = "\uE000";
|
|
33
35
|
const SEP = "\uE001";
|
|
34
36
|
const END = "\uE002";
|
|
@@ -38,21 +40,29 @@ const SENTINELS = /[\uE000-\uE002]/g;
|
|
|
38
40
|
export const EDIT_MARKER_PREFIX = "caelo:f";
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
* Field kinds the preview marks: text (`text`, `text
|
|
42
|
-
* `
|
|
43
|
-
* `
|
|
43
|
+
* Field kinds the preview marks: text (`text`, the `text` fields of a
|
|
44
|
+
* local `list`'s entries) and link / image targets (`url`, `image`, the
|
|
45
|
+
* `href` of a local list entry's `link` fields, #918) — the latter
|
|
46
|
+
* usually land in `href` / `src`.
|
|
44
47
|
*/
|
|
45
|
-
const EDITABLE_KINDS = new Set(["text", "
|
|
48
|
+
const EDITABLE_KINDS = new Set(["text", "url", "image", "list"]);
|
|
46
49
|
|
|
47
50
|
/** Attributes whose marked value the editor can change (link target, image, alt text). */
|
|
48
51
|
const MARKABLE_ATTRIBUTES = new Set(["href", "src", "alt"]);
|
|
49
52
|
|
|
50
|
-
/** Field shape {@link wrapEditableValues} needs: name + kind. */
|
|
53
|
+
/** Field shape {@link wrapEditableValues} needs: name + kind (+ a list field's type and binding). */
|
|
51
54
|
export interface EditableFieldDef {
|
|
52
55
|
readonly name: string;
|
|
53
56
|
readonly kind?: string;
|
|
57
|
+
readonly listType?: string;
|
|
58
|
+
readonly bind?: string;
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/** List type lookup by slug, for marking a local list's entries. */
|
|
62
|
+
export type EditableListTypes = (slug: string) => ListTypeDef | undefined;
|
|
63
|
+
|
|
64
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
65
|
+
|
|
56
66
|
function editable(value: unknown): value is string {
|
|
57
67
|
return typeof value === "string" && value.trim().length > 0 && !value.includes("<");
|
|
58
68
|
}
|
|
@@ -65,9 +75,12 @@ function wrap(path: string, value: string): string {
|
|
|
65
75
|
* Wrap the editable text of a content instance's values in sentinels.
|
|
66
76
|
* Returns a new object; non-editable values pass through untouched.
|
|
67
77
|
*
|
|
68
|
-
* Paths: `<field>` for a `text` / `url` / `image` field
|
|
69
|
-
* for a `text
|
|
70
|
-
* `link
|
|
78
|
+
* Paths: `<field>` for a `text` / `url` / `image` field;
|
|
79
|
+
* `<field>.<itemId>.<key>` for a `text` value of a local list entry and
|
|
80
|
+
* `<field>.<itemId>.<key>.href` for the target of its `link` value (item
|
|
81
|
+
* ids are stable uuids, so a path survives reordering). Shared lists are
|
|
82
|
+
* edited in the list editor, not in place: their values are not part of
|
|
83
|
+
* the placement.
|
|
71
84
|
*
|
|
72
85
|
* @example
|
|
73
86
|
* wrapEditableValues({ hero_title: "Hi" }, [{ name: "hero_title", kind: "text" }]);
|
|
@@ -76,6 +89,7 @@ function wrap(path: string, value: string): string {
|
|
|
76
89
|
export function wrapEditableValues(
|
|
77
90
|
values: Readonly<Record<string, unknown>>,
|
|
78
91
|
fields: readonly EditableFieldDef[],
|
|
92
|
+
listTypes?: EditableListTypes,
|
|
79
93
|
): Record<string, unknown> {
|
|
80
94
|
const out: Record<string, unknown> = { ...values };
|
|
81
95
|
for (const f of fields) {
|
|
@@ -84,23 +98,41 @@ export function wrapEditableValues(
|
|
|
84
98
|
const v = values[f.name];
|
|
85
99
|
if (kind === "text" || kind === "url" || kind === "image") {
|
|
86
100
|
if (editable(v)) out[f.name] = wrap(f.name, v);
|
|
87
|
-
} else if (kind === "
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
out[f.name] = v.map((item, i) => {
|
|
91
|
-
if (typeof item !== "object" || item === null) return item;
|
|
92
|
-
const { label, href } = item as { label?: unknown; href?: unknown };
|
|
93
|
-
return {
|
|
94
|
-
...item,
|
|
95
|
-
...(editable(label) ? { label: wrap(`${f.name}.${i}.label`, label) } : {}),
|
|
96
|
-
...(editable(href) ? { href: wrap(`${f.name}.${i}.href`, href) } : {}),
|
|
97
|
-
};
|
|
98
|
-
});
|
|
101
|
+
} else if (kind === "list" && (f.bind ?? "local") === "local" && Array.isArray(v)) {
|
|
102
|
+
const type = f.listType === undefined ? undefined : listTypes?.(f.listType);
|
|
103
|
+
if (type) out[f.name] = wrapListEntries(f.name, v, type);
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
return out;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
/** Mark the text and link targets of local list entries, at any depth. */
|
|
110
|
+
function wrapListEntries(field: string, items: readonly unknown[], type: ListTypeDef): unknown[] {
|
|
111
|
+
return items.map((raw) => {
|
|
112
|
+
if (typeof raw !== "object" || raw === null) return raw;
|
|
113
|
+
const item = raw as { id?: unknown; node?: unknown; values?: unknown; children?: unknown };
|
|
114
|
+
const node = typeof item.node === "string" ? type.nodes[item.node] : undefined;
|
|
115
|
+
if (!node || typeof item.id !== "string" || !UUID_RE.test(item.id)) return raw;
|
|
116
|
+
const values = { ...((item.values ?? {}) as Record<string, unknown>) };
|
|
117
|
+
for (const def of node.fields) {
|
|
118
|
+
const v = values[def.name];
|
|
119
|
+
const at = `${field}.${item.id}.${def.name}`;
|
|
120
|
+
if (def.type === "text" && editable(v)) values[def.name] = wrap(at, v);
|
|
121
|
+
if (def.type === "link" && typeof v === "object" && v !== null) {
|
|
122
|
+
const href = (v as { href?: unknown }).href;
|
|
123
|
+
if (editable(href)) values[def.name] = { ...v, href: wrap(`${at}.href`, href) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...item,
|
|
128
|
+
values,
|
|
129
|
+
...(Array.isArray(item.children)
|
|
130
|
+
? { children: wrapListEntries(field, item.children, type) }
|
|
131
|
+
: {}),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
/** Comment-safe encoding: no `--`, no `>`, no whitespace. */
|
|
105
137
|
function attr(value: string): string {
|
|
106
138
|
return encodeURIComponent(value).replace(/-/g, "%2D");
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { idSafeCutIndex, truncateKeepingIds } from "./id-safe-truncate.js";
|
|
5
|
+
|
|
6
|
+
const ID = "c9c7ddcb-cc85-4747-84fa-90967da6f769";
|
|
7
|
+
|
|
8
|
+
describe("idSafeCutIndex (#1094)", () => {
|
|
9
|
+
it("moves a cut inside a UUID back to just before it", () => {
|
|
10
|
+
const text = `ref ${ID} tail`;
|
|
11
|
+
// 4 = where the id starts; every cut inside it lands there.
|
|
12
|
+
for (let at = 5; at < 4 + ID.length; at++) expect(idSafeCutIndex(text, at)).toBe(4);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps a cut that does not split an id", () => {
|
|
16
|
+
const text = `ref ${ID} tail`;
|
|
17
|
+
expect(idSafeCutIndex(text, 4)).toBe(4);
|
|
18
|
+
expect(idSafeCutIndex(text, 4 + ID.length)).toBe(4 + ID.length);
|
|
19
|
+
expect(idSafeCutIndex("plain text here", 5)).toBe(5);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("truncateKeepingIds", () => {
|
|
24
|
+
it("never leaves a UUID fragment before the suffix", () => {
|
|
25
|
+
const text = `contentInstanceId: ${ID}, more`;
|
|
26
|
+
const out = truncateKeepingIds(text, 45);
|
|
27
|
+
expect(out).toBe("contentInstanceId: …");
|
|
28
|
+
expect(truncateKeepingIds(text, 80)).toBe(text);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps a whole id that fits", () => {
|
|
32
|
+
const text = `id ${ID} and a long trailing explanation`;
|
|
33
|
+
expect(truncateKeepingIds(text, 3 + ID.length + 2)).toBe(`id ${ID} …`);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cut text for the AI without ever cutting an id in half.
|
|
5
|
+
*
|
|
6
|
+
* #1089 / #1094 — the model called tools with UUIDs one character short
|
|
7
|
+
* (`c9c7ddcb-cc85-4747-84f-90967da6f769`). Every place that shortens text
|
|
8
|
+
* the AI reads (compacted tool results, digests, previews, capped finding
|
|
9
|
+
* messages) used a plain `slice(0, n)`, so an id straddling the cut reached
|
|
10
|
+
* the model as a fragment it then "completed" from memory. An id is copied,
|
|
11
|
+
* never reconstructed: the cut moves back to just before an id it would
|
|
12
|
+
* split, so an id is either shown whole or not at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The largest index `<= at` at which `text` can be cut without splitting
|
|
19
|
+
* a UUID.
|
|
20
|
+
*
|
|
21
|
+
* @param at - the desired cut index (characters kept).
|
|
22
|
+
*/
|
|
23
|
+
export function idSafeCutIndex(text: string, at: number): number {
|
|
24
|
+
if (at <= 0) return 0;
|
|
25
|
+
if (at >= text.length) return text.length;
|
|
26
|
+
// Only an id that could reach the cut matters: scan a window around it.
|
|
27
|
+
const from = Math.max(0, at - 36);
|
|
28
|
+
const window = text.slice(from, at + 36);
|
|
29
|
+
for (const m of window.matchAll(UUID_PATTERN)) {
|
|
30
|
+
const start = from + (m.index ?? 0);
|
|
31
|
+
const end = start + m[0].length;
|
|
32
|
+
if (start < at && at < end) return start;
|
|
33
|
+
}
|
|
34
|
+
return at;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `text` cut to at most `max` characters (the suffix included) without
|
|
39
|
+
* splitting a UUID; unchanged when it already fits.
|
|
40
|
+
*
|
|
41
|
+
* @param suffix - appended when cut (default `…`).
|
|
42
|
+
*/
|
|
43
|
+
export function truncateKeepingIds(text: string, max: number, suffix = "…"): string {
|
|
44
|
+
if (text.length <= max) return text;
|
|
45
|
+
return `${text.slice(0, idSafeCutIndex(text, Math.max(0, max - suffix.length)))}${suffix}`;
|
|
46
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./content.js";
|
|
|
14
14
|
export * from "./context.js";
|
|
15
15
|
export * from "./css-gradient-scan.js";
|
|
16
16
|
export * from "./css-scope-scan.js";
|
|
17
|
+
export * from "./css-var-aliases.js";
|
|
17
18
|
export * from "./css-var-scan.js";
|
|
18
19
|
export * from "./database-url.js";
|
|
19
20
|
export * from "./design-draft-shell.js";
|
|
@@ -27,10 +28,12 @@ export * from "./genesis.js";
|
|
|
27
28
|
export * from "./genesis-inventory.js";
|
|
28
29
|
export * from "./google-models.js";
|
|
29
30
|
export * from "./google-oauth.js";
|
|
31
|
+
export * from "./id-safe-truncate.js";
|
|
30
32
|
export * from "./image-capabilities.js";
|
|
31
33
|
export * from "./interactions.js";
|
|
32
34
|
export * from "./link-canonicalise.js";
|
|
33
35
|
export * from "./link-refs.js";
|
|
36
|
+
export * from "./lists/index.js";
|
|
34
37
|
export {
|
|
35
38
|
type LogContext,
|
|
36
39
|
type Logger,
|
|
@@ -71,11 +74,11 @@ export * from "./screenshot-capture.js";
|
|
|
71
74
|
export * from "./seo.js";
|
|
72
75
|
export * from "./skills.js";
|
|
73
76
|
export * from "./snapshots.js";
|
|
77
|
+
export * from "./source-text-transport.js";
|
|
74
78
|
export * from "./staging-gate.js";
|
|
75
79
|
export * from "./static-cache-policy.js";
|
|
76
80
|
export * from "./static-security-headers.js";
|
|
77
81
|
export * from "./strip-cdata.js";
|
|
78
|
-
export * from "./structured-sets.js";
|
|
79
82
|
export * from "./subagents.js";
|
|
80
83
|
export * from "./template-engine.js";
|
|
81
84
|
export * from "./theme-importers/auto-detect.js";
|
|
@@ -92,6 +95,7 @@ export * from "./themes.js";
|
|
|
92
95
|
export * from "./themes-errors.js";
|
|
93
96
|
export * from "./untrusted-tool-result.js";
|
|
94
97
|
export * from "./url.js";
|
|
98
|
+
export * from "./uuid-diagnose.js";
|
|
95
99
|
export {
|
|
96
100
|
CAELO_VERSION,
|
|
97
101
|
CALEO_VERSION,
|
package/src/interactions.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface InteractionSnippet {
|
|
|
32
32
|
readonly js: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Functional-only rules for
|
|
35
|
+
/** Functional-only rules for a collapsible nav (`mobile-nav`): collapse/expand, zero aesthetics. */
|
|
36
36
|
const MOBILE_NAV_CSS =
|
|
37
37
|
".caelo-nav-menu .caelo-nav-toggle{display:none;background:none;border:0;cursor:pointer;padding:0.5rem;color:inherit;font:inherit}" +
|
|
38
38
|
".caelo-nav-menu .caelo-nav-toggle .caelo-nav-bar{display:block;width:1.25em;height:2px;background:currentColor;margin:0.25em 0}" +
|
|
@@ -199,5 +199,5 @@ export const INTERACTION_MARKUP_CONTRACT =
|
|
|
199
199
|
'`accordion` (FAQ, collapsible sections): `<div data-caelo-ui="accordion" data-single>` containing `<details id="..."><summary>Question</summary><div>Answer</div></details>` items - `data-single` keeps one open; a link to `#<details id>` opens it. ' +
|
|
200
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 must be unique on the page. ' +
|
|
201
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>` (an icon button keeps its visible content aria-hidden; a text button needs no aria-label) - one slide per view by default; set the slides\' `flex-basis` in module CSS to show more. ' +
|
|
202
|
-
|
|
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. ' +
|
|
203
203
|
"Write module JS only for behaviour the kit does not cover.";
|
|
@@ -349,7 +349,7 @@ describe("HTML and JSON positions", () => {
|
|
|
349
349
|
{ name: "cta", kind: "url" },
|
|
350
350
|
{ name: "hero", kind: "image" },
|
|
351
351
|
{ name: "price_suffix", kind: "text" },
|
|
352
|
-
{ name: "nav", kind: "
|
|
352
|
+
{ name: "nav", kind: "list" },
|
|
353
353
|
{ name: "body", kind: "richtext" },
|
|
354
354
|
{ name: "card", kind: "module" },
|
|
355
355
|
],
|
|
@@ -357,7 +357,20 @@ describe("HTML and JSON positions", () => {
|
|
|
357
357
|
cta: "/company/about",
|
|
358
358
|
hero: "/_caelo/media/hero",
|
|
359
359
|
price_suffix: "/month",
|
|
360
|
-
nav: [
|
|
360
|
+
nav: [
|
|
361
|
+
{
|
|
362
|
+
id: "00000000-0000-4000-9000-000000000001",
|
|
363
|
+
node: "entry",
|
|
364
|
+
values: { label: "/company/about", link: { href: "/" } },
|
|
365
|
+
children: [
|
|
366
|
+
{
|
|
367
|
+
id: "00000000-0000-4000-9000-000000000002",
|
|
368
|
+
node: "entry",
|
|
369
|
+
values: { label: "Us", link: { href: "/company/about", target: "_blank" } },
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
],
|
|
361
374
|
body: '<p><a href="/company/about">us</a></p>',
|
|
362
375
|
card: { moduleId: ABOUT, contentInstanceId: HOME },
|
|
363
376
|
legacy_href: "/company/about",
|
|
@@ -368,7 +381,22 @@ describe("HTML and JSON positions", () => {
|
|
|
368
381
|
cta: `caelo:page/${ABOUT}`,
|
|
369
382
|
hero: `caelo:media/${HERO}`,
|
|
370
383
|
price_suffix: "/month",
|
|
371
|
-
|
|
384
|
+
// Entry link values canonicalise at any depth; a label is text even
|
|
385
|
+
// when it reads like a path.
|
|
386
|
+
nav: [
|
|
387
|
+
{
|
|
388
|
+
id: "00000000-0000-4000-9000-000000000001",
|
|
389
|
+
node: "entry",
|
|
390
|
+
values: { label: "/company/about", link: { href: `caelo:page/${HOME}` } },
|
|
391
|
+
children: [
|
|
392
|
+
{
|
|
393
|
+
id: "00000000-0000-4000-9000-000000000002",
|
|
394
|
+
node: "entry",
|
|
395
|
+
values: { label: "Us", link: { href: `caelo:page/${ABOUT}`, target: "_blank" } },
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
],
|
|
372
400
|
body: `<p><a href="caelo:page/${ABOUT}">us</a></p>`,
|
|
373
401
|
card: { moduleId: ABOUT, contentInstanceId: HOME },
|
|
374
402
|
legacy_href: `caelo:page/${ABOUT}`,
|
|
@@ -382,15 +410,17 @@ describe("HTML and JSON positions", () => {
|
|
|
382
410
|
[
|
|
383
411
|
{
|
|
384
412
|
name: "links",
|
|
385
|
-
kind: "
|
|
413
|
+
kind: "list",
|
|
386
414
|
label: "L",
|
|
387
|
-
default: [{ label: "A", href: "/company/about" }],
|
|
415
|
+
default: [{ node: "link", values: { label: "A", link: { href: "/company/about" } } }],
|
|
388
416
|
},
|
|
389
417
|
{ name: "title", kind: "text", label: "T", default: "/company/about" },
|
|
390
418
|
],
|
|
391
419
|
r,
|
|
392
420
|
);
|
|
393
|
-
expect(fields[0]?.default).toEqual([
|
|
421
|
+
expect(fields[0]?.default).toEqual([
|
|
422
|
+
{ node: "link", values: { label: "A", link: { href: `caelo:page/${ABOUT}` } } },
|
|
423
|
+
]);
|
|
394
424
|
expect(fields[1]?.default).toBe("/company/about");
|
|
395
425
|
});
|
|
396
426
|
});
|
package/src/link-canonicalise.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - a WHOLE value that is a URL: `url` / `link` / `image` fields, any
|
|
15
15
|
* field or object key named `href` / `src` / `url` / `link` or ending
|
|
16
16
|
* in `_href` / `_url` / `_link` / `_src` (`primary_cta_href`), the
|
|
17
|
-
* `href` of `link
|
|
17
|
+
* `href` of a list entry's `link` value (`values.<f>.href`, any depth);
|
|
18
18
|
* - HTML anywhere (module HTML, rich text, any string value carrying
|
|
19
19
|
* markup): `href` (pages and media), `src` / `poster` / `srcset`
|
|
20
20
|
* (media only — a `src="/x"` that is not media is not a page link).
|
|
@@ -587,6 +587,11 @@ function canonicaliseFieldValue(
|
|
|
587
587
|
urlNames: ReadonlySet<string> | undefined,
|
|
588
588
|
): unknown {
|
|
589
589
|
if (field && NESTED_KINDS.has(field.kind)) return value;
|
|
590
|
+
// A list (epic #1107) is never a URL as a whole, whatever its name or
|
|
591
|
+
// where its section sits: its entries decide per value key — a `link`
|
|
592
|
+
// value's `href` canonicalises, a label stays text. A shared list's
|
|
593
|
+
// `caelo:list/<id>` is no link at all.
|
|
594
|
+
if (field?.kind === "list") return canonicaliseJsonLinks(value, run, where, false);
|
|
590
595
|
const asUrl =
|
|
591
596
|
(field !== undefined && URL_KINDS.has(field.kind)) ||
|
|
592
597
|
isLinkKeyName(name) ||
|