@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.
- package/dist/ai-tools.d.ts +30 -144
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +41 -141
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +5 -5
- package/dist/build-page.js +9 -9
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +13 -2
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +45 -12
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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/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/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/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 +49 -153
- package/src/bug-intake.test.ts +1 -1
- package/src/build-page.ts +9 -9
- package/src/content.test.ts +26 -1
- package/src/content.ts +47 -13
- package/src/css-scope-scan.ts +1 -1
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +1 -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/nested-module-render.test.ts +39 -0
- package/src/nested-module-render.ts +35 -0
- 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/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";
|
package/src/interactions.ts
CHANGED
|
@@ -195,9 +195,9 @@ export function interactionAssets(used: ReadonlySet<InteractionId>): {
|
|
|
195
195
|
* guidance cannot drift from the snippets above.
|
|
196
196
|
*/
|
|
197
197
|
export const INTERACTION_MARKUP_CONTRACT =
|
|
198
|
-
|
|
199
|
-
'`accordion` (FAQ, collapsible
|
|
200
|
-
'`tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div
|
|
201
|
-
'`carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</span></button></section
|
|
202
|
-
'`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">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</span></button></section>`; icon buttons keep visible content aria-hidden, text buttons need no aria-label. One slide per view; set the slides\' `flex-basis` in module CSS to show more.\n' +
|
|
202
|
+
'- `mobile-nav` (menu collapsing behind a toggle on small screens): `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false"><button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span></button><ul>{{#menu}}<li>…</li>{{/menu}}</ul></nav>` around the module\'s menu `list` field.\n' +
|
|
203
203
|
"Write module JS only for behaviour the kit does not cover.";
|
package/src/link-refs.test.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/lists/fields.ts
CHANGED
|
@@ -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
|
+
});
|