@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.
Files changed (137) hide show
  1. package/dist/ai-tools.d.ts +32 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +46 -139
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +46 -3
  6. package/dist/bug-intake.d.ts.map +1 -1
  7. package/dist/bug-intake.js +52 -3
  8. package/dist/bug-intake.js.map +1 -1
  9. package/dist/build-page.d.ts +5 -5
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +14 -9
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +14 -2
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +73 -15
  16. package/dist/content.js.map +1 -1
  17. package/dist/css-scope-scan.js +1 -1
  18. package/dist/css-var-scan.js +3 -3
  19. package/dist/css-var-scan.js.map +1 -1
  20. package/dist/escaped-line-breaks.d.ts +44 -0
  21. package/dist/escaped-line-breaks.d.ts.map +1 -0
  22. package/dist/escaped-line-breaks.js +203 -0
  23. package/dist/escaped-line-breaks.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/interactions.js +5 -5
  29. package/dist/interactions.js.map +1 -1
  30. package/dist/lists/default-types.d.ts +12 -0
  31. package/dist/lists/default-types.d.ts.map +1 -1
  32. package/dist/lists/default-types.js +36 -0
  33. package/dist/lists/default-types.js.map +1 -1
  34. package/dist/lists/fields.d.ts.map +1 -1
  35. package/dist/lists/fields.js +1 -0
  36. package/dist/lists/fields.js.map +1 -1
  37. package/dist/lists/render-data.d.ts +9 -0
  38. package/dist/lists/render-data.d.ts.map +1 -1
  39. package/dist/lists/render-deps.d.ts +4 -3
  40. package/dist/lists/render-deps.d.ts.map +1 -1
  41. package/dist/lists/render-deps.js +5 -3
  42. package/dist/lists/render-deps.js.map +1 -1
  43. package/dist/lists/resolve.d.ts +13 -0
  44. package/dist/lists/resolve.d.ts.map +1 -1
  45. package/dist/lists/resolve.js +95 -3
  46. package/dist/lists/resolve.js.map +1 -1
  47. package/dist/live-events.d.ts +44 -0
  48. package/dist/live-events.d.ts.map +1 -0
  49. package/dist/live-events.js +128 -0
  50. package/dist/live-events.js.map +1 -0
  51. package/dist/markup-structure.d.ts +20 -2
  52. package/dist/markup-structure.d.ts.map +1 -1
  53. package/dist/markup-structure.js +69 -3
  54. package/dist/markup-structure.js.map +1 -1
  55. package/dist/media.d.ts +15 -0
  56. package/dist/media.d.ts.map +1 -1
  57. package/dist/media.js.map +1 -1
  58. package/dist/module-id-tag.d.ts +4 -1
  59. package/dist/module-id-tag.d.ts.map +1 -1
  60. package/dist/module-id-tag.js +11 -2
  61. package/dist/module-id-tag.js.map +1 -1
  62. package/dist/nested-module-render.d.ts +13 -0
  63. package/dist/nested-module-render.d.ts.map +1 -1
  64. package/dist/nested-module-render.js +34 -0
  65. package/dist/nested-module-render.js.map +1 -1
  66. package/dist/preview-compose.d.ts +18 -1
  67. package/dist/preview-compose.d.ts.map +1 -1
  68. package/dist/preview-compose.js +26 -7
  69. package/dist/preview-compose.js.map +1 -1
  70. package/dist/redirect-plan.d.ts +13 -0
  71. package/dist/redirect-plan.d.ts.map +1 -1
  72. package/dist/redirect-plan.js +22 -0
  73. package/dist/redirect-plan.js.map +1 -1
  74. package/dist/seo.d.ts +21 -0
  75. package/dist/seo.d.ts.map +1 -1
  76. package/dist/seo.js +32 -0
  77. package/dist/seo.js.map +1 -1
  78. package/dist/source-text-transport.d.ts +28 -2
  79. package/dist/source-text-transport.d.ts.map +1 -1
  80. package/dist/source-text-transport.js +12 -4
  81. package/dist/source-text-transport.js.map +1 -1
  82. package/dist/template-sections.d.ts.map +1 -1
  83. package/dist/template-sections.js +17 -4
  84. package/dist/template-sections.js.map +1 -1
  85. package/dist/theme-render.js +11 -26
  86. package/dist/theme-render.js.map +1 -1
  87. package/dist/themes.d.ts.map +1 -1
  88. package/dist/themes.js +12 -9
  89. package/dist/themes.js.map +1 -1
  90. package/dist/version.d.ts +2 -2
  91. package/dist/version.js +1 -1
  92. package/package.json +1 -1
  93. package/src/__tests__/redos-hardening.test.ts +1 -0
  94. package/src/ai-tools-add-module-modes.test.ts +1 -1
  95. package/src/ai-tools.test.ts +7 -20
  96. package/src/ai-tools.ts +57 -151
  97. package/src/bug-intake.test.ts +48 -1
  98. package/src/bug-intake.ts +58 -3
  99. package/src/build-page.ts +14 -9
  100. package/src/content.test.ts +26 -1
  101. package/src/content.ts +84 -16
  102. package/src/css-scope-scan.ts +1 -1
  103. package/src/css-var-scan.ts +3 -3
  104. package/src/escaped-line-breaks.ts +221 -0
  105. package/src/index.ts +2 -0
  106. package/src/interactions.ts +5 -5
  107. package/src/link-refs.test.ts +1 -0
  108. package/src/lists/default-types.ts +39 -0
  109. package/src/lists/fields.ts +1 -0
  110. package/src/lists/list-fixtures.test.ts +9 -3
  111. package/src/lists/render-data.ts +9 -0
  112. package/src/lists/render-deps.ts +6 -3
  113. package/src/lists/resolve.test.ts +182 -5
  114. package/src/lists/resolve.ts +101 -3
  115. package/src/live-events.ts +138 -0
  116. package/src/markup-structure.test.ts +47 -0
  117. package/src/markup-structure.ts +74 -3
  118. package/src/media.ts +12 -0
  119. package/src/module-id-tag.test.ts +38 -0
  120. package/src/module-id-tag.ts +11 -2
  121. package/src/nested-module-render.test.ts +39 -0
  122. package/src/nested-module-render.ts +35 -0
  123. package/src/preview-compose-deferrals.test.ts +44 -0
  124. package/src/preview-compose.ts +47 -10
  125. package/src/redirect-plan.test.ts +25 -0
  126. package/src/redirect-plan.ts +24 -0
  127. package/src/seo.test.ts +58 -0
  128. package/src/seo.ts +49 -0
  129. package/src/source-text-transport.test.ts +98 -3
  130. package/src/source-text-transport.ts +15 -4
  131. package/src/template-engine-lists.test.ts +55 -0
  132. package/src/template-sections.ts +18 -4
  133. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  134. package/src/theme-render-sanitize.test.ts +8 -0
  135. package/src/theme-render.ts +11 -26
  136. package/src/themes.ts +12 -7
  137. 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
 
@@ -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`). */
@@ -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 = 2;
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 build membership
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 { fixtureData, fixtureLinkIndex, ITEM, LIST, PAGE, TERM } from "./list-fixtures.test.js";
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
+ });
@@ -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
- const pageEntries = (pages: readonly ListPageRecord[]): ScopeEntry[] =>
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) => get(key) ?? positionValue(key, pos),
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
- return listValue(pageEntries(readPages(chain)));
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>',