@caelo-cms/shared 0.11.0 → 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.
Files changed (92) hide show
  1. package/dist/ai-tools.d.ts +30 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +41 -141
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +5 -5
  6. package/dist/build-page.js +9 -9
  7. package/dist/build-page.js.map +1 -1
  8. package/dist/content.d.ts +13 -2
  9. package/dist/content.d.ts.map +1 -1
  10. package/dist/content.js +45 -12
  11. package/dist/content.js.map +1 -1
  12. package/dist/css-scope-scan.js +1 -1
  13. package/dist/escaped-line-breaks.d.ts +44 -0
  14. package/dist/escaped-line-breaks.d.ts.map +1 -0
  15. package/dist/escaped-line-breaks.js +203 -0
  16. package/dist/escaped-line-breaks.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/interactions.js +5 -5
  22. package/dist/interactions.js.map +1 -1
  23. package/dist/lists/default-types.d.ts +12 -0
  24. package/dist/lists/default-types.d.ts.map +1 -1
  25. package/dist/lists/default-types.js +36 -0
  26. package/dist/lists/default-types.js.map +1 -1
  27. package/dist/lists/fields.d.ts.map +1 -1
  28. package/dist/lists/fields.js +1 -0
  29. package/dist/lists/fields.js.map +1 -1
  30. package/dist/lists/render-data.d.ts +9 -0
  31. package/dist/lists/render-data.d.ts.map +1 -1
  32. package/dist/lists/render-deps.d.ts +4 -3
  33. package/dist/lists/render-deps.d.ts.map +1 -1
  34. package/dist/lists/render-deps.js +5 -3
  35. package/dist/lists/render-deps.js.map +1 -1
  36. package/dist/lists/resolve.d.ts +13 -0
  37. package/dist/lists/resolve.d.ts.map +1 -1
  38. package/dist/lists/resolve.js +95 -3
  39. package/dist/lists/resolve.js.map +1 -1
  40. package/dist/nested-module-render.d.ts +13 -0
  41. package/dist/nested-module-render.d.ts.map +1 -1
  42. package/dist/nested-module-render.js +34 -0
  43. package/dist/nested-module-render.js.map +1 -1
  44. package/dist/redirect-plan.d.ts +13 -0
  45. package/dist/redirect-plan.d.ts.map +1 -1
  46. package/dist/redirect-plan.js +22 -0
  47. package/dist/redirect-plan.js.map +1 -1
  48. package/dist/seo.d.ts +21 -0
  49. package/dist/seo.d.ts.map +1 -1
  50. package/dist/seo.js +32 -0
  51. package/dist/seo.js.map +1 -1
  52. package/dist/source-text-transport.d.ts +28 -2
  53. package/dist/source-text-transport.d.ts.map +1 -1
  54. package/dist/source-text-transport.js +12 -4
  55. package/dist/source-text-transport.js.map +1 -1
  56. package/dist/template-sections.d.ts.map +1 -1
  57. package/dist/template-sections.js +17 -4
  58. package/dist/template-sections.js.map +1 -1
  59. package/dist/version.d.ts +2 -2
  60. package/dist/version.js +1 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/redos-hardening.test.ts +1 -0
  63. package/src/ai-tools-add-module-modes.test.ts +1 -1
  64. package/src/ai-tools.test.ts +7 -20
  65. package/src/ai-tools.ts +49 -153
  66. package/src/bug-intake.test.ts +1 -1
  67. package/src/build-page.ts +9 -9
  68. package/src/content.test.ts +26 -1
  69. package/src/content.ts +47 -13
  70. package/src/css-scope-scan.ts +1 -1
  71. package/src/escaped-line-breaks.ts +221 -0
  72. package/src/index.ts +1 -0
  73. package/src/interactions.ts +5 -5
  74. package/src/link-refs.test.ts +1 -0
  75. package/src/lists/default-types.ts +39 -0
  76. package/src/lists/fields.ts +1 -0
  77. package/src/lists/list-fixtures.test.ts +9 -3
  78. package/src/lists/render-data.ts +9 -0
  79. package/src/lists/render-deps.ts +6 -3
  80. package/src/lists/resolve.test.ts +182 -5
  81. package/src/lists/resolve.ts +101 -3
  82. package/src/nested-module-render.test.ts +39 -0
  83. package/src/nested-module-render.ts +35 -0
  84. package/src/redirect-plan.test.ts +25 -0
  85. package/src/redirect-plan.ts +24 -0
  86. package/src/seo.test.ts +58 -0
  87. package/src/seo.ts +49 -0
  88. package/src/source-text-transport.test.ts +98 -3
  89. package/src/source-text-transport.ts +15 -4
  90. package/src/template-engine-lists.test.ts +55 -0
  91. package/src/template-sections.ts +18 -4
  92. package/src/version.ts +1 -1
