@caelo-cms/shared 0.10.71 → 0.11.0
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 +125 -50
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +50 -38
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +110 -30
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +26 -7
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +203 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +178 -76
- package/dist/content.js.map +1 -1
- package/dist/css-var-aliases.d.ts +20 -0
- package/dist/css-var-aliases.d.ts.map +1 -0
- package/dist/css-var-aliases.js +95 -0
- package/dist/css-var-aliases.js.map +1 -0
- package/dist/css-var-scan.d.ts +0 -16
- package/dist/css-var-scan.d.ts.map +1 -1
- package/dist/css-var-scan.js +24 -0
- package/dist/css-var-scan.js.map +1 -1
- package/dist/edit-markers.d.ts +41 -5
- package/dist/edit-markers.d.ts.map +1 -1
- package/dist/edit-markers.js +47 -50
- package/dist/edit-markers.js.map +1 -1
- package/dist/id-safe-truncate.d.ts +15 -0
- package/dist/id-safe-truncate.d.ts.map +1 -0
- package/dist/id-safe-truncate.js +47 -0
- package/dist/id-safe-truncate.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +2 -2
- package/dist/interactions.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +7 -1
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/link-refs.d.ts +1 -1
- package/dist/link-refs.js +3 -3
- package/dist/lists/address.d.ts +34 -0
- package/dist/lists/address.d.ts.map +1 -0
- package/dist/lists/address.js +43 -0
- package/dist/lists/address.js.map +1 -0
- package/dist/lists/default-types.d.ts +10 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +91 -0
- package/dist/lists/default-types.js.map +1 -0
- package/dist/lists/fields.d.ts +152 -0
- package/dist/lists/fields.d.ts.map +1 -0
- package/dist/lists/fields.js +258 -0
- package/dist/lists/fields.js.map +1 -0
- package/dist/lists/index.d.ts +14 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +15 -0
- package/dist/lists/index.js.map +1 -0
- package/dist/lists/item-attr.d.ts +26 -0
- package/dist/lists/item-attr.d.ts.map +1 -0
- package/dist/lists/item-attr.js +31 -0
- package/dist/lists/item-attr.js.map +1 -0
- package/dist/lists/items.d.ts +105 -0
- package/dist/lists/items.d.ts.map +1 -0
- package/dist/lists/items.js +304 -0
- package/dist/lists/items.js.map +1 -0
- package/dist/lists/list-type.d.ts +401 -0
- package/dist/lists/list-type.d.ts.map +1 -0
- package/dist/lists/list-type.js +275 -0
- package/dist/lists/list-type.js.map +1 -0
- package/dist/lists/model.d.ts +14 -0
- package/dist/lists/model.d.ts.map +1 -0
- package/dist/lists/model.js +15 -0
- package/dist/lists/model.js.map +1 -0
- package/dist/lists/page-type.d.ts +115 -0
- package/dist/lists/page-type.d.ts.map +1 -0
- package/dist/lists/page-type.js +66 -0
- package/dist/lists/page-type.js.map +1 -0
- package/dist/lists/refs.d.ts +13 -0
- package/dist/lists/refs.d.ts.map +1 -0
- package/dist/lists/refs.js +18 -0
- package/dist/lists/refs.js.map +1 -0
- package/dist/lists/render-data.d.ts +114 -0
- package/dist/lists/render-data.d.ts.map +1 -0
- package/dist/lists/render-data.js +3 -0
- package/dist/lists/render-data.js.map +1 -0
- package/dist/lists/render-deps.d.ts +68 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +91 -0
- package/dist/lists/render-deps.js.map +1 -0
- package/dist/lists/resolve-values.d.ts +63 -0
- package/dist/lists/resolve-values.d.ts.map +1 -0
- package/dist/lists/resolve-values.js +181 -0
- package/dist/lists/resolve-values.js.map +1 -0
- package/dist/lists/resolve.d.ts +106 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +878 -0
- package/dist/lists/resolve.js.map +1 -0
- package/dist/lists/scope.d.ts +81 -0
- package/dist/lists/scope.d.ts.map +1 -0
- package/dist/lists/scope.js +3 -0
- package/dist/lists/scope.js.map +1 -0
- package/dist/markup-structure.d.ts +8 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +2 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +25 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +122 -31
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts +24 -21
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +34 -118
- package/dist/preview-compose.js.map +1 -1
- package/dist/preview-scanner.d.ts +5 -2
- package/dist/preview-scanner.d.ts.map +1 -1
- package/dist/preview-scanner.js +5 -3
- package/dist/preview-scanner.js.map +1 -1
- package/dist/source-text-transport.d.ts +17 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +71 -0
- package/dist/source-text-transport.js.map +1 -0
- package/dist/template-engine.d.ts +44 -31
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +75 -174
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts +69 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/dist/template-sections.js +423 -0
- package/dist/template-sections.js.map +1 -0
- package/dist/uuid-diagnose.d.ts +9 -0
- package/dist/uuid-diagnose.d.ts.map +1 -0
- package/dist/uuid-diagnose.js +47 -0
- package/dist/uuid-diagnose.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/src/__tests__/redos-hardening.test.ts +50 -16
- package/src/ai-tools-add-module-modes.test.ts +50 -0
- package/src/ai-tools.test.ts +10 -0
- package/src/ai-tools.ts +53 -39
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +29 -6
- package/src/content.test.ts +111 -3
- package/src/content.ts +188 -77
- package/src/css-var-aliases.test.ts +61 -0
- package/src/css-var-aliases.ts +109 -0
- package/src/css-var-scan.ts +9 -0
- package/src/edit-markers.test.ts +37 -21
- package/src/edit-markers.ts +52 -20
- package/src/id-safe-truncate.test.ts +35 -0
- package/src/id-safe-truncate.ts +46 -0
- package/src/index.ts +5 -1
- package/src/interactions.ts +2 -2
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +66 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +102 -0
- package/src/lists/fields.ts +285 -0
- package/src/lists/index.ts +16 -0
- package/src/lists/item-attr.test.ts +36 -0
- package/src/lists/item-attr.ts +49 -0
- package/src/lists/items.ts +402 -0
- package/src/lists/list-fixtures.test.ts +302 -0
- package/src/lists/list-type.ts +306 -0
- package/src/lists/lists.test.ts +328 -0
- package/src/lists/model.ts +16 -0
- package/src/lists/page-type.ts +71 -0
- package/src/lists/refs.ts +21 -0
- package/src/lists/render-data.ts +125 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +144 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +295 -0
- package/src/lists/resolve.ts +1148 -0
- package/src/lists/scope.ts +69 -0
- package/src/lists-compose-parity.test.ts +262 -0
- package/src/markup-structure.ts +10 -0
- package/src/nested-module-render.test.ts +111 -77
- package/src/nested-module-render.ts +85 -48
- package/src/preview-compose.test.ts +74 -19
- package/src/preview-compose.ts +59 -154
- package/src/preview-scanner.test.ts +8 -4
- package/src/preview-scanner.ts +5 -3
- package/src/source-text-transport.test.ts +63 -0
- package/src/source-text-transport.ts +70 -0
- package/src/template-engine-lists.test.ts +553 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +500 -0
- package/src/uuid-diagnose.test.ts +28 -0
- package/src/uuid-diagnose.ts +50 -0
- package/src/version.ts +1 -1
- package/dist/structured-sets.d.ts +0 -70
- package/dist/structured-sets.d.ts.map +0 -1
- package/dist/structured-sets.js +0 -92
- package/dist/structured-sets.js.map +0 -1
- package/src/structured-sets.ts +0 -114
- package/src/template-engine-data-lists.test.ts +0 -90
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import { parseLinkRef } from "../link-refs.js";
|
|
3
|
+
import { TERM_REF_RE, validateFieldValue } from "./fields.js";
|
|
4
|
+
import { effectiveMaxDepth, LIST_QUERY_MAX_LIMIT, pageQuerySchema, } from "./list-type.js";
|
|
5
|
+
import { parseListRef } from "./refs.js";
|
|
6
|
+
import { bool, dateValue, EMPTY_LIST, EMPTY_TEXT, fieldValue, linkValue, listValue, mediaValue, positionValue, selectFlag, text, untypedValue, } from "./resolve-values.js";
|
|
7
|
+
/** Prefixes of the list failure markers (`docs/lists.md` §8). */
|
|
8
|
+
const LIST_MARKER_PREFIXES = [
|
|
9
|
+
"list-unbound:",
|
|
10
|
+
"list-missing:",
|
|
11
|
+
"list-type-missing:",
|
|
12
|
+
"list-source-unavailable:",
|
|
13
|
+
"list-malformed:",
|
|
14
|
+
"list-context-unavailable:",
|
|
15
|
+
];
|
|
16
|
+
/** True for a list failure marker — the editor's missing-content surface shows these. */
|
|
17
|
+
export function isListFailureMarker(marker) {
|
|
18
|
+
return LIST_MARKER_PREFIXES.some((p) => marker.startsWith(p));
|
|
19
|
+
}
|
|
20
|
+
/** Resolve one list field for a page (thin wrapper, `docs/lists.md` §10). */
|
|
21
|
+
export function resolveListField(field, value, page, holder = { contentInstanceId: null }) {
|
|
22
|
+
return page.resolveField(field, value, holder);
|
|
23
|
+
}
|
|
24
|
+
// ─── Overlays ────────────────────────────────────────────────────────
|
|
25
|
+
const SEG = "[a-z0-9_-]+";
|
|
26
|
+
const LIST_VALUE_RE = new RegExp(`^list/(${SEG})/item/(${SEG})/(${SEG})$`);
|
|
27
|
+
const INSTANCE_VALUE_RE = new RegExp(`^instance/(${SEG})/(${SEG})/item/(${SEG})/(${SEG})$`);
|
|
28
|
+
const PAGE_PROP_RE = new RegExp(`^page/(${SEG})/props/(${SEG})$`);
|
|
29
|
+
const LIST_HIDDEN_RE = new RegExp(`^list/(${SEG})/item/(${SEG})$`);
|
|
30
|
+
const INSTANCE_HIDDEN_RE = new RegExp(`^instance/(${SEG})/(${SEG})/item/(${SEG})$`);
|
|
31
|
+
/** Field types an overlay may not replace: they change structure, not text. */
|
|
32
|
+
const STRUCTURAL_TYPES = new Set(["module", "term"]);
|
|
33
|
+
function setIn(m, k, f, v) {
|
|
34
|
+
const inner = m.get(k) ?? new Map();
|
|
35
|
+
inner.set(f, v);
|
|
36
|
+
m.set(k, inner);
|
|
37
|
+
}
|
|
38
|
+
function prepareOverlays(data, issues) {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
if (!data.overlays)
|
|
41
|
+
return out;
|
|
42
|
+
const itemIndex = new Map();
|
|
43
|
+
for (const list of data.lists.values()) {
|
|
44
|
+
const go = (items) => {
|
|
45
|
+
for (const it of items) {
|
|
46
|
+
itemIndex.set(`${list.id}/${it.id}`, { list, item: it });
|
|
47
|
+
if (it.children)
|
|
48
|
+
go(it.children);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
go(list.items);
|
|
52
|
+
}
|
|
53
|
+
for (const [variant, overlay] of data.overlays) {
|
|
54
|
+
const prepared = {
|
|
55
|
+
listValues: new Map(),
|
|
56
|
+
listHidden: new Set(),
|
|
57
|
+
instanceValues: new Map(),
|
|
58
|
+
instanceHidden: new Set(),
|
|
59
|
+
pageProps: new Map(),
|
|
60
|
+
};
|
|
61
|
+
const refuse = (address, reason) => {
|
|
62
|
+
issues.push({ variant, address, reason });
|
|
63
|
+
};
|
|
64
|
+
for (const [address, value] of overlay.values) {
|
|
65
|
+
const lm = LIST_VALUE_RE.exec(address);
|
|
66
|
+
if (lm) {
|
|
67
|
+
const [, listId, itemId, fieldName] = lm;
|
|
68
|
+
const hit = itemIndex.get(`${listId}/${itemId}`);
|
|
69
|
+
if (!hit) {
|
|
70
|
+
refuse(address, "no such list entry");
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const field = fieldOf(data, hit.list.typeSlug, hit.item.node, fieldName);
|
|
74
|
+
const problem = overlayValueProblem(field, value, address);
|
|
75
|
+
if (problem)
|
|
76
|
+
refuse(address, problem);
|
|
77
|
+
else
|
|
78
|
+
setIn(prepared.listValues, `${listId}/${itemId}`, fieldName, value);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const im = INSTANCE_VALUE_RE.exec(address);
|
|
82
|
+
if (im) {
|
|
83
|
+
const [, ci, f, itemId, itemField] = im;
|
|
84
|
+
setIn(prepared.instanceValues, `${ci}/${f}/${itemId}`, itemField, value);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const pm = PAGE_PROP_RE.exec(address);
|
|
88
|
+
if (pm) {
|
|
89
|
+
const [, pageId, prop] = pm;
|
|
90
|
+
const page = data.pages.get(pageId);
|
|
91
|
+
const def = page
|
|
92
|
+
? data.pageTypes.get(page.pageTypeSlug)?.def.properties.find((p) => p.name === prop)
|
|
93
|
+
: undefined;
|
|
94
|
+
if (!page)
|
|
95
|
+
refuse(address, "no such page");
|
|
96
|
+
else {
|
|
97
|
+
const problem = overlayValueProblem(def, value, address);
|
|
98
|
+
if (problem)
|
|
99
|
+
refuse(address, problem);
|
|
100
|
+
else
|
|
101
|
+
setIn(prepared.pageProps, pageId, prop, value);
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
refuse(address, "not a rendered value address");
|
|
106
|
+
}
|
|
107
|
+
for (const address of overlay.hidden) {
|
|
108
|
+
const lh = LIST_HIDDEN_RE.exec(address);
|
|
109
|
+
if (lh) {
|
|
110
|
+
const key = `${lh[1]}/${lh[2]}`;
|
|
111
|
+
if (itemIndex.has(key))
|
|
112
|
+
prepared.listHidden.add(key);
|
|
113
|
+
else
|
|
114
|
+
refuse(address, "no such list entry");
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const ih = INSTANCE_HIDDEN_RE.exec(address);
|
|
118
|
+
if (ih) {
|
|
119
|
+
prepared.instanceHidden.add(`${ih[1]}/${ih[2]}/${ih[3]}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
refuse(address, "not an entry address");
|
|
123
|
+
}
|
|
124
|
+
out.set(variant, prepared);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function fieldOf(data, typeSlug, node, fieldName) {
|
|
129
|
+
return data.listTypes.get(typeSlug)?.def.nodes[node]?.fields.find((f) => f.name === fieldName);
|
|
130
|
+
}
|
|
131
|
+
/** Why an overlay value cannot replace a stored one; null when it can. */
|
|
132
|
+
function overlayValueProblem(field, value, address) {
|
|
133
|
+
if (!field)
|
|
134
|
+
return "the entry's node declares no such field";
|
|
135
|
+
if (STRUCTURAL_TYPES.has(field.type)) {
|
|
136
|
+
return `a ${field.type} field changes structure, an overlay may only replace values`;
|
|
137
|
+
}
|
|
138
|
+
const bad = validateFieldValue({ ...field, required: false }, value, address);
|
|
139
|
+
return bad.length > 0 ? (bad[0]?.message ?? "invalid value") : null;
|
|
140
|
+
}
|
|
141
|
+
// ─── Context ─────────────────────────────────────────────────────────
|
|
142
|
+
/** Build the render-wide list context from loaded data. */
|
|
143
|
+
export function createListRenderContext(data, opts = {}) {
|
|
144
|
+
const overlayIssues = [];
|
|
145
|
+
const overlays = prepareOverlays(data, overlayIssues);
|
|
146
|
+
const dateFormat = makeDateFormatter(data.siteLanguage);
|
|
147
|
+
const collator = new Intl.Collator(data.siteLanguage);
|
|
148
|
+
const pageList = [...data.pages.values()];
|
|
149
|
+
const listItemIndex = new Map();
|
|
150
|
+
const itemsOf = (list) => {
|
|
151
|
+
let idx = listItemIndex.get(list.id);
|
|
152
|
+
if (!idx) {
|
|
153
|
+
idx = new Map();
|
|
154
|
+
const go = (items, depth) => {
|
|
155
|
+
for (const it of items) {
|
|
156
|
+
idx?.set(it.id, { item: it, depth });
|
|
157
|
+
if (it.children)
|
|
158
|
+
go(it.children, depth + 1);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
go(list.items, 1);
|
|
162
|
+
listItemIndex.set(list.id, idx);
|
|
163
|
+
}
|
|
164
|
+
return idx;
|
|
165
|
+
};
|
|
166
|
+
const shared = {
|
|
167
|
+
data,
|
|
168
|
+
overlays,
|
|
169
|
+
dateFormat,
|
|
170
|
+
collator,
|
|
171
|
+
pageList,
|
|
172
|
+
itemsOf,
|
|
173
|
+
dormant: opts.dormantPluginSources ?? {},
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
overlayIssues,
|
|
177
|
+
forPage: (pageId, pageOpts = {}) => createPageContext(shared, pageId, pageOpts, overlayIssues),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function makeDateFormatter(language) {
|
|
181
|
+
let fmt;
|
|
182
|
+
try {
|
|
183
|
+
fmt = new Intl.DateTimeFormat(language, { dateStyle: "long", timeZone: "UTC" });
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// An invalid BCP-47 tag in site_settings is caught on write; the
|
|
187
|
+
// render must not crash on stored data, so format in the root locale.
|
|
188
|
+
fmt = new Intl.DateTimeFormat("und", { dateStyle: "long", timeZone: "UTC" });
|
|
189
|
+
}
|
|
190
|
+
const cache = new Map();
|
|
191
|
+
return (iso) => {
|
|
192
|
+
let out = cache.get(iso);
|
|
193
|
+
if (out === undefined) {
|
|
194
|
+
const t = Date.parse(iso);
|
|
195
|
+
out = Number.isNaN(t) ? iso : fmt.format(new Date(t));
|
|
196
|
+
cache.set(iso, out);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function createPageContext(s, pageId, opts, renderIssues) {
|
|
202
|
+
const { data } = s;
|
|
203
|
+
const current = pageId === null ? null : (data.pages.get(pageId) ?? null);
|
|
204
|
+
const variant = pageId === null ? null : (data.pageVariants?.get(pageId)?.variant ?? null);
|
|
205
|
+
const overlay = variant === null ? undefined : s.overlays.get(variant);
|
|
206
|
+
const resolver = opts.linkResolver;
|
|
207
|
+
const pageIssues = [];
|
|
208
|
+
const depLists = new Set();
|
|
209
|
+
const depTypes = new Set();
|
|
210
|
+
const depQueries = new Map();
|
|
211
|
+
let depTree = false;
|
|
212
|
+
const depPages = new Set();
|
|
213
|
+
/** Entries of a page-shaped system source: their pages become deps. */
|
|
214
|
+
const readPages = (pages) => {
|
|
215
|
+
for (const pg of pages)
|
|
216
|
+
depPages.add(pg.id);
|
|
217
|
+
return pages;
|
|
218
|
+
};
|
|
219
|
+
const urlOf = (href) => {
|
|
220
|
+
const ref = parseLinkRef(href);
|
|
221
|
+
if (ref === null)
|
|
222
|
+
return href.startsWith("/") ? normaliseUrl(href) : null;
|
|
223
|
+
if (!resolver)
|
|
224
|
+
return null;
|
|
225
|
+
const r = resolver.resolve(ref);
|
|
226
|
+
return r.status === "resolved" || r.status === "unpublished" ? normaliseUrl(r.url) : null;
|
|
227
|
+
};
|
|
228
|
+
let currentUrlMemo;
|
|
229
|
+
const currentUrl = () => {
|
|
230
|
+
if (currentUrlMemo === undefined) {
|
|
231
|
+
currentUrlMemo = pageId === null ? null : urlOf(`caelo:page/${pageId}`);
|
|
232
|
+
}
|
|
233
|
+
return currentUrlMemo;
|
|
234
|
+
};
|
|
235
|
+
/** True when `href` points at the rendering page. */
|
|
236
|
+
const isCurrentHref = (href) => {
|
|
237
|
+
if (href.length === 0 || pageId === null)
|
|
238
|
+
return false;
|
|
239
|
+
const ref = parseLinkRef(href);
|
|
240
|
+
if (ref?.kind === "page" && ref.pageId === pageId)
|
|
241
|
+
return true;
|
|
242
|
+
if (ref?.kind === "media")
|
|
243
|
+
return false;
|
|
244
|
+
const here = currentUrl();
|
|
245
|
+
return here !== null && urlOf(href) === here;
|
|
246
|
+
};
|
|
247
|
+
/** True when the rendering page sits below page `ancestorId` in the page tree. */
|
|
248
|
+
const isPageAncestor = (ancestorId) => {
|
|
249
|
+
let p = current?.parentId ?? null;
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
while (p !== null && !seen.has(p)) {
|
|
252
|
+
if (p === ancestorId)
|
|
253
|
+
return true;
|
|
254
|
+
seen.add(p);
|
|
255
|
+
p = data.pages.get(p)?.parentId ?? null;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
};
|
|
259
|
+
const valueEnv = {
|
|
260
|
+
data,
|
|
261
|
+
formatDate: s.dateFormat,
|
|
262
|
+
termEntries: (refs, field) => termEntries(refs, field),
|
|
263
|
+
};
|
|
264
|
+
// ── Stored trees ──
|
|
265
|
+
/** Structural check of a stored tree before any entry renders. */
|
|
266
|
+
const checkTree = (type, items, field) => {
|
|
267
|
+
if (!Array.isArray(items))
|
|
268
|
+
return { ok: false, marker: `list-malformed:${field}[items]` };
|
|
269
|
+
const max = effectiveMaxDepth(type);
|
|
270
|
+
const walk = (level, depth, path) => {
|
|
271
|
+
for (let i = 0; i < level.length; i += 1) {
|
|
272
|
+
const it = level[i];
|
|
273
|
+
const at = `${path}[${i}]`;
|
|
274
|
+
if (typeof it !== "object" ||
|
|
275
|
+
it === null ||
|
|
276
|
+
typeof it.id !== "string" ||
|
|
277
|
+
typeof it.node !== "string" ||
|
|
278
|
+
typeof it.values !== "object" ||
|
|
279
|
+
it.values === null) {
|
|
280
|
+
return at;
|
|
281
|
+
}
|
|
282
|
+
if (!type.nodes[it.node])
|
|
283
|
+
return `${at}.node`;
|
|
284
|
+
if (depth > max)
|
|
285
|
+
return at;
|
|
286
|
+
if (it.children !== undefined) {
|
|
287
|
+
if (!Array.isArray(it.children))
|
|
288
|
+
return `${at}.children`;
|
|
289
|
+
const bad = walk(it.children, depth + 1, `${at}.children`);
|
|
290
|
+
if (bad)
|
|
291
|
+
return bad;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
};
|
|
296
|
+
const bad = walk(items, 1, "items");
|
|
297
|
+
return bad === null
|
|
298
|
+
? { ok: true, items: items }
|
|
299
|
+
: { ok: false, marker: `list-malformed:${field}[${bad}]` };
|
|
300
|
+
};
|
|
301
|
+
const entriesOf = (items, tree, depth) => {
|
|
302
|
+
const visible = items.filter((it) => !tree.hidden(it.id));
|
|
303
|
+
return visible.map((it, index) => storedEntry(it, tree, { index, count: visible.length, depth }));
|
|
304
|
+
};
|
|
305
|
+
const storedEntry = (item, tree, pos) => {
|
|
306
|
+
const node = tree.type.nodes[item.node];
|
|
307
|
+
const overlayValues = tree.overlayValues(item.id);
|
|
308
|
+
const memo = new Map();
|
|
309
|
+
const storedValue = (f) => {
|
|
310
|
+
if (overlayValues?.has(f.name)) {
|
|
311
|
+
const v = overlayValues.get(f.name);
|
|
312
|
+
if (!tree.checkOverlay || tree.checkOverlay(item, f, v))
|
|
313
|
+
return v;
|
|
314
|
+
}
|
|
315
|
+
return item.values[f.name];
|
|
316
|
+
};
|
|
317
|
+
const children = () => item.children ? entriesOf(item.children, tree, pos.depth + 1) : [];
|
|
318
|
+
let isCurrentMemo;
|
|
319
|
+
const isCurrent = () => {
|
|
320
|
+
if (isCurrentMemo === undefined) {
|
|
321
|
+
const f = node.current_from ? node.fields.find((x) => x.name === node.current_from) : null;
|
|
322
|
+
const v = f ? storedValue(f) : undefined;
|
|
323
|
+
const href = typeof v?.href === "string" ? v.href : "";
|
|
324
|
+
isCurrentMemo = isCurrentHref(href);
|
|
325
|
+
}
|
|
326
|
+
return isCurrentMemo;
|
|
327
|
+
};
|
|
328
|
+
const isAncestor = () => {
|
|
329
|
+
const f = node.current_from ? node.fields.find((x) => x.name === node.current_from) : null;
|
|
330
|
+
const v = f ? storedValue(f) : undefined;
|
|
331
|
+
const href = typeof v?.href === "string" ? v.href : "";
|
|
332
|
+
const ref = href ? parseLinkRef(href) : null;
|
|
333
|
+
if (ref?.kind === "page" && ref.pageId !== pageId && isPageAncestor(ref.pageId))
|
|
334
|
+
return true;
|
|
335
|
+
const anyBelow = (entries) => entries.some((e) => {
|
|
336
|
+
const c = e.get("is_current");
|
|
337
|
+
const a = e.get("is_ancestor");
|
|
338
|
+
return (c?.kind === "bool" && c.value) || (a?.kind === "bool" && a.value);
|
|
339
|
+
});
|
|
340
|
+
return anyBelow(children());
|
|
341
|
+
};
|
|
342
|
+
const items = () => node.source ? sourceItems(node.source, item, node, tree.field) : EMPTY_LIST;
|
|
343
|
+
const compute = (key) => {
|
|
344
|
+
const field = node.fields.find((f) => f.name === key);
|
|
345
|
+
if (field)
|
|
346
|
+
return fieldValue(field, storedValue(field), valueEnv);
|
|
347
|
+
if (key === "id")
|
|
348
|
+
return text(item.id);
|
|
349
|
+
if (key === "node")
|
|
350
|
+
return text(item.node);
|
|
351
|
+
const p = positionValue(key, pos);
|
|
352
|
+
if (p)
|
|
353
|
+
return p;
|
|
354
|
+
if (key === "is_current")
|
|
355
|
+
return bool(node.current_from ? isCurrent() : false);
|
|
356
|
+
// Descendant-based, so a node without its own link (a column) is
|
|
357
|
+
// still an ancestor of the current entry below it.
|
|
358
|
+
if (key === "is_ancestor")
|
|
359
|
+
return bool(isAncestor());
|
|
360
|
+
const kids = node.children?.name;
|
|
361
|
+
if (kids !== undefined && key === kids)
|
|
362
|
+
return listValue(children());
|
|
363
|
+
// `items` is reserved: a node without a source simply has none, so
|
|
364
|
+
// a block shared by mixed node kinds needs no `is_<node>` guard.
|
|
365
|
+
if (key === "items")
|
|
366
|
+
return items();
|
|
367
|
+
if (key.startsWith("has_") || key.startsWith("count_")) {
|
|
368
|
+
const target = key.startsWith("has_") ? key.slice(4) : key.slice(6);
|
|
369
|
+
let n = null;
|
|
370
|
+
if (kids !== undefined && target === kids)
|
|
371
|
+
n = children().length;
|
|
372
|
+
else if (target === "items") {
|
|
373
|
+
const v = items();
|
|
374
|
+
n = v.kind === "list" ? v.entries.length : 0;
|
|
375
|
+
}
|
|
376
|
+
if (n === null)
|
|
377
|
+
return undefined;
|
|
378
|
+
return key.startsWith("has_") ? bool(n > 0) : text(String(n));
|
|
379
|
+
}
|
|
380
|
+
if (key.startsWith("is_") && tree.type.nodes[key.slice(3)]) {
|
|
381
|
+
return bool(key.slice(3) === item.node);
|
|
382
|
+
}
|
|
383
|
+
const flag = selectFlag(node.fields, item.values, key);
|
|
384
|
+
if (flag)
|
|
385
|
+
return flag;
|
|
386
|
+
if (tree.termLink && key === "link") {
|
|
387
|
+
const lp = node.fields.find((f) => f.name === "landing_page" && f.type === "link");
|
|
388
|
+
return lp ? fieldValue(lp, storedValue(lp), valueEnv) : linkValue({ href: "" }, null);
|
|
389
|
+
}
|
|
390
|
+
return undefined;
|
|
391
|
+
};
|
|
392
|
+
return {
|
|
393
|
+
id: item.id,
|
|
394
|
+
itemAttr: `${tree.attrPrefix}${item.id}`,
|
|
395
|
+
childrenKey: node.children?.name ?? null,
|
|
396
|
+
depth: pos.depth,
|
|
397
|
+
get(key) {
|
|
398
|
+
if (!memo.has(key))
|
|
399
|
+
memo.set(key, compute(key));
|
|
400
|
+
return memo.get(key);
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
const sharedTree = (list, type, field, termLink) => ({
|
|
405
|
+
type,
|
|
406
|
+
field,
|
|
407
|
+
attrPrefix: `${list.id}/`,
|
|
408
|
+
overlayValues: (itemId) => overlay?.listValues.get(`${list.id}/${itemId}`),
|
|
409
|
+
hidden: (itemId) => overlay?.listHidden.has(`${list.id}/${itemId}`) ?? false,
|
|
410
|
+
termLink,
|
|
411
|
+
});
|
|
412
|
+
// ── Terms ──
|
|
413
|
+
const termEntries = (refs, field) => {
|
|
414
|
+
const out = [];
|
|
415
|
+
const found = [];
|
|
416
|
+
for (const ref of refs) {
|
|
417
|
+
const m = TERM_REF_RE.exec(ref);
|
|
418
|
+
if (!m)
|
|
419
|
+
return { kind: "failed", marker: `list-malformed:${field}[${ref}]` };
|
|
420
|
+
const [, listId, itemId] = m;
|
|
421
|
+
depLists.add(listId);
|
|
422
|
+
const list = data.lists.get(listId);
|
|
423
|
+
if (!list)
|
|
424
|
+
return { kind: "failed", marker: `list-missing:${listId}` };
|
|
425
|
+
const type = data.listTypes.get(list.typeSlug);
|
|
426
|
+
if (!type)
|
|
427
|
+
return { kind: "failed", marker: `list-type-missing:${list.typeSlug}` };
|
|
428
|
+
depTypes.add(list.typeSlug);
|
|
429
|
+
const hit = s.itemsOf(list).get(itemId);
|
|
430
|
+
if (!hit)
|
|
431
|
+
return { kind: "failed", marker: `list-missing:${listId}/item/${itemId}` };
|
|
432
|
+
if (overlay?.listHidden.has(`${listId}/${itemId}`))
|
|
433
|
+
continue;
|
|
434
|
+
found.push({ list, type: type.def, item: hit.item, depth: hit.depth });
|
|
435
|
+
}
|
|
436
|
+
found.forEach((t, index) => {
|
|
437
|
+
out.push(storedEntry(t.item, sharedTree(t.list, t.type, field, true), {
|
|
438
|
+
index,
|
|
439
|
+
count: found.length,
|
|
440
|
+
depth: t.depth,
|
|
441
|
+
}));
|
|
442
|
+
});
|
|
443
|
+
return listValue(out);
|
|
444
|
+
};
|
|
445
|
+
// ── Pages ──
|
|
446
|
+
const propsValue = (p) => {
|
|
447
|
+
const defs = data.pageTypes.get(p.pageTypeSlug)?.def.properties ?? [];
|
|
448
|
+
const propOverlay = overlay?.pageProps.get(p.id);
|
|
449
|
+
const memo = new Map();
|
|
450
|
+
return {
|
|
451
|
+
kind: "record",
|
|
452
|
+
text: null,
|
|
453
|
+
present: true,
|
|
454
|
+
get(key) {
|
|
455
|
+
if (!memo.has(key)) {
|
|
456
|
+
const def = defs.find((d) => d.name === key);
|
|
457
|
+
const raw = propOverlay?.has(key) ? propOverlay.get(key) : p.properties[key];
|
|
458
|
+
memo.set(key, def ? fieldValue(def, raw, valueEnv) : undefined);
|
|
459
|
+
}
|
|
460
|
+
return memo.get(key);
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
/** `terms.<vocabulary slug>` of a page. */
|
|
465
|
+
const pageTermsValue = (p) => ({
|
|
466
|
+
kind: "record",
|
|
467
|
+
text: null,
|
|
468
|
+
present: p.terms.length > 0,
|
|
469
|
+
get(slug) {
|
|
470
|
+
const lists = [...data.lists.values()].filter((l) => l.slug === slug);
|
|
471
|
+
if (lists.length === 0)
|
|
472
|
+
return undefined;
|
|
473
|
+
const ids = new Set(lists.map((l) => l.id));
|
|
474
|
+
return termEntries(p.terms.filter((t) => ids.has(TERM_REF_RE.exec(t)?.[1] ?? "")), `terms.${slug}`);
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
const pageValue = (p) => {
|
|
478
|
+
const memo = new Map();
|
|
479
|
+
const compute = (key) => {
|
|
480
|
+
switch (key) {
|
|
481
|
+
case "id":
|
|
482
|
+
return text(p.id);
|
|
483
|
+
case "title":
|
|
484
|
+
return text(p.title);
|
|
485
|
+
case "link":
|
|
486
|
+
return linkValue({ href: `caelo:page/${p.id}` }, data);
|
|
487
|
+
case "published_at":
|
|
488
|
+
return p.firstPublishedAt === null
|
|
489
|
+
? dateValue("", valueEnv)
|
|
490
|
+
: dateValue(p.firstPublishedAt, valueEnv);
|
|
491
|
+
case "updated_at":
|
|
492
|
+
return dateValue(p.updatedAt, valueEnv);
|
|
493
|
+
case "summary":
|
|
494
|
+
return text(p.summary ?? "");
|
|
495
|
+
case "image":
|
|
496
|
+
return p.image === null ? mediaValue("", data) : mediaValue(p.image, data);
|
|
497
|
+
case "page_type":
|
|
498
|
+
return text(p.pageTypeSlug);
|
|
499
|
+
case "props":
|
|
500
|
+
return propsValue(p);
|
|
501
|
+
case "terms":
|
|
502
|
+
return pageTermsValue(p);
|
|
503
|
+
case "is_current":
|
|
504
|
+
return bool(p.id === pageId);
|
|
505
|
+
case "is_ancestor":
|
|
506
|
+
return bool(p.id !== pageId && isPageAncestor(p.id));
|
|
507
|
+
default:
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
return (key) => {
|
|
512
|
+
if (!memo.has(key))
|
|
513
|
+
memo.set(key, compute(key));
|
|
514
|
+
return memo.get(key);
|
|
515
|
+
};
|
|
516
|
+
};
|
|
517
|
+
const pageEntries = (pages) => pages.map((p, index) => {
|
|
518
|
+
const get = pageValue(p);
|
|
519
|
+
const pos = { index, count: pages.length, depth: 1 };
|
|
520
|
+
return {
|
|
521
|
+
id: p.id,
|
|
522
|
+
itemAttr: null,
|
|
523
|
+
childrenKey: null,
|
|
524
|
+
depth: 1,
|
|
525
|
+
get: (key) => get(key) ?? positionValue(key, pos),
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
const sameVariant = (p) => variant === null || data.pageVariants?.get(p.id)?.variant === variant;
|
|
529
|
+
const runQuery = (q) => {
|
|
530
|
+
const parent = q.parent
|
|
531
|
+
? (parseLinkRef(q.parent)?.pageId ?? null)
|
|
532
|
+
: null;
|
|
533
|
+
const terms = q.terms ?? [];
|
|
534
|
+
const key = JSON.stringify([q.page_type ?? null, [...terms].sort(), parent]);
|
|
535
|
+
if (!depQueries.has(key)) {
|
|
536
|
+
depQueries.set(key, { pageType: q.page_type ?? null, terms: [...terms].sort(), parent });
|
|
537
|
+
}
|
|
538
|
+
const excludeCurrent = q.exclude_current ?? true;
|
|
539
|
+
const mode = q.terms_mode ?? "any";
|
|
540
|
+
const hits = s.pageList.filter((p) => p.inBuild &&
|
|
541
|
+
sameVariant(p) &&
|
|
542
|
+
(q.page_type === undefined || p.pageTypeSlug === q.page_type) &&
|
|
543
|
+
(parent === null || p.parentId === parent) &&
|
|
544
|
+
(!excludeCurrent || p.id !== pageId) &&
|
|
545
|
+
(terms.length === 0 ||
|
|
546
|
+
(mode === "all"
|
|
547
|
+
? terms.every((t) => p.terms.includes(t))
|
|
548
|
+
: terms.some((t) => p.terms.includes(t)))));
|
|
549
|
+
hits.sort(pageOrder(q.order, s.collator));
|
|
550
|
+
return listValue(pageEntries(hits.slice(0, q.limit)));
|
|
551
|
+
};
|
|
552
|
+
const byTitle = (a, b) => s.collator.compare(a.title, b.title) || compareIds(a.id, b.id);
|
|
553
|
+
const systemSource = (name, terms, vocabularyIds, field) => {
|
|
554
|
+
switch (name) {
|
|
555
|
+
case "breadcrumbs": {
|
|
556
|
+
depTree = true;
|
|
557
|
+
if (!current)
|
|
558
|
+
return EMPTY_LIST;
|
|
559
|
+
const chain = [];
|
|
560
|
+
const seen = new Set();
|
|
561
|
+
let p = current;
|
|
562
|
+
while (p && !seen.has(p.id)) {
|
|
563
|
+
chain.unshift(p);
|
|
564
|
+
seen.add(p.id);
|
|
565
|
+
p = p.parentId === null ? undefined : data.pages.get(p.parentId);
|
|
566
|
+
}
|
|
567
|
+
return listValue(pageEntries(readPages(chain)));
|
|
568
|
+
}
|
|
569
|
+
case "children": {
|
|
570
|
+
depTree = true;
|
|
571
|
+
if (!current)
|
|
572
|
+
return EMPTY_LIST;
|
|
573
|
+
return listValue(pageEntries(readPages(s.pageList.filter((p) => p.inBuild && p.parentId === current.id).sort(byTitle))));
|
|
574
|
+
}
|
|
575
|
+
case "siblings": {
|
|
576
|
+
depTree = true;
|
|
577
|
+
if (!current)
|
|
578
|
+
return EMPTY_LIST;
|
|
579
|
+
return listValue(pageEntries(readPages(s.pageList.filter((p) => p.inBuild && p.parentId === current.parentId).sort(byTitle))));
|
|
580
|
+
}
|
|
581
|
+
case "page_terms": {
|
|
582
|
+
if (!current)
|
|
583
|
+
return EMPTY_LIST;
|
|
584
|
+
const ids = vocabularyIds === null ? null : new Set(vocabularyIds);
|
|
585
|
+
for (const id of vocabularyIds ?? [])
|
|
586
|
+
depLists.add(id);
|
|
587
|
+
return termEntries(current.terms.filter((t) => ids === null || ids.has(TERM_REF_RE.exec(t)?.[1] ?? "")), field);
|
|
588
|
+
}
|
|
589
|
+
case "term_pages": {
|
|
590
|
+
depTree = true;
|
|
591
|
+
if (terms.length === 0)
|
|
592
|
+
return EMPTY_LIST;
|
|
593
|
+
const sorted = [...terms].sort();
|
|
594
|
+
const key = JSON.stringify([null, sorted, null]);
|
|
595
|
+
if (!depQueries.has(key))
|
|
596
|
+
depQueries.set(key, { pageType: null, terms: sorted, parent: null });
|
|
597
|
+
const hits = s.pageList
|
|
598
|
+
.filter((p) => p.inBuild &&
|
|
599
|
+
sameVariant(p) &&
|
|
600
|
+
p.id !== pageId &&
|
|
601
|
+
terms.some((t) => p.terms.includes(t)))
|
|
602
|
+
.sort(byTitle);
|
|
603
|
+
return listValue(pageEntries(hits.slice(0, LIST_QUERY_MAX_LIMIT)));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const pluginEntries = (name) => {
|
|
608
|
+
const entries = opts.pluginSources?.[name];
|
|
609
|
+
if (entries === undefined) {
|
|
610
|
+
const owner = s.dormant[name] ?? "unknown";
|
|
611
|
+
return { kind: "failed", marker: `list-source-unavailable:${name} plugin=${owner}` };
|
|
612
|
+
}
|
|
613
|
+
return listValue(entries.map((e, index) => {
|
|
614
|
+
const pos = { index, count: entries.length, depth: 1 };
|
|
615
|
+
return {
|
|
616
|
+
id: e.id,
|
|
617
|
+
itemAttr: null,
|
|
618
|
+
childrenKey: null,
|
|
619
|
+
depth: 1,
|
|
620
|
+
get: (key) => {
|
|
621
|
+
if (key === "id")
|
|
622
|
+
return text(e.id);
|
|
623
|
+
if (Object.hasOwn(e.values, key))
|
|
624
|
+
return untypedValue(e.values[key]) ?? EMPTY_TEXT;
|
|
625
|
+
return positionValue(key, pos);
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}));
|
|
629
|
+
};
|
|
630
|
+
/** Merge a source's query defaults with the knobs an entry / placement may set. */
|
|
631
|
+
const mergedQuery = (source, overrides, field, path) => {
|
|
632
|
+
const merged = { ...source.defaults };
|
|
633
|
+
if (overrides !== undefined && overrides !== null) {
|
|
634
|
+
if (typeof overrides !== "object" || Array.isArray(overrides)) {
|
|
635
|
+
return { marker: `list-malformed:${field}[${path}]` };
|
|
636
|
+
}
|
|
637
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
638
|
+
if (!source.editable.includes(k)) {
|
|
639
|
+
return { marker: `list-malformed:${field}[${path}.${k}]` };
|
|
640
|
+
}
|
|
641
|
+
merged[k] = v;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const parsed = pageQuerySchema.safeParse(merged);
|
|
645
|
+
return parsed.success ? parsed.data : { marker: `list-malformed:${field}[${path}]` };
|
|
646
|
+
};
|
|
647
|
+
/** Generated entries of a source node (`{{#items}}`). */
|
|
648
|
+
const sourceItems = (source, item, node, field) => {
|
|
649
|
+
switch (source.kind) {
|
|
650
|
+
case "query": {
|
|
651
|
+
const q = mergedQuery(source, item.query, field, `${item.id}.query`);
|
|
652
|
+
return "marker" in q ? { kind: "failed", marker: q.marker } : runQuery(q);
|
|
653
|
+
}
|
|
654
|
+
case "system": {
|
|
655
|
+
const termFields = node.fields.filter((f) => f.type === "term");
|
|
656
|
+
const terms = termFields.flatMap((f) => {
|
|
657
|
+
const v = item.values[f.name];
|
|
658
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
659
|
+
});
|
|
660
|
+
const vocabSlugs = termFields.map((f) => (f.type === "term" ? f.list : ""));
|
|
661
|
+
const vocabIds = vocabSlugs.length === 0
|
|
662
|
+
? null
|
|
663
|
+
: [...data.lists.values()].filter((l) => vocabSlugs.includes(l.slug)).map((l) => l.id);
|
|
664
|
+
return systemSource(source.name, terms, vocabIds, field);
|
|
665
|
+
}
|
|
666
|
+
case "plugin":
|
|
667
|
+
return pluginEntries(source.name);
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
const fromValue = (v) => v.kind === "failed"
|
|
671
|
+
? { ok: false, marker: v.marker }
|
|
672
|
+
: { ok: true, entries: v.kind === "list" ? v.entries : [] };
|
|
673
|
+
const resolveField = (field, value, holder) => {
|
|
674
|
+
const bind = field.bind ?? "local";
|
|
675
|
+
if (bind === "source") {
|
|
676
|
+
const source = field.source;
|
|
677
|
+
if (!source)
|
|
678
|
+
return { ok: false, marker: `list-malformed:${field.name}[source]` };
|
|
679
|
+
switch (source.kind) {
|
|
680
|
+
case "query": {
|
|
681
|
+
const q = mergedQuery(source, value, field.name, "query");
|
|
682
|
+
return "marker" in q ? { ok: false, marker: q.marker } : fromValue(runQuery(q));
|
|
683
|
+
}
|
|
684
|
+
case "system": {
|
|
685
|
+
const overrides = (value ?? {});
|
|
686
|
+
const terms = Array.isArray(overrides.terms)
|
|
687
|
+
? overrides.terms.filter((t) => typeof t === "string")
|
|
688
|
+
: [];
|
|
689
|
+
const vocab = field.list === undefined ? null : parseListRef(field.list);
|
|
690
|
+
return fromValue(systemSource(source.name, terms, vocab === null ? null : [vocab], field.name));
|
|
691
|
+
}
|
|
692
|
+
case "plugin":
|
|
693
|
+
return fromValue(pluginEntries(source.name));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const typeRow = data.listTypes.get(field.listType);
|
|
697
|
+
if (!typeRow)
|
|
698
|
+
return { ok: false, marker: `list-type-missing:${field.listType}` };
|
|
699
|
+
depTypes.add(field.listType);
|
|
700
|
+
const type = typeRow.def;
|
|
701
|
+
if (bind === "shared") {
|
|
702
|
+
const raw = field.list ?? (typeof value === "string" ? value : undefined) ?? field.default;
|
|
703
|
+
if (raw === undefined || raw === null || raw === "") {
|
|
704
|
+
return { ok: false, marker: `list-unbound:${field.name}` };
|
|
705
|
+
}
|
|
706
|
+
const listId = typeof raw === "string" ? parseListRef(raw) : null;
|
|
707
|
+
if (listId === null)
|
|
708
|
+
return { ok: false, marker: `list-malformed:${field.name}[list]` };
|
|
709
|
+
depLists.add(listId);
|
|
710
|
+
const list = data.lists.get(listId);
|
|
711
|
+
if (!list)
|
|
712
|
+
return { ok: false, marker: `list-missing:${listId}` };
|
|
713
|
+
if (list.typeSlug !== field.listType) {
|
|
714
|
+
return { ok: false, marker: `list-malformed:${field.name}[listType]` };
|
|
715
|
+
}
|
|
716
|
+
const checked = checkTree(type, list.items, field.name);
|
|
717
|
+
if (!checked.ok)
|
|
718
|
+
return checked;
|
|
719
|
+
return {
|
|
720
|
+
ok: true,
|
|
721
|
+
entries: entriesOf(checked.items, sharedTree(list, type, field.name, false), 1),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
const raw = value === undefined || value === null ? field.default : value;
|
|
725
|
+
if (raw === undefined || raw === null)
|
|
726
|
+
return { ok: true, entries: [] };
|
|
727
|
+
const checked = checkTree(type, raw, field.name);
|
|
728
|
+
if (!checked.ok)
|
|
729
|
+
return checked;
|
|
730
|
+
const ci = holder.contentInstanceId;
|
|
731
|
+
const local = {
|
|
732
|
+
type,
|
|
733
|
+
field: field.name,
|
|
734
|
+
attrPrefix: ci === null ? `local/${field.name}/` : `instance/${ci}/${field.name}/`,
|
|
735
|
+
overlayValues: (itemId) => ci === null ? undefined : overlay?.instanceValues.get(`${ci}/${field.name}/${itemId}`),
|
|
736
|
+
hidden: (itemId) => ci !== null && (overlay?.instanceHidden.has(`${ci}/${field.name}/${itemId}`) ?? false),
|
|
737
|
+
checkOverlay: (item, f, v) => {
|
|
738
|
+
const address = `instance/${ci}/${field.name}/item/${item.id}/${f.name}`;
|
|
739
|
+
const problem = overlayValueProblem(f, v, address);
|
|
740
|
+
if (problem && variant !== null && !pageIssues.some((i) => i.address === address)) {
|
|
741
|
+
pageIssues.push({ variant, address, reason: problem });
|
|
742
|
+
}
|
|
743
|
+
return problem === null;
|
|
744
|
+
},
|
|
745
|
+
termLink: false,
|
|
746
|
+
};
|
|
747
|
+
if (ci !== null && overlay) {
|
|
748
|
+
// Instance overlay addresses naming an entry or field this list
|
|
749
|
+
// does not have can only be found here, where the stored entries
|
|
750
|
+
// are known.
|
|
751
|
+
const ids = new Set();
|
|
752
|
+
const fieldsByItem = new Map();
|
|
753
|
+
const go = (items) => {
|
|
754
|
+
for (const it of items) {
|
|
755
|
+
ids.add(it.id);
|
|
756
|
+
fieldsByItem.set(it.id, type.nodes[it.node]);
|
|
757
|
+
if (it.children)
|
|
758
|
+
go(it.children);
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
go(checked.items);
|
|
762
|
+
const prefix = `${ci}/${field.name}/`;
|
|
763
|
+
for (const [key, values] of overlay.instanceValues) {
|
|
764
|
+
if (!key.startsWith(prefix) || variant === null)
|
|
765
|
+
continue;
|
|
766
|
+
const itemId = key.slice(prefix.length);
|
|
767
|
+
const node = fieldsByItem.get(itemId);
|
|
768
|
+
for (const f of values.keys()) {
|
|
769
|
+
const address = `instance/${ci}/${field.name}/item/${itemId}/${f}`;
|
|
770
|
+
if (!node?.fields.some((x) => x.name === f)) {
|
|
771
|
+
if (!pageIssues.some((i) => i.address === address)) {
|
|
772
|
+
pageIssues.push({
|
|
773
|
+
variant,
|
|
774
|
+
address,
|
|
775
|
+
reason: node ? "the entry's node declares no such field" : "no such list entry",
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return { ok: true, entries: entriesOf(checked.items, local, 1) };
|
|
783
|
+
};
|
|
784
|
+
let pageScopeMemo;
|
|
785
|
+
return {
|
|
786
|
+
pageId,
|
|
787
|
+
resolveField,
|
|
788
|
+
pageScope: () => {
|
|
789
|
+
if (pageScopeMemo === undefined) {
|
|
790
|
+
if (current === null)
|
|
791
|
+
pageScopeMemo = null;
|
|
792
|
+
else {
|
|
793
|
+
const get = pageValue(current);
|
|
794
|
+
pageScopeMemo = { kind: "record", text: null, present: true, get };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return pageScopeMemo;
|
|
798
|
+
},
|
|
799
|
+
pluginSource: (name) => {
|
|
800
|
+
if (opts.pluginSources?.[name] === undefined && s.dormant[name] === undefined)
|
|
801
|
+
return null;
|
|
802
|
+
return fromValue(pluginEntries(name));
|
|
803
|
+
},
|
|
804
|
+
deps: () => ({
|
|
805
|
+
lists: [...depLists].sort(),
|
|
806
|
+
listTypes: [...depTypes].sort(),
|
|
807
|
+
queries: [...depQueries.values()],
|
|
808
|
+
pageTree: depTree,
|
|
809
|
+
pages: [...depPages].sort(),
|
|
810
|
+
}),
|
|
811
|
+
overlayIssues: () => [...renderIssues, ...pageIssues],
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
function normaliseUrl(url) {
|
|
815
|
+
const cut = url.split(/[?#]/, 1)[0] ?? url;
|
|
816
|
+
return cut.length > 1 && cut.endsWith("/") ? cut.slice(0, -1) : cut;
|
|
817
|
+
}
|
|
818
|
+
function compareIds(a, b) {
|
|
819
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
820
|
+
}
|
|
821
|
+
/** Sort order of a query. Pages without the sort key sort last; ties break by id (deterministic). */
|
|
822
|
+
function pageOrder(order, collator) {
|
|
823
|
+
const nullsLast = (a, b, cmp) => {
|
|
824
|
+
const an = a === null || a === undefined;
|
|
825
|
+
const bn = b === null || b === undefined;
|
|
826
|
+
if (an || bn)
|
|
827
|
+
return an === bn ? 0 : an ? 1 : -1;
|
|
828
|
+
return cmp(a, b);
|
|
829
|
+
};
|
|
830
|
+
const cmpValue = (x, y) => {
|
|
831
|
+
if (typeof x === "number" && typeof y === "number")
|
|
832
|
+
return x - y;
|
|
833
|
+
return collator.compare(String(x), String(y));
|
|
834
|
+
};
|
|
835
|
+
switch (order) {
|
|
836
|
+
case "published_desc":
|
|
837
|
+
return (a, b) => nullsLast(a.firstPublishedAt, b.firstPublishedAt, (x, y) => compareIds(y, x)) ||
|
|
838
|
+
compareIds(b.updatedAt, a.updatedAt) ||
|
|
839
|
+
compareIds(a.id, b.id);
|
|
840
|
+
case "published_asc":
|
|
841
|
+
return (a, b) => nullsLast(a.firstPublishedAt, b.firstPublishedAt, compareIds) ||
|
|
842
|
+
compareIds(a.updatedAt, b.updatedAt) ||
|
|
843
|
+
compareIds(a.id, b.id);
|
|
844
|
+
case "title_asc":
|
|
845
|
+
return (a, b) => collator.compare(a.title, b.title) || compareIds(a.id, b.id);
|
|
846
|
+
default: {
|
|
847
|
+
const desc = order.startsWith("prop_desc:");
|
|
848
|
+
const prop = order.slice(order.indexOf(":") + 1);
|
|
849
|
+
return (a, b) => nullsLast(a.properties[prop], b.properties[prop], (x, y) => desc ? cmpValue(y, x) : cmpValue(x, y)) || compareIds(a.id, b.id);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Every embedded module a set of shared lists references (`module` fields), for batch loading. */
|
|
854
|
+
export function collectListModuleRefs(data) {
|
|
855
|
+
const out = [];
|
|
856
|
+
for (const list of data.lists.values()) {
|
|
857
|
+
const type = data.listTypes.get(list.typeSlug)?.def;
|
|
858
|
+
if (!type)
|
|
859
|
+
continue;
|
|
860
|
+
const go = (items) => {
|
|
861
|
+
for (const it of items) {
|
|
862
|
+
for (const f of type.nodes[it.node]?.fields ?? []) {
|
|
863
|
+
if (f.type !== "module")
|
|
864
|
+
continue;
|
|
865
|
+
const v = it.values[f.name];
|
|
866
|
+
if (typeof v?.moduleId === "string" && typeof v.contentInstanceId === "string") {
|
|
867
|
+
out.push({ moduleId: v.moduleId, contentInstanceId: v.contentInstanceId });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (it.children)
|
|
871
|
+
go(it.children);
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
go(list.items);
|
|
875
|
+
}
|
|
876
|
+
return out;
|
|
877
|
+
}
|
|
878
|
+
//# sourceMappingURL=resolve.js.map
|