@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/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");
|
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";
|
|
@@ -32,6 +33,7 @@ export * from "./image-capabilities.js";
|
|
|
32
33
|
export * from "./interactions.js";
|
|
33
34
|
export * from "./link-canonicalise.js";
|
|
34
35
|
export * from "./link-refs.js";
|
|
36
|
+
export * from "./lists/index.js";
|
|
35
37
|
export {
|
|
36
38
|
type LogContext,
|
|
37
39
|
type Logger,
|
|
@@ -72,11 +74,11 @@ export * from "./screenshot-capture.js";
|
|
|
72
74
|
export * from "./seo.js";
|
|
73
75
|
export * from "./skills.js";
|
|
74
76
|
export * from "./snapshots.js";
|
|
77
|
+
export * from "./source-text-transport.js";
|
|
75
78
|
export * from "./staging-gate.js";
|
|
76
79
|
export * from "./static-cache-policy.js";
|
|
77
80
|
export * from "./static-security-headers.js";
|
|
78
81
|
export * from "./strip-cdata.js";
|
|
79
|
-
export * from "./structured-sets.js";
|
|
80
82
|
export * from "./subagents.js";
|
|
81
83
|
export * from "./template-engine.js";
|
|
82
84
|
export * from "./theme-importers/auto-detect.js";
|
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) ||
|
package/src/link-refs.test.ts
CHANGED
|
@@ -25,10 +25,31 @@ import {
|
|
|
25
25
|
parseLinkRef,
|
|
26
26
|
resolveLinkRefsInHtml,
|
|
27
27
|
} from "./link-refs.js";
|
|
28
|
+
import { DEFAULT_LIST_TYPES } from "./lists/default-types.js";
|
|
29
|
+
import { createListRenderContext } from "./lists/resolve.js";
|
|
28
30
|
import { renderPlacedModule } from "./nested-module-render.js";
|
|
29
31
|
import { composePageWithLayout } from "./preview-compose.js";
|
|
30
32
|
import { renderTemplate } from "./template-engine.js";
|
|
31
33
|
|
|
34
|
+
/** A list context that knows only the shipped list types (local lists need nothing else). */
|
|
35
|
+
const lists = createListRenderContext({
|
|
36
|
+
listTypes: new Map(DEFAULT_LIST_TYPES.map((t, i) => [t.slug, { id: `t${i}`, def: t }])),
|
|
37
|
+
lists: new Map(),
|
|
38
|
+
pageTypes: new Map(),
|
|
39
|
+
pages: new Map(),
|
|
40
|
+
media: new Map(),
|
|
41
|
+
siteLanguage: "en",
|
|
42
|
+
}).forPage(null);
|
|
43
|
+
const uid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
|
|
44
|
+
const links = (...ls: [string, string][]) =>
|
|
45
|
+
ls.map(([label, href], i) => ({
|
|
46
|
+
id: uid(i + 1),
|
|
47
|
+
node: "link",
|
|
48
|
+
values: { label, link: { href } },
|
|
49
|
+
}));
|
|
50
|
+
const unstamp = (html: string) => html.replace(/ data-caelo-list-item="[^"]*"/g, "");
|
|
51
|
+
const NAV_HTML = '<nav>{{#nav}}<a href="{{link}}">{{label}}</a>{{/nav}}</nav>';
|
|
52
|
+
|
|
32
53
|
const PAGE = "11111111-1111-4111-8111-111111111111";
|
|
33
54
|
const DRAFT = "22222222-2222-4222-8222-222222222222";
|
|
34
55
|
const GONE = "33333333-3333-4333-8333-333333333333";
|
|
@@ -172,25 +193,26 @@ describe("resolution", () => {
|
|
|
172
193
|
describe("template engine — references resolve wherever they come from", () => {
|
|
173
194
|
const resolver = () => index().forPage(null);
|
|
174
195
|
|
|
175
|
-
it("resolves module HTML attributes, values, defaults and
|
|
196
|
+
it("resolves module HTML attributes, values, defaults and list entries", () => {
|
|
176
197
|
const r = renderTemplate({
|
|
177
|
-
html: `<a href="caelo:page/${PAGE}">Home</a><a href="{{cta_href}}">{{cta_label}}</a><img src="{{hero}}">{{#nav}}<a href="{{
|
|
198
|
+
html: `<a href="caelo:page/${PAGE}">Home</a><a href="{{cta_href}}">{{cta_label}}</a><img src="{{hero}}">{{#nav}}<a href="{{link}}">{{label}}</a>{{/nav}}<div>{{body}}</div>`,
|
|
178
199
|
fields: [
|
|
179
200
|
{ name: "cta_href", kind: "url", default: `caelo:page/${PAGE}#contact` },
|
|
180
201
|
{ name: "cta_label", kind: "text" },
|
|
181
202
|
{ name: "hero", kind: "image" },
|
|
182
|
-
{ name: "nav", kind: "
|
|
203
|
+
{ name: "nav", kind: "list", listType: "links" },
|
|
183
204
|
{ name: "body", kind: "richtext" },
|
|
184
205
|
],
|
|
185
206
|
contentValues: {
|
|
186
207
|
cta_label: "Talk to us",
|
|
187
208
|
hero: `caelo:media/${MEDIA}`,
|
|
188
|
-
nav: [
|
|
209
|
+
nav: links(["Pricing", `caelo:page/${DRAFT}`]),
|
|
189
210
|
body: `<p>See <a href="caelo:page/${PAGE}?x=1">about</a></p>`,
|
|
190
211
|
},
|
|
212
|
+
lists,
|
|
191
213
|
linkResolver: resolver(),
|
|
192
214
|
});
|
|
193
|
-
expect(r.html).toBe(
|
|
215
|
+
expect(unstamp(r.html)).toBe(
|
|
194
216
|
'<a href="/company/about/">Home</a><a href="/company/about/#contact">Talk to us</a>' +
|
|
195
217
|
'<img src="/_caelo/media/hero-shot"><a href="/pricing/">Pricing</a>' +
|
|
196
218
|
'<div><p>See <a href="/company/about/?x=1">about</a></p></div>',
|
|
@@ -241,8 +263,8 @@ describe("template engine — references resolve wherever they come from", () =>
|
|
|
241
263
|
});
|
|
242
264
|
});
|
|
243
265
|
|
|
244
|
-
describe("composer —
|
|
245
|
-
it("resolves
|
|
266
|
+
describe("composer — chrome lists and pre-rendered modules", () => {
|
|
267
|
+
it("resolves a chrome module's list entries and attributes a broken one to the module", () => {
|
|
246
268
|
const out = composePageWithLayout({
|
|
247
269
|
layoutHtml:
|
|
248
270
|
'<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>',
|
|
@@ -253,11 +275,19 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
|
253
275
|
modules: [
|
|
254
276
|
{
|
|
255
277
|
moduleId: "m-nav",
|
|
256
|
-
slug: "nav
|
|
278
|
+
slug: "main-nav",
|
|
257
279
|
displayName: "Nav",
|
|
258
|
-
html:
|
|
280
|
+
html: NAV_HTML,
|
|
259
281
|
css: "",
|
|
260
282
|
js: "",
|
|
283
|
+
fields: [
|
|
284
|
+
{
|
|
285
|
+
name: "nav",
|
|
286
|
+
kind: "list",
|
|
287
|
+
listType: "links",
|
|
288
|
+
default: links(["About", `caelo:page/${PAGE}`], ["Gone", `caelo:page/${GONE}`]),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
261
291
|
},
|
|
262
292
|
],
|
|
263
293
|
},
|
|
@@ -279,23 +309,16 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
|
279
309
|
],
|
|
280
310
|
},
|
|
281
311
|
],
|
|
282
|
-
|
|
283
|
-
byKindSlug: {
|
|
284
|
-
"nav-menu/main": [
|
|
285
|
-
{ label: "About", href: `caelo:page/${PAGE}` },
|
|
286
|
-
{ label: "Gone", href: `caelo:page/${GONE}` },
|
|
287
|
-
],
|
|
288
|
-
},
|
|
289
|
-
},
|
|
312
|
+
lists,
|
|
290
313
|
linkResolver: index().forPage(PAGE),
|
|
291
314
|
});
|
|
292
|
-
expect(out.html).toContain('<a href="/company/about/">About</a>');
|
|
315
|
+
expect(unstamp(out.html)).toContain('<a href="/company/about/">About</a>');
|
|
293
316
|
expect(out.html).toContain(`href="caelo:page/${GONE}"`);
|
|
294
317
|
expect(out.moduleFailures).toEqual([
|
|
295
318
|
{
|
|
296
319
|
blockName: "header",
|
|
297
320
|
moduleId: "m-nav",
|
|
298
|
-
moduleSlug: "nav
|
|
321
|
+
moduleSlug: "main-nav",
|
|
299
322
|
field: "",
|
|
300
323
|
reason: `link-target-missing:page/${GONE}`,
|
|
301
324
|
},
|
|
@@ -314,7 +337,6 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
|
314
337
|
templateHtml: '<caelo-slot name="content"></caelo-slot>',
|
|
315
338
|
templateCss: "",
|
|
316
339
|
blocks: [],
|
|
317
|
-
structuredSets: { byKindSlug: {} },
|
|
318
340
|
linkResolver: index().forPage(PAGE),
|
|
319
341
|
});
|
|
320
342
|
expect(out.html).toContain('<a class="home" href="/company/about/#top">Home</a>');
|
|
@@ -631,25 +653,23 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
631
653
|
});
|
|
632
654
|
});
|
|
633
655
|
|
|
634
|
-
it("covers HTML href, rich text, link field and
|
|
656
|
+
it("covers HTML href, rich text, link field and list entries in one render", () => {
|
|
635
657
|
const r = renderTemplate({
|
|
636
|
-
html: `<a href="caelo:page/${EN_HOME}">Home</a><a href="{{cta_href}}">Go</a>{{#nav}}<a href="{{
|
|
658
|
+
html: `<a href="caelo:page/${EN_HOME}">Home</a><a href="{{cta_href}}">Go</a>{{#nav}}<a href="{{link}}">{{label}}</a>{{/nav}}<div>{{body}}</div>`,
|
|
637
659
|
fields: [
|
|
638
660
|
{ name: "cta_href", kind: "link" },
|
|
639
|
-
{ name: "nav", kind: "
|
|
661
|
+
{ name: "nav", kind: "list", listType: "links" },
|
|
640
662
|
{ name: "body", kind: "richtext" },
|
|
641
663
|
],
|
|
642
664
|
contentValues: {
|
|
643
665
|
cta_href: `caelo:page/${EN_PRICING}#plans`,
|
|
644
|
-
nav: [
|
|
645
|
-
{ label: "About", href: `caelo:page/${EN_ABOUT}` },
|
|
646
|
-
{ label: "Solo", href: `caelo:page/${EN_SOLO}` },
|
|
647
|
-
],
|
|
666
|
+
nav: links(["About", `caelo:page/${EN_ABOUT}`], ["Solo", `caelo:page/${EN_SOLO}`]),
|
|
648
667
|
body: `<p><a href="caelo:page/${EN_ABOUT}?x=1">about</a></p>`,
|
|
649
668
|
},
|
|
669
|
+
lists,
|
|
650
670
|
linkResolver: variantIndex("preview").forPage(DE_PRICING),
|
|
651
671
|
});
|
|
652
|
-
expect(r.html).toBe(
|
|
672
|
+
expect(unstamp(r.html)).toBe(
|
|
653
673
|
'<a href="/de/">Home</a><a href="/de/preise/#plans">Go</a>' +
|
|
654
674
|
`<a href="/de/ueber/">About</a><a href="caelo:page/${EN_SOLO}">Solo</a>` +
|
|
655
675
|
'<div><p><a href="/de/ueber/?x=1">about</a></p></div>',
|
|
@@ -657,7 +677,7 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
657
677
|
expect(r.missingSlots).toEqual([`link-variant-missing:page/${EN_SOLO}:de`]);
|
|
658
678
|
});
|
|
659
679
|
|
|
660
|
-
it("covers
|
|
680
|
+
it("covers chrome lists in the composer and attributes a missing sibling to the module", () => {
|
|
661
681
|
const out = composePageWithLayout({
|
|
662
682
|
layoutHtml:
|
|
663
683
|
'<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>',
|
|
@@ -668,11 +688,22 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
668
688
|
modules: [
|
|
669
689
|
{
|
|
670
690
|
moduleId: "m-nav",
|
|
671
|
-
slug: "nav
|
|
691
|
+
slug: "main-nav",
|
|
672
692
|
displayName: "Nav",
|
|
673
|
-
html:
|
|
693
|
+
html: NAV_HTML,
|
|
674
694
|
css: "",
|
|
675
695
|
js: "",
|
|
696
|
+
fields: [
|
|
697
|
+
{
|
|
698
|
+
name: "nav",
|
|
699
|
+
kind: "list",
|
|
700
|
+
listType: "links",
|
|
701
|
+
default: links(
|
|
702
|
+
["About", `caelo:page/${EN_ABOUT}`],
|
|
703
|
+
["Solo", `caelo:page/${EN_SOLO}`],
|
|
704
|
+
),
|
|
705
|
+
},
|
|
706
|
+
],
|
|
676
707
|
},
|
|
677
708
|
],
|
|
678
709
|
},
|
|
@@ -680,22 +711,15 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
680
711
|
templateHtml: '<caelo-slot name="content"></caelo-slot>',
|
|
681
712
|
templateCss: "",
|
|
682
713
|
blocks: [],
|
|
683
|
-
|
|
684
|
-
byKindSlug: {
|
|
685
|
-
"nav-menu/main": [
|
|
686
|
-
{ label: "About", href: `caelo:page/${EN_ABOUT}` },
|
|
687
|
-
{ label: "Solo", href: `caelo:page/${EN_SOLO}` },
|
|
688
|
-
],
|
|
689
|
-
},
|
|
690
|
-
},
|
|
714
|
+
lists,
|
|
691
715
|
linkResolver: variantIndex().forPage(DE_ABOUT),
|
|
692
716
|
});
|
|
693
|
-
expect(out.html).toContain('<a href="/de/ueber/">About</a>');
|
|
717
|
+
expect(unstamp(out.html)).toContain('<a href="/de/ueber/">About</a>');
|
|
694
718
|
expect(out.moduleFailures).toEqual([
|
|
695
719
|
{
|
|
696
720
|
blockName: "header",
|
|
697
721
|
moduleId: "m-nav",
|
|
698
|
-
moduleSlug: "nav
|
|
722
|
+
moduleSlug: "main-nav",
|
|
699
723
|
field: "",
|
|
700
724
|
reason: `link-variant-missing:page/${EN_SOLO}:de`,
|
|
701
725
|
},
|
|
@@ -745,7 +769,7 @@ describe("link failure markers — what the build gate and the preview report",
|
|
|
745
769
|
|
|
746
770
|
it("leaves every other marker alone, in order", () => {
|
|
747
771
|
expect(isLinkFailureMarker("field-not-declared:x")).toBe(false);
|
|
748
|
-
expect(isLinkFailureMarker("
|
|
772
|
+
expect(isLinkFailureMarker("list-malformed:nav[items[0]]")).toBe(false);
|
|
749
773
|
expect(
|
|
750
774
|
linkMarkersOf([
|
|
751
775
|
"theme-asset-unbound:logo",
|
package/src/link-refs.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* rendered for. Renaming or moving a page then changes no stored link.
|
|
10
10
|
*
|
|
11
11
|
* Grammar (one form for every place a link can live — `link` / `url`
|
|
12
|
-
* fields, `link
|
|
13
|
-
* rich text,
|
|
12
|
+
* fields, list entry `link` values, `*_href` values, module HTML attributes,
|
|
13
|
+
* rich text, field defaults):
|
|
14
14
|
*
|
|
15
15
|
* caelo:page/<pageId>[?query][#anchor]
|
|
16
16
|
* caelo:media/<mediaId>[/<variant>]
|
|
@@ -277,7 +277,7 @@ export function replaceLinkRefs(
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
/**
|
|
280
|
-
* Walk a JSON value (content values, field defaults,
|
|
280
|
+
* Walk a JSON value (content values, field defaults, list entries) and map
|
|
281
281
|
* every string through {@link replaceLinkRefs}. Returns the SAME
|
|
282
282
|
* reference when nothing changed, so callers can cheaply detect no-ops.
|
|
283
283
|
*/
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable value addresses (epic #1107, `docs/lists.md` §9). Core has no
|
|
5
|
+
* locale concept; a translation plugin keys its own storage by these
|
|
6
|
+
* addresses and answers the `list-resolve` composition point with
|
|
7
|
+
* overlays keyed the same way. Entry ids never change, so an address
|
|
8
|
+
* survives moves, renames of loop names and reordering.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One addressable stored value. */
|
|
12
|
+
export type ValueAddress =
|
|
13
|
+
| {
|
|
14
|
+
readonly kind: "list";
|
|
15
|
+
readonly listId: string;
|
|
16
|
+
readonly itemId: string;
|
|
17
|
+
readonly field: string;
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "instance";
|
|
21
|
+
readonly contentInstanceId: string;
|
|
22
|
+
readonly field: string;
|
|
23
|
+
readonly itemId: string;
|
|
24
|
+
readonly itemField: string;
|
|
25
|
+
}
|
|
26
|
+
| { readonly kind: "pageProp"; readonly pageId: string; readonly field: string }
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: "listTypeField";
|
|
29
|
+
readonly listTypeId: string;
|
|
30
|
+
readonly node: string;
|
|
31
|
+
readonly field: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SEG = "[a-z0-9_-]+";
|
|
35
|
+
const ADDRESS_RE = new RegExp(
|
|
36
|
+
`^(?:list/(${SEG})/item/(${SEG})/(${SEG})` +
|
|
37
|
+
`|instance/(${SEG})/(${SEG})/item/(${SEG})/(${SEG})` +
|
|
38
|
+
`|page/(${SEG})/props/(${SEG})` +
|
|
39
|
+
`|list_type/(${SEG})/node/(${SEG})/(${SEG}))$`,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Format an address as its canonical string. */
|
|
43
|
+
export function formatValueAddress(a: ValueAddress): string {
|
|
44
|
+
switch (a.kind) {
|
|
45
|
+
case "list":
|
|
46
|
+
return `list/${a.listId}/item/${a.itemId}/${a.field}`;
|
|
47
|
+
case "instance":
|
|
48
|
+
return `instance/${a.contentInstanceId}/${a.field}/item/${a.itemId}/${a.itemField}`;
|
|
49
|
+
case "pageProp":
|
|
50
|
+
return `page/${a.pageId}/props/${a.field}`;
|
|
51
|
+
case "listTypeField":
|
|
52
|
+
return `list_type/${a.listTypeId}/node/${a.node}/${a.field}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse an address string; null when it is not one. */
|
|
57
|
+
export function parseValueAddress(s: string): ValueAddress | null {
|
|
58
|
+
const m = ADDRESS_RE.exec(s);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
if (m[1] && m[2] && m[3]) return { kind: "list", listId: m[1], itemId: m[2], field: m[3] };
|
|
61
|
+
if (m[4] && m[5] && m[6] && m[7]) {
|
|
62
|
+
return {
|
|
63
|
+
kind: "instance",
|
|
64
|
+
contentInstanceId: m[4],
|
|
65
|
+
field: m[5],
|
|
66
|
+
itemId: m[6],
|
|
67
|
+
itemField: m[7],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (m[8] && m[9]) return { kind: "pageProp", pageId: m[8], field: m[9] };
|
|
71
|
+
if (m[10] && m[11] && m[12]) {
|
|
72
|
+
return { kind: "listTypeField", listTypeId: m[10], node: m[11], field: m[12] };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|