@caelo-cms/shared 0.10.72 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tools.d.ts +103 -142
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +79 -167
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +115 -35
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +31 -12
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +214 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +219 -84
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +1 -1
- package/dist/css-var-aliases.d.ts +20 -0
- package/dist/css-var-aliases.d.ts.map +1 -0
- package/dist/css-var-aliases.js +95 -0
- package/dist/css-var-aliases.js.map +1 -0
- package/dist/css-var-scan.d.ts +0 -16
- package/dist/css-var-scan.d.ts.map +1 -1
- package/dist/css-var-scan.js +24 -0
- package/dist/css-var-scan.js.map +1 -1
- package/dist/edit-markers.d.ts +41 -5
- package/dist/edit-markers.d.ts.map +1 -1
- package/dist/edit-markers.js +47 -50
- package/dist/edit-markers.js.map +1 -1
- package/dist/escaped-line-breaks.d.ts +44 -0
- package/dist/escaped-line-breaks.d.ts.map +1 -0
- package/dist/escaped-line-breaks.js +203 -0
- package/dist/escaped-line-breaks.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +6 -6
- package/dist/interactions.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +7 -1
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/link-refs.d.ts +1 -1
- package/dist/link-refs.js +3 -3
- package/dist/lists/address.d.ts +34 -0
- package/dist/lists/address.d.ts.map +1 -0
- package/dist/lists/address.js +43 -0
- package/dist/lists/address.js.map +1 -0
- package/dist/lists/default-types.d.ts +22 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +127 -0
- package/dist/lists/default-types.js.map +1 -0
- package/dist/lists/fields.d.ts +152 -0
- package/dist/lists/fields.d.ts.map +1 -0
- package/dist/lists/fields.js +259 -0
- package/dist/lists/fields.js.map +1 -0
- package/dist/lists/index.d.ts +14 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +15 -0
- package/dist/lists/index.js.map +1 -0
- package/dist/lists/item-attr.d.ts +26 -0
- package/dist/lists/item-attr.d.ts.map +1 -0
- package/dist/lists/item-attr.js +31 -0
- package/dist/lists/item-attr.js.map +1 -0
- package/dist/lists/items.d.ts +105 -0
- package/dist/lists/items.d.ts.map +1 -0
- package/dist/lists/items.js +304 -0
- package/dist/lists/items.js.map +1 -0
- package/dist/lists/list-type.d.ts +401 -0
- package/dist/lists/list-type.d.ts.map +1 -0
- package/dist/lists/list-type.js +275 -0
- package/dist/lists/list-type.js.map +1 -0
- package/dist/lists/model.d.ts +14 -0
- package/dist/lists/model.d.ts.map +1 -0
- package/dist/lists/model.js +15 -0
- package/dist/lists/model.js.map +1 -0
- package/dist/lists/page-type.d.ts +115 -0
- package/dist/lists/page-type.d.ts.map +1 -0
- package/dist/lists/page-type.js +66 -0
- package/dist/lists/page-type.js.map +1 -0
- package/dist/lists/refs.d.ts +13 -0
- package/dist/lists/refs.d.ts.map +1 -0
- package/dist/lists/refs.js +18 -0
- package/dist/lists/refs.js.map +1 -0
- package/dist/lists/render-data.d.ts +123 -0
- package/dist/lists/render-data.d.ts.map +1 -0
- package/dist/lists/render-data.js +3 -0
- package/dist/lists/render-data.js.map +1 -0
- package/dist/lists/render-deps.d.ts +69 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +93 -0
- package/dist/lists/render-deps.js.map +1 -0
- package/dist/lists/resolve-values.d.ts +63 -0
- package/dist/lists/resolve-values.d.ts.map +1 -0
- package/dist/lists/resolve-values.js +181 -0
- package/dist/lists/resolve-values.js.map +1 -0
- package/dist/lists/resolve.d.ts +119 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +970 -0
- package/dist/lists/resolve.js.map +1 -0
- package/dist/lists/scope.d.ts +81 -0
- package/dist/lists/scope.d.ts.map +1 -0
- package/dist/lists/scope.js +3 -0
- package/dist/lists/scope.js.map +1 -0
- package/dist/markup-structure.d.ts +8 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +2 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +38 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +156 -31
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts +24 -21
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +34 -118
- package/dist/preview-compose.js.map +1 -1
- package/dist/redirect-plan.d.ts +13 -0
- package/dist/redirect-plan.d.ts.map +1 -1
- package/dist/redirect-plan.js +22 -0
- package/dist/redirect-plan.js.map +1 -1
- package/dist/seo.d.ts +21 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +32 -0
- package/dist/seo.js.map +1 -1
- package/dist/source-text-transport.d.ts +43 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +79 -0
- package/dist/source-text-transport.js.map +1 -0
- package/dist/template-engine.d.ts +44 -31
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +75 -174
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts +69 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/dist/template-sections.js +436 -0
- package/dist/template-sections.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/src/__tests__/redos-hardening.test.ts +51 -16
- package/src/ai-tools-add-module-modes.test.ts +51 -1
- package/src/ai-tools.test.ts +7 -10
- package/src/ai-tools.ts +90 -180
- package/src/bug-intake.test.ts +1 -1
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +34 -11
- package/src/content.test.ts +137 -4
- package/src/content.ts +230 -85
- package/src/css-scope-scan.ts +1 -1
- package/src/css-var-aliases.test.ts +61 -0
- package/src/css-var-aliases.ts +109 -0
- package/src/css-var-scan.ts +9 -0
- package/src/edit-markers.test.ts +37 -21
- package/src/edit-markers.ts +52 -20
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +4 -1
- package/src/interactions.ts +6 -6
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +67 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +141 -0
- package/src/lists/fields.ts +286 -0
- package/src/lists/index.ts +16 -0
- package/src/lists/item-attr.test.ts +36 -0
- package/src/lists/item-attr.ts +49 -0
- package/src/lists/items.ts +402 -0
- package/src/lists/list-fixtures.test.ts +308 -0
- package/src/lists/list-type.ts +306 -0
- package/src/lists/lists.test.ts +328 -0
- package/src/lists/model.ts +16 -0
- package/src/lists/page-type.ts +71 -0
- package/src/lists/refs.ts +21 -0
- package/src/lists/render-data.ts +134 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +147 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +472 -0
- package/src/lists/resolve.ts +1246 -0
- package/src/lists/scope.ts +69 -0
- package/src/lists-compose-parity.test.ts +262 -0
- package/src/markup-structure.ts +10 -0
- package/src/nested-module-render.test.ts +147 -74
- package/src/nested-module-render.ts +120 -48
- package/src/preview-compose.test.ts +55 -19
- package/src/preview-compose.ts +59 -154
- package/src/redirect-plan.test.ts +25 -0
- package/src/redirect-plan.ts +24 -0
- package/src/seo.test.ts +58 -0
- package/src/seo.ts +49 -0
- package/src/source-text-transport.test.ts +158 -0
- package/src/source-text-transport.ts +81 -0
- package/src/template-engine-lists.test.ts +608 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +514 -0
- package/src/version.ts +1 -1
- package/dist/structured-sets.d.ts +0 -70
- package/dist/structured-sets.d.ts.map +0 -1
- package/dist/structured-sets.js +0 -92
- package/dist/structured-sets.js.map +0 -1
- package/src/structured-sets.ts +0 -114
- package/src/template-engine-data-lists.test.ts +0 -90
package/src/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}" +
|
|
@@ -195,9 +195,9 @@ export function interactionAssets(used: ReadonlySet<InteractionId>): {
|
|
|
195
195
|
* guidance cannot drift from the snippets above.
|
|
196
196
|
*/
|
|
197
197
|
export const INTERACTION_MARKUP_CONTRACT =
|
|
198
|
-
|
|
199
|
-
'`accordion` (FAQ, collapsible
|
|
200
|
-
'`tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div
|
|
201
|
-
'`carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</span></button></section
|
|
202
|
-
|
|
198
|
+
"`data-caelo-ui` kit — use it instead of JS for these (accessible, shipped once per page, style freely in module CSS):\n" +
|
|
199
|
+
'- `accordion` (FAQ, collapsible): `<div data-caelo-ui="accordion" data-single>` with `<details id="..."><summary>Question</summary><div>Answer</div></details>` items; `data-single` keeps one open; a link to `#<details id>` opens it.\n' +
|
|
200
|
+
'- `tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div>`; ids unique on the page.\n' +
|
|
201
|
+
'- `carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</span></button></section>`; icon buttons keep visible content aria-hidden, text buttons need no aria-label. One slide per view; set the slides\' `flex-basis` in module CSS to show more.\n' +
|
|
202
|
+
'- `mobile-nav` (menu collapsing behind a toggle on small screens): `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false"><button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span></button><ul>{{#menu}}<li>…</li>{{/menu}}</ul></nav>` around the module\'s menu `list` field.\n' +
|
|
203
203
|
"Write module JS only for behaviour the kit does not cover.";
|
|
@@ -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,32 @@ 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
|
+
homePageId: null,
|
|
43
|
+
}).forPage(null);
|
|
44
|
+
const uid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
|
|
45
|
+
const links = (...ls: [string, string][]) =>
|
|
46
|
+
ls.map(([label, href], i) => ({
|
|
47
|
+
id: uid(i + 1),
|
|
48
|
+
node: "link",
|
|
49
|
+
values: { label, link: { href } },
|
|
50
|
+
}));
|
|
51
|
+
const unstamp = (html: string) => html.replace(/ data-caelo-list-item="[^"]*"/g, "");
|
|
52
|
+
const NAV_HTML = '<nav>{{#nav}}<a href="{{link}}">{{label}}</a>{{/nav}}</nav>';
|
|
53
|
+
|
|
32
54
|
const PAGE = "11111111-1111-4111-8111-111111111111";
|
|
33
55
|
const DRAFT = "22222222-2222-4222-8222-222222222222";
|
|
34
56
|
const GONE = "33333333-3333-4333-8333-333333333333";
|
|
@@ -172,25 +194,26 @@ describe("resolution", () => {
|
|
|
172
194
|
describe("template engine — references resolve wherever they come from", () => {
|
|
173
195
|
const resolver = () => index().forPage(null);
|
|
174
196
|
|
|
175
|
-
it("resolves module HTML attributes, values, defaults and
|
|
197
|
+
it("resolves module HTML attributes, values, defaults and list entries", () => {
|
|
176
198
|
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="{{
|
|
199
|
+
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
200
|
fields: [
|
|
179
201
|
{ name: "cta_href", kind: "url", default: `caelo:page/${PAGE}#contact` },
|
|
180
202
|
{ name: "cta_label", kind: "text" },
|
|
181
203
|
{ name: "hero", kind: "image" },
|
|
182
|
-
{ name: "nav", kind: "
|
|
204
|
+
{ name: "nav", kind: "list", listType: "links" },
|
|
183
205
|
{ name: "body", kind: "richtext" },
|
|
184
206
|
],
|
|
185
207
|
contentValues: {
|
|
186
208
|
cta_label: "Talk to us",
|
|
187
209
|
hero: `caelo:media/${MEDIA}`,
|
|
188
|
-
nav: [
|
|
210
|
+
nav: links(["Pricing", `caelo:page/${DRAFT}`]),
|
|
189
211
|
body: `<p>See <a href="caelo:page/${PAGE}?x=1">about</a></p>`,
|
|
190
212
|
},
|
|
213
|
+
lists,
|
|
191
214
|
linkResolver: resolver(),
|
|
192
215
|
});
|
|
193
|
-
expect(r.html).toBe(
|
|
216
|
+
expect(unstamp(r.html)).toBe(
|
|
194
217
|
'<a href="/company/about/">Home</a><a href="/company/about/#contact">Talk to us</a>' +
|
|
195
218
|
'<img src="/_caelo/media/hero-shot"><a href="/pricing/">Pricing</a>' +
|
|
196
219
|
'<div><p>See <a href="/company/about/?x=1">about</a></p></div>',
|
|
@@ -241,8 +264,8 @@ describe("template engine — references resolve wherever they come from", () =>
|
|
|
241
264
|
});
|
|
242
265
|
});
|
|
243
266
|
|
|
244
|
-
describe("composer —
|
|
245
|
-
it("resolves
|
|
267
|
+
describe("composer — chrome lists and pre-rendered modules", () => {
|
|
268
|
+
it("resolves a chrome module's list entries and attributes a broken one to the module", () => {
|
|
246
269
|
const out = composePageWithLayout({
|
|
247
270
|
layoutHtml:
|
|
248
271
|
'<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>',
|
|
@@ -253,11 +276,19 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
|
253
276
|
modules: [
|
|
254
277
|
{
|
|
255
278
|
moduleId: "m-nav",
|
|
256
|
-
slug: "nav
|
|
279
|
+
slug: "main-nav",
|
|
257
280
|
displayName: "Nav",
|
|
258
|
-
html:
|
|
281
|
+
html: NAV_HTML,
|
|
259
282
|
css: "",
|
|
260
283
|
js: "",
|
|
284
|
+
fields: [
|
|
285
|
+
{
|
|
286
|
+
name: "nav",
|
|
287
|
+
kind: "list",
|
|
288
|
+
listType: "links",
|
|
289
|
+
default: links(["About", `caelo:page/${PAGE}`], ["Gone", `caelo:page/${GONE}`]),
|
|
290
|
+
},
|
|
291
|
+
],
|
|
261
292
|
},
|
|
262
293
|
],
|
|
263
294
|
},
|
|
@@ -279,23 +310,16 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
|
279
310
|
],
|
|
280
311
|
},
|
|
281
312
|
],
|
|
282
|
-
|
|
283
|
-
byKindSlug: {
|
|
284
|
-
"nav-menu/main": [
|
|
285
|
-
{ label: "About", href: `caelo:page/${PAGE}` },
|
|
286
|
-
{ label: "Gone", href: `caelo:page/${GONE}` },
|
|
287
|
-
],
|
|
288
|
-
},
|
|
289
|
-
},
|
|
313
|
+
lists,
|
|
290
314
|
linkResolver: index().forPage(PAGE),
|
|
291
315
|
});
|
|
292
|
-
expect(out.html).toContain('<a href="/company/about/">About</a>');
|
|
316
|
+
expect(unstamp(out.html)).toContain('<a href="/company/about/">About</a>');
|
|
293
317
|
expect(out.html).toContain(`href="caelo:page/${GONE}"`);
|
|
294
318
|
expect(out.moduleFailures).toEqual([
|
|
295
319
|
{
|
|
296
320
|
blockName: "header",
|
|
297
321
|
moduleId: "m-nav",
|
|
298
|
-
moduleSlug: "nav
|
|
322
|
+
moduleSlug: "main-nav",
|
|
299
323
|
field: "",
|
|
300
324
|
reason: `link-target-missing:page/${GONE}`,
|
|
301
325
|
},
|
|
@@ -314,7 +338,6 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
|
314
338
|
templateHtml: '<caelo-slot name="content"></caelo-slot>',
|
|
315
339
|
templateCss: "",
|
|
316
340
|
blocks: [],
|
|
317
|
-
structuredSets: { byKindSlug: {} },
|
|
318
341
|
linkResolver: index().forPage(PAGE),
|
|
319
342
|
});
|
|
320
343
|
expect(out.html).toContain('<a class="home" href="/company/about/#top">Home</a>');
|
|
@@ -631,25 +654,23 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
631
654
|
});
|
|
632
655
|
});
|
|
633
656
|
|
|
634
|
-
it("covers HTML href, rich text, link field and
|
|
657
|
+
it("covers HTML href, rich text, link field and list entries in one render", () => {
|
|
635
658
|
const r = renderTemplate({
|
|
636
|
-
html: `<a href="caelo:page/${EN_HOME}">Home</a><a href="{{cta_href}}">Go</a>{{#nav}}<a href="{{
|
|
659
|
+
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
660
|
fields: [
|
|
638
661
|
{ name: "cta_href", kind: "link" },
|
|
639
|
-
{ name: "nav", kind: "
|
|
662
|
+
{ name: "nav", kind: "list", listType: "links" },
|
|
640
663
|
{ name: "body", kind: "richtext" },
|
|
641
664
|
],
|
|
642
665
|
contentValues: {
|
|
643
666
|
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
|
-
],
|
|
667
|
+
nav: links(["About", `caelo:page/${EN_ABOUT}`], ["Solo", `caelo:page/${EN_SOLO}`]),
|
|
648
668
|
body: `<p><a href="caelo:page/${EN_ABOUT}?x=1">about</a></p>`,
|
|
649
669
|
},
|
|
670
|
+
lists,
|
|
650
671
|
linkResolver: variantIndex("preview").forPage(DE_PRICING),
|
|
651
672
|
});
|
|
652
|
-
expect(r.html).toBe(
|
|
673
|
+
expect(unstamp(r.html)).toBe(
|
|
653
674
|
'<a href="/de/">Home</a><a href="/de/preise/#plans">Go</a>' +
|
|
654
675
|
`<a href="/de/ueber/">About</a><a href="caelo:page/${EN_SOLO}">Solo</a>` +
|
|
655
676
|
'<div><p><a href="/de/ueber/?x=1">about</a></p></div>',
|
|
@@ -657,7 +678,7 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
657
678
|
expect(r.missingSlots).toEqual([`link-variant-missing:page/${EN_SOLO}:de`]);
|
|
658
679
|
});
|
|
659
680
|
|
|
660
|
-
it("covers
|
|
681
|
+
it("covers chrome lists in the composer and attributes a missing sibling to the module", () => {
|
|
661
682
|
const out = composePageWithLayout({
|
|
662
683
|
layoutHtml:
|
|
663
684
|
'<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>',
|
|
@@ -668,11 +689,22 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
668
689
|
modules: [
|
|
669
690
|
{
|
|
670
691
|
moduleId: "m-nav",
|
|
671
|
-
slug: "nav
|
|
692
|
+
slug: "main-nav",
|
|
672
693
|
displayName: "Nav",
|
|
673
|
-
html:
|
|
694
|
+
html: NAV_HTML,
|
|
674
695
|
css: "",
|
|
675
696
|
js: "",
|
|
697
|
+
fields: [
|
|
698
|
+
{
|
|
699
|
+
name: "nav",
|
|
700
|
+
kind: "list",
|
|
701
|
+
listType: "links",
|
|
702
|
+
default: links(
|
|
703
|
+
["About", `caelo:page/${EN_ABOUT}`],
|
|
704
|
+
["Solo", `caelo:page/${EN_SOLO}`],
|
|
705
|
+
),
|
|
706
|
+
},
|
|
707
|
+
],
|
|
676
708
|
},
|
|
677
709
|
],
|
|
678
710
|
},
|
|
@@ -680,22 +712,15 @@ describe("page variants (link-resolve) — links go to the rendering page's vari
|
|
|
680
712
|
templateHtml: '<caelo-slot name="content"></caelo-slot>',
|
|
681
713
|
templateCss: "",
|
|
682
714
|
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
|
-
},
|
|
715
|
+
lists,
|
|
691
716
|
linkResolver: variantIndex().forPage(DE_ABOUT),
|
|
692
717
|
});
|
|
693
|
-
expect(out.html).toContain('<a href="/de/ueber/">About</a>');
|
|
718
|
+
expect(unstamp(out.html)).toContain('<a href="/de/ueber/">About</a>');
|
|
694
719
|
expect(out.moduleFailures).toEqual([
|
|
695
720
|
{
|
|
696
721
|
blockName: "header",
|
|
697
722
|
moduleId: "m-nav",
|
|
698
|
-
moduleSlug: "nav
|
|
723
|
+
moduleSlug: "main-nav",
|
|
699
724
|
field: "",
|
|
700
725
|
reason: `link-variant-missing:page/${EN_SOLO}:de`,
|
|
701
726
|
},
|
|
@@ -745,7 +770,7 @@ describe("link failure markers — what the build gate and the preview report",
|
|
|
745
770
|
|
|
746
771
|
it("leaves every other marker alone, in order", () => {
|
|
747
772
|
expect(isLinkFailureMarker("field-not-declared:x")).toBe(false);
|
|
748
|
-
expect(isLinkFailureMarker("
|
|
773
|
+
expect(isLinkFailureMarker("list-malformed:nav[items[0]]")).toBe(false);
|
|
749
774
|
expect(
|
|
750
775
|
linkMarkersOf([
|
|
751
776
|
"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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The list types every install starts with (epic #1107,
|
|
5
|
+
* `docs/lists.md` §3). They are seeded as STORED data by migration 0352
|
|
6
|
+
* and edited like any other type afterwards — these objects are the
|
|
7
|
+
* source the seed and the tests share, not a render-time fallback
|
|
8
|
+
* (CLAUDE.md §2).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ListTypeDef } from "./list-type.js";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_LIST_TYPES: readonly ListTypeDef[] = [
|
|
14
|
+
{
|
|
15
|
+
slug: "texts",
|
|
16
|
+
name: "Texts",
|
|
17
|
+
purpose: "A flat list of short texts inside one module: feature bullets, chips, steps.",
|
|
18
|
+
role: "content",
|
|
19
|
+
root: { allow: ["text"] },
|
|
20
|
+
nodes: {
|
|
21
|
+
text: {
|
|
22
|
+
label: "Text",
|
|
23
|
+
fields: [{ name: "text", label: "Text", type: "text", required: true }],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
slug: "links",
|
|
29
|
+
name: "Links",
|
|
30
|
+
purpose: "A flat group of links: legal links, social links, a footer column.",
|
|
31
|
+
role: "content",
|
|
32
|
+
root: { allow: ["link"] },
|
|
33
|
+
nodes: {
|
|
34
|
+
link: {
|
|
35
|
+
label: "Link",
|
|
36
|
+
fields: [
|
|
37
|
+
{ name: "label", label: "Label", type: "text", required: true },
|
|
38
|
+
{ name: "link", label: "Link", type: "link", required: true },
|
|
39
|
+
],
|
|
40
|
+
current_from: "link",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
slug: "menu",
|
|
46
|
+
name: "Menu",
|
|
47
|
+
purpose:
|
|
48
|
+
"Site navigation with up to three levels: header menu, footer menu, mobile menu. Use a custom type for mega menus with columns or teasers.",
|
|
49
|
+
role: "navigation",
|
|
50
|
+
root: { allow: ["entry"] },
|
|
51
|
+
nodes: {
|
|
52
|
+
entry: {
|
|
53
|
+
label: "Menu entry",
|
|
54
|
+
fields: [
|
|
55
|
+
{ name: "label", label: "Label", type: "text", required: true },
|
|
56
|
+
{ name: "link", label: "Link", type: "link" },
|
|
57
|
+
],
|
|
58
|
+
children: { name: "children", allow: ["entry"] },
|
|
59
|
+
current_from: "link",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
max_depth: 3,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
slug: "categories",
|
|
66
|
+
name: "Categories",
|
|
67
|
+
purpose: "A category tree pages are assigned to: blog sections, product groups, doc areas.",
|
|
68
|
+
role: "vocabulary",
|
|
69
|
+
root: { allow: ["category"] },
|
|
70
|
+
nodes: {
|
|
71
|
+
category: {
|
|
72
|
+
label: "Category",
|
|
73
|
+
fields: [
|
|
74
|
+
{ name: "name", label: "Name", type: "text", required: true },
|
|
75
|
+
{ name: "slug", label: "Slug", type: "text", required: true },
|
|
76
|
+
{ name: "description", label: "Description", type: "richtext" },
|
|
77
|
+
{ name: "landing_page", label: "Landing page", type: "link" },
|
|
78
|
+
],
|
|
79
|
+
children: { name: "children", allow: ["category"] },
|
|
80
|
+
current_from: "landing_page",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
max_depth: 6,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
slug: "tags",
|
|
87
|
+
name: "Tags",
|
|
88
|
+
purpose: "Flat keywords pages are tagged with, for filters, tag clouds and related content.",
|
|
89
|
+
role: "vocabulary",
|
|
90
|
+
root: { allow: ["tag"] },
|
|
91
|
+
nodes: {
|
|
92
|
+
tag: {
|
|
93
|
+
label: "Tag",
|
|
94
|
+
fields: [
|
|
95
|
+
{ name: "name", label: "Name", type: "text", required: true },
|
|
96
|
+
{ name: "slug", label: "Slug", type: "text", required: true },
|
|
97
|
+
{ name: "color", label: "Color", type: "text", maxLength: 9 },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** Slug of the list type whose entries are the site texts (`{{site_text.<key>}}`, #1142). */
|
|
105
|
+
export const SITE_TEXTS_LIST_TYPE = "site-texts";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The site texts type: fixed interface strings of the website that are no
|
|
109
|
+
* page content ("Start" in a trail, the breadcrumb `aria-label`, "Read
|
|
110
|
+
* more"). Module HTML reads one by key with `{{site_text.<key>}}`, so the
|
|
111
|
+
* string is translatable (shared-list overlays) instead of frozen into
|
|
112
|
+
* the module. Seeded by migration 0358 together with the "Site texts"
|
|
113
|
+
* list; installs that ran 0352 never had it, so it is not part of
|
|
114
|
+
* {@link DEFAULT_LIST_TYPES} (whose seed is pinned to 0352).
|
|
115
|
+
*/
|
|
116
|
+
export const SITE_TEXTS_TYPE: ListTypeDef = {
|
|
117
|
+
slug: SITE_TEXTS_LIST_TYPE,
|
|
118
|
+
name: "Site texts",
|
|
119
|
+
purpose:
|
|
120
|
+
"Fixed interface texts of the website, read by key in module HTML with {{site_text.<key>}}: the breadcrumb aria-label, button and link texts that repeat across modules. Not page content.",
|
|
121
|
+
role: "content",
|
|
122
|
+
root: { allow: ["text"] },
|
|
123
|
+
nodes: {
|
|
124
|
+
text: {
|
|
125
|
+
label: "Site text",
|
|
126
|
+
fields: [
|
|
127
|
+
// The lookup key is code, not copy: a translated key would stop
|
|
128
|
+
// `{{site_text.<key>}}` from finding the entry in that language.
|
|
129
|
+
{
|
|
130
|
+
name: "key",
|
|
131
|
+
label: "Key",
|
|
132
|
+
type: "text",
|
|
133
|
+
required: true,
|
|
134
|
+
maxLength: 64,
|
|
135
|
+
ext: { "international-site": { translate: false } },
|
|
136
|
+
},
|
|
137
|
+
{ name: "text", label: "Text", type: "text", required: true },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|