@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,1148 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The list resolver (epic #1107, `docs/lists.md` §6, §7, §10). Turns a
|
|
5
|
+
* module's `list` field — local entries, a shared list, a page query, a
|
|
6
|
+
* system source or a plugin source — into the entry scopes the template
|
|
7
|
+
* engine iterates, for ONE rendering page.
|
|
8
|
+
*
|
|
9
|
+
* Built once per preview / per build from batch-loaded
|
|
10
|
+
* {@link ListRenderData} and cut per page with `forPage`, the same split
|
|
11
|
+
* as `SiteLinkIndex`: everything after loading is pure and synchronous,
|
|
12
|
+
* and the editor preview and the static generator get byte-identical
|
|
13
|
+
* output from the same input.
|
|
14
|
+
*
|
|
15
|
+
* Per page it also records what the render read ({@link ListRenderDeps}),
|
|
16
|
+
* which the generator writes to `render-deps.json` so an incremental
|
|
17
|
+
* build can rebuild exactly the pages a change reaches.
|
|
18
|
+
*
|
|
19
|
+
* Loud, never silently empty (CLAUDE.md §2): an unbound shared field, a
|
|
20
|
+
* deleted list, an unknown type, a plugin source whose plugin is off and
|
|
21
|
+
* a malformed entry all resolve to a failure marker the engine renders
|
|
22
|
+
* raw.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { LinkResolver } from "../link-refs.js";
|
|
26
|
+
import { parseLinkRef } from "../link-refs.js";
|
|
27
|
+
import { type FieldDef, TERM_REF_RE, validateFieldValue } from "./fields.js";
|
|
28
|
+
import type { ListItem } from "./items.js";
|
|
29
|
+
import {
|
|
30
|
+
effectiveMaxDepth,
|
|
31
|
+
LIST_QUERY_MAX_LIMIT,
|
|
32
|
+
type ListTypeDef,
|
|
33
|
+
type NodeDef,
|
|
34
|
+
type PageQuery,
|
|
35
|
+
pageQuerySchema,
|
|
36
|
+
type QueryKnob,
|
|
37
|
+
type SourceDef,
|
|
38
|
+
type SystemSourceName,
|
|
39
|
+
} from "./list-type.js";
|
|
40
|
+
import { parseListRef } from "./refs.js";
|
|
41
|
+
import type {
|
|
42
|
+
ListPageRecord,
|
|
43
|
+
ListRenderData,
|
|
44
|
+
ListRenderDeps,
|
|
45
|
+
LoadedList,
|
|
46
|
+
PluginSourceEntry,
|
|
47
|
+
} from "./render-data.js";
|
|
48
|
+
import {
|
|
49
|
+
bool,
|
|
50
|
+
dateValue,
|
|
51
|
+
EMPTY_LIST,
|
|
52
|
+
EMPTY_TEXT,
|
|
53
|
+
fieldValue,
|
|
54
|
+
linkValue,
|
|
55
|
+
listValue,
|
|
56
|
+
mediaValue,
|
|
57
|
+
positionValue,
|
|
58
|
+
selectFlag,
|
|
59
|
+
text,
|
|
60
|
+
untypedValue,
|
|
61
|
+
type ValueEnv,
|
|
62
|
+
} from "./resolve-values.js";
|
|
63
|
+
import type { ScopeEntry, ScopeValue } from "./scope.js";
|
|
64
|
+
|
|
65
|
+
/** Prefixes of the list failure markers (`docs/lists.md` §8). */
|
|
66
|
+
const LIST_MARKER_PREFIXES = [
|
|
67
|
+
"list-unbound:",
|
|
68
|
+
"list-missing:",
|
|
69
|
+
"list-type-missing:",
|
|
70
|
+
"list-source-unavailable:",
|
|
71
|
+
"list-malformed:",
|
|
72
|
+
"list-context-unavailable:",
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
/** True for a list failure marker — the editor's missing-content surface shows these. */
|
|
76
|
+
export function isListFailureMarker(marker: string): boolean {
|
|
77
|
+
return LIST_MARKER_PREFIXES.some((p) => marker.startsWith(p));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What the resolver needs from a module `list` field (`docs/lists.md` §7). */
|
|
81
|
+
export interface ListFieldSpec {
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly listType: string;
|
|
84
|
+
readonly bind?: "local" | "shared" | "source";
|
|
85
|
+
readonly list?: string;
|
|
86
|
+
readonly source?: SourceDef;
|
|
87
|
+
readonly default?: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Result of resolving one list field for one page. */
|
|
91
|
+
export type ListFieldResolution =
|
|
92
|
+
| { readonly ok: true; readonly entries: readonly ScopeEntry[] }
|
|
93
|
+
| { readonly ok: false; readonly marker: string };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where a local list is stored: the content instance holding the module
|
|
97
|
+
* field's value. Null for module field defaults (site chrome renders
|
|
98
|
+
* from defaults) — then entries cannot be addressed by a plugin overlay
|
|
99
|
+
* and carry `local/<field>/<itemId>` as their item attribute.
|
|
100
|
+
*/
|
|
101
|
+
export interface ListHolder {
|
|
102
|
+
readonly contentInstanceId: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An overlay entry the resolver refused (unknown address, structural or invalid value). */
|
|
106
|
+
export interface ListOverlayIssue {
|
|
107
|
+
readonly variant: string;
|
|
108
|
+
readonly address: string;
|
|
109
|
+
readonly reason: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Per-page options of {@link ListRenderContext.forPage}. */
|
|
113
|
+
export interface ListPageOptions {
|
|
114
|
+
/** The page's link resolver; drives `is_current` URL comparison. */
|
|
115
|
+
readonly linkResolver?: LinkResolver;
|
|
116
|
+
/** Entries of every ACTIVE plugin list source for this page, keyed by source name. */
|
|
117
|
+
readonly pluginSources?: Readonly<Record<string, readonly PluginSourceEntry[]>>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The list view of ONE rendering page, handed to the template engine. */
|
|
121
|
+
export interface ListPageContext {
|
|
122
|
+
/** The rendering page, or null for a render outside any page. */
|
|
123
|
+
readonly pageId: string | null;
|
|
124
|
+
/** Resolve a module list field with its placement value. */
|
|
125
|
+
resolveField(field: ListFieldSpec, value: unknown, holder: ListHolder): ListFieldResolution;
|
|
126
|
+
/** `{{page.*}}`: the rendering page as a record scope; null outside a page. */
|
|
127
|
+
pageScope(): ScopeValue | null;
|
|
128
|
+
/**
|
|
129
|
+
* A section name no module field declares, read as a plugin list
|
|
130
|
+
* source (`{{#language_links}}` written before the field kind existed).
|
|
131
|
+
* Null when no installed plugin declares the name.
|
|
132
|
+
*/
|
|
133
|
+
pluginSource(name: string): ListFieldResolution | null;
|
|
134
|
+
/** What this page's render read so far. */
|
|
135
|
+
deps(): ListRenderDeps;
|
|
136
|
+
/** Overlay values this page's render refused (also the render-wide ones). */
|
|
137
|
+
overlayIssues(): readonly ListOverlayIssue[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Render-wide options of {@link createListRenderContext}. */
|
|
141
|
+
export interface ListRenderContextOptions {
|
|
142
|
+
/** Source names declared by installed-but-inactive plugins → plugin slug. */
|
|
143
|
+
readonly dormantPluginSources?: Readonly<Record<string, string>>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The render-wide list context; cut per page with {@link ListRenderContext.forPage}. */
|
|
147
|
+
export interface ListRenderContext {
|
|
148
|
+
forPage(pageId: string | null, opts?: ListPageOptions): ListPageContext;
|
|
149
|
+
/** Overlay entries refused while preparing the render (shared lists, page props). */
|
|
150
|
+
readonly overlayIssues: readonly ListOverlayIssue[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resolve one list field for a page (thin wrapper, `docs/lists.md` §10). */
|
|
154
|
+
export function resolveListField(
|
|
155
|
+
field: ListFieldSpec,
|
|
156
|
+
value: unknown,
|
|
157
|
+
page: ListPageContext,
|
|
158
|
+
holder: ListHolder = { contentInstanceId: null },
|
|
159
|
+
): ListFieldResolution {
|
|
160
|
+
return page.resolveField(field, value, holder);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Overlays ────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const SEG = "[a-z0-9_-]+";
|
|
166
|
+
const LIST_VALUE_RE = new RegExp(`^list/(${SEG})/item/(${SEG})/(${SEG})$`);
|
|
167
|
+
const INSTANCE_VALUE_RE = new RegExp(`^instance/(${SEG})/(${SEG})/item/(${SEG})/(${SEG})$`);
|
|
168
|
+
const PAGE_PROP_RE = new RegExp(`^page/(${SEG})/props/(${SEG})$`);
|
|
169
|
+
const LIST_HIDDEN_RE = new RegExp(`^list/(${SEG})/item/(${SEG})$`);
|
|
170
|
+
const INSTANCE_HIDDEN_RE = new RegExp(`^instance/(${SEG})/(${SEG})/item/(${SEG})$`);
|
|
171
|
+
|
|
172
|
+
/** One variant's overlay, validated where the render data allows it up front. */
|
|
173
|
+
interface PreparedOverlay {
|
|
174
|
+
/** `<listId>/<itemId>` → field → value. */
|
|
175
|
+
readonly listValues: Map<string, Map<string, unknown>>;
|
|
176
|
+
readonly listHidden: Set<string>;
|
|
177
|
+
/** `<ciId>/<field>/<itemId>` → item field → value (validated when the field renders). */
|
|
178
|
+
readonly instanceValues: Map<string, Map<string, unknown>>;
|
|
179
|
+
readonly instanceHidden: Set<string>;
|
|
180
|
+
/** pageId → property → value. */
|
|
181
|
+
readonly pageProps: Map<string, Map<string, unknown>>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Field types an overlay may not replace: they change structure, not text. */
|
|
185
|
+
const STRUCTURAL_TYPES = new Set(["module", "term"]);
|
|
186
|
+
|
|
187
|
+
function setIn(m: Map<string, Map<string, unknown>>, k: string, f: string, v: unknown): void {
|
|
188
|
+
const inner = m.get(k) ?? new Map<string, unknown>();
|
|
189
|
+
inner.set(f, v);
|
|
190
|
+
m.set(k, inner);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function prepareOverlays(
|
|
194
|
+
data: ListRenderData,
|
|
195
|
+
issues: ListOverlayIssue[],
|
|
196
|
+
): Map<string, PreparedOverlay> {
|
|
197
|
+
const out = new Map<string, PreparedOverlay>();
|
|
198
|
+
if (!data.overlays) return out;
|
|
199
|
+
const itemIndex = new Map<string, { list: LoadedList; item: ListItem }>();
|
|
200
|
+
for (const list of data.lists.values()) {
|
|
201
|
+
const go = (items: readonly ListItem[]): void => {
|
|
202
|
+
for (const it of items) {
|
|
203
|
+
itemIndex.set(`${list.id}/${it.id}`, { list, item: it });
|
|
204
|
+
if (it.children) go(it.children);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
go(list.items);
|
|
208
|
+
}
|
|
209
|
+
for (const [variant, overlay] of data.overlays) {
|
|
210
|
+
const prepared: PreparedOverlay = {
|
|
211
|
+
listValues: new Map(),
|
|
212
|
+
listHidden: new Set(),
|
|
213
|
+
instanceValues: new Map(),
|
|
214
|
+
instanceHidden: new Set(),
|
|
215
|
+
pageProps: new Map(),
|
|
216
|
+
};
|
|
217
|
+
const refuse = (address: string, reason: string): void => {
|
|
218
|
+
issues.push({ variant, address, reason });
|
|
219
|
+
};
|
|
220
|
+
for (const [address, value] of overlay.values) {
|
|
221
|
+
const lm = LIST_VALUE_RE.exec(address);
|
|
222
|
+
if (lm) {
|
|
223
|
+
const [, listId, itemId, fieldName] = lm as unknown as [string, string, string, string];
|
|
224
|
+
const hit = itemIndex.get(`${listId}/${itemId}`);
|
|
225
|
+
if (!hit) {
|
|
226
|
+
refuse(address, "no such list entry");
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const field = fieldOf(data, hit.list.typeSlug, hit.item.node, fieldName);
|
|
230
|
+
const problem = overlayValueProblem(field, value, address);
|
|
231
|
+
if (problem) refuse(address, problem);
|
|
232
|
+
else setIn(prepared.listValues, `${listId}/${itemId}`, fieldName, value);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const im = INSTANCE_VALUE_RE.exec(address);
|
|
236
|
+
if (im) {
|
|
237
|
+
const [, ci, f, itemId, itemField] = im as unknown as [
|
|
238
|
+
string,
|
|
239
|
+
string,
|
|
240
|
+
string,
|
|
241
|
+
string,
|
|
242
|
+
string,
|
|
243
|
+
];
|
|
244
|
+
setIn(prepared.instanceValues, `${ci}/${f}/${itemId}`, itemField, value);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const pm = PAGE_PROP_RE.exec(address);
|
|
248
|
+
if (pm) {
|
|
249
|
+
const [, pageId, prop] = pm as unknown as [string, string, string];
|
|
250
|
+
const page = data.pages.get(pageId);
|
|
251
|
+
const def = page
|
|
252
|
+
? data.pageTypes.get(page.pageTypeSlug)?.def.properties.find((p) => p.name === prop)
|
|
253
|
+
: undefined;
|
|
254
|
+
if (!page) refuse(address, "no such page");
|
|
255
|
+
else {
|
|
256
|
+
const problem = overlayValueProblem(def, value, address);
|
|
257
|
+
if (problem) refuse(address, problem);
|
|
258
|
+
else setIn(prepared.pageProps, pageId, prop, value);
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
refuse(address, "not a rendered value address");
|
|
263
|
+
}
|
|
264
|
+
for (const address of overlay.hidden) {
|
|
265
|
+
const lh = LIST_HIDDEN_RE.exec(address);
|
|
266
|
+
if (lh) {
|
|
267
|
+
const key = `${lh[1]}/${lh[2]}`;
|
|
268
|
+
if (itemIndex.has(key)) prepared.listHidden.add(key);
|
|
269
|
+
else refuse(address, "no such list entry");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const ih = INSTANCE_HIDDEN_RE.exec(address);
|
|
273
|
+
if (ih) {
|
|
274
|
+
prepared.instanceHidden.add(`${ih[1]}/${ih[2]}/${ih[3]}`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
refuse(address, "not an entry address");
|
|
278
|
+
}
|
|
279
|
+
out.set(variant, prepared);
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function fieldOf(
|
|
285
|
+
data: ListRenderData,
|
|
286
|
+
typeSlug: string,
|
|
287
|
+
node: string,
|
|
288
|
+
fieldName: string,
|
|
289
|
+
): FieldDef | undefined {
|
|
290
|
+
return data.listTypes.get(typeSlug)?.def.nodes[node]?.fields.find((f) => f.name === fieldName);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Why an overlay value cannot replace a stored one; null when it can. */
|
|
294
|
+
function overlayValueProblem(
|
|
295
|
+
field: FieldDef | undefined,
|
|
296
|
+
value: unknown,
|
|
297
|
+
address: string,
|
|
298
|
+
): string | null {
|
|
299
|
+
if (!field) return "the entry's node declares no such field";
|
|
300
|
+
if (STRUCTURAL_TYPES.has(field.type)) {
|
|
301
|
+
return `a ${field.type} field changes structure, an overlay may only replace values`;
|
|
302
|
+
}
|
|
303
|
+
const bad = validateFieldValue({ ...field, required: false }, value, address);
|
|
304
|
+
return bad.length > 0 ? (bad[0]?.message ?? "invalid value") : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Context ─────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/** Build the render-wide list context from loaded data. */
|
|
310
|
+
export function createListRenderContext(
|
|
311
|
+
data: ListRenderData,
|
|
312
|
+
opts: ListRenderContextOptions = {},
|
|
313
|
+
): ListRenderContext {
|
|
314
|
+
const overlayIssues: ListOverlayIssue[] = [];
|
|
315
|
+
const overlays = prepareOverlays(data, overlayIssues);
|
|
316
|
+
const dateFormat = makeDateFormatter(data.siteLanguage);
|
|
317
|
+
const collator = new Intl.Collator(data.siteLanguage);
|
|
318
|
+
const pageList = [...data.pages.values()];
|
|
319
|
+
const listItemIndex = new Map<string, Map<string, { item: ListItem; depth: number }>>();
|
|
320
|
+
const itemsOf = (list: LoadedList): Map<string, { item: ListItem; depth: number }> => {
|
|
321
|
+
let idx = listItemIndex.get(list.id);
|
|
322
|
+
if (!idx) {
|
|
323
|
+
idx = new Map();
|
|
324
|
+
const go = (items: readonly ListItem[], depth: number): void => {
|
|
325
|
+
for (const it of items) {
|
|
326
|
+
idx?.set(it.id, { item: it, depth });
|
|
327
|
+
if (it.children) go(it.children, depth + 1);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
go(list.items, 1);
|
|
331
|
+
listItemIndex.set(list.id, idx);
|
|
332
|
+
}
|
|
333
|
+
return idx;
|
|
334
|
+
};
|
|
335
|
+
const shared: SharedState = {
|
|
336
|
+
data,
|
|
337
|
+
overlays,
|
|
338
|
+
dateFormat,
|
|
339
|
+
collator,
|
|
340
|
+
pageList,
|
|
341
|
+
itemsOf,
|
|
342
|
+
dormant: opts.dormantPluginSources ?? {},
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
overlayIssues,
|
|
346
|
+
forPage: (pageId, pageOpts = {}) => createPageContext(shared, pageId, pageOpts, overlayIssues),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface SharedState {
|
|
351
|
+
readonly data: ListRenderData;
|
|
352
|
+
readonly overlays: ReadonlyMap<string, PreparedOverlay>;
|
|
353
|
+
readonly dateFormat: (iso: string) => string;
|
|
354
|
+
readonly collator: Intl.Collator;
|
|
355
|
+
readonly pageList: readonly ListPageRecord[];
|
|
356
|
+
readonly itemsOf: (list: LoadedList) => Map<string, { item: ListItem; depth: number }>;
|
|
357
|
+
readonly dormant: Readonly<Record<string, string>>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function makeDateFormatter(language: string): (iso: string) => string {
|
|
361
|
+
let fmt: Intl.DateTimeFormat;
|
|
362
|
+
try {
|
|
363
|
+
fmt = new Intl.DateTimeFormat(language, { dateStyle: "long", timeZone: "UTC" });
|
|
364
|
+
} catch {
|
|
365
|
+
// An invalid BCP-47 tag in site_settings is caught on write; the
|
|
366
|
+
// render must not crash on stored data, so format in the root locale.
|
|
367
|
+
fmt = new Intl.DateTimeFormat("und", { dateStyle: "long", timeZone: "UTC" });
|
|
368
|
+
}
|
|
369
|
+
const cache = new Map<string, string>();
|
|
370
|
+
return (iso) => {
|
|
371
|
+
let out = cache.get(iso);
|
|
372
|
+
if (out === undefined) {
|
|
373
|
+
const t = Date.parse(iso);
|
|
374
|
+
out = Number.isNaN(t) ? iso : fmt.format(new Date(t));
|
|
375
|
+
cache.set(iso, out);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Where a stored tree lives — decides item attributes, overlays and markers. */
|
|
382
|
+
interface TreeCtx {
|
|
383
|
+
readonly type: ListTypeDef;
|
|
384
|
+
/** The module field (for markers) or the vocabulary slug for term entries. */
|
|
385
|
+
readonly field: string;
|
|
386
|
+
/** Prefix of `data-caelo-list-item` values; null = entries carry none. */
|
|
387
|
+
readonly attrPrefix: string;
|
|
388
|
+
/** Overlay values for an entry id. */
|
|
389
|
+
readonly overlayValues: (itemId: string) => ReadonlyMap<string, unknown> | undefined;
|
|
390
|
+
readonly hidden: (itemId: string) => boolean;
|
|
391
|
+
/** Validate an overlay value lazily (instance overlays); returns the value to use. */
|
|
392
|
+
readonly checkOverlay?: (item: ListItem, field: FieldDef, value: unknown) => boolean;
|
|
393
|
+
/** Term entries alias `link` to `landing_page`. */
|
|
394
|
+
readonly termLink: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface Position {
|
|
398
|
+
readonly index: number;
|
|
399
|
+
readonly count: number;
|
|
400
|
+
readonly depth: number;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function createPageContext(
|
|
404
|
+
s: SharedState,
|
|
405
|
+
pageId: string | null,
|
|
406
|
+
opts: ListPageOptions,
|
|
407
|
+
renderIssues: readonly ListOverlayIssue[],
|
|
408
|
+
): ListPageContext {
|
|
409
|
+
const { data } = s;
|
|
410
|
+
const current = pageId === null ? null : (data.pages.get(pageId) ?? null);
|
|
411
|
+
const variant = pageId === null ? null : (data.pageVariants?.get(pageId)?.variant ?? null);
|
|
412
|
+
const overlay = variant === null ? undefined : s.overlays.get(variant);
|
|
413
|
+
const resolver = opts.linkResolver;
|
|
414
|
+
const pageIssues: ListOverlayIssue[] = [];
|
|
415
|
+
const depLists = new Set<string>();
|
|
416
|
+
const depTypes = new Set<string>();
|
|
417
|
+
const depQueries = new Map<
|
|
418
|
+
string,
|
|
419
|
+
{ pageType: string | null; terms: readonly string[]; parent: string | null }
|
|
420
|
+
>();
|
|
421
|
+
let depTree = false;
|
|
422
|
+
const depPages = new Set<string>();
|
|
423
|
+
/** Entries of a page-shaped system source: their pages become deps. */
|
|
424
|
+
const readPages = (pages: ListPageRecord[]): ListPageRecord[] => {
|
|
425
|
+
for (const pg of pages) depPages.add(pg.id);
|
|
426
|
+
return pages;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const urlOf = (href: string): string | null => {
|
|
430
|
+
const ref = parseLinkRef(href);
|
|
431
|
+
if (ref === null) return href.startsWith("/") ? normaliseUrl(href) : null;
|
|
432
|
+
if (!resolver) return null;
|
|
433
|
+
const r = resolver.resolve(ref);
|
|
434
|
+
return r.status === "resolved" || r.status === "unpublished" ? normaliseUrl(r.url) : null;
|
|
435
|
+
};
|
|
436
|
+
let currentUrlMemo: string | null | undefined;
|
|
437
|
+
const currentUrl = (): string | null => {
|
|
438
|
+
if (currentUrlMemo === undefined) {
|
|
439
|
+
currentUrlMemo = pageId === null ? null : urlOf(`caelo:page/${pageId}`);
|
|
440
|
+
}
|
|
441
|
+
return currentUrlMemo;
|
|
442
|
+
};
|
|
443
|
+
/** True when `href` points at the rendering page. */
|
|
444
|
+
const isCurrentHref = (href: string): boolean => {
|
|
445
|
+
if (href.length === 0 || pageId === null) return false;
|
|
446
|
+
const ref = parseLinkRef(href);
|
|
447
|
+
if (ref?.kind === "page" && ref.pageId === pageId) return true;
|
|
448
|
+
if (ref?.kind === "media") return false;
|
|
449
|
+
const here = currentUrl();
|
|
450
|
+
return here !== null && urlOf(href) === here;
|
|
451
|
+
};
|
|
452
|
+
/** True when the rendering page sits below page `ancestorId` in the page tree. */
|
|
453
|
+
const isPageAncestor = (ancestorId: string): boolean => {
|
|
454
|
+
let p = current?.parentId ?? null;
|
|
455
|
+
const seen = new Set<string>();
|
|
456
|
+
while (p !== null && !seen.has(p)) {
|
|
457
|
+
if (p === ancestorId) return true;
|
|
458
|
+
seen.add(p);
|
|
459
|
+
p = data.pages.get(p)?.parentId ?? null;
|
|
460
|
+
}
|
|
461
|
+
return false;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const valueEnv: ValueEnv = {
|
|
465
|
+
data,
|
|
466
|
+
formatDate: s.dateFormat,
|
|
467
|
+
termEntries: (refs, field) => termEntries(refs, field),
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// ── Stored trees ──
|
|
471
|
+
|
|
472
|
+
/** Structural check of a stored tree before any entry renders. */
|
|
473
|
+
const checkTree = (
|
|
474
|
+
type: ListTypeDef,
|
|
475
|
+
items: unknown,
|
|
476
|
+
field: string,
|
|
477
|
+
): { ok: true; items: readonly ListItem[] } | { ok: false; marker: string } => {
|
|
478
|
+
if (!Array.isArray(items)) return { ok: false, marker: `list-malformed:${field}[items]` };
|
|
479
|
+
const max = effectiveMaxDepth(type);
|
|
480
|
+
const walk = (level: readonly unknown[], depth: number, path: string): string | null => {
|
|
481
|
+
for (let i = 0; i < level.length; i += 1) {
|
|
482
|
+
const it = level[i] as Partial<ListItem> | null;
|
|
483
|
+
const at = `${path}[${i}]`;
|
|
484
|
+
if (
|
|
485
|
+
typeof it !== "object" ||
|
|
486
|
+
it === null ||
|
|
487
|
+
typeof it.id !== "string" ||
|
|
488
|
+
typeof it.node !== "string" ||
|
|
489
|
+
typeof it.values !== "object" ||
|
|
490
|
+
it.values === null
|
|
491
|
+
) {
|
|
492
|
+
return at;
|
|
493
|
+
}
|
|
494
|
+
if (!type.nodes[it.node]) return `${at}.node`;
|
|
495
|
+
if (depth > max) return at;
|
|
496
|
+
if (it.children !== undefined) {
|
|
497
|
+
if (!Array.isArray(it.children)) return `${at}.children`;
|
|
498
|
+
const bad = walk(it.children, depth + 1, `${at}.children`);
|
|
499
|
+
if (bad) return bad;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
};
|
|
504
|
+
const bad = walk(items, 1, "items");
|
|
505
|
+
return bad === null
|
|
506
|
+
? { ok: true, items: items as ListItem[] }
|
|
507
|
+
: { ok: false, marker: `list-malformed:${field}[${bad}]` };
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const entriesOf = (items: readonly ListItem[], tree: TreeCtx, depth: number): ScopeEntry[] => {
|
|
511
|
+
const visible = items.filter((it) => !tree.hidden(it.id));
|
|
512
|
+
return visible.map((it, index) =>
|
|
513
|
+
storedEntry(it, tree, { index, count: visible.length, depth }),
|
|
514
|
+
);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const storedEntry = (item: ListItem, tree: TreeCtx, pos: Position): ScopeEntry => {
|
|
518
|
+
const node = tree.type.nodes[item.node] as NodeDef;
|
|
519
|
+
const overlayValues = tree.overlayValues(item.id);
|
|
520
|
+
const memo = new Map<string, ScopeValue | undefined>();
|
|
521
|
+
const storedValue = (f: FieldDef): unknown => {
|
|
522
|
+
if (overlayValues?.has(f.name)) {
|
|
523
|
+
const v = overlayValues.get(f.name);
|
|
524
|
+
if (!tree.checkOverlay || tree.checkOverlay(item, f, v)) return v;
|
|
525
|
+
}
|
|
526
|
+
return item.values[f.name];
|
|
527
|
+
};
|
|
528
|
+
const children = (): ScopeEntry[] =>
|
|
529
|
+
item.children ? entriesOf(item.children, tree, pos.depth + 1) : [];
|
|
530
|
+
let isCurrentMemo: boolean | undefined;
|
|
531
|
+
const isCurrent = (): boolean => {
|
|
532
|
+
if (isCurrentMemo === undefined) {
|
|
533
|
+
const f = node.current_from ? node.fields.find((x) => x.name === node.current_from) : null;
|
|
534
|
+
const v = f ? storedValue(f) : undefined;
|
|
535
|
+
const href =
|
|
536
|
+
typeof (v as { href?: unknown })?.href === "string" ? (v as { href: string }).href : "";
|
|
537
|
+
isCurrentMemo = isCurrentHref(href);
|
|
538
|
+
}
|
|
539
|
+
return isCurrentMemo;
|
|
540
|
+
};
|
|
541
|
+
const isAncestor = (): boolean => {
|
|
542
|
+
const f = node.current_from ? node.fields.find((x) => x.name === node.current_from) : null;
|
|
543
|
+
const v = f ? storedValue(f) : undefined;
|
|
544
|
+
const href =
|
|
545
|
+
typeof (v as { href?: unknown })?.href === "string" ? (v as { href: string }).href : "";
|
|
546
|
+
const ref = href ? parseLinkRef(href) : null;
|
|
547
|
+
if (ref?.kind === "page" && ref.pageId !== pageId && isPageAncestor(ref.pageId)) return true;
|
|
548
|
+
const anyBelow = (entries: readonly ScopeEntry[]): boolean =>
|
|
549
|
+
entries.some((e) => {
|
|
550
|
+
const c = e.get("is_current");
|
|
551
|
+
const a = e.get("is_ancestor");
|
|
552
|
+
return (c?.kind === "bool" && c.value) || (a?.kind === "bool" && a.value);
|
|
553
|
+
});
|
|
554
|
+
return anyBelow(children());
|
|
555
|
+
};
|
|
556
|
+
const items = (): ScopeValue =>
|
|
557
|
+
node.source ? sourceItems(node.source, item, node, tree.field) : EMPTY_LIST;
|
|
558
|
+
const compute = (key: string): ScopeValue | undefined => {
|
|
559
|
+
const field = node.fields.find((f) => f.name === key);
|
|
560
|
+
if (field) return fieldValue(field, storedValue(field), valueEnv);
|
|
561
|
+
if (key === "id") return text(item.id);
|
|
562
|
+
if (key === "node") return text(item.node);
|
|
563
|
+
const p = positionValue(key, pos);
|
|
564
|
+
if (p) return p;
|
|
565
|
+
if (key === "is_current") return bool(node.current_from ? isCurrent() : false);
|
|
566
|
+
// Descendant-based, so a node without its own link (a column) is
|
|
567
|
+
// still an ancestor of the current entry below it.
|
|
568
|
+
if (key === "is_ancestor") return bool(isAncestor());
|
|
569
|
+
const kids = node.children?.name;
|
|
570
|
+
if (kids !== undefined && key === kids) return listValue(children());
|
|
571
|
+
// `items` is reserved: a node without a source simply has none, so
|
|
572
|
+
// a block shared by mixed node kinds needs no `is_<node>` guard.
|
|
573
|
+
if (key === "items") return items();
|
|
574
|
+
if (key.startsWith("has_") || key.startsWith("count_")) {
|
|
575
|
+
const target = key.startsWith("has_") ? key.slice(4) : key.slice(6);
|
|
576
|
+
let n: number | null = null;
|
|
577
|
+
if (kids !== undefined && target === kids) n = children().length;
|
|
578
|
+
else if (target === "items") {
|
|
579
|
+
const v = items();
|
|
580
|
+
n = v.kind === "list" ? v.entries.length : 0;
|
|
581
|
+
}
|
|
582
|
+
if (n === null) return undefined;
|
|
583
|
+
return key.startsWith("has_") ? bool(n > 0) : text(String(n));
|
|
584
|
+
}
|
|
585
|
+
if (key.startsWith("is_") && tree.type.nodes[key.slice(3)]) {
|
|
586
|
+
return bool(key.slice(3) === item.node);
|
|
587
|
+
}
|
|
588
|
+
const flag = selectFlag(node.fields, item.values, key);
|
|
589
|
+
if (flag) return flag;
|
|
590
|
+
if (tree.termLink && key === "link") {
|
|
591
|
+
const lp = node.fields.find((f) => f.name === "landing_page" && f.type === "link");
|
|
592
|
+
return lp ? fieldValue(lp, storedValue(lp), valueEnv) : linkValue({ href: "" }, null);
|
|
593
|
+
}
|
|
594
|
+
return undefined;
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
id: item.id,
|
|
598
|
+
itemAttr: `${tree.attrPrefix}${item.id}`,
|
|
599
|
+
childrenKey: node.children?.name ?? null,
|
|
600
|
+
depth: pos.depth,
|
|
601
|
+
get(key) {
|
|
602
|
+
if (!memo.has(key)) memo.set(key, compute(key));
|
|
603
|
+
return memo.get(key);
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const sharedTree = (
|
|
609
|
+
list: LoadedList,
|
|
610
|
+
type: ListTypeDef,
|
|
611
|
+
field: string,
|
|
612
|
+
termLink: boolean,
|
|
613
|
+
): TreeCtx => ({
|
|
614
|
+
type,
|
|
615
|
+
field,
|
|
616
|
+
attrPrefix: `${list.id}/`,
|
|
617
|
+
overlayValues: (itemId) => overlay?.listValues.get(`${list.id}/${itemId}`),
|
|
618
|
+
hidden: (itemId) => overlay?.listHidden.has(`${list.id}/${itemId}`) ?? false,
|
|
619
|
+
termLink,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ── Terms ──
|
|
623
|
+
|
|
624
|
+
const termEntries = (refs: readonly string[], field: string): ScopeValue => {
|
|
625
|
+
const out: ScopeEntry[] = [];
|
|
626
|
+
const found: { list: LoadedList; type: ListTypeDef; item: ListItem; depth: number }[] = [];
|
|
627
|
+
for (const ref of refs) {
|
|
628
|
+
const m = TERM_REF_RE.exec(ref);
|
|
629
|
+
if (!m) return { kind: "failed", marker: `list-malformed:${field}[${ref}]` };
|
|
630
|
+
const [, listId, itemId] = m as unknown as [string, string, string];
|
|
631
|
+
depLists.add(listId);
|
|
632
|
+
const list = data.lists.get(listId);
|
|
633
|
+
if (!list) return { kind: "failed", marker: `list-missing:${listId}` };
|
|
634
|
+
const type = data.listTypes.get(list.typeSlug);
|
|
635
|
+
if (!type) return { kind: "failed", marker: `list-type-missing:${list.typeSlug}` };
|
|
636
|
+
depTypes.add(list.typeSlug);
|
|
637
|
+
const hit = s.itemsOf(list).get(itemId);
|
|
638
|
+
if (!hit) return { kind: "failed", marker: `list-missing:${listId}/item/${itemId}` };
|
|
639
|
+
if (overlay?.listHidden.has(`${listId}/${itemId}`)) continue;
|
|
640
|
+
found.push({ list, type: type.def, item: hit.item, depth: hit.depth });
|
|
641
|
+
}
|
|
642
|
+
found.forEach((t, index) => {
|
|
643
|
+
out.push(
|
|
644
|
+
storedEntry(t.item, sharedTree(t.list, t.type, field, true), {
|
|
645
|
+
index,
|
|
646
|
+
count: found.length,
|
|
647
|
+
depth: t.depth,
|
|
648
|
+
}),
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
return listValue(out);
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// ── Pages ──
|
|
655
|
+
|
|
656
|
+
const propsValue = (p: ListPageRecord): ScopeValue => {
|
|
657
|
+
const defs = data.pageTypes.get(p.pageTypeSlug)?.def.properties ?? [];
|
|
658
|
+
const propOverlay = overlay?.pageProps.get(p.id);
|
|
659
|
+
const memo = new Map<string, ScopeValue | undefined>();
|
|
660
|
+
return {
|
|
661
|
+
kind: "record",
|
|
662
|
+
text: null,
|
|
663
|
+
present: true,
|
|
664
|
+
get(key) {
|
|
665
|
+
if (!memo.has(key)) {
|
|
666
|
+
const def = defs.find((d) => d.name === key);
|
|
667
|
+
const raw = propOverlay?.has(key) ? propOverlay.get(key) : p.properties[key];
|
|
668
|
+
memo.set(key, def ? fieldValue(def, raw, valueEnv) : undefined);
|
|
669
|
+
}
|
|
670
|
+
return memo.get(key);
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
/** `terms.<vocabulary slug>` of a page. */
|
|
676
|
+
const pageTermsValue = (p: ListPageRecord): ScopeValue => ({
|
|
677
|
+
kind: "record",
|
|
678
|
+
text: null,
|
|
679
|
+
present: p.terms.length > 0,
|
|
680
|
+
get(slug) {
|
|
681
|
+
const lists = [...data.lists.values()].filter((l) => l.slug === slug);
|
|
682
|
+
if (lists.length === 0) return undefined;
|
|
683
|
+
const ids = new Set(lists.map((l) => l.id));
|
|
684
|
+
return termEntries(
|
|
685
|
+
p.terms.filter((t) => ids.has(TERM_REF_RE.exec(t)?.[1] ?? "")),
|
|
686
|
+
`terms.${slug}`,
|
|
687
|
+
);
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const pageValue = (p: ListPageRecord): ((key: string) => ScopeValue | undefined) => {
|
|
692
|
+
const memo = new Map<string, ScopeValue | undefined>();
|
|
693
|
+
const compute = (key: string): ScopeValue | undefined => {
|
|
694
|
+
switch (key) {
|
|
695
|
+
case "id":
|
|
696
|
+
return text(p.id);
|
|
697
|
+
case "title":
|
|
698
|
+
return text(p.title);
|
|
699
|
+
case "link":
|
|
700
|
+
return linkValue({ href: `caelo:page/${p.id}` }, data);
|
|
701
|
+
case "published_at":
|
|
702
|
+
return p.firstPublishedAt === null
|
|
703
|
+
? dateValue("", valueEnv)
|
|
704
|
+
: dateValue(p.firstPublishedAt, valueEnv);
|
|
705
|
+
case "updated_at":
|
|
706
|
+
return dateValue(p.updatedAt, valueEnv);
|
|
707
|
+
case "summary":
|
|
708
|
+
return text(p.summary ?? "");
|
|
709
|
+
case "image":
|
|
710
|
+
return p.image === null ? mediaValue("", data) : mediaValue(p.image, data);
|
|
711
|
+
case "page_type":
|
|
712
|
+
return text(p.pageTypeSlug);
|
|
713
|
+
case "props":
|
|
714
|
+
return propsValue(p);
|
|
715
|
+
case "terms":
|
|
716
|
+
return pageTermsValue(p);
|
|
717
|
+
case "is_current":
|
|
718
|
+
return bool(p.id === pageId);
|
|
719
|
+
case "is_ancestor":
|
|
720
|
+
return bool(p.id !== pageId && isPageAncestor(p.id));
|
|
721
|
+
default:
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
return (key) => {
|
|
726
|
+
if (!memo.has(key)) memo.set(key, compute(key));
|
|
727
|
+
return memo.get(key);
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const pageEntries = (pages: readonly ListPageRecord[]): ScopeEntry[] =>
|
|
732
|
+
pages.map((p, index) => {
|
|
733
|
+
const get = pageValue(p);
|
|
734
|
+
const pos = { index, count: pages.length, depth: 1 };
|
|
735
|
+
return {
|
|
736
|
+
id: p.id,
|
|
737
|
+
itemAttr: null,
|
|
738
|
+
childrenKey: null,
|
|
739
|
+
depth: 1,
|
|
740
|
+
get: (key) => get(key) ?? positionValue(key, pos),
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const sameVariant = (p: ListPageRecord): boolean =>
|
|
745
|
+
variant === null || data.pageVariants?.get(p.id)?.variant === variant;
|
|
746
|
+
|
|
747
|
+
const runQuery = (q: PageQuery): ScopeValue => {
|
|
748
|
+
const parent = q.parent
|
|
749
|
+
? ((parseLinkRef(q.parent) as { pageId: string } | null)?.pageId ?? null)
|
|
750
|
+
: null;
|
|
751
|
+
const terms = q.terms ?? [];
|
|
752
|
+
const key = JSON.stringify([q.page_type ?? null, [...terms].sort(), parent]);
|
|
753
|
+
if (!depQueries.has(key)) {
|
|
754
|
+
depQueries.set(key, { pageType: q.page_type ?? null, terms: [...terms].sort(), parent });
|
|
755
|
+
}
|
|
756
|
+
const excludeCurrent = q.exclude_current ?? true;
|
|
757
|
+
const mode = q.terms_mode ?? "any";
|
|
758
|
+
const hits = s.pageList.filter(
|
|
759
|
+
(p) =>
|
|
760
|
+
p.inBuild &&
|
|
761
|
+
sameVariant(p) &&
|
|
762
|
+
(q.page_type === undefined || p.pageTypeSlug === q.page_type) &&
|
|
763
|
+
(parent === null || p.parentId === parent) &&
|
|
764
|
+
(!excludeCurrent || p.id !== pageId) &&
|
|
765
|
+
(terms.length === 0 ||
|
|
766
|
+
(mode === "all"
|
|
767
|
+
? terms.every((t) => p.terms.includes(t))
|
|
768
|
+
: terms.some((t) => p.terms.includes(t)))),
|
|
769
|
+
);
|
|
770
|
+
hits.sort(pageOrder(q.order, s.collator));
|
|
771
|
+
return listValue(pageEntries(hits.slice(0, q.limit)));
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const byTitle = (a: ListPageRecord, b: ListPageRecord): number =>
|
|
775
|
+
s.collator.compare(a.title, b.title) || compareIds(a.id, b.id);
|
|
776
|
+
|
|
777
|
+
const systemSource = (
|
|
778
|
+
name: SystemSourceName,
|
|
779
|
+
terms: readonly string[],
|
|
780
|
+
vocabularyIds: readonly string[] | null,
|
|
781
|
+
field: string,
|
|
782
|
+
): ScopeValue => {
|
|
783
|
+
switch (name) {
|
|
784
|
+
case "breadcrumbs": {
|
|
785
|
+
depTree = true;
|
|
786
|
+
if (!current) return EMPTY_LIST;
|
|
787
|
+
const chain: ListPageRecord[] = [];
|
|
788
|
+
const seen = new Set<string>();
|
|
789
|
+
let p: ListPageRecord | undefined = current;
|
|
790
|
+
while (p && !seen.has(p.id)) {
|
|
791
|
+
chain.unshift(p);
|
|
792
|
+
seen.add(p.id);
|
|
793
|
+
p = p.parentId === null ? undefined : data.pages.get(p.parentId);
|
|
794
|
+
}
|
|
795
|
+
return listValue(pageEntries(readPages(chain)));
|
|
796
|
+
}
|
|
797
|
+
case "children": {
|
|
798
|
+
depTree = true;
|
|
799
|
+
if (!current) return EMPTY_LIST;
|
|
800
|
+
return listValue(
|
|
801
|
+
pageEntries(
|
|
802
|
+
readPages(
|
|
803
|
+
s.pageList.filter((p) => p.inBuild && p.parentId === current.id).sort(byTitle),
|
|
804
|
+
),
|
|
805
|
+
),
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
case "siblings": {
|
|
809
|
+
depTree = true;
|
|
810
|
+
if (!current) return EMPTY_LIST;
|
|
811
|
+
return listValue(
|
|
812
|
+
pageEntries(
|
|
813
|
+
readPages(
|
|
814
|
+
s.pageList.filter((p) => p.inBuild && p.parentId === current.parentId).sort(byTitle),
|
|
815
|
+
),
|
|
816
|
+
),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
case "page_terms": {
|
|
820
|
+
if (!current) return EMPTY_LIST;
|
|
821
|
+
const ids = vocabularyIds === null ? null : new Set(vocabularyIds);
|
|
822
|
+
for (const id of vocabularyIds ?? []) depLists.add(id);
|
|
823
|
+
return termEntries(
|
|
824
|
+
current.terms.filter((t) => ids === null || ids.has(TERM_REF_RE.exec(t)?.[1] ?? "")),
|
|
825
|
+
field,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
case "term_pages": {
|
|
829
|
+
depTree = true;
|
|
830
|
+
if (terms.length === 0) return EMPTY_LIST;
|
|
831
|
+
const sorted = [...terms].sort();
|
|
832
|
+
const key = JSON.stringify([null, sorted, null]);
|
|
833
|
+
if (!depQueries.has(key))
|
|
834
|
+
depQueries.set(key, { pageType: null, terms: sorted, parent: null });
|
|
835
|
+
const hits = s.pageList
|
|
836
|
+
.filter(
|
|
837
|
+
(p) =>
|
|
838
|
+
p.inBuild &&
|
|
839
|
+
sameVariant(p) &&
|
|
840
|
+
p.id !== pageId &&
|
|
841
|
+
terms.some((t) => p.terms.includes(t)),
|
|
842
|
+
)
|
|
843
|
+
.sort(byTitle);
|
|
844
|
+
return listValue(pageEntries(hits.slice(0, LIST_QUERY_MAX_LIMIT)));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const pluginEntries = (name: string): ScopeValue => {
|
|
850
|
+
const entries = opts.pluginSources?.[name];
|
|
851
|
+
if (entries === undefined) {
|
|
852
|
+
const owner = s.dormant[name] ?? "unknown";
|
|
853
|
+
return { kind: "failed", marker: `list-source-unavailable:${name} plugin=${owner}` };
|
|
854
|
+
}
|
|
855
|
+
return listValue(
|
|
856
|
+
entries.map((e, index) => {
|
|
857
|
+
const pos = { index, count: entries.length, depth: 1 };
|
|
858
|
+
return {
|
|
859
|
+
id: e.id,
|
|
860
|
+
itemAttr: null,
|
|
861
|
+
childrenKey: null,
|
|
862
|
+
depth: 1,
|
|
863
|
+
get: (key: string) => {
|
|
864
|
+
if (key === "id") return text(e.id);
|
|
865
|
+
if (Object.hasOwn(e.values, key)) return untypedValue(e.values[key]) ?? EMPTY_TEXT;
|
|
866
|
+
return positionValue(key, pos);
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
}),
|
|
870
|
+
);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
/** Merge a source's query defaults with the knobs an entry / placement may set. */
|
|
874
|
+
const mergedQuery = (
|
|
875
|
+
source: Extract<SourceDef, { kind: "query" }>,
|
|
876
|
+
overrides: unknown,
|
|
877
|
+
field: string,
|
|
878
|
+
path: string,
|
|
879
|
+
): PageQuery | { marker: string } => {
|
|
880
|
+
const merged: Record<string, unknown> = { ...source.defaults };
|
|
881
|
+
if (overrides !== undefined && overrides !== null) {
|
|
882
|
+
if (typeof overrides !== "object" || Array.isArray(overrides)) {
|
|
883
|
+
return { marker: `list-malformed:${field}[${path}]` };
|
|
884
|
+
}
|
|
885
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
886
|
+
if (!(source.editable as readonly string[]).includes(k as QueryKnob)) {
|
|
887
|
+
return { marker: `list-malformed:${field}[${path}.${k}]` };
|
|
888
|
+
}
|
|
889
|
+
merged[k] = v;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const parsed = pageQuerySchema.safeParse(merged);
|
|
893
|
+
return parsed.success ? parsed.data : { marker: `list-malformed:${field}[${path}]` };
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
/** Generated entries of a source node (`{{#items}}`). */
|
|
897
|
+
const sourceItems = (
|
|
898
|
+
source: SourceDef,
|
|
899
|
+
item: ListItem,
|
|
900
|
+
node: NodeDef,
|
|
901
|
+
field: string,
|
|
902
|
+
): ScopeValue => {
|
|
903
|
+
switch (source.kind) {
|
|
904
|
+
case "query": {
|
|
905
|
+
const q = mergedQuery(source, item.query, field, `${item.id}.query`);
|
|
906
|
+
return "marker" in q ? { kind: "failed", marker: q.marker } : runQuery(q);
|
|
907
|
+
}
|
|
908
|
+
case "system": {
|
|
909
|
+
const termFields = node.fields.filter((f) => f.type === "term");
|
|
910
|
+
const terms = termFields.flatMap((f) => {
|
|
911
|
+
const v = item.values[f.name];
|
|
912
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
913
|
+
});
|
|
914
|
+
const vocabSlugs = termFields.map((f) => (f.type === "term" ? f.list : ""));
|
|
915
|
+
const vocabIds =
|
|
916
|
+
vocabSlugs.length === 0
|
|
917
|
+
? null
|
|
918
|
+
: [...data.lists.values()].filter((l) => vocabSlugs.includes(l.slug)).map((l) => l.id);
|
|
919
|
+
return systemSource(source.name, terms, vocabIds, field);
|
|
920
|
+
}
|
|
921
|
+
case "plugin":
|
|
922
|
+
return pluginEntries(source.name);
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const fromValue = (v: ScopeValue): ListFieldResolution =>
|
|
927
|
+
v.kind === "failed"
|
|
928
|
+
? { ok: false, marker: v.marker }
|
|
929
|
+
: { ok: true, entries: v.kind === "list" ? v.entries : [] };
|
|
930
|
+
|
|
931
|
+
const resolveField = (
|
|
932
|
+
field: ListFieldSpec,
|
|
933
|
+
value: unknown,
|
|
934
|
+
holder: ListHolder,
|
|
935
|
+
): ListFieldResolution => {
|
|
936
|
+
const bind = field.bind ?? "local";
|
|
937
|
+
if (bind === "source") {
|
|
938
|
+
const source = field.source;
|
|
939
|
+
if (!source) return { ok: false, marker: `list-malformed:${field.name}[source]` };
|
|
940
|
+
switch (source.kind) {
|
|
941
|
+
case "query": {
|
|
942
|
+
const q = mergedQuery(source, value, field.name, "query");
|
|
943
|
+
return "marker" in q ? { ok: false, marker: q.marker } : fromValue(runQuery(q));
|
|
944
|
+
}
|
|
945
|
+
case "system": {
|
|
946
|
+
const overrides = (value ?? {}) as { terms?: unknown };
|
|
947
|
+
const terms = Array.isArray(overrides.terms)
|
|
948
|
+
? overrides.terms.filter((t): t is string => typeof t === "string")
|
|
949
|
+
: [];
|
|
950
|
+
const vocab = field.list === undefined ? null : parseListRef(field.list);
|
|
951
|
+
return fromValue(
|
|
952
|
+
systemSource(source.name, terms, vocab === null ? null : [vocab], field.name),
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
case "plugin":
|
|
956
|
+
return fromValue(pluginEntries(source.name));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const typeRow = data.listTypes.get(field.listType);
|
|
960
|
+
if (!typeRow) return { ok: false, marker: `list-type-missing:${field.listType}` };
|
|
961
|
+
depTypes.add(field.listType);
|
|
962
|
+
const type = typeRow.def;
|
|
963
|
+
if (bind === "shared") {
|
|
964
|
+
const raw = field.list ?? (typeof value === "string" ? value : undefined) ?? field.default;
|
|
965
|
+
if (raw === undefined || raw === null || raw === "") {
|
|
966
|
+
return { ok: false, marker: `list-unbound:${field.name}` };
|
|
967
|
+
}
|
|
968
|
+
const listId = typeof raw === "string" ? parseListRef(raw) : null;
|
|
969
|
+
if (listId === null) return { ok: false, marker: `list-malformed:${field.name}[list]` };
|
|
970
|
+
depLists.add(listId);
|
|
971
|
+
const list = data.lists.get(listId);
|
|
972
|
+
if (!list) return { ok: false, marker: `list-missing:${listId}` };
|
|
973
|
+
if (list.typeSlug !== field.listType) {
|
|
974
|
+
return { ok: false, marker: `list-malformed:${field.name}[listType]` };
|
|
975
|
+
}
|
|
976
|
+
const checked = checkTree(type, list.items, field.name);
|
|
977
|
+
if (!checked.ok) return checked;
|
|
978
|
+
return {
|
|
979
|
+
ok: true,
|
|
980
|
+
entries: entriesOf(checked.items, sharedTree(list, type, field.name, false), 1),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
const raw = value === undefined || value === null ? field.default : value;
|
|
984
|
+
if (raw === undefined || raw === null) return { ok: true, entries: [] };
|
|
985
|
+
const checked = checkTree(type, raw, field.name);
|
|
986
|
+
if (!checked.ok) return checked;
|
|
987
|
+
const ci = holder.contentInstanceId;
|
|
988
|
+
const local: TreeCtx = {
|
|
989
|
+
type,
|
|
990
|
+
field: field.name,
|
|
991
|
+
attrPrefix: ci === null ? `local/${field.name}/` : `instance/${ci}/${field.name}/`,
|
|
992
|
+
overlayValues: (itemId) =>
|
|
993
|
+
ci === null ? undefined : overlay?.instanceValues.get(`${ci}/${field.name}/${itemId}`),
|
|
994
|
+
hidden: (itemId) =>
|
|
995
|
+
ci !== null && (overlay?.instanceHidden.has(`${ci}/${field.name}/${itemId}`) ?? false),
|
|
996
|
+
checkOverlay: (item, f, v) => {
|
|
997
|
+
const address = `instance/${ci}/${field.name}/item/${item.id}/${f.name}`;
|
|
998
|
+
const problem = overlayValueProblem(f, v, address);
|
|
999
|
+
if (problem && variant !== null && !pageIssues.some((i) => i.address === address)) {
|
|
1000
|
+
pageIssues.push({ variant, address, reason: problem });
|
|
1001
|
+
}
|
|
1002
|
+
return problem === null;
|
|
1003
|
+
},
|
|
1004
|
+
termLink: false,
|
|
1005
|
+
};
|
|
1006
|
+
if (ci !== null && overlay) {
|
|
1007
|
+
// Instance overlay addresses naming an entry or field this list
|
|
1008
|
+
// does not have can only be found here, where the stored entries
|
|
1009
|
+
// are known.
|
|
1010
|
+
const ids = new Set<string>();
|
|
1011
|
+
const fieldsByItem = new Map<string, NodeDef>();
|
|
1012
|
+
const go = (items: readonly ListItem[]): void => {
|
|
1013
|
+
for (const it of items) {
|
|
1014
|
+
ids.add(it.id);
|
|
1015
|
+
fieldsByItem.set(it.id, type.nodes[it.node] as NodeDef);
|
|
1016
|
+
if (it.children) go(it.children);
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
go(checked.items);
|
|
1020
|
+
const prefix = `${ci}/${field.name}/`;
|
|
1021
|
+
for (const [key, values] of overlay.instanceValues) {
|
|
1022
|
+
if (!key.startsWith(prefix) || variant === null) continue;
|
|
1023
|
+
const itemId = key.slice(prefix.length);
|
|
1024
|
+
const node = fieldsByItem.get(itemId);
|
|
1025
|
+
for (const f of values.keys()) {
|
|
1026
|
+
const address = `instance/${ci}/${field.name}/item/${itemId}/${f}`;
|
|
1027
|
+
if (!node?.fields.some((x) => x.name === f)) {
|
|
1028
|
+
if (!pageIssues.some((i) => i.address === address)) {
|
|
1029
|
+
pageIssues.push({
|
|
1030
|
+
variant,
|
|
1031
|
+
address,
|
|
1032
|
+
reason: node ? "the entry's node declares no such field" : "no such list entry",
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return { ok: true, entries: entriesOf(checked.items, local, 1) };
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
let pageScopeMemo: ScopeValue | null | undefined;
|
|
1043
|
+
return {
|
|
1044
|
+
pageId,
|
|
1045
|
+
resolveField,
|
|
1046
|
+
pageScope: () => {
|
|
1047
|
+
if (pageScopeMemo === undefined) {
|
|
1048
|
+
if (current === null) pageScopeMemo = null;
|
|
1049
|
+
else {
|
|
1050
|
+
const get = pageValue(current);
|
|
1051
|
+
pageScopeMemo = { kind: "record", text: null, present: true, get };
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return pageScopeMemo;
|
|
1055
|
+
},
|
|
1056
|
+
pluginSource: (name) => {
|
|
1057
|
+
if (opts.pluginSources?.[name] === undefined && s.dormant[name] === undefined) return null;
|
|
1058
|
+
return fromValue(pluginEntries(name));
|
|
1059
|
+
},
|
|
1060
|
+
deps: () => ({
|
|
1061
|
+
lists: [...depLists].sort(),
|
|
1062
|
+
listTypes: [...depTypes].sort(),
|
|
1063
|
+
queries: [...depQueries.values()],
|
|
1064
|
+
pageTree: depTree,
|
|
1065
|
+
pages: [...depPages].sort(),
|
|
1066
|
+
}),
|
|
1067
|
+
overlayIssues: () => [...renderIssues, ...pageIssues],
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function normaliseUrl(url: string): string {
|
|
1072
|
+
const cut = url.split(/[?#]/, 1)[0] ?? url;
|
|
1073
|
+
return cut.length > 1 && cut.endsWith("/") ? cut.slice(0, -1) : cut;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function compareIds(a: string, b: string): number {
|
|
1077
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Sort order of a query. Pages without the sort key sort last; ties break by id (deterministic). */
|
|
1081
|
+
function pageOrder(
|
|
1082
|
+
order: PageQuery["order"],
|
|
1083
|
+
collator: Intl.Collator,
|
|
1084
|
+
): (a: ListPageRecord, b: ListPageRecord) => number {
|
|
1085
|
+
const nullsLast = <T>(
|
|
1086
|
+
a: T | null | undefined,
|
|
1087
|
+
b: T | null | undefined,
|
|
1088
|
+
cmp: (x: T, y: T) => number,
|
|
1089
|
+
): number => {
|
|
1090
|
+
const an = a === null || a === undefined;
|
|
1091
|
+
const bn = b === null || b === undefined;
|
|
1092
|
+
if (an || bn) return an === bn ? 0 : an ? 1 : -1;
|
|
1093
|
+
return cmp(a as T, b as T);
|
|
1094
|
+
};
|
|
1095
|
+
const cmpValue = (x: unknown, y: unknown): number => {
|
|
1096
|
+
if (typeof x === "number" && typeof y === "number") return x - y;
|
|
1097
|
+
return collator.compare(String(x), String(y));
|
|
1098
|
+
};
|
|
1099
|
+
switch (order) {
|
|
1100
|
+
case "published_desc":
|
|
1101
|
+
return (a, b) =>
|
|
1102
|
+
nullsLast(a.firstPublishedAt, b.firstPublishedAt, (x, y) => compareIds(y, x)) ||
|
|
1103
|
+
compareIds(b.updatedAt, a.updatedAt) ||
|
|
1104
|
+
compareIds(a.id, b.id);
|
|
1105
|
+
case "published_asc":
|
|
1106
|
+
return (a, b) =>
|
|
1107
|
+
nullsLast(a.firstPublishedAt, b.firstPublishedAt, compareIds) ||
|
|
1108
|
+
compareIds(a.updatedAt, b.updatedAt) ||
|
|
1109
|
+
compareIds(a.id, b.id);
|
|
1110
|
+
case "title_asc":
|
|
1111
|
+
return (a, b) => collator.compare(a.title, b.title) || compareIds(a.id, b.id);
|
|
1112
|
+
default: {
|
|
1113
|
+
const desc = order.startsWith("prop_desc:");
|
|
1114
|
+
const prop = order.slice(order.indexOf(":") + 1);
|
|
1115
|
+
return (a, b) =>
|
|
1116
|
+
nullsLast(a.properties[prop], b.properties[prop], (x, y) =>
|
|
1117
|
+
desc ? cmpValue(y, x) : cmpValue(x, y),
|
|
1118
|
+
) || compareIds(a.id, b.id);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Every embedded module a set of shared lists references (`module` fields), for batch loading. */
|
|
1124
|
+
export function collectListModuleRefs(
|
|
1125
|
+
data: Pick<ListRenderData, "lists" | "listTypes">,
|
|
1126
|
+
): { moduleId: string; contentInstanceId: string }[] {
|
|
1127
|
+
const out: { moduleId: string; contentInstanceId: string }[] = [];
|
|
1128
|
+
for (const list of data.lists.values()) {
|
|
1129
|
+
const type = data.listTypes.get(list.typeSlug)?.def;
|
|
1130
|
+
if (!type) continue;
|
|
1131
|
+
const go = (items: readonly ListItem[]): void => {
|
|
1132
|
+
for (const it of items) {
|
|
1133
|
+
for (const f of type.nodes[it.node]?.fields ?? []) {
|
|
1134
|
+
if (f.type !== "module") continue;
|
|
1135
|
+
const v = it.values[f.name] as
|
|
1136
|
+
| { moduleId?: unknown; contentInstanceId?: unknown }
|
|
1137
|
+
| undefined;
|
|
1138
|
+
if (typeof v?.moduleId === "string" && typeof v.contentInstanceId === "string") {
|
|
1139
|
+
out.push({ moduleId: v.moduleId, contentInstanceId: v.contentInstanceId });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (it.children) go(it.children);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
go(list.items);
|
|
1146
|
+
}
|
|
1147
|
+
return out;
|
|
1148
|
+
}
|