@caelo-cms/shared 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tools.d.ts +32 -144
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +46 -139
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +46 -3
- package/dist/bug-intake.d.ts.map +1 -1
- package/dist/bug-intake.js +52 -3
- package/dist/bug-intake.js.map +1 -1
- package/dist/build-page.d.ts +5 -5
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +14 -9
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +14 -2
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +73 -15
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +1 -1
- package/dist/css-var-scan.js +3 -3
- package/dist/css-var-scan.js.map +1 -1
- package/dist/escaped-line-breaks.d.ts +44 -0
- package/dist/escaped-line-breaks.d.ts.map +1 -0
- package/dist/escaped-line-breaks.js +203 -0
- package/dist/escaped-line-breaks.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +5 -5
- package/dist/interactions.js.map +1 -1
- package/dist/lists/default-types.d.ts +12 -0
- package/dist/lists/default-types.d.ts.map +1 -1
- package/dist/lists/default-types.js +36 -0
- package/dist/lists/default-types.js.map +1 -1
- package/dist/lists/fields.d.ts.map +1 -1
- package/dist/lists/fields.js +1 -0
- package/dist/lists/fields.js.map +1 -1
- package/dist/lists/render-data.d.ts +9 -0
- package/dist/lists/render-data.d.ts.map +1 -1
- package/dist/lists/render-deps.d.ts +4 -3
- package/dist/lists/render-deps.d.ts.map +1 -1
- package/dist/lists/render-deps.js +5 -3
- package/dist/lists/render-deps.js.map +1 -1
- package/dist/lists/resolve.d.ts +13 -0
- package/dist/lists/resolve.d.ts.map +1 -1
- package/dist/lists/resolve.js +95 -3
- package/dist/lists/resolve.js.map +1 -1
- package/dist/live-events.d.ts +44 -0
- package/dist/live-events.d.ts.map +1 -0
- package/dist/live-events.js +128 -0
- package/dist/live-events.js.map +1 -0
- package/dist/markup-structure.d.ts +20 -2
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +69 -3
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +15 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js.map +1 -1
- package/dist/module-id-tag.d.ts +4 -1
- package/dist/module-id-tag.d.ts.map +1 -1
- package/dist/module-id-tag.js +11 -2
- package/dist/module-id-tag.js.map +1 -1
- package/dist/nested-module-render.d.ts +13 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +34 -0
- package/dist/nested-module-render.js.map +1 -1
- package/dist/preview-compose.d.ts +18 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +26 -7
- package/dist/preview-compose.js.map +1 -1
- package/dist/redirect-plan.d.ts +13 -0
- package/dist/redirect-plan.d.ts.map +1 -1
- package/dist/redirect-plan.js +22 -0
- package/dist/redirect-plan.js.map +1 -1
- package/dist/seo.d.ts +21 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +32 -0
- package/dist/seo.js.map +1 -1
- package/dist/source-text-transport.d.ts +28 -2
- package/dist/source-text-transport.d.ts.map +1 -1
- package/dist/source-text-transport.js +12 -4
- package/dist/source-text-transport.js.map +1 -1
- package/dist/template-sections.d.ts.map +1 -1
- package/dist/template-sections.js +17 -4
- package/dist/template-sections.js.map +1 -1
- package/dist/theme-render.js +11 -26
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +12 -9
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/redos-hardening.test.ts +1 -0
- package/src/ai-tools-add-module-modes.test.ts +1 -1
- package/src/ai-tools.test.ts +7 -20
- package/src/ai-tools.ts +57 -151
- package/src/bug-intake.test.ts +48 -1
- package/src/bug-intake.ts +58 -3
- package/src/build-page.ts +14 -9
- package/src/content.test.ts +26 -1
- package/src/content.ts +84 -16
- package/src/css-scope-scan.ts +1 -1
- package/src/css-var-scan.ts +3 -3
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +2 -0
- package/src/interactions.ts +5 -5
- package/src/link-refs.test.ts +1 -0
- package/src/lists/default-types.ts +39 -0
- package/src/lists/fields.ts +1 -0
- package/src/lists/list-fixtures.test.ts +9 -3
- package/src/lists/render-data.ts +9 -0
- package/src/lists/render-deps.ts +6 -3
- package/src/lists/resolve.test.ts +182 -5
- package/src/lists/resolve.ts +101 -3
- package/src/live-events.ts +138 -0
- package/src/markup-structure.test.ts +47 -0
- package/src/markup-structure.ts +74 -3
- package/src/media.ts +12 -0
- package/src/module-id-tag.test.ts +38 -0
- package/src/module-id-tag.ts +11 -2
- package/src/nested-module-render.test.ts +39 -0
- package/src/nested-module-render.ts +35 -0
- package/src/preview-compose-deferrals.test.ts +44 -0
- package/src/preview-compose.ts +47 -10
- package/src/redirect-plan.test.ts +25 -0
- package/src/redirect-plan.ts +24 -0
- package/src/seo.test.ts +58 -0
- package/src/seo.ts +49 -0
- package/src/source-text-transport.test.ts +98 -3
- package/src/source-text-transport.ts +15 -4
- package/src/template-engine-lists.test.ts +55 -0
- package/src/template-sections.ts +18 -4
- package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
- package/src/theme-render-sanitize.test.ts +8 -0
- package/src/theme-render.ts +11 -26
- package/src/themes.ts +12 -7
- package/src/version.ts +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createSiteLinkIndex, type LinkPageVariant } from "../link-refs.js";
|
|
15
|
-
import { DEFAULT_LIST_TYPES } from "./default-types.js";
|
|
15
|
+
import { DEFAULT_LIST_TYPES, SITE_TEXTS_TYPE } from "./default-types.js";
|
|
16
16
|
import type { ListItem } from "./items.js";
|
|
17
17
|
import type { ListTypeDef } from "./list-type.js";
|
|
18
18
|
import type {
|
|
@@ -116,6 +116,7 @@ const page = (
|
|
|
116
116
|
): ListPageRecord => ({
|
|
117
117
|
id: pageId,
|
|
118
118
|
title,
|
|
119
|
+
shortTitle: title,
|
|
119
120
|
parentId: null,
|
|
120
121
|
pageTypeSlug: "page",
|
|
121
122
|
properties: {},
|
|
@@ -242,12 +243,16 @@ export function fixtureData(
|
|
|
242
243
|
overlays?: ReadonlyMap<string, ListVariantOverlay>;
|
|
243
244
|
pageVariants?: ReadonlyMap<string, LinkPageVariant>;
|
|
244
245
|
pages?: readonly ListPageRecord[];
|
|
246
|
+
/** Default: the fixture home page. */
|
|
247
|
+
homePageId?: string | null;
|
|
248
|
+
/** Shared lists added to the fixture ones (e.g. a site texts list). */
|
|
249
|
+
lists?: readonly LoadedList[];
|
|
245
250
|
} = {},
|
|
246
251
|
): ListRenderData {
|
|
247
|
-
const types = [...DEFAULT_LIST_TYPES, MEGA_MENU_TYPE];
|
|
252
|
+
const types = [...DEFAULT_LIST_TYPES, MEGA_MENU_TYPE, SITE_TEXTS_TYPE];
|
|
248
253
|
return {
|
|
249
254
|
listTypes: new Map(types.map((t, i) => [t.slug, { id: id(600 + i), def: t }])),
|
|
250
|
-
lists: new Map(LISTS.map((l) => [l.id, l])),
|
|
255
|
+
lists: new Map([...LISTS, ...(extra.lists ?? [])].map((l) => [l.id, l])),
|
|
251
256
|
pageTypes: new Map([
|
|
252
257
|
[
|
|
253
258
|
"news",
|
|
@@ -273,6 +278,7 @@ export function fixtureData(
|
|
|
273
278
|
...(extra.pageVariants ? { pageVariants: extra.pageVariants } : {}),
|
|
274
279
|
...(extra.overlays ? { overlays: extra.overlays } : {}),
|
|
275
280
|
siteLanguage: "en",
|
|
281
|
+
homePageId: extra.homePageId === undefined ? PAGE.home : extra.homePageId,
|
|
276
282
|
};
|
|
277
283
|
}
|
|
278
284
|
|
package/src/lists/render-data.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface LoadedPageType {
|
|
|
44
44
|
export interface ListPageRecord {
|
|
45
45
|
readonly id: string;
|
|
46
46
|
readonly title: string;
|
|
47
|
+
/** `pages.short_title`: the page's label in breadcrumbs and navigation (#1142). */
|
|
48
|
+
readonly shortTitle: string;
|
|
47
49
|
readonly parentId: string | null;
|
|
48
50
|
readonly pageTypeSlug: string;
|
|
49
51
|
readonly properties: Readonly<Record<string, unknown>>;
|
|
@@ -101,6 +103,13 @@ export interface ListRenderData {
|
|
|
101
103
|
readonly overlays?: ReadonlyMap<string, ListVariantOverlay>;
|
|
102
104
|
/** `site_settings.site_language`, for `{{date.formatted}}`. */
|
|
103
105
|
readonly siteLanguage: string;
|
|
106
|
+
/**
|
|
107
|
+
* `site_defaults.home_page_id`: where every breadcrumb trail starts
|
|
108
|
+
* (#1142). A variant page's trail starts at the home page's version in
|
|
109
|
+
* that variant (same `link-resolve` group). Null while no home page is
|
|
110
|
+
* designated.
|
|
111
|
+
*/
|
|
112
|
+
readonly homePageId: string | null;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/** Render dependencies one page collected (build manifest `render-deps.json`). */
|
package/src/lists/render-deps.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import type { ListPageRecord, ListRenderDeps } from "./render-data.js";
|
|
21
21
|
|
|
22
22
|
export const RENDER_DEPS_FILE = "render-deps.json";
|
|
23
|
-
export const RENDER_DEPS_VERSION =
|
|
23
|
+
export const RENDER_DEPS_VERSION = 3;
|
|
24
24
|
|
|
25
25
|
/** A page's filter-relevant attributes as of a build. */
|
|
26
26
|
export interface RenderDepsPageAttrs {
|
|
@@ -28,6 +28,7 @@ export interface RenderDepsPageAttrs {
|
|
|
28
28
|
readonly terms: readonly string[];
|
|
29
29
|
readonly parent: string | null;
|
|
30
30
|
readonly title: string;
|
|
31
|
+
readonly shortTitle: string;
|
|
31
32
|
readonly inBuild: boolean;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -47,6 +48,7 @@ export function renderDepsAttrs(p: ListPageRecord): RenderDepsPageAttrs {
|
|
|
47
48
|
terms: [...p.terms].sort(),
|
|
48
49
|
parent: p.parentId,
|
|
49
50
|
title: p.title,
|
|
51
|
+
shortTitle: p.shortTitle,
|
|
50
52
|
inBuild: p.inBuild,
|
|
51
53
|
};
|
|
52
54
|
}
|
|
@@ -92,8 +94,8 @@ export interface RenderChange {
|
|
|
92
94
|
* page's attributes before OR after the change (entered, left or
|
|
93
95
|
* changed inside the filter);
|
|
94
96
|
* - every page with page-tree deps (breadcrumbs, children, siblings,
|
|
95
|
-
* term pages) when a changed page's title, parent or
|
|
96
|
-
* changed;
|
|
97
|
+
* term pages) when a changed page's title, short title, parent or
|
|
98
|
+
* build membership changed;
|
|
97
99
|
* - every page whose system source rendered a changed page as an entry
|
|
98
100
|
* (its `props`, `terms`, `updated_at` show there).
|
|
99
101
|
*
|
|
@@ -119,6 +121,7 @@ export function selectDependentPages(
|
|
|
119
121
|
!before ||
|
|
120
122
|
!after ||
|
|
121
123
|
before.title !== after.title ||
|
|
124
|
+
before.shortTitle !== after.shortTitle ||
|
|
122
125
|
before.parent !== after.parent ||
|
|
123
126
|
before.inBuild !== after.inBuild
|
|
124
127
|
) {
|
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from "bun:test";
|
|
10
10
|
import type { LinkPageVariant } from "../link-refs.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
fixtureData,
|
|
13
|
+
fixtureLinkIndex,
|
|
14
|
+
ITEM,
|
|
15
|
+
LIST,
|
|
16
|
+
PAGE,
|
|
17
|
+
PAGES,
|
|
18
|
+
TERM,
|
|
19
|
+
} from "./list-fixtures.test.js";
|
|
12
20
|
import type { QueryKnob } from "./list-type.js";
|
|
13
|
-
import type { ListVariantOverlay } from "./render-data.js";
|
|
21
|
+
import type { ListPageRecord, ListVariantOverlay, LoadedList } from "./render-data.js";
|
|
14
22
|
import {
|
|
15
23
|
createListRenderContext,
|
|
16
24
|
type ListFieldSpec,
|
|
@@ -126,8 +134,9 @@ describe("system sources", () => {
|
|
|
126
134
|
source: { kind: "system", name },
|
|
127
135
|
});
|
|
128
136
|
|
|
129
|
-
it("breadcrumbs run root → current", () => {
|
|
137
|
+
it("breadcrumbs run home → root → current", () => {
|
|
130
138
|
expect(entries(page(PAGE.team), sys("breadcrumbs")).map((e) => textOf(e, "title"))).toEqual([
|
|
139
|
+
"Home",
|
|
131
140
|
"About",
|
|
132
141
|
"Team",
|
|
133
142
|
]);
|
|
@@ -263,8 +272,8 @@ describe("render dependencies", () => {
|
|
|
263
272
|
listTypes: ["menu"],
|
|
264
273
|
queries: [{ pageType: "news", terms: [TERM.event], parent: null }],
|
|
265
274
|
pageTree: true,
|
|
266
|
-
// The breadcrumb trail's pages: their values render as entries.
|
|
267
|
-
pages: [PAGE.about, PAGE.team].sort(),
|
|
275
|
+
// The breadcrumb trail's pages (home included): their values render as entries.
|
|
276
|
+
pages: [PAGE.home, PAGE.about, PAGE.team].sort(),
|
|
268
277
|
});
|
|
269
278
|
});
|
|
270
279
|
|
|
@@ -293,3 +302,171 @@ describe("render dependencies", () => {
|
|
|
293
302
|
expect(ctx.deps().lists).toEqual([LIST.tags]);
|
|
294
303
|
});
|
|
295
304
|
});
|
|
305
|
+
|
|
306
|
+
describe("breadcrumbs (#1142)", () => {
|
|
307
|
+
const CRUMBS: ListFieldSpec = {
|
|
308
|
+
name: "crumbs",
|
|
309
|
+
listType: "links",
|
|
310
|
+
bind: "source",
|
|
311
|
+
source: { kind: "system", name: "breadcrumbs" },
|
|
312
|
+
};
|
|
313
|
+
const at = (id: string): ListPageRecord => PAGES.find((p) => p.id === id) as ListPageRecord;
|
|
314
|
+
const withShort = (id: string, shortTitle: string): ListPageRecord => ({ ...at(id), shortTitle });
|
|
315
|
+
|
|
316
|
+
it("labels crumbs with the short title and marks every crumb but the last as ancestor", () => {
|
|
317
|
+
const data = fixtureData({
|
|
318
|
+
pages: PAGES.map((p) =>
|
|
319
|
+
p.id === PAGE.home
|
|
320
|
+
? withShort(PAGE.home, "Start")
|
|
321
|
+
: p.id === PAGE.about
|
|
322
|
+
? withShort(PAGE.about, "Us")
|
|
323
|
+
: p,
|
|
324
|
+
),
|
|
325
|
+
});
|
|
326
|
+
const ctx = page(PAGE.team, data);
|
|
327
|
+
const got = entries(ctx, CRUMBS);
|
|
328
|
+
expect(got.map((e) => textOf(e, "short_title"))).toEqual(["Start", "Us", "Team"]);
|
|
329
|
+
expect(got.map((e) => e.get("is_ancestor"))).toEqual([
|
|
330
|
+
{ kind: "bool", value: true },
|
|
331
|
+
{ kind: "bool", value: true },
|
|
332
|
+
{ kind: "bool", value: false },
|
|
333
|
+
]);
|
|
334
|
+
expect(got.map((e) => e.get("is_current"))).toEqual([
|
|
335
|
+
{ kind: "bool", value: false },
|
|
336
|
+
{ kind: "bool", value: false },
|
|
337
|
+
{ kind: "bool", value: true },
|
|
338
|
+
]);
|
|
339
|
+
expect(ctx.breadcrumbTrail()).toEqual([PAGE.home, PAGE.about, PAGE.team]);
|
|
340
|
+
expect(ctx.deps().pageTree).toBe(true);
|
|
341
|
+
expect(ctx.deps().pages).toEqual([PAGE.home, PAGE.about, PAGE.team].sort());
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("the home page's own trail is the home page alone", () => {
|
|
345
|
+
const ctx = page(PAGE.home);
|
|
346
|
+
expect(entries(ctx, CRUMBS).map((e) => textOf(e, "title"))).toEqual(["Home"]);
|
|
347
|
+
expect(ctx.breadcrumbTrail()).toEqual([PAGE.home]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("without a designated home the trail is the page tree alone", () => {
|
|
351
|
+
const ctx = page(PAGE.team, fixtureData({ homePageId: null }));
|
|
352
|
+
expect(entries(ctx, CRUMBS).map((e) => textOf(e, "title"))).toEqual(["About", "Team"]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("a page that renders no breadcrumbs records no trail", () => {
|
|
356
|
+
expect(page(PAGE.team).breadcrumbTrail()).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("follows the page tree as it is: a moved page's trail needs no edit", () => {
|
|
360
|
+
// Team moved from About to News.
|
|
361
|
+
const moved = PAGES.map((p) => (p.id === PAGE.team ? { ...p, parentId: PAGE.news } : p));
|
|
362
|
+
const ctx = page(PAGE.team, fixtureData({ pages: moved }));
|
|
363
|
+
expect(entries(ctx, CRUMBS).map((e) => textOf(e, "title"))).toEqual(["Home", "News", "Team"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("variants", () => {
|
|
367
|
+
const HOME_DE = "00000000-0000-4000-8000-0000000000d1";
|
|
368
|
+
const ABOUT_DE = "00000000-0000-4000-8000-0000000000d2";
|
|
369
|
+
const pages = [
|
|
370
|
+
...PAGES,
|
|
371
|
+
{ ...withShort(PAGE.home, "Startseite"), id: HOME_DE },
|
|
372
|
+
{ ...withShort(PAGE.about, "Über uns"), id: ABOUT_DE },
|
|
373
|
+
];
|
|
374
|
+
const pageVariants = new Map<string, LinkPageVariant>([
|
|
375
|
+
[PAGE.home, { variant: "en", group: "g-home" }],
|
|
376
|
+
[PAGE.about, { variant: "en", group: "g-about" }],
|
|
377
|
+
[HOME_DE, { variant: "de", group: "g-home" }],
|
|
378
|
+
[ABOUT_DE, { variant: "de", group: "g-about" }],
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
it("a variant page's trail starts at the home page's version in that variant", () => {
|
|
382
|
+
const ctx = page(ABOUT_DE, fixtureData({ pages, pageVariants }));
|
|
383
|
+
expect(entries(ctx, CRUMBS).map((e) => textOf(e, "short_title"))).toEqual([
|
|
384
|
+
"Startseite",
|
|
385
|
+
"Über uns",
|
|
386
|
+
]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("a variant without a version of the home page shows no home crumb", () => {
|
|
390
|
+
const fr = new Map(pageVariants).set(ABOUT_DE, { variant: "fr", group: "g-about" });
|
|
391
|
+
const ctx = page(ABOUT_DE, fixtureData({ pages, pageVariants: fr }));
|
|
392
|
+
expect(entries(ctx, CRUMBS).map((e) => textOf(e, "short_title"))).toEqual(["Über uns"]);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("site texts (#1142)", () => {
|
|
398
|
+
const SITE_TEXTS = "00000000-0000-4000-8000-0000000000e1";
|
|
399
|
+
const LABEL = "00000000-0000-4000-9000-0000000000e1";
|
|
400
|
+
const MORE = "00000000-0000-4000-9000-0000000000e2";
|
|
401
|
+
const list = (items: LoadedList["items"], id = SITE_TEXTS): LoadedList => ({
|
|
402
|
+
id,
|
|
403
|
+
slug: id === SITE_TEXTS ? "site-texts" : "more-texts",
|
|
404
|
+
name: "Site texts",
|
|
405
|
+
typeSlug: "site-texts",
|
|
406
|
+
items,
|
|
407
|
+
});
|
|
408
|
+
const text = (itemId: string, key: string, value: string) => ({
|
|
409
|
+
id: itemId,
|
|
410
|
+
node: "text",
|
|
411
|
+
values: { key, text: value },
|
|
412
|
+
});
|
|
413
|
+
const read = (ctx: ListPageContext, key: string) => ctx.siteTextScope().get(key);
|
|
414
|
+
|
|
415
|
+
it("reads a text by key and records the list as a dependency", () => {
|
|
416
|
+
const ctx = page(
|
|
417
|
+
PAGE.about,
|
|
418
|
+
fixtureData({ lists: [list([text(LABEL, "breadcrumb_label", "Breadcrumb")])] }),
|
|
419
|
+
);
|
|
420
|
+
expect(read(ctx, "breadcrumb_label")).toEqual({ kind: "text", text: "Breadcrumb" });
|
|
421
|
+
expect(ctx.deps().lists).toContain(SITE_TEXTS);
|
|
422
|
+
expect(ctx.deps().listTypes).toContain("site-texts");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("an unknown key is loud, never empty", () => {
|
|
426
|
+
const ctx = page(PAGE.about, fixtureData({ lists: [list([])] }));
|
|
427
|
+
expect(read(ctx, "breadcrumb_label")).toEqual({
|
|
428
|
+
kind: "failed",
|
|
429
|
+
marker: "site-text-missing:breadcrumb_label",
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("a key defined twice is loud", () => {
|
|
434
|
+
const ctx = page(
|
|
435
|
+
PAGE.about,
|
|
436
|
+
fixtureData({
|
|
437
|
+
lists: [
|
|
438
|
+
list([text(LABEL, "read_more", "Read more")]),
|
|
439
|
+
list([text(MORE, "read_more", "More")], "00000000-0000-4000-8000-0000000000e2"),
|
|
440
|
+
],
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
expect(read(ctx, "read_more")).toEqual({
|
|
444
|
+
kind: "failed",
|
|
445
|
+
marker: "site-text-duplicate:read_more",
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("a variant's overlay translates the text", () => {
|
|
450
|
+
const data = fixtureData({
|
|
451
|
+
lists: [list([text(LABEL, "breadcrumb_label", "Breadcrumb")])],
|
|
452
|
+
pageVariants: new Map([[PAGE.about, { variant: "de", group: null }]]),
|
|
453
|
+
overlays: new Map([
|
|
454
|
+
[
|
|
455
|
+
"de",
|
|
456
|
+
{
|
|
457
|
+
values: new Map([[`list/${SITE_TEXTS}/item/${LABEL}/text`, "Brotkrumen"]]),
|
|
458
|
+
hidden: new Set<string>(),
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
]),
|
|
462
|
+
});
|
|
463
|
+
expect(read(page(PAGE.about, data), "breadcrumb_label")).toEqual({
|
|
464
|
+
kind: "text",
|
|
465
|
+
text: "Brotkrumen",
|
|
466
|
+
});
|
|
467
|
+
expect(read(page(PAGE.home, data), "breadcrumb_label")).toEqual({
|
|
468
|
+
kind: "text",
|
|
469
|
+
text: "Breadcrumb",
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
package/src/lists/resolve.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import type { LinkResolver } from "../link-refs.js";
|
|
26
26
|
import { parseLinkRef } from "../link-refs.js";
|
|
27
|
+
import { SITE_TEXTS_LIST_TYPE } from "./default-types.js";
|
|
27
28
|
import { type FieldDef, TERM_REF_RE, validateFieldValue } from "./fields.js";
|
|
28
29
|
import type { ListItem } from "./items.js";
|
|
29
30
|
import {
|
|
@@ -125,6 +126,19 @@ export interface ListPageContext {
|
|
|
125
126
|
resolveField(field: ListFieldSpec, value: unknown, holder: ListHolder): ListFieldResolution;
|
|
126
127
|
/** `{{page.*}}`: the rendering page as a record scope; null outside a page. */
|
|
127
128
|
pageScope(): ScopeValue | null;
|
|
129
|
+
/**
|
|
130
|
+
* `{{site_text.<key>}}`: the site texts (entries of every list of type
|
|
131
|
+
* {@link SITE_TEXTS_LIST_TYPE}) in the rendering page's variant. An
|
|
132
|
+
* unknown or duplicate key resolves to a failure marker (#1142).
|
|
133
|
+
*/
|
|
134
|
+
siteTextScope(): ScopeValue;
|
|
135
|
+
/**
|
|
136
|
+
* The page ids of the breadcrumb trail this render showed (home →
|
|
137
|
+
* current), or null when no `breadcrumbs` source rendered. Drives the
|
|
138
|
+
* `BreadcrumbList` JSON-LD, so structured data always matches the
|
|
139
|
+
* visible trail (#1142).
|
|
140
|
+
*/
|
|
141
|
+
breadcrumbTrail(): readonly string[] | null;
|
|
128
142
|
/**
|
|
129
143
|
* A section name no module field declares, read as a plugin list
|
|
130
144
|
* source (`{{#language_links}}` written before the field kind existed).
|
|
@@ -420,6 +434,7 @@ function createPageContext(
|
|
|
420
434
|
>();
|
|
421
435
|
let depTree = false;
|
|
422
436
|
const depPages = new Set<string>();
|
|
437
|
+
let trail: readonly string[] | null = null;
|
|
423
438
|
/** Entries of a page-shaped system source: their pages become deps. */
|
|
424
439
|
const readPages = (pages: ListPageRecord[]): ListPageRecord[] => {
|
|
425
440
|
for (const pg of pages) depPages.add(pg.id);
|
|
@@ -696,6 +711,8 @@ function createPageContext(
|
|
|
696
711
|
return text(p.id);
|
|
697
712
|
case "title":
|
|
698
713
|
return text(p.title);
|
|
714
|
+
case "short_title":
|
|
715
|
+
return text(p.shortTitle);
|
|
699
716
|
case "link":
|
|
700
717
|
return linkValue({ href: `caelo:page/${p.id}` }, data);
|
|
701
718
|
case "published_at":
|
|
@@ -728,19 +745,48 @@ function createPageContext(
|
|
|
728
745
|
};
|
|
729
746
|
};
|
|
730
747
|
|
|
731
|
-
|
|
748
|
+
/**
|
|
749
|
+
* Page-shaped entries. `ancestor` overrides `is_ancestor` where the
|
|
750
|
+
* source knows better than the page tree: a breadcrumb trail's home
|
|
751
|
+
* crumb sits above the current page without being its parent.
|
|
752
|
+
*/
|
|
753
|
+
const pageEntries = (
|
|
754
|
+
pages: readonly ListPageRecord[],
|
|
755
|
+
ancestor?: (p: ListPageRecord, index: number) => boolean,
|
|
756
|
+
): ScopeEntry[] =>
|
|
732
757
|
pages.map((p, index) => {
|
|
733
758
|
const get = pageValue(p);
|
|
734
759
|
const pos = { index, count: pages.length, depth: 1 };
|
|
760
|
+
const isAncestor = ancestor ? bool(ancestor(p, index)) : undefined;
|
|
735
761
|
return {
|
|
736
762
|
id: p.id,
|
|
737
763
|
itemAttr: null,
|
|
738
764
|
childrenKey: null,
|
|
739
765
|
depth: 1,
|
|
740
|
-
get: (key) =>
|
|
766
|
+
get: (key) =>
|
|
767
|
+
(key === "is_ancestor" ? isAncestor : undefined) ?? get(key) ?? positionValue(key, pos),
|
|
741
768
|
};
|
|
742
769
|
});
|
|
743
770
|
|
|
771
|
+
/**
|
|
772
|
+
* The home page in the rendering page's variant: the designated home
|
|
773
|
+
* itself, or its version in the variant's `link-resolve` group. Null
|
|
774
|
+
* when no home is designated or the variant has no version of it yet
|
|
775
|
+
* (an untranslated home is absent, never the other language's page).
|
|
776
|
+
*/
|
|
777
|
+
const homeOfVariant = (): ListPageRecord | null => {
|
|
778
|
+
if (data.homePageId === null) return null;
|
|
779
|
+
const home = data.pages.get(data.homePageId) ?? null;
|
|
780
|
+
if (home === null || variant === null) return home;
|
|
781
|
+
const hv = data.pageVariants?.get(home.id);
|
|
782
|
+
if (hv === undefined || hv.variant === variant) return home;
|
|
783
|
+
if (hv.group === null) return null;
|
|
784
|
+
for (const [id, v] of data.pageVariants ?? []) {
|
|
785
|
+
if (v.group === hv.group && v.variant === variant) return data.pages.get(id) ?? null;
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
};
|
|
789
|
+
|
|
744
790
|
const sameVariant = (p: ListPageRecord): boolean =>
|
|
745
791
|
variant === null || data.pageVariants?.get(p.id)?.variant === variant;
|
|
746
792
|
|
|
@@ -792,7 +838,13 @@ function createPageContext(
|
|
|
792
838
|
seen.add(p.id);
|
|
793
839
|
p = p.parentId === null ? undefined : data.pages.get(p.parentId);
|
|
794
840
|
}
|
|
795
|
-
|
|
841
|
+
// The home page is no parent in the tree, yet every trail starts
|
|
842
|
+
// there; its short name is the "Start" crumb, translated with it.
|
|
843
|
+
const home = homeOfVariant();
|
|
844
|
+
if (home && !seen.has(home.id)) chain.unshift(home);
|
|
845
|
+
trail = chain.map((pg) => pg.id);
|
|
846
|
+
const last = chain.length - 1;
|
|
847
|
+
return listValue(pageEntries(readPages(chain), (_pg, index) => index < last));
|
|
796
848
|
}
|
|
797
849
|
case "children": {
|
|
798
850
|
depTree = true;
|
|
@@ -1039,10 +1091,56 @@ function createPageContext(
|
|
|
1039
1091
|
return { ok: true, entries: entriesOf(checked.items, local, 1) };
|
|
1040
1092
|
};
|
|
1041
1093
|
|
|
1094
|
+
let siteTextMemo: ScopeValue | undefined;
|
|
1095
|
+
/** `{{site_text.<key>}}` — see {@link ListPageContext.siteTextScope}. */
|
|
1096
|
+
const siteTextScope = (): ScopeValue => {
|
|
1097
|
+
if (siteTextMemo !== undefined) return siteTextMemo;
|
|
1098
|
+
depTypes.add(SITE_TEXTS_LIST_TYPE);
|
|
1099
|
+
const byKey = new Map<string, { list: LoadedList; item: ListItem }[]>();
|
|
1100
|
+
for (const list of data.lists.values()) {
|
|
1101
|
+
if (list.typeSlug !== SITE_TEXTS_LIST_TYPE) continue;
|
|
1102
|
+
depLists.add(list.id);
|
|
1103
|
+
for (const item of list.items) {
|
|
1104
|
+
if (overlay?.listHidden.has(`${list.id}/${item.id}`)) continue;
|
|
1105
|
+
const key = item.values.key;
|
|
1106
|
+
if (typeof key !== "string") continue;
|
|
1107
|
+
byKey.set(key, [...(byKey.get(key) ?? []), { list, item }]);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const memo = new Map<string, ScopeValue>();
|
|
1111
|
+
siteTextMemo = {
|
|
1112
|
+
kind: "record",
|
|
1113
|
+
text: null,
|
|
1114
|
+
present: true,
|
|
1115
|
+
get(key) {
|
|
1116
|
+
const hit = memo.get(key);
|
|
1117
|
+
if (hit) return hit;
|
|
1118
|
+
const found = byKey.get(key) ?? [];
|
|
1119
|
+
let v: ScopeValue;
|
|
1120
|
+
if (found.length === 0) v = { kind: "failed", marker: `site-text-missing:${key}` };
|
|
1121
|
+
else if (found.length > 1) v = { kind: "failed", marker: `site-text-duplicate:${key}` };
|
|
1122
|
+
else {
|
|
1123
|
+
const { list, item } = found[0] as { list: LoadedList; item: ListItem };
|
|
1124
|
+
const translated = overlay?.listValues.get(`${list.id}/${item.id}`)?.get("text");
|
|
1125
|
+
const raw = translated ?? item.values.text;
|
|
1126
|
+
v =
|
|
1127
|
+
typeof raw === "string"
|
|
1128
|
+
? text(raw)
|
|
1129
|
+
: { kind: "failed", marker: `site-text-missing:${key}` };
|
|
1130
|
+
}
|
|
1131
|
+
memo.set(key, v);
|
|
1132
|
+
return v;
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
return siteTextMemo;
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1042
1138
|
let pageScopeMemo: ScopeValue | null | undefined;
|
|
1043
1139
|
return {
|
|
1044
1140
|
pageId,
|
|
1045
1141
|
resolveField,
|
|
1142
|
+
siteTextScope,
|
|
1143
|
+
breadcrumbTrail: () => trail,
|
|
1046
1144
|
pageScope: () => {
|
|
1047
1145
|
if (pageScopeMemo === undefined) {
|
|
1048
1146
|
if (current === null) pageScopeMemo = null;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #1215 — the editor's live-refresh vocabulary. CLAUDE.md §11.B:
|
|
5
|
+
* clients must not poll the admin on a timer, because every request keeps an
|
|
6
|
+
* instance up. Instead the admin tells an open editor WHAT changed, as a
|
|
7
|
+
* topic, and the editor re-reads that one surface through its usual read
|
|
8
|
+
* endpoint (`/content/chat/<id>/pending`, `/api/agents`, …).
|
|
9
|
+
*
|
|
10
|
+
* A topic is a hint, never data: it carries no ids, no rows and no actor, so
|
|
11
|
+
* it is safe to hand to every signed-in tab. The read that follows goes
|
|
12
|
+
* through the endpoint's own permission checks.
|
|
13
|
+
*
|
|
14
|
+
* Topics come from the names of operations that committed (the Query API's
|
|
15
|
+
* `observeCommittedOperations`). Only WRITE operations are listed: a read
|
|
16
|
+
* classified as a change would make every refresh announce another change.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One surface an editor tab may need to re-read. */
|
|
20
|
+
export const LIVE_TOPICS = [
|
|
21
|
+
/** Running agents (turns, subagents, external MCP agents). */
|
|
22
|
+
"agents",
|
|
23
|
+
/** Pending approvals: queued proposals and paused in-chat approval cards. */
|
|
24
|
+
"pending",
|
|
25
|
+
/** One-time credentials an approval left for the operator (#971). */
|
|
26
|
+
"credentials",
|
|
27
|
+
/** Quality-audit status and the Publish gate (#553). */
|
|
28
|
+
"quality",
|
|
29
|
+
/** Site-import crawl progress (#228). */
|
|
30
|
+
"crawl",
|
|
31
|
+
/** Deploy runs (Stage, Publish live, rollback). */
|
|
32
|
+
"deploys",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type LiveTopic = (typeof LIVE_TOPICS)[number];
|
|
36
|
+
|
|
37
|
+
const TOPIC_SET: ReadonlySet<string> = new Set(LIVE_TOPICS);
|
|
38
|
+
|
|
39
|
+
/** Is `value` a known topic? */
|
|
40
|
+
export function isLiveTopic(value: string): value is LiveTopic {
|
|
41
|
+
return TOPIC_SET.has(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a comma-separated topic list (a query parameter). Unknown names are
|
|
46
|
+
* an error, not dropped: a client asking for a topic the server does not
|
|
47
|
+
* know would otherwise wait for an event that can never come.
|
|
48
|
+
*/
|
|
49
|
+
export function parseLiveTopics(
|
|
50
|
+
raw: string | null,
|
|
51
|
+
): { ok: true; topics: LiveTopic[] } | { ok: false; unknown: string[] } {
|
|
52
|
+
const names = (raw ?? "")
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter((s) => s.length > 0);
|
|
56
|
+
const unknown = names.filter((n) => !isLiveTopic(n));
|
|
57
|
+
if (unknown.length > 0) return { ok: false, unknown };
|
|
58
|
+
return { ok: true, topics: [...new Set(names)] as LiveTopic[] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Write ops per topic. Heartbeats (`chat.heartbeat_agent_run`,
|
|
62
|
+
// `chat.renew_turn_lease`) and the audit worker's idle `claim_next` are left
|
|
63
|
+
// out on purpose: they commit on a clock, and a hint on a clock is the very
|
|
64
|
+
// polling this replaces.
|
|
65
|
+
const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
|
|
66
|
+
"chat.register_agent_run": ["agents"],
|
|
67
|
+
"chat.finish_agent_run": ["agents"],
|
|
68
|
+
"chat.cancel_agents": ["agents"],
|
|
69
|
+
"chat.allow_external_agent": ["agents"],
|
|
70
|
+
// An external agent's tool call: first sight of it lists it as running,
|
|
71
|
+
// and it may have queued a card.
|
|
72
|
+
"chat.touch_external_agent": ["agents", "pending"],
|
|
73
|
+
"chat.record_turn_approvals": ["pending"],
|
|
74
|
+
"chat.decide_turn_approval": ["pending"],
|
|
75
|
+
"chat.supersede_turn_approvals": ["pending"],
|
|
76
|
+
"chat.claim_turn_approvals": ["pending"],
|
|
77
|
+
// Proposal flows whose op names do not follow propose_* / *_proposal.
|
|
78
|
+
"skills.review_proposal": ["pending"],
|
|
79
|
+
"gateway.execute_rate_limit_proposal": ["pending"],
|
|
80
|
+
"gateway.reject_rate_limit_proposal": ["pending"],
|
|
81
|
+
"plugins.approve_installation": ["pending"],
|
|
82
|
+
"credentials.issue": ["credentials"],
|
|
83
|
+
"chat.stage": ["quality", "deploys"],
|
|
84
|
+
"chat.unstage": ["quality"],
|
|
85
|
+
"chat.finalize_stage": ["quality", "deploys"],
|
|
86
|
+
"chat.finalize_draft_stage": ["quality", "deploys"],
|
|
87
|
+
"chat.publish": ["quality", "deploys"],
|
|
88
|
+
"quality_audits.enqueue": ["quality"],
|
|
89
|
+
"quality_audits.record_result": ["quality"],
|
|
90
|
+
"quality_audits.retry": ["quality"],
|
|
91
|
+
"quality_audits.claim_chat_notification": ["quality"],
|
|
92
|
+
"quality_audits.ack_chat_notification": ["quality"],
|
|
93
|
+
"quality_audits.release_chat_notification": ["quality"],
|
|
94
|
+
"quality_audits.publish_anyway": ["quality"],
|
|
95
|
+
"imports.create_run": ["crawl"],
|
|
96
|
+
"imports.update_run_status": ["crawl"],
|
|
97
|
+
"imports.write_extracted_pages": ["crawl"],
|
|
98
|
+
"deploy.trigger": ["deploys"],
|
|
99
|
+
// #1211 — the Stage's steps: its run appears, ends failed, ends recorded.
|
|
100
|
+
"deploy.start_trigger": ["deploys"],
|
|
101
|
+
"deploy.fail_run": ["deploys"],
|
|
102
|
+
"deploy.finish_trigger": ["deploys"],
|
|
103
|
+
"deploy.begin_run": ["deploys"],
|
|
104
|
+
"deploy.update_progress": ["deploys"],
|
|
105
|
+
"deploy.promote": ["deploys"],
|
|
106
|
+
"deploy.rollback": ["deploys"],
|
|
107
|
+
"deploy.build_plugin_data": ["deploys"],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The topics a committed operation touches (empty for every other op).
|
|
112
|
+
*
|
|
113
|
+
* Every proposal op (`<domain>.propose_*`, `.execute_proposal`,
|
|
114
|
+
* `.reject_proposal`, `.cancel_proposal`) moves the pending strip; an
|
|
115
|
+
* applied proposal may also leave a one-time credential (#971) and, for
|
|
116
|
+
* the quality and deploy domains, move their state.
|
|
117
|
+
*
|
|
118
|
+
* @example liveTopicsOfOperation("users.execute_proposal") // ["pending", "credentials"]
|
|
119
|
+
*/
|
|
120
|
+
export function liveTopicsOfOperation(operationName: string): readonly LiveTopic[] {
|
|
121
|
+
const exact = EXACT[operationName];
|
|
122
|
+
if (exact) return exact;
|
|
123
|
+
const dot = operationName.lastIndexOf(".");
|
|
124
|
+
const domain = operationName.slice(0, dot);
|
|
125
|
+
const action = operationName.slice(dot + 1);
|
|
126
|
+
const extra: LiveTopic[] =
|
|
127
|
+
domain === "quality_audits" ? ["quality"] : domain === "deploy" ? ["deploys"] : [];
|
|
128
|
+
if (action === "execute_proposal") return ["pending", "credentials", ...extra];
|
|
129
|
+
if (
|
|
130
|
+
action === "propose" ||
|
|
131
|
+
action.startsWith("propose_") ||
|
|
132
|
+
action === "reject_proposal" ||
|
|
133
|
+
action === "cancel_proposal"
|
|
134
|
+
) {
|
|
135
|
+
return ["pending"];
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
@@ -158,6 +158,53 @@ describe("templates and free regions", () => {
|
|
|
158
158
|
expect(freeRegions(root, parseMarkup(stripMustache(template)))).toBeNull();
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it("a section inside a tag is optional: dropped by default, kept on request (#1104)", () => {
|
|
162
|
+
const t =
|
|
163
|
+
'<div class="wrapc split {{#reverse}}rev{{/reverse}}" data-x="{{^a}}1{{/a}}"><p>{{t}}</p></div>';
|
|
164
|
+
expect(stripMustache(t)).toBe('<div class="wrapc split " data-x=""><p></p></div>');
|
|
165
|
+
expect(stripMustache(t, { keepOptionalAttributeText: true })).toBe(
|
|
166
|
+
'<div class="wrapc split rev" data-x="1"><p></p></div>',
|
|
167
|
+
);
|
|
168
|
+
// A section between elements still renders its body once (a list).
|
|
169
|
+
expect(stripMustache('<ul>{{#items}}<li class="i">{{.}}</li>{{/items}}</ul>')).toBe(
|
|
170
|
+
'<ul><li class="i"></li></ul>',
|
|
171
|
+
);
|
|
172
|
+
// A `>` inside a quoted value or a partial does not end the tag.
|
|
173
|
+
expect(stripMustache('<a title="x>y" class="{{#on}}on{{/on}}">{{>p}}</a>')).toBe(
|
|
174
|
+
'<a title="x>y" class=""></a>',
|
|
175
|
+
);
|
|
176
|
+
const started = performance.now();
|
|
177
|
+
stripMustache(`<a ${"{{#x}}".repeat(50_000)}>`);
|
|
178
|
+
expect(performance.now() - started).toBeLessThan(500);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("an optional class does not make renders without it free markup (#1104)", () => {
|
|
182
|
+
const tpl =
|
|
183
|
+
'<section class="sec"><div class="wrapc split {{#reverse}}rev{{/reverse}}"><h2>{{t}}</h2><div class="body">{{{body}}}</div></div></section>';
|
|
184
|
+
const template = parseMarkup(stripMustache(tpl));
|
|
185
|
+
for (const cls of ["wrapc split", "wrapc split rev"]) {
|
|
186
|
+
const rendered = parseMarkup(
|
|
187
|
+
`<section class="sec" data-caelo-module-id="m1"><div class="${cls}"><h2>Hi</h2><div class="body"><p>x</p></div></div></section>`,
|
|
188
|
+
);
|
|
189
|
+
const root = moduleRoots(rendered)[0];
|
|
190
|
+
if (!root) throw new Error("no module root");
|
|
191
|
+
expect(freeRegions(root, template)?.map((n) => n.tag)).toEqual(["p"]);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("a root whose classes all sit in sections is still checked (#1104)", () => {
|
|
196
|
+
const tpl =
|
|
197
|
+
'<div class="{{#moment}}moment{{/moment}}{{^moment}}sec{{/moment}}"><h2>{{t}}</h2><div class="body">{{{body}}}</div></div>';
|
|
198
|
+
const rendered = parseMarkup(
|
|
199
|
+
`<div class="sec" data-caelo-module-id="m1"><h2>Hi</h2><div class="body">${chain(["a", "b"])}</div></div>`,
|
|
200
|
+
);
|
|
201
|
+
const root = moduleRoots(rendered)[0];
|
|
202
|
+
if (!root) throw new Error("no module root");
|
|
203
|
+
const free = freeRegions(root, parseMarkup(stripMustache(tpl)));
|
|
204
|
+
expect(free).not.toBeNull();
|
|
205
|
+
expect(outermostPatterns(free ?? []).map((f) => f.node.classes[0])).toEqual(["chain"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
161
208
|
it("templateShape fingerprints the template's root element", () => {
|
|
162
209
|
const t = templateShape(
|
|
163
210
|
'<div class="chain">{{#steps}}<div class="step"><span class="n">{{n}}</span><p>{{t}}</p></div><span class="arrow"></span>{{/steps}}</div>',
|