@@ -0,0 +1,221 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Source text whose line breaks arrived string-escaped as a whole.
5
+ *
6
+ * e2e-livedit run 40532297620 (release gate v0.11.1): `add_module`'s html,
7
+ * css and js came in with every line break as the two characters `\` `n`
8
+ * (`(function () {\n document…`). Stored as sent, the footer showed `\n`
9
+ * text, its CSS broke, and the page's shared module `<script>` threw
10
+ * `SyntaxError: Invalid or unexpected token` — a backslash outside a string
11
+ * is not JavaScript. Like the other whole-value transport encodings
12
+ * (`source-text-transport.ts`) the AI tool boundary decodes it, once.
13
+ *
14
+ * The same two characters are legitimate inside a string literal (`"a\nb"`
15
+ * in JS or a CSS `content`), a regex, an HTML attribute value, or the body
16
+ * of a raw-text element. A value is only decoded when EVERY escaped line
17
+ * break sits where a real line break could have stood — between tokens of
18
+ * code, or in markup text between tags. One escape inside a literal makes
19
+ * the value ambiguous and it is left exactly as sent: decoding it would
20
+ * turn valid source into broken source (a raw newline inside a JS string
21
+ * is a syntax error). The op validators still refuse what is left broken
22
+ * (module js is parsed in admin-core `module-js-syntax.ts`).
23
+ */
24
+
25
+ /** Which grammar a source value is written in; decides what counts as a literal. */
26
+ export type SourceLanguage = "html" | "css" | "js";
27
+
28
+ /** `\n` or `\r\n` escape starting at `i` (a single backslash, not an escaped one). */
29
+ function escapedLineBreakLength(value: string, i: number): number {
30
+ if (value[i] !== "\\") return 0;
31
+ if (value[i + 1] === "n") return 2;
32
+ if (value[i + 1] === "r" && value[i + 2] === "\\" && value[i + 3] === "n") return 4;
33
+ return 0;
34
+ }
35
+
36
+ /** Characters after which a `/` in JS starts a regex literal rather than a division. */
37
+ const REGEX_PRECEDERS = new Set([..."(,=:[!&|?{};+-*%<>~^"]);
38
+
39
+ /**
40
+ * Index just past a quoted literal starting at `start` (its quote char), or
41
+ * -1 when an escaped line break sits inside it.
42
+ */
43
+ function skipQuoted(value: string, start: number): number {
44
+ const quote = value[start];
45
+ let j = start + 1;
46
+ while (j < value.length) {
47
+ const ch = value[j];
48
+ if (ch === "\\") {
49
+ if (value[j + 1] === "n" || value[j + 1] === "r") return -1;
50
+ j += 2;
51
+ continue;
52
+ }
53
+ if (ch === quote) return j + 1;
54
+ j += 1;
55
+ }
56
+ return j;
57
+ }
58
+
59
+ /** True when an escaped line break sits inside a JS string, template, or regex literal. */
60
+ function jsLiteralHoldsEscape(value: string): boolean {
61
+ let prev = "";
62
+ let i = 0;
63
+ while (i < value.length) {
64
+ const ch = value[i] as string;
65
+ const lineBreak = escapedLineBreakLength(value, i);
66
+ if (lineBreak > 0) {
67
+ prev = "";
68
+ i += lineBreak;
69
+ continue;
70
+ }
71
+ if (ch === "\\") {
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch === '"' || ch === "'" || ch === "`") {
76
+ const end = skipQuoted(value, i);
77
+ if (end < 0) return true;
78
+ prev = ch;
79
+ i = end;
80
+ continue;
81
+ }
82
+ if (ch === "/" && value[i + 1] === "/") {
83
+ // A line comment runs to the next (escaped) line break.
84
+ i += 2;
85
+ while (i < value.length && escapedLineBreakLength(value, i) === 0) i += 1;
86
+ continue;
87
+ }
88
+ if (ch === "/" && value[i + 1] === "*") {
89
+ const close = value.indexOf("*/", i + 2);
90
+ i = close < 0 ? value.length : close + 2;
91
+ continue;
92
+ }
93
+ if (ch === "/" && (prev === "" || REGEX_PRECEDERS.has(prev))) {
94
+ let j = i + 1;
95
+ let inClass = false;
96
+ while (j < value.length) {
97
+ const r = value[j];
98
+ if (r === "\\") {
99
+ if (value[j + 1] === "n" || value[j + 1] === "r") return true;
100
+ j += 2;
101
+ continue;
102
+ }
103
+ if (r === "[") inClass = true;
104
+ else if (r === "]") inClass = false;
105
+ else if (r === "/" && !inClass) break;
106
+ j += 1;
107
+ }
108
+ prev = "/";
109
+ i = j + 1;
110
+ continue;
111
+ }
112
+ if (ch !== " " && ch !== "\t") prev = ch;
113
+ i += 1;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** True when an escaped line break sits inside a CSS string. */
119
+ function cssLiteralHoldsEscape(value: string): boolean {
120
+ let i = 0;
121
+ while (i < value.length) {
122
+ const ch = value[i];
123
+ if (ch === "/" && value[i + 1] === "*") {
124
+ const close = value.indexOf("*/", i + 2);
125
+ i = close < 0 ? value.length : close + 2;
126
+ continue;
127
+ }
128
+ if (ch === '"' || ch === "'") {
129
+ const end = skipQuoted(value, i);
130
+ if (end < 0) return true;
131
+ i = end;
132
+ continue;
133
+ }
134
+ i += ch === "\\" ? 2 : 1;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /** Elements whose body is not markup text: an escape there belongs to that body. */
140
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "pre", "textarea"]);
141
+
142
+ /** True when an escaped line break sits inside an attribute value, a comment, or a raw-text element body. */
143
+ function htmlLiteralHoldsEscape(value: string): boolean {
144
+ const lower = value.toLowerCase();
145
+ let i = 0;
146
+ while (i < value.length) {
147
+ if (value[i] !== "<") {
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if (value.startsWith("<!--", i)) {
152
+ const close = value.indexOf("-->", i + 4);
153
+ const end = close < 0 ? value.length : close + 3;
154
+ if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
155
+ i = end;
156
+ continue;
157
+ }
158
+ const name = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(value.slice(i, i + 64))?.[1]?.toLowerCase();
159
+ let j = i + 1;
160
+ while (j < value.length && value[j] !== ">") {
161
+ if (value[j] === '"' || value[j] === "'") {
162
+ const end = skipQuoted(value, j);
163
+ if (end < 0) return true;
164
+ j = end;
165
+ continue;
166
+ }
167
+ j += 1;
168
+ }
169
+ i = j + 1;
170
+ if (name && RAW_TEXT_ELEMENTS.has(name)) {
171
+ const close = lower.indexOf(`</${name}`, i);
172
+ const end = close < 0 ? value.length : close;
173
+ if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
174
+ i = end;
175
+ }
176
+ }
177
+ return false;
178
+ }
179
+
180
+ const LITERAL_HOLDS_ESCAPE: Readonly<Record<SourceLanguage, (value: string) => boolean>> = {
181
+ html: htmlLiteralHoldsEscape,
182
+ css: cssLiteralHoldsEscape,
183
+ js: jsLiteralHoldsEscape,
184
+ };
185
+
186
+ /** An escaped line break (`\n`, `\r\n`) not preceded by another backslash. */
187
+ const ESCAPED_LINE_BREAK = /(?<!\\)(?:\\r)?\\n/g;
188
+
189
+ /**
190
+ * True when a source value's line breaks were string-escaped as a whole:
191
+ * no real line break at all, at least two escaped ones, one of them
192
+ * followed by indentation (the signature of pretty-printed source), and
193
+ * none inside a literal of `language` (see the file comment).
194
+ *
195
+ * @param value - the source text as sent.
196
+ * @param language - the grammar the value is written in.
197
+ */
198
+ export function hasEscapedLineBreaks(value: string, language: SourceLanguage): boolean {
199
+ if (/[\r\n]/.test(value)) return false;
200
+ const hits = value.match(ESCAPED_LINE_BREAK)?.length ?? 0;
201
+ if (hits < 2) return false;
202
+ if (!/(?<!\\)\\n(?:[ \t]|\\t)/.test(value)) return false;
203
+ return !LITERAL_HOLDS_ESCAPE[language](value);
204
+ }
205
+
206
+ /**
207
+ * The value with every escaped line break turned back into a real one (and
208
+ * the escaped tabs indenting the next line into real tabs) when
209
+ * `hasEscapedLineBreaks` holds; the input unchanged otherwise. Nothing else
210
+ * is unescaped: quotes and backslashes arrived as they were meant.
211
+ *
212
+ * @param value - the tool argument (non-strings pass through).
213
+ * @param language - the grammar the value is written in.
214
+ */
215
+ export function decodeEscapedLineBreaks(value: unknown, language: SourceLanguage): unknown {
216
+ if (typeof value !== "string" || !hasEscapedLineBreaks(value, language)) return value;
217
+ return value.replace(
218
+ /(?<!\\)(?:\\r)?\\n((?:\\t)*)/g,
219
+ (_m, tabs: string) => `\n${"\t".repeat(tabs.length / 2)}`,
220
+ );
221
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export * from "./database-url.js";
20
20
  export * from "./design-draft-shell.js";
21
21
  export * from "./document-language.js";
22
22
  export * from "./edit-markers.js";
23
+ export * from "./escaped-line-breaks.js";
23
24
  export * from "./font-assets.js";
24
25
  export * from "./font-axis-policy.js";
25
26
  export * from "./font-usage.js";
@@ -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
- '**Vetted interactions - use these instead of writing JS for them.** Add `data-caelo-ui="<id>"` to the markup and Caelo ships accessible, dependency-free behaviour once per page; style it freely in the module CSS. ' +
199
- '`accordion` (FAQ, collapsible sections): `<div data-caelo-ui="accordion" data-single>` containing `<details id="..."><summary>Question</summary><div>Answer</div></details>` items - `data-single` keeps one open; a link to `#<details id>` opens it. ' +
200
- '`tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div>` - ids must be unique on the page. ' +
201
- '`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">&lsaquo;</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">&rsaquo;</span></button></section>` (an icon button keeps its visible content aria-hidden; a text button needs no aria-label) - one slide per view by default; set the slides\' `flex-basis` in module CSS to show more. ' +
202
- '`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. ' +
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">&lsaquo;</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">&rsaquo;</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.";
@@ -39,6 +39,7 @@ const lists = createListRenderContext({
39
39
  pages: new Map(),
40
40
  media: new Map(),
41
41
  siteLanguage: "en",
42
+ homePageId: null,
42
43
  }).forPage(null);
43
44
  const uid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
44
45
  const links = (...ls: [string, string][]) =>
@@ -100,3 +100,42 @@ export const DEFAULT_LIST_TYPES: readonly ListTypeDef[] = [
100
100
  },
101
101
  },
102
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
+ };
@@ -50,6 +50,7 @@ export const LIST_RESERVED_NAMES: ReadonlySet<string> = new Set([
50
50
  "last",
51
51
  "items",
52
52
  "page",
53
+ "site_text",
53
54
  "recurse",
54
55
  ]);
55
56
  const RESERVED_PREFIXES = ["is_", "has_", "count_"] as const;
@@ -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
+ });