@crombolo/render 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +36 -4
- package/src/context.ts +112 -0
- package/src/form-script.ts +112 -0
- package/src/forms.ts +557 -0
- package/src/html.ts +285 -0
- package/src/index.ts +16 -0
- package/src/jsx-attributes.ts +91 -0
- package/src/jsx-runtime.ts +334 -0
- package/src/loose/jsx-runtime.ts +21 -0
- package/src/media.ts +205 -0
- package/src/model.ts +303 -0
- package/src/published-cache.ts +865 -0
- package/src/redirects.ts +131 -0
- package/src/renderer.ts +374 -0
- package/src/seo.ts +209 -0
- package/src/sitemap.ts +99 -0
- package/src/snapshots.ts +398 -0
- package/src/url-segment.ts +34 -0
- package/src/values.ts +325 -0
- package/README.md +0 -3
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published content cache.
|
|
3
|
+
*
|
|
4
|
+
* Published only — drafts are never cached — and rebuilt from the database on
|
|
5
|
+
* invalidation. Umbraco runs three tiers here (in-process objects, a hybrid
|
|
6
|
+
* memory/distributed cache, and a serialised table); a single Bun process needs
|
|
7
|
+
* only the first, and the other two are pure throughput. See docs/05-rendering.md.
|
|
8
|
+
*
|
|
9
|
+
* Content is held once per culture: a node that varies by culture appears in
|
|
10
|
+
* each culture it is published in, with that culture's name, URL and values
|
|
11
|
+
* in scope; an invariant node appears in every culture. Pickers, links and
|
|
12
|
+
* navigation resolve within the culture of the page that asks.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SchemaForm } from '@crombolo/core'
|
|
16
|
+
import { PublishedMedia } from './media.ts'
|
|
17
|
+
import { type Navigation, PublishedContent, type PublishedProperty } from './model.ts'
|
|
18
|
+
import { matchRedirect, type RedirectRule } from './redirects.ts'
|
|
19
|
+
import {
|
|
20
|
+
joinPath,
|
|
21
|
+
REDIRECT_ALIAS,
|
|
22
|
+
URL_ALIAS_ALIAS,
|
|
23
|
+
URL_NAME_ALIAS,
|
|
24
|
+
urlSegmentFor,
|
|
25
|
+
} from './url-segment.ts'
|
|
26
|
+
import { PublishedElement, type ValueContext } from './values.ts'
|
|
27
|
+
|
|
28
|
+
export interface PublishedNode {
|
|
29
|
+
key: string
|
|
30
|
+
id: number
|
|
31
|
+
parentId: number
|
|
32
|
+
level: number
|
|
33
|
+
path: string
|
|
34
|
+
sortOrder: number
|
|
35
|
+
/** The invariant name; a culture's own name comes from `names`. */
|
|
36
|
+
name: string
|
|
37
|
+
contentTypeAlias: string
|
|
38
|
+
componentAlias: string | null
|
|
39
|
+
createDate: Date
|
|
40
|
+
updateDate: Date
|
|
41
|
+
/** The cultures a culture-varying node is published in; empty for an invariant node. */
|
|
42
|
+
cultures: string[]
|
|
43
|
+
/** Each published culture's name. */
|
|
44
|
+
names?: Record<string, string>
|
|
45
|
+
properties: PublishedProperty[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A hostname (or wildcard) assigned to a document; see `DomainRepository`. */
|
|
49
|
+
export interface PublishedDomain {
|
|
50
|
+
nodeId: number
|
|
51
|
+
/** Normalised: no scheme, lowercase, no trailing slash, e.g. `example.com/en`. */
|
|
52
|
+
domainName: string
|
|
53
|
+
isoCode: string | null
|
|
54
|
+
isWildcard: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PublishedLanguage {
|
|
58
|
+
isoCode: string
|
|
59
|
+
isDefault: boolean
|
|
60
|
+
fallbackIsoCode: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PublishedContentSource {
|
|
64
|
+
/** Every published document, in tree order. */
|
|
65
|
+
loadPublished(): Promise<PublishedNode[]>
|
|
66
|
+
/** Culture and hostnames, in sort order; none when omitted. */
|
|
67
|
+
loadDomains?(): Promise<PublishedDomain[]>
|
|
68
|
+
/** Every media item not in the recycle bin, for pickers; none when omitted. */
|
|
69
|
+
loadMedia?(): Promise<PublishedMediaNode[]>
|
|
70
|
+
/** The site's languages; an invariant site when omitted. */
|
|
71
|
+
loadLanguages?(): Promise<PublishedLanguage[]>
|
|
72
|
+
/** Dictionary translations, by item key then culture; none when omitted. */
|
|
73
|
+
loadDictionary?(): Promise<Map<string, Map<string, string>>>
|
|
74
|
+
/** Redirect rules, configured ones first; none when omitted. */
|
|
75
|
+
loadRedirects?(): Promise<RedirectRule[]>
|
|
76
|
+
/** Published library elements, for the pickers that point at them; none when omitted. */
|
|
77
|
+
loadElements?(): Promise<PublishedElementNode[]>
|
|
78
|
+
/** Content type aliases by key, so a block knows its element type; none when omitted. */
|
|
79
|
+
loadContentTypeAliases?(): Promise<Map<string, string>>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A published element: content something else points at, with no URL and no place
|
|
84
|
+
* in a route. `cultures` is the set it is published in, empty for an invariant one,
|
|
85
|
+
* so a culture's view holds only the elements that culture can see — which is how
|
|
86
|
+
* Umbraco's own converter drops an element unpublished in the ambient culture.
|
|
87
|
+
*/
|
|
88
|
+
export interface PublishedElementNode {
|
|
89
|
+
key: string
|
|
90
|
+
contentTypeKey: string
|
|
91
|
+
contentTypeAlias: string
|
|
92
|
+
cultures: string[]
|
|
93
|
+
properties: PublishedProperty[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PublishedMediaNode {
|
|
97
|
+
key: string
|
|
98
|
+
id: number
|
|
99
|
+
name: string
|
|
100
|
+
mediaTypeAlias: string
|
|
101
|
+
properties: PublishedProperty[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The published tree in one culture. */
|
|
105
|
+
export interface CultureView {
|
|
106
|
+
culture: string | null
|
|
107
|
+
byId: Map<number, PublishedContent>
|
|
108
|
+
byKey: Map<string, PublishedContent>
|
|
109
|
+
/** Published elements this culture can see, by lowercase key. */
|
|
110
|
+
elements: Map<string, PublishedElement>
|
|
111
|
+
childrenOf: Map<number, PublishedContent[]>
|
|
112
|
+
roots: PublishedContent[]
|
|
113
|
+
parentIdOf: Map<number, number>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CacheSnapshot {
|
|
117
|
+
/** Keyed by lowercase culture; `''` holds an invariant site's single view. */
|
|
118
|
+
views: Map<string, CultureView>
|
|
119
|
+
defaultCulture: string | null
|
|
120
|
+
/** Routes without a hostname, in the default culture. */
|
|
121
|
+
byRoute: Map<string, PublishedContent>
|
|
122
|
+
/** `<domain name>:<route below it>`, for requests that match a hostname. */
|
|
123
|
+
byDomainRoute: Map<string, PublishedContent>
|
|
124
|
+
domains: PublishedDomain[]
|
|
125
|
+
/** The key of the document each hostname roots, by its node id. */
|
|
126
|
+
domainRoots: Map<number, string>
|
|
127
|
+
/** `<culture>|<node id>` → the route the resolver reaches that content by. */
|
|
128
|
+
routeKeys: Map<string, RouteKey>
|
|
129
|
+
/** Configured rules first, in their declared order, then the tracked ones. */
|
|
130
|
+
redirects: RedirectRule[]
|
|
131
|
+
languages: PublishedLanguage[]
|
|
132
|
+
media: Map<string, PublishedMedia>
|
|
133
|
+
/** Item key (lowercase) → culture (lowercase) → translation. */
|
|
134
|
+
dictionary: Map<string, Map<string, string>>
|
|
135
|
+
/** Content type key (lowercase) → alias. */
|
|
136
|
+
contentTypeAliases: Map<string, string>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What a request resolved to: the content, and the culture its hostname assigns. */
|
|
140
|
+
export interface ResolvedRequest {
|
|
141
|
+
content: PublishedContent
|
|
142
|
+
culture: string | null
|
|
143
|
+
/** Where `umbracoRedirect` sends the visitor instead, when the page sets it. */
|
|
144
|
+
redirect?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How the resolver identifies a route: the path, and — when a hostname roots it —
|
|
149
|
+
* the document that hostname sits on, so the identity survives a hostname change.
|
|
150
|
+
*/
|
|
151
|
+
export interface RouteKey {
|
|
152
|
+
rootKey: string | null
|
|
153
|
+
path: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One route a document answers on, in one culture. */
|
|
157
|
+
export interface DocumentRoute extends RouteKey {
|
|
158
|
+
key: string
|
|
159
|
+
culture: string | null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Where a matched redirect sends the visitor. */
|
|
163
|
+
export interface RedirectHit {
|
|
164
|
+
location: string
|
|
165
|
+
statusCode: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const EMPTY: CacheSnapshot = {
|
|
169
|
+
views: new Map(),
|
|
170
|
+
defaultCulture: null,
|
|
171
|
+
byRoute: new Map(),
|
|
172
|
+
byDomainRoute: new Map(),
|
|
173
|
+
domains: [],
|
|
174
|
+
domainRoots: new Map(),
|
|
175
|
+
routeKeys: new Map(),
|
|
176
|
+
redirects: [],
|
|
177
|
+
languages: [],
|
|
178
|
+
media: new Map(),
|
|
179
|
+
dictionary: new Map(),
|
|
180
|
+
contentTypeAliases: new Map(),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const normaliseRoute = (route: string) =>
|
|
184
|
+
(route === '' ? '/' : route.replace(/\/+$/, '') || '/').toLowerCase()
|
|
185
|
+
const viewKey = (culture: string | null) => (culture ?? '').toLowerCase()
|
|
186
|
+
const routeKeyId = (culture: string | null, id: number) => `${viewKey(culture)}|${id}`
|
|
187
|
+
|
|
188
|
+
/** The fallback languages of `culture`, nearest first, stopping at a cycle. */
|
|
189
|
+
export function fallbackChain(
|
|
190
|
+
languages: readonly PublishedLanguage[],
|
|
191
|
+
culture: string | null,
|
|
192
|
+
): string[] {
|
|
193
|
+
const chain: string[] = []
|
|
194
|
+
let current = languages.find((l) => l.isoCode.toLowerCase() === viewKey(culture))
|
|
195
|
+
while (current?.fallbackIsoCode) {
|
|
196
|
+
const next = current.fallbackIsoCode
|
|
197
|
+
if (
|
|
198
|
+
chain.some((c) => c.toLowerCase() === next.toLowerCase()) ||
|
|
199
|
+
next.toLowerCase() === viewKey(culture)
|
|
200
|
+
)
|
|
201
|
+
break
|
|
202
|
+
chain.push(next)
|
|
203
|
+
current = languages.find((l) => l.isoCode.toLowerCase() === next.toLowerCase())
|
|
204
|
+
}
|
|
205
|
+
return chain
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What the cache needs besides content, for values that resolve outside it. */
|
|
209
|
+
export interface PublishedCacheOptions {
|
|
210
|
+
/**
|
|
211
|
+
* A form definition by key, for a `formPicker` property.
|
|
212
|
+
*
|
|
213
|
+
* A function rather than a map: form definitions are files, so they can change
|
|
214
|
+
* without a publish invalidating this cache, and the lookup has to be able to
|
|
215
|
+
* see that.
|
|
216
|
+
*/
|
|
217
|
+
form?: (key: string) => SchemaForm | undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export class PublishedCache {
|
|
221
|
+
#source: PublishedContentSource
|
|
222
|
+
#snapshot: CacheSnapshot = EMPTY
|
|
223
|
+
#loaded = false
|
|
224
|
+
#options: PublishedCacheOptions
|
|
225
|
+
|
|
226
|
+
constructor(source: PublishedContentSource, options: PublishedCacheOptions = {}) {
|
|
227
|
+
this.#source = source
|
|
228
|
+
this.#options = options
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Dropped on publish or unpublish; the next read rebuilds it. */
|
|
232
|
+
invalidate(): void {
|
|
233
|
+
this.#loaded = false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async snapshot(): Promise<CacheSnapshot> {
|
|
237
|
+
if (!this.#loaded) {
|
|
238
|
+
this.#snapshot = await this.#build()
|
|
239
|
+
this.#loaded = true
|
|
240
|
+
}
|
|
241
|
+
return this.#snapshot
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#view(snapshot: CacheSnapshot, culture: string | null | undefined): CultureView | undefined {
|
|
245
|
+
return (
|
|
246
|
+
snapshot.views.get(viewKey(culture ?? snapshot.defaultCulture)) ??
|
|
247
|
+
snapshot.views.get(viewKey(snapshot.defaultCulture))
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async byRoute(route: string): Promise<PublishedContent | undefined> {
|
|
252
|
+
const snapshot = await this.snapshot()
|
|
253
|
+
return snapshot.byRoute.get(normaliseRoute(route))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resolves a request as Umbraco does: the longest hostname (with its optional
|
|
258
|
+
* path prefix) that matches roots the route at that domain's document, in its
|
|
259
|
+
* culture; with no match, the route resolves without domains, in the default
|
|
260
|
+
* culture or the one a wildcard domain assigns.
|
|
261
|
+
*/
|
|
262
|
+
/** The longest hostname (with its optional path prefix) that matches the request. */
|
|
263
|
+
#domainFor(
|
|
264
|
+
snapshot: CacheSnapshot,
|
|
265
|
+
host: string,
|
|
266
|
+
route: string,
|
|
267
|
+
): { domain: PublishedDomain; rest: string } | undefined {
|
|
268
|
+
const hostName = host.toLowerCase()
|
|
269
|
+
const bare = hostName.replace(/:\d+$/, '')
|
|
270
|
+
let best: { domain: PublishedDomain; rest: string; prefixLength: number } | undefined
|
|
271
|
+
for (const domain of snapshot.domains) {
|
|
272
|
+
if (domain.isWildcard) continue
|
|
273
|
+
const slash = domain.domainName.indexOf('/')
|
|
274
|
+
const domainHost = slash < 0 ? domain.domainName : domain.domainName.slice(0, slash)
|
|
275
|
+
const prefix = slash < 0 ? '' : domain.domainName.slice(slash)
|
|
276
|
+
if (domainHost !== hostName && domainHost !== bare) continue
|
|
277
|
+
if (prefix && route !== prefix && !route.startsWith(`${prefix}/`)) continue
|
|
278
|
+
if (best && best.prefixLength >= prefix.length) continue
|
|
279
|
+
best = { domain, rest: route.slice(prefix.length) || '/', prefixLength: prefix.length }
|
|
280
|
+
}
|
|
281
|
+
return best
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async resolve(host: string, pathname: string): Promise<ResolvedRequest | undefined> {
|
|
285
|
+
const found = await this.#resolve(host, pathname)
|
|
286
|
+
if (!found) return undefined
|
|
287
|
+
const target = found.content.value(REDIRECT_ALIAS)
|
|
288
|
+
if (
|
|
289
|
+
target instanceof PublishedContent &&
|
|
290
|
+
target.url !== '#' &&
|
|
291
|
+
target.key !== found.content.key
|
|
292
|
+
)
|
|
293
|
+
return { ...found, redirect: target.url }
|
|
294
|
+
return found
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The culture a request's hostname assigns, or the default; what a 404 renders in. */
|
|
298
|
+
async cultureFor(host: string, pathname: string): Promise<string | null> {
|
|
299
|
+
const snapshot = await this.snapshot()
|
|
300
|
+
const best = this.#domainFor(snapshot, host, normaliseRoute(pathname))
|
|
301
|
+
return best?.domain.isoCode ?? snapshot.defaultCulture
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async #resolve(host: string, pathname: string): Promise<ResolvedRequest | undefined> {
|
|
305
|
+
const snapshot = await this.snapshot()
|
|
306
|
+
const route = normaliseRoute(pathname)
|
|
307
|
+
const best = this.#domainFor(snapshot, host, route)
|
|
308
|
+
if (best) {
|
|
309
|
+
const content = snapshot.byDomainRoute.get(`${best.domain.domainName}:${best.rest}`)
|
|
310
|
+
return content ? { content, culture: best.domain.isoCode ?? content.culture } : undefined
|
|
311
|
+
}
|
|
312
|
+
const content = snapshot.byRoute.get(route)
|
|
313
|
+
if (!content) return undefined
|
|
314
|
+
const wildcard = this.#wildcardCulture(snapshot, content)
|
|
315
|
+
if (wildcard) {
|
|
316
|
+
const localised = snapshot.views.get(viewKey(wildcard))?.byId.get(content.id)
|
|
317
|
+
if (localised) return { content: localised, culture: wildcard }
|
|
318
|
+
}
|
|
319
|
+
return { content, culture: content.culture }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The redirect a request matches, once route resolution has found no page.
|
|
324
|
+
*
|
|
325
|
+
* Configured rules are tried before tracked ones, and a rule a hostname scopes
|
|
326
|
+
* only against a request that hostname roots. A rule naming a document resolves
|
|
327
|
+
* through the cache, so it follows the page when it is renamed; one naming a
|
|
328
|
+
* document that is gone or unpublished does not match at all, which leaves the
|
|
329
|
+
* 404 rather than sending the visitor nowhere.
|
|
330
|
+
*/
|
|
331
|
+
async redirect(host: string, pathname: string): Promise<RedirectHit | undefined> {
|
|
332
|
+
const snapshot = await this.snapshot()
|
|
333
|
+
if (snapshot.redirects.length === 0) return undefined
|
|
334
|
+
const route = normaliseRoute(pathname)
|
|
335
|
+
const domain = this.#domainFor(snapshot, host, route)
|
|
336
|
+
const rootKey = domain ? (snapshot.domainRoots.get(domain.domain.nodeId) ?? null) : null
|
|
337
|
+
const culture = domain?.domain.isoCode ?? snapshot.defaultCulture
|
|
338
|
+
for (const rule of snapshot.redirects) {
|
|
339
|
+
if (rule.rootKey !== null && rule.rootKey !== rootKey) continue
|
|
340
|
+
if (rule.culture !== null && viewKey(rule.culture) !== viewKey(culture)) continue
|
|
341
|
+
const scoped = rule.rootKey !== null && domain ? domain.rest : route
|
|
342
|
+
const target = matchRedirect(rule, scoped)
|
|
343
|
+
if (target === undefined) continue
|
|
344
|
+
if (rule.targetKind !== 'document') return { location: target, statusCode: rule.statusCode }
|
|
345
|
+
const content = this.#view(snapshot, culture)?.byKey.get(target.toLowerCase())
|
|
346
|
+
if (!content || content.url === '#') continue
|
|
347
|
+
return { location: content.url, statusCode: rule.statusCode }
|
|
348
|
+
}
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Every route the document answers on, per culture, and optionally those of its
|
|
354
|
+
* published descendants — what the redirect tracker compares before and after a
|
|
355
|
+
* publish or a move.
|
|
356
|
+
*/
|
|
357
|
+
async routes(key: string, includeDescendants = false): Promise<DocumentRoute[]> {
|
|
358
|
+
const snapshot = await this.snapshot()
|
|
359
|
+
const out: DocumentRoute[] = []
|
|
360
|
+
for (const view of snapshot.views.values()) {
|
|
361
|
+
const content = view.byKey.get(key.toLowerCase())
|
|
362
|
+
if (!content) continue
|
|
363
|
+
const branch = includeDescendants ? [content, ...descendantsOf(view, content)] : [content]
|
|
364
|
+
for (const node of branch) {
|
|
365
|
+
// A culture with no reachable URL has no route to redirect — Umbraco's
|
|
366
|
+
// `IsValidRoute` guard, which keeps dead rules out of the table.
|
|
367
|
+
if (node.url === '#') continue
|
|
368
|
+
const route = snapshot.routeKeys.get(routeKeyId(view.culture, node.id))
|
|
369
|
+
if (route) out.push({ key: node.key, culture: view.culture, ...route })
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The published children of a document, or of the tree root; keys, once each. */
|
|
376
|
+
async childKeys(parentKey: string | null): Promise<string[]> {
|
|
377
|
+
const snapshot = await this.snapshot()
|
|
378
|
+
const keys = new Set<string>()
|
|
379
|
+
for (const view of snapshot.views.values()) {
|
|
380
|
+
if (parentKey === null) {
|
|
381
|
+
for (const root of view.roots) keys.add(root.key)
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
const parent = view.byKey.get(parentKey.toLowerCase())
|
|
385
|
+
if (!parent) continue
|
|
386
|
+
for (const child of view.childrenOf.get(parent.id) ?? []) keys.add(child.key)
|
|
387
|
+
}
|
|
388
|
+
return [...keys]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The culture the nearest wildcard domain above the content assigns. */
|
|
392
|
+
#wildcardCulture(snapshot: CacheSnapshot, content: PublishedContent): string | null {
|
|
393
|
+
const ids = content.path.split(',').map(Number).reverse()
|
|
394
|
+
for (const id of ids) {
|
|
395
|
+
const wildcard = snapshot.domains.find((d) => d.nodeId === id && d.isWildcard)
|
|
396
|
+
if (wildcard?.isoCode) return wildcard.isoCode
|
|
397
|
+
}
|
|
398
|
+
return null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The value context of a culture's view, for a model built outside it (preview). */
|
|
402
|
+
async valueContext(culture?: string | null): Promise<ValueContext<PublishedContent>> {
|
|
403
|
+
const snapshot = await this.snapshot()
|
|
404
|
+
const view = this.#view(snapshot, culture)
|
|
405
|
+
return {
|
|
406
|
+
content: (key) => view?.byKey.get(key.toLowerCase()),
|
|
407
|
+
media: (key) => snapshot.media.get(key.toLowerCase()),
|
|
408
|
+
form: this.#options.form,
|
|
409
|
+
contentTypeAlias: (key) => snapshot.contentTypeAliases.get(key.toLowerCase()),
|
|
410
|
+
urlOf: (content) => content.url,
|
|
411
|
+
mediaUrlOf: (item) => (item as PublishedMedia).url,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The content in `culture`, or in the default culture. */
|
|
416
|
+
async byKey(key: string, culture?: string | null): Promise<PublishedContent | undefined> {
|
|
417
|
+
const snapshot = await this.snapshot()
|
|
418
|
+
return this.#view(snapshot, culture)?.byKey.get(key.toLowerCase())
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The content's URL in each culture it is published in; one invariant entry
|
|
423
|
+
* for invariant content on a site without cultures in play.
|
|
424
|
+
*/
|
|
425
|
+
async urls(key: string): Promise<Array<{ culture: string | null; url: string }>> {
|
|
426
|
+
return urlsIn(await this.snapshot(), key).map(({ culture, url }) => ({ culture, url }))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** A dictionary item's translation in `culture`, then its fallback languages; '' when none. */
|
|
430
|
+
async dictionary(key: string, culture: string | null): Promise<string> {
|
|
431
|
+
const snapshot = await this.snapshot()
|
|
432
|
+
return translate(snapshot, key, culture)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async navigation(culture?: string | null): Promise<Navigation> {
|
|
436
|
+
const snapshot = await this.snapshot()
|
|
437
|
+
const view = this.#view(snapshot, culture)
|
|
438
|
+
const byId = view?.byId ?? new Map<number, PublishedContent>()
|
|
439
|
+
const byKey = view?.byKey ?? new Map<string, PublishedContent>()
|
|
440
|
+
const parentIdOf = view?.parentIdOf ?? new Map<number, number>()
|
|
441
|
+
return {
|
|
442
|
+
parent: (content) => {
|
|
443
|
+
const parentId = parentIdOf.get(content.id)
|
|
444
|
+
return parentId === undefined ? undefined : byId.get(parentId)
|
|
445
|
+
},
|
|
446
|
+
children: (content, options) => {
|
|
447
|
+
const children = view?.childrenOf.get(content.id) ?? []
|
|
448
|
+
return options?.visibleOnly ? children.filter((c) => c.isVisible) : children
|
|
449
|
+
},
|
|
450
|
+
ancestors: (content) => {
|
|
451
|
+
const found: PublishedContent[] = []
|
|
452
|
+
let current = content
|
|
453
|
+
for (;;) {
|
|
454
|
+
const parentId = parentIdOf.get(current.id)
|
|
455
|
+
const parent = parentId === undefined ? undefined : byId.get(parentId)
|
|
456
|
+
if (!parent) break
|
|
457
|
+
found.push(parent)
|
|
458
|
+
current = parent
|
|
459
|
+
}
|
|
460
|
+
return found
|
|
461
|
+
},
|
|
462
|
+
root: (options) => {
|
|
463
|
+
const roots = view?.roots ?? []
|
|
464
|
+
return options?.visibleOnly ? roots.filter((c) => c.isVisible) : roots
|
|
465
|
+
},
|
|
466
|
+
byKey: (key) => byKey.get(key.toLowerCase()),
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async #build(): Promise<CacheSnapshot> {
|
|
471
|
+
const nodes = await this.#source.loadPublished()
|
|
472
|
+
const domains = (await this.#source.loadDomains?.()) ?? []
|
|
473
|
+
const languages = (await this.#source.loadLanguages?.()) ?? []
|
|
474
|
+
const dictionary = new Map<string, Map<string, string>>()
|
|
475
|
+
for (const [key, translations] of (await this.#source.loadDictionary?.()) ?? new Map())
|
|
476
|
+
dictionary.set(
|
|
477
|
+
key.toLowerCase(),
|
|
478
|
+
new Map([...translations].map(([culture, text]) => [culture.toLowerCase(), text])),
|
|
479
|
+
)
|
|
480
|
+
const media = new Map(
|
|
481
|
+
((await this.#source.loadMedia?.()) ?? []).map((m) => [
|
|
482
|
+
m.key.toLowerCase(),
|
|
483
|
+
new PublishedMedia(m),
|
|
484
|
+
]),
|
|
485
|
+
)
|
|
486
|
+
const redirects = (await this.#source.loadRedirects?.()) ?? []
|
|
487
|
+
const elementNodes = (await this.#source.loadElements?.()) ?? []
|
|
488
|
+
const contentTypeAliases = new Map(
|
|
489
|
+
[...((await this.#source.loadContentTypeAliases?.()) ?? new Map())].map(([key, alias]) => [
|
|
490
|
+
key.toLowerCase(),
|
|
491
|
+
alias,
|
|
492
|
+
]),
|
|
493
|
+
)
|
|
494
|
+
const defaultCulture =
|
|
495
|
+
languages.find((l) => l.isDefault)?.isoCode ?? languages[0]?.isoCode ?? null
|
|
496
|
+
const cultures: Array<string | null> =
|
|
497
|
+
languages.length > 0 ? languages.map((l) => l.isoCode) : [null]
|
|
498
|
+
|
|
499
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
|
500
|
+
const domainsOf = new Map<number, PublishedDomain[]>()
|
|
501
|
+
for (const domain of domains)
|
|
502
|
+
if (!domain.isWildcard)
|
|
503
|
+
domainsOf.set(domain.nodeId, [...(domainsOf.get(domain.nodeId) ?? []), domain])
|
|
504
|
+
|
|
505
|
+
/** The hostname that roots a node in a culture: one of that culture, else one of none. */
|
|
506
|
+
const domainFor = (nodeId: number, culture: string | null) => {
|
|
507
|
+
const own = domainsOf.get(nodeId) ?? []
|
|
508
|
+
return (
|
|
509
|
+
own.find((d) => viewKey(d.isoCode) === viewKey(culture) && d.isoCode !== null) ??
|
|
510
|
+
own.find((d) => d.isoCode === null) ??
|
|
511
|
+
(viewKey(culture) === viewKey(defaultCulture) ? own[0] : undefined)
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const firstRootId = nodes
|
|
516
|
+
.filter((node) => node.parentId <= 0)
|
|
517
|
+
.sort((a, b) => a.sortOrder - b.sortOrder)[0]?.id
|
|
518
|
+
|
|
519
|
+
const byRoute = new Map<string, PublishedContent>()
|
|
520
|
+
const byDomainRoute = new Map<string, PublishedContent>()
|
|
521
|
+
const views = new Map<string, CultureView>()
|
|
522
|
+
const routeKeys = new Map<string, RouteKey>()
|
|
523
|
+
const domainRoots = new Map<number, string>()
|
|
524
|
+
for (const nodeId of domainsOf.keys()) {
|
|
525
|
+
const key = nodeById.get(nodeId)?.key
|
|
526
|
+
if (key) domainRoots.set(nodeId, key.toLowerCase())
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// `umbracoUrlAlias` routes, claimed only once every real route has been, so an
|
|
530
|
+
// alias can never take an address a page already answers on.
|
|
531
|
+
const aliasClaims: Array<{ content: PublishedContent; route: string; domainKeys: string[] }> =
|
|
532
|
+
[]
|
|
533
|
+
|
|
534
|
+
for (const culture of cultures) {
|
|
535
|
+
const inCulture = (node: PublishedNode) =>
|
|
536
|
+
node.cultures.length === 0 || node.cultures.some((c) => viewKey(c) === viewKey(culture))
|
|
537
|
+
const nameIn = (node: PublishedNode) => {
|
|
538
|
+
if (node.cultures.length === 0) return node.name
|
|
539
|
+
const found = Object.entries(node.names ?? {}).find(
|
|
540
|
+
([c]) => viewKey(c) === viewKey(culture),
|
|
541
|
+
)
|
|
542
|
+
return found?.[1] ?? node.name
|
|
543
|
+
}
|
|
544
|
+
const segmentOf = new Map<number, string>()
|
|
545
|
+
for (const node of nodes) {
|
|
546
|
+
if (!inCulture(node)) continue
|
|
547
|
+
const override =
|
|
548
|
+
node.properties.find(
|
|
549
|
+
(p) =>
|
|
550
|
+
p.alias === URL_NAME_ALIAS && viewKey(p.culture) === viewKey(culture) && p.culture,
|
|
551
|
+
) ?? node.properties.find((p) => p.alias === URL_NAME_ALIAS && p.culture === null)
|
|
552
|
+
segmentOf.set(
|
|
553
|
+
node.id,
|
|
554
|
+
urlSegmentFor(nameIn(node), typeof override?.value === 'string' ? override.value : null),
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Umbraco's default (`HideTopLevelNodeFromPath`): a top-level node's segment
|
|
560
|
+
* is left out of every URL below it, the first top-level node is `/`, and any
|
|
561
|
+
* other is `/<segment>`. A route only exists if every ancestor is also
|
|
562
|
+
* published in the culture — an unpublished ancestor breaks the route rather
|
|
563
|
+
* than leaving a reachable orphan.
|
|
564
|
+
*/
|
|
565
|
+
const routeOf = (node: PublishedNode): string | undefined => {
|
|
566
|
+
if (node.parentId <= 0)
|
|
567
|
+
return node.id === firstRootId ? '/' : joinPath([segmentOf.get(node.id) as string])
|
|
568
|
+
const segments: string[] = []
|
|
569
|
+
let current: PublishedNode = node
|
|
570
|
+
for (;;) {
|
|
571
|
+
const parent = nodeById.get(current.parentId)
|
|
572
|
+
if (!parent || !segmentOf.has(parent.id)) return undefined
|
|
573
|
+
segments.unshift(segmentOf.get(current.id) as string)
|
|
574
|
+
if (parent.parentId <= 0) break
|
|
575
|
+
current = parent
|
|
576
|
+
}
|
|
577
|
+
return joinPath(segments)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The route below the nearest document with a hostname for this culture, if there is one. */
|
|
581
|
+
const domainRouteOf = (
|
|
582
|
+
node: PublishedNode,
|
|
583
|
+
): { domain: PublishedDomain; route: string } | undefined => {
|
|
584
|
+
const segments: string[] = []
|
|
585
|
+
let current: PublishedNode | undefined = node
|
|
586
|
+
while (current && segmentOf.has(current.id)) {
|
|
587
|
+
const domain = domainFor(current.id, culture)
|
|
588
|
+
if (domain) return { domain, route: joinPath(segments) }
|
|
589
|
+
segments.unshift(segmentOf.get(current.id) as string)
|
|
590
|
+
current = nodeById.get(current.parentId)
|
|
591
|
+
}
|
|
592
|
+
return undefined
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const view: CultureView = {
|
|
596
|
+
culture,
|
|
597
|
+
byId: new Map(),
|
|
598
|
+
byKey: new Map(),
|
|
599
|
+
elements: new Map(),
|
|
600
|
+
childrenOf: new Map(),
|
|
601
|
+
roots: [],
|
|
602
|
+
parentIdOf: new Map(),
|
|
603
|
+
}
|
|
604
|
+
const isDefault = viewKey(culture) === viewKey(defaultCulture)
|
|
605
|
+
// Shallow and first-sorted nodes claim a route before any that collide with it.
|
|
606
|
+
const ordered = [...nodes]
|
|
607
|
+
.filter(inCulture)
|
|
608
|
+
.sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder)
|
|
609
|
+
for (const node of ordered) {
|
|
610
|
+
const route = routeOf(node)
|
|
611
|
+
if (route === undefined) continue
|
|
612
|
+
const domainRoute = domainRouteOf(node)
|
|
613
|
+
const variant = node.cultures.length > 0
|
|
614
|
+
const url = domainRoute
|
|
615
|
+
? `//${domainRoute.domain.domainName}${domainRoute.route === '/' ? '/' : domainRoute.route}`
|
|
616
|
+
: isDefault || !variant
|
|
617
|
+
? route
|
|
618
|
+
: '#'
|
|
619
|
+
const content = new PublishedContent({
|
|
620
|
+
key: node.key,
|
|
621
|
+
id: node.id,
|
|
622
|
+
name: nameIn(node),
|
|
623
|
+
contentTypeAlias: node.contentTypeAlias,
|
|
624
|
+
urlSegment: segmentOf.get(node.id) as string,
|
|
625
|
+
url,
|
|
626
|
+
level: node.level,
|
|
627
|
+
path: node.path,
|
|
628
|
+
sortOrder: node.sortOrder,
|
|
629
|
+
createDate: node.createDate,
|
|
630
|
+
updateDate: node.updateDate,
|
|
631
|
+
componentAlias: node.componentAlias,
|
|
632
|
+
cultures: node.cultures,
|
|
633
|
+
properties: node.properties,
|
|
634
|
+
variationCulture: variant ? culture : null,
|
|
635
|
+
languageFallback: fallbackChain(languages, culture),
|
|
636
|
+
})
|
|
637
|
+
view.byId.set(node.id, content)
|
|
638
|
+
view.byKey.set(node.key.toLowerCase(), content)
|
|
639
|
+
for (const alias of urlAliasesOf(node, culture)) {
|
|
640
|
+
const domainKeys = domainRoute
|
|
641
|
+
? (domainsOf.get(domainRoute.domain.nodeId) ?? [])
|
|
642
|
+
.filter((d) =>
|
|
643
|
+
d.isoCode === null ? isDefault : viewKey(d.isoCode) === viewKey(culture),
|
|
644
|
+
)
|
|
645
|
+
.map((d) => `${d.domainName}:${alias}`)
|
|
646
|
+
: []
|
|
647
|
+
aliasClaims.push({ content, route: isDefault ? alias : '', domainKeys })
|
|
648
|
+
}
|
|
649
|
+
routeKeys.set(
|
|
650
|
+
routeKeyId(culture, node.id),
|
|
651
|
+
domainRoute
|
|
652
|
+
? {
|
|
653
|
+
rootKey: domainRoots.get(domainRoute.domain.nodeId) ?? null,
|
|
654
|
+
path: normaliseRoute(domainRoute.route),
|
|
655
|
+
}
|
|
656
|
+
: { rootKey: null, path: normaliseRoute(route) },
|
|
657
|
+
)
|
|
658
|
+
if (isDefault && !byRoute.has(route.toLowerCase()))
|
|
659
|
+
byRoute.set(route.toLowerCase(), content)
|
|
660
|
+
// Every hostname on the rooting document serves its own culture's view;
|
|
661
|
+
// one with no culture serves the default's
|
|
662
|
+
if (domainRoute)
|
|
663
|
+
for (const domain of domainsOf.get(domainRoute.domain.nodeId) ?? []) {
|
|
664
|
+
const serves =
|
|
665
|
+
domain.isoCode === null ? isDefault : viewKey(domain.isoCode) === viewKey(culture)
|
|
666
|
+
const key = `${domain.domainName}:${domainRoute.route.toLowerCase()}`
|
|
667
|
+
if (serves && !byDomainRoute.has(key)) byDomainRoute.set(key, content)
|
|
668
|
+
}
|
|
669
|
+
if (nodeById.has(node.parentId) && segmentOf.has(node.parentId)) {
|
|
670
|
+
view.parentIdOf.set(node.id, node.parentId)
|
|
671
|
+
const siblings = view.childrenOf.get(node.parentId) ?? []
|
|
672
|
+
siblings.push(content)
|
|
673
|
+
view.childrenOf.set(node.parentId, siblings)
|
|
674
|
+
} else {
|
|
675
|
+
view.roots.push(content)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const siblings of view.childrenOf.values())
|
|
679
|
+
siblings.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
680
|
+
view.roots.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
681
|
+
|
|
682
|
+
// Pickers and links resolve against this culture's view.
|
|
683
|
+
const context: ValueContext<PublishedContent, PublishedMedia> = {
|
|
684
|
+
content: (key) => view.byKey.get(key.toLowerCase()),
|
|
685
|
+
media: (key) => media.get(key.toLowerCase()),
|
|
686
|
+
// Read lazily: the map is filled just below, with this very context, so an
|
|
687
|
+
// element picking another element resolves too.
|
|
688
|
+
element: (key) => view.elements.get(key.toLowerCase()),
|
|
689
|
+
form: this.#options.form,
|
|
690
|
+
contentTypeAlias: (key) => contentTypeAliases.get(key.toLowerCase()),
|
|
691
|
+
urlOf: (content) => content.url,
|
|
692
|
+
mediaUrlOf: (item) => item.url,
|
|
693
|
+
}
|
|
694
|
+
// An invariant element is visible in every culture; a varying one only in the
|
|
695
|
+
// cultures it is published in. Built with this culture's context, so a
|
|
696
|
+
// property inside an element resolves pickers within the same culture.
|
|
697
|
+
for (const node of elementNodes) {
|
|
698
|
+
if (node.cultures.length > 0 && !node.cultures.some((c) => viewKey(c) === viewKey(culture)))
|
|
699
|
+
continue
|
|
700
|
+
view.elements.set(
|
|
701
|
+
node.key.toLowerCase(),
|
|
702
|
+
new PublishedElement(
|
|
703
|
+
{
|
|
704
|
+
key: node.key,
|
|
705
|
+
contentTypeKey: node.contentTypeKey,
|
|
706
|
+
contentTypeAlias: node.contentTypeAlias,
|
|
707
|
+
properties: node.properties,
|
|
708
|
+
},
|
|
709
|
+
context,
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
for (const content of view.byId.values()) {
|
|
715
|
+
content.withValueContext(context)
|
|
716
|
+
// Ancestors are wired after every node exists, for the `ancestors` fallback.
|
|
717
|
+
const ancestors: PublishedContent[] = []
|
|
718
|
+
let currentId = view.parentIdOf.get(content.id)
|
|
719
|
+
while (currentId !== undefined) {
|
|
720
|
+
const parent = view.byId.get(currentId)
|
|
721
|
+
if (!parent) break
|
|
722
|
+
ancestors.push(parent)
|
|
723
|
+
currentId = view.parentIdOf.get(parent.id)
|
|
724
|
+
}
|
|
725
|
+
content.withAncestors(ancestors)
|
|
726
|
+
}
|
|
727
|
+
views.set(viewKey(culture), view)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
for (const claim of aliasClaims) {
|
|
731
|
+
if (claim.route && !byRoute.has(claim.route)) byRoute.set(claim.route, claim.content)
|
|
732
|
+
for (const key of claim.domainKeys)
|
|
733
|
+
if (!byDomainRoute.has(key)) byDomainRoute.set(key, claim.content)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
views,
|
|
738
|
+
defaultCulture,
|
|
739
|
+
byRoute,
|
|
740
|
+
byDomainRoute,
|
|
741
|
+
domains,
|
|
742
|
+
domainRoots,
|
|
743
|
+
routeKeys,
|
|
744
|
+
redirects,
|
|
745
|
+
languages,
|
|
746
|
+
media,
|
|
747
|
+
dictionary,
|
|
748
|
+
contentTypeAliases,
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A document's address in each culture it is published in, or its one address
|
|
755
|
+
* when it does not vary; `isDefault` marks the default language's.
|
|
756
|
+
*/
|
|
757
|
+
export function urlsIn(
|
|
758
|
+
snapshot: CacheSnapshot,
|
|
759
|
+
key: string,
|
|
760
|
+
): Array<{ culture: string | null; url: string; isDefault: boolean }> {
|
|
761
|
+
const out: Array<{ culture: string | null; url: string; isDefault: boolean }> = []
|
|
762
|
+
let invariant: PublishedContent | undefined
|
|
763
|
+
for (const view of snapshot.views.values()) {
|
|
764
|
+
const content = view.byKey.get(key.toLowerCase())
|
|
765
|
+
if (!content) continue
|
|
766
|
+
if (content.cultures.length === 0) invariant ??= content
|
|
767
|
+
else
|
|
768
|
+
out.push({
|
|
769
|
+
culture: view.culture,
|
|
770
|
+
url: content.url,
|
|
771
|
+
isDefault: viewKey(view.culture) === viewKey(snapshot.defaultCulture),
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
if (out.length === 0 && invariant) return [{ culture: null, url: invariant.url, isDefault: true }]
|
|
775
|
+
return out
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** A property's raw value in a culture: the culture's own first, then the invariant one. */
|
|
779
|
+
function rawValueIn(node: PublishedNode, alias: string, culture: string | null): unknown {
|
|
780
|
+
return (
|
|
781
|
+
node.properties.find(
|
|
782
|
+
(p) => p.alias === alias && p.culture && viewKey(p.culture) === viewKey(culture),
|
|
783
|
+
) ?? node.properties.find((p) => p.alias === alias && p.culture === null)
|
|
784
|
+
)?.value
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* The extra addresses `umbracoUrlAlias` gives a page: comma-separated, each
|
|
789
|
+
* relative to the site root or to the hostname that roots the page.
|
|
790
|
+
*/
|
|
791
|
+
function urlAliasesOf(node: PublishedNode, culture: string | null): string[] {
|
|
792
|
+
const value = rawValueIn(node, URL_ALIAS_ALIAS, culture)
|
|
793
|
+
if (typeof value !== 'string') return []
|
|
794
|
+
return value
|
|
795
|
+
.split(',')
|
|
796
|
+
.map((alias) => alias.trim())
|
|
797
|
+
.filter((alias) => alias.length > 0)
|
|
798
|
+
.map((alias) => normaliseRoute(alias.startsWith('/') ? alias : `/${alias}`).toLowerCase())
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** The content below `content` in one culture's view, breadth first. */
|
|
802
|
+
function descendantsOf(view: CultureView, content: PublishedContent): PublishedContent[] {
|
|
803
|
+
const out: PublishedContent[] = []
|
|
804
|
+
const queue = [content]
|
|
805
|
+
while (queue.length > 0) {
|
|
806
|
+
const next = queue.shift() as PublishedContent
|
|
807
|
+
for (const child of view.childrenOf.get(next.id) ?? []) {
|
|
808
|
+
out.push(child)
|
|
809
|
+
queue.push(child)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return out
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** A dictionary item's translation in `culture`, then its fallback languages; '' when none. */
|
|
816
|
+
export function translate(snapshot: CacheSnapshot, key: string, culture: string | null): string {
|
|
817
|
+
const item = snapshot.dictionary.get(key.toLowerCase())
|
|
818
|
+
if (!item) return ''
|
|
819
|
+
const wanted = culture ?? snapshot.defaultCulture
|
|
820
|
+
for (const c of [wanted, ...fallbackChain(snapshot.languages, wanted)]) {
|
|
821
|
+
const text = c ? item.get(c.toLowerCase()) : undefined
|
|
822
|
+
if (text) return text
|
|
823
|
+
}
|
|
824
|
+
return ''
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** A model for one node outside the cache, such as a draft in preview. */
|
|
828
|
+
export function contentForNode(
|
|
829
|
+
node: PublishedNode,
|
|
830
|
+
url: string,
|
|
831
|
+
culture: string | null = null,
|
|
832
|
+
languages: readonly PublishedLanguage[] = [],
|
|
833
|
+
): PublishedContent {
|
|
834
|
+
const variant = node.cultures.length > 0
|
|
835
|
+
const scoped = variant ? (culture ?? node.cultures[0] ?? null) : null
|
|
836
|
+
const name =
|
|
837
|
+
(scoped &&
|
|
838
|
+
Object.entries(node.names ?? {}).find(([c]) => viewKey(c) === viewKey(scoped))?.[1]) ||
|
|
839
|
+
node.name
|
|
840
|
+
const override =
|
|
841
|
+
node.properties.find(
|
|
842
|
+
(p) => p.alias === URL_NAME_ALIAS && p.culture && viewKey(p.culture) === viewKey(scoped),
|
|
843
|
+
) ??
|
|
844
|
+
node.properties.find(
|
|
845
|
+
(property) => property.alias === URL_NAME_ALIAS && property.culture === null,
|
|
846
|
+
)
|
|
847
|
+
return new PublishedContent({
|
|
848
|
+
key: node.key,
|
|
849
|
+
id: node.id,
|
|
850
|
+
name,
|
|
851
|
+
contentTypeAlias: node.contentTypeAlias,
|
|
852
|
+
urlSegment: urlSegmentFor(name, typeof override?.value === 'string' ? override.value : null),
|
|
853
|
+
url,
|
|
854
|
+
level: node.level,
|
|
855
|
+
path: node.path,
|
|
856
|
+
sortOrder: node.sortOrder,
|
|
857
|
+
createDate: node.createDate,
|
|
858
|
+
updateDate: node.updateDate,
|
|
859
|
+
componentAlias: node.componentAlias,
|
|
860
|
+
cultures: node.cultures,
|
|
861
|
+
properties: node.properties,
|
|
862
|
+
variationCulture: scoped,
|
|
863
|
+
languageFallback: fallbackChain(languages, scoped),
|
|
864
|
+
})
|
|
865
|
+
}
|