@crombolo/render 0.0.0-stage → 0.10.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.
@@ -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/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
+ }