@crombolo/render 0.11.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/render",
3
- "version": "0.11.0",
3
+ "version": "0.13.1",
4
4
  "description": "TSX server rendering and the published-content model for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@crombolo/core": "0.11.0",
35
+ "@crombolo/core": "0.13.1",
36
36
  "@types/react": "^19.3.0"
37
37
  }
38
38
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The progressive-enhancement script for a rendered form.
2
+ * The progressive-enhancement script for a rendered form: conditions, and
3
+ * stepping a `step-pages` form one page at a time.
3
4
  *
4
5
  * Held as a string rather than a file so it travels with the package and needs
5
6
  * no build step or path resolution. It is small on purpose: the server
@@ -11,9 +12,11 @@
11
12
  * step by `tests/forms-conditions.test.ts`, which runs the same cases through
12
13
  * both.
13
14
  */
15
+ import { FORM_PAGE_FIELD } from '@crombolo/core'
16
+
14
17
  export const FORM_SCRIPT_PATH = '/crombolo/forms.js'
15
18
 
16
- export const FORM_SCRIPT = `/* crombolo form conditions */
19
+ export const FORM_SCRIPT = `/* crombolo forms: conditions and stepped pages */
17
20
  (function () {
18
21
  if (window.__cromboloForms) return
19
22
 
@@ -78,7 +81,11 @@ export const FORM_SCRIPT = `/* crombolo form conditions */
78
81
  continue
79
82
  }
80
83
  var visible = shown(form, condition)
81
- element.hidden = !visible
84
+ // A stepped form's page is shown by the stepper, which skips one its
85
+ // condition hides; everything else is shown or hidden here.
86
+ if (stepped(form) && element.classList.contains('crombolo-form__page'))
87
+ element.setAttribute('data-condition-hidden', visible ? 'false' : 'true')
88
+ else element.hidden = !visible
82
89
  // A hidden field must not be required, or the browser refuses to submit a
83
90
  // form over something nobody can see.
84
91
  var wasRequired = element.getAttribute('data-required') === 'true'
@@ -95,16 +102,146 @@ export const FORM_SCRIPT = `/* crombolo form conditions */
95
102
  }
96
103
  }
97
104
 
105
+ function stepped(form) {
106
+ return form.getAttribute('data-step-pages') === 'true'
107
+ }
108
+
109
+ function pagesOf(form) {
110
+ return Array.prototype.slice.call(form.querySelectorAll('.crombolo-form__page'))
111
+ }
112
+
113
+ function open(page) {
114
+ return page.getAttribute('data-condition-hidden') !== 'true'
115
+ }
116
+
117
+ /** The next page a visitor can reach from \`from\`, going \`step\` (1 or -1); -1 when none. */
118
+ function neighbour(form, from, step) {
119
+ var pages = pagesOf(form)
120
+ for (var i = from + step; i >= 0 && i < pages.length; i += step) if (open(pages[i])) return i
121
+ return -1
122
+ }
123
+
124
+ function current(form) {
125
+ return Number(form.getAttribute('data-step-current') || 0)
126
+ }
127
+
128
+ function layout(form) {
129
+ if (!stepped(form)) return
130
+ var pages = pagesOf(form)
131
+ var at = current(form)
132
+ // A condition can close the page a visitor is on: step back to one that is open.
133
+ if (!pages[at] || !open(pages[at])) {
134
+ var back = neighbour(form, at, -1)
135
+ at = back === -1 ? neighbour(form, -1, 1) : back
136
+ form.setAttribute('data-step-current', String(at))
137
+ }
138
+ for (var i = 0; i < pages.length; i++) pages[i].hidden = i !== at
139
+ var last = neighbour(form, at, 1) === -1
140
+ var buttons = {
141
+ back: form.querySelector('[data-step="back"]'),
142
+ next: form.querySelector('[data-step="next"]'),
143
+ submit: form.querySelector('.crombolo-form__submit'),
144
+ }
145
+ if (buttons.back) buttons.back.hidden = neighbour(form, at, -1) === -1
146
+ if (buttons.next) buttons.next.hidden = last
147
+ if (buttons.submit) buttons.submit.hidden = !last
148
+ }
149
+
150
+ function showErrors(page, errors) {
151
+ var old = page.querySelectorAll('[data-step-error]')
152
+ for (var i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i])
153
+ var marked = page.querySelectorAll('.crombolo-form__field--invalid')
154
+ for (var j = 0; j < marked.length; j++) marked[j].classList.remove('crombolo-form__field--invalid')
155
+ var first = null
156
+ for (var k = 0; k < errors.length; k++) {
157
+ var field = page.querySelector('[data-field="' + errors[k].field + '"]')
158
+ if (!field) continue
159
+ field.classList.add('crombolo-form__field--invalid')
160
+ var message = document.createElement('p')
161
+ message.className = 'crombolo-form__error'
162
+ message.setAttribute('role', 'alert')
163
+ message.setAttribute('data-step-error', 'true')
164
+ message.textContent = errors[k].message
165
+ field.appendChild(message)
166
+ if (!first) first = field.querySelector('input, select, textarea')
167
+ }
168
+ if (first) first.focus()
169
+ }
170
+
171
+ function go(form, to) {
172
+ form.setAttribute('data-step-current', String(to))
173
+ layout(form)
174
+ var page = pagesOf(form)[to]
175
+ if (page && page.scrollIntoView) page.scrollIntoView({ block: 'start' })
176
+ var input = page && page.querySelector('input:not([type="hidden"]), select, textarea')
177
+ if (input) input.focus()
178
+ }
179
+
180
+ /** Checks this page on the server, then moves on. A failure to reach it moves on too: the submit checks everything. */
181
+ function next(form) {
182
+ var at = current(form)
183
+ var to = neighbour(form, at, 1)
184
+ if (to === -1) return
185
+ var button = form.querySelector('[data-step="next"]')
186
+ if (button) button.disabled = true
187
+ var data = new FormData(form)
188
+ data.set('${FORM_PAGE_FIELD}', String(at))
189
+ fetch(form.action, {
190
+ method: 'POST',
191
+ body: data,
192
+ credentials: 'same-origin',
193
+ headers: { accept: 'application/json' },
194
+ })
195
+ .then(function (response) {
196
+ return response.json().catch(function () { return { ok: response.ok } })
197
+ })
198
+ .catch(function () { return { ok: true } })
199
+ .then(function (result) {
200
+ if (button) button.disabled = false
201
+ var page = pagesOf(form)[at]
202
+ if (result && result.ok === false && result.errors) return showErrors(page, result.errors)
203
+ showErrors(page, [])
204
+ go(form, to)
205
+ })
206
+ }
207
+
208
+ function attachSteps(form) {
209
+ if (!stepped(form)) return
210
+ // After a refused submission, open on the first page the server complained about.
211
+ var pages = pagesOf(form)
212
+ for (var i = 0; i < pages.length; i++)
213
+ if (pages[i].querySelector('.crombolo-form__field--invalid')) {
214
+ form.setAttribute('data-step-current', String(i))
215
+ break
216
+ }
217
+ layout(form)
218
+ var nextButton = form.querySelector('[data-step="next"]')
219
+ var backButton = form.querySelector('[data-step="back"]')
220
+ if (nextButton) nextButton.addEventListener('click', function () { next(form) })
221
+ if (backButton)
222
+ backButton.addEventListener('click', function () {
223
+ var to = neighbour(form, current(form), -1)
224
+ if (to !== -1) go(form, to)
225
+ })
226
+ // Enter on an earlier page means "next", not "submit".
227
+ form.addEventListener('submit', function (event) {
228
+ if (neighbour(form, current(form), 1) === -1) return
229
+ event.preventDefault()
230
+ next(form)
231
+ })
232
+ }
233
+
98
234
  function attach(form) {
99
235
  apply(form)
100
- form.addEventListener('input', function () { apply(form) })
101
- form.addEventListener('change', function () { apply(form) })
236
+ attachSteps(form)
237
+ form.addEventListener('input', function () { apply(form); layout(form) })
238
+ form.addEventListener('change', function () { apply(form); layout(form) })
102
239
  }
103
240
 
104
241
  // Also the guard against running twice, which two forms on a page would
105
242
  // otherwise do. Exposed rather than a bare flag so the suite can hold these
106
243
  // two functions against the server's own -- see tests/forms-conditions.
107
- window.__cromboloForms = { rule: rule, shown: shown, apply: apply }
244
+ window.__cromboloForms = { rule: rule, shown: shown, apply: apply, layout: layout, neighbour: neighbour }
108
245
 
109
246
  var forms = document.querySelectorAll('form.crombolo-form')
110
247
  for (var i = 0; i < forms.length; i++) attach(forms[i])
package/src/forms.ts CHANGED
@@ -17,13 +17,18 @@
17
17
  * `docs/forms.md` says why this diverges from Umbraco's theme folders.
18
18
  */
19
19
  import {
20
+ BUILT_IN_FORM_TYPES,
21
+ type BuiltInFormFieldType,
22
+ choiceOptions,
20
23
  FORM_HONEYPOT_FIELD,
21
24
  FORM_RENDERED_FIELD,
22
25
  FORM_TOKEN_FIELD,
23
26
  type FormFieldError,
24
27
  type FormFieldType,
25
28
  type FormSubmissionState,
29
+ type FormTypes,
26
30
  formFieldType,
31
+ isBuiltInFieldType,
27
32
  isShown,
28
33
  type SchemaForm,
29
34
  type SchemaFormField,
@@ -54,6 +59,23 @@ export function setFormTokenSigner(
54
59
  signer = sign
55
60
  }
56
61
 
62
+ /**
63
+ * The field types a wired bundle adds, and how each draws itself. Process-wide
64
+ * for the reason the signer is: bundles are wired once per process, at boot.
65
+ * A site's own `components` still wins over a bundle's renderer.
66
+ */
67
+ let registered: { types: FormTypes; renderers: ReadonlyMap<string, FormFieldRenderer> } = {
68
+ types: BUILT_IN_FORM_TYPES,
69
+ renderers: new Map(),
70
+ }
71
+
72
+ export function setFormFieldTypes(
73
+ types: FormTypes,
74
+ renderers: ReadonlyMap<string, FormFieldRenderer>,
75
+ ): void {
76
+ registered = { types, renderers }
77
+ }
78
+
57
79
  export const formAction = (form: SchemaForm): string =>
58
80
  `${FORM_SUBMIT_PATH}/${encodeURIComponent(form.key ?? form.alias)}`
59
81
 
@@ -119,7 +141,7 @@ const checked = (values: readonly string[]): boolean =>
119
141
  values.some((value) => value === 'true' || value === 'on' || value === '1')
120
142
 
121
143
  /** The input types that are a plain `<input>` differing only by `type`. */
122
- const INPUT_TYPES: Partial<Record<FormFieldType, string>> = {
144
+ const INPUT_TYPES: Partial<Record<BuiltInFormFieldType, string>> = {
123
145
  shortAnswer: 'text',
124
146
  email: 'email',
125
147
  number: 'number',
@@ -131,7 +153,7 @@ function textInput({ field, values, id, name, error, shown }: FormFieldRenderPro
131
153
  return element(
132
154
  'input',
133
155
  attrs({
134
- type: INPUT_TYPES[field.type] ?? 'text',
156
+ type: INPUT_TYPES[field.type as BuiltInFormFieldType] ?? 'text',
135
157
  id,
136
158
  name,
137
159
  value: first(values),
@@ -159,7 +181,7 @@ const describedBy = (
159
181
  return ids.length > 0 ? ids.join(' ') : undefined
160
182
  }
161
183
 
162
- const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
184
+ const DEFAULTS: Record<BuiltInFormFieldType, FormFieldRenderer> = {
163
185
  shortAnswer: textInput,
164
186
  email: textInput,
165
187
  number: textInput,
@@ -229,13 +251,13 @@ const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
229
251
  field.mandatory || first(values) === ''
230
252
  ? element('option', { value: '', children: field.placeholder ?? '' })
231
253
  : null,
232
- ...field.values.map((option) =>
254
+ ...choiceOptions(field).map((option) =>
233
255
  element(
234
256
  'option',
235
257
  attrs({
236
- value: option,
237
- selected: option === first(values) || undefined,
238
- children: option,
258
+ value: option.value,
259
+ selected: option.value === first(values) || undefined,
260
+ children: option.label,
239
261
  }),
240
262
  ),
241
263
  ),
@@ -247,7 +269,7 @@ const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
247
269
  element('div', {
248
270
  class: 'crombolo-form__choices',
249
271
  role: 'radiogroup',
250
- children: field.values.map((option, index) =>
272
+ children: choiceOptions(field).map((option, index) =>
251
273
  element('label', {
252
274
  class: 'crombolo-form__choice',
253
275
  children: [
@@ -257,12 +279,12 @@ const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
257
279
  type: 'radio',
258
280
  id: `${id}-${index}`,
259
281
  name,
260
- value: option,
261
- checked: option === first(values) || undefined,
282
+ value: option.value,
283
+ checked: option.value === first(values) || undefined,
262
284
  required: (field.mandatory && shown) || undefined,
263
285
  }),
264
286
  ),
265
- element('span', { children: option }),
287
+ element('span', { children: option.label }),
266
288
  ],
267
289
  }),
268
290
  ),
@@ -271,7 +293,7 @@ const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
271
293
  multipleChoice: ({ field, values, id, name }) =>
272
294
  element('div', {
273
295
  class: 'crombolo-form__choices',
274
- children: field.values.map((option, index) =>
296
+ children: choiceOptions(field).map((option, index) =>
275
297
  element('label', {
276
298
  class: 'crombolo-form__choice',
277
299
  children: [
@@ -281,11 +303,11 @@ const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
281
303
  type: 'checkbox',
282
304
  id: `${id}-${index}`,
283
305
  name,
284
- value: option,
285
- checked: values.includes(option) || undefined,
306
+ value: option.value,
307
+ checked: values.includes(option.value) || undefined,
286
308
  }),
287
309
  ),
288
- element('span', { children: option }),
310
+ element('span', { children: option.label }),
289
311
  ],
290
312
  }),
291
313
  ),
@@ -333,7 +355,11 @@ export function rendererFor(
333
355
  type: FormFieldType,
334
356
  components: FormFieldComponents | undefined,
335
357
  ): FormFieldRenderer | undefined {
336
- return components?.[type] ?? DEFAULTS[type]
358
+ return (
359
+ components?.[type] ??
360
+ registered.renderers.get(type) ??
361
+ (isBuiltInFieldType(type) ? DEFAULTS[type] : undefined)
362
+ )
337
363
  }
338
364
 
339
365
  function renderField(
@@ -345,7 +371,7 @@ function renderField(
345
371
  idPrefix: string
346
372
  },
347
373
  ): Child {
348
- const info = formFieldType(field.type)
374
+ const info = formFieldType(field.type, registered.types)
349
375
  if (!info) return null
350
376
  const renderer = rendererFor(field.type, options.components)
351
377
  if (!renderer) return null
@@ -459,9 +485,9 @@ function renderPage(
459
485
  /**
460
486
  * A form, as HTML.
461
487
  *
462
- * Every page is rendered into one form rather than one page at a time. A
463
- * multi-page definition therefore submits in one go; stepping through pages with
464
- * a round trip each is a refinement on top of this, not a different shape.
488
+ * Every page is rendered into one form. With `step-pages` the script shows one
489
+ * at a time and checks each on the server before the next; answers stay in the
490
+ * form, uploads included, until the one submission at the end.
465
491
  */
466
492
  export function Form(props: FormProps): RawHtml {
467
493
  const form = props.form
@@ -495,6 +521,7 @@ export function Form(props: FormProps): RawHtml {
495
521
  if (props.className) classes.push(props.className)
496
522
 
497
523
  const key = form.key ?? form.alias
524
+ const stepped = form.stepPages === true && form.pages.length > 1
498
525
  const renderedAt = props.renderedAt ?? Date.now()
499
526
  const token = props.token ?? signer?.(key, renderedAt)
500
527
 
@@ -508,6 +535,7 @@ export function Form(props: FormProps): RawHtml {
508
535
  enctype: hasUpload ? 'multipart/form-data' : undefined,
509
536
  class: classes.join(' '),
510
537
  'data-form': form.alias,
538
+ 'data-step-pages': stepped ? 'true' : undefined,
511
539
  novalidate: undefined,
512
540
  children: [
513
541
  errors.length > 0
@@ -545,11 +573,33 @@ export function Form(props: FormProps): RawHtml {
545
573
  : null,
546
574
  element('div', {
547
575
  class: 'crombolo-form__actions',
548
- children: element('button', {
549
- type: 'submit',
550
- class: 'crombolo-form__submit',
551
- children: form.submitLabel ?? 'Submit',
552
- }),
576
+ children: [
577
+ // Hidden until the script takes over: without it every page shows
578
+ // and the form submits in one go, so these would do nothing.
579
+ stepped
580
+ ? element('button', {
581
+ type: 'button',
582
+ class: 'crombolo-form__back',
583
+ 'data-step': 'back',
584
+ hidden: true,
585
+ children: form.previousLabel ?? 'Back',
586
+ })
587
+ : null,
588
+ stepped
589
+ ? element('button', {
590
+ type: 'button',
591
+ class: 'crombolo-form__next',
592
+ 'data-step': 'next',
593
+ hidden: true,
594
+ children: form.nextLabel ?? 'Next',
595
+ })
596
+ : null,
597
+ element('button', {
598
+ type: 'submit',
599
+ class: 'crombolo-form__submit',
600
+ children: form.submitLabel ?? 'Submit',
601
+ }),
602
+ ],
553
603
  }),
554
604
  ],
555
605
  }),
package/src/media.ts CHANGED
@@ -124,6 +124,10 @@ export class PublishedMedia {
124
124
  readonly name: string
125
125
  readonly mediaType: { alias: string }
126
126
  readonly properties: PublishedProperty[]
127
+ readonly parentId: number
128
+ readonly sortOrder: number
129
+ readonly createDate: Date
130
+ readonly updateDate: Date
127
131
 
128
132
  constructor(init: {
129
133
  key: string
@@ -131,12 +135,20 @@ export class PublishedMedia {
131
135
  name: string
132
136
  mediaTypeAlias: string
133
137
  properties: PublishedProperty[]
138
+ parentId?: number
139
+ sortOrder?: number
140
+ createDate?: Date
141
+ updateDate?: Date
134
142
  }) {
135
143
  this.key = init.key
136
144
  this.id = init.id
137
145
  this.name = init.name
138
146
  this.mediaType = { alias: init.mediaTypeAlias }
139
147
  this.properties = init.properties
148
+ this.parentId = init.parentId ?? -1
149
+ this.sortOrder = init.sortOrder ?? 0
150
+ this.createDate = init.createDate ?? new Date(0)
151
+ this.updateDate = init.updateDate ?? this.createDate
140
152
  }
141
153
 
142
154
  /** The file's URL: what `umbracoFile` holds. */
@@ -12,7 +12,12 @@
12
12
  * navigation resolve within the culture of the page that asks.
13
13
  */
14
14
 
15
- import type { SchemaForm } from '@crombolo/core'
15
+ import {
16
+ type ChoiceOption,
17
+ type ChoiceSource,
18
+ type SchemaForm,
19
+ withResolvedChoices,
20
+ } from '@crombolo/core'
16
21
  import { PublishedMedia } from './media.ts'
17
22
  import { type Navigation, PublishedContent, type PublishedProperty } from './model.ts'
18
23
  import { matchRedirect, type RedirectRule } from './redirects.ts'
@@ -71,12 +76,52 @@ export interface PublishedContentSource {
71
76
  loadLanguages?(): Promise<PublishedLanguage[]>
72
77
  /** Dictionary translations, by item key then culture; none when omitted. */
73
78
  loadDictionary?(): Promise<Map<string, Map<string, string>>>
79
+ /** Every dictionary item with its parent's name, for choice lists; none when omitted. */
80
+ loadDictionaryOutline?(): Promise<DictionaryOutlineItem[]>
74
81
  /** Redirect rules, configured ones first; none when omitted. */
75
82
  loadRedirects?(): Promise<RedirectRule[]>
76
83
  /** Published library elements, for the pickers that point at them; none when omitted. */
77
84
  loadElements?(): Promise<PublishedElementNode[]>
78
85
  /** Content type aliases by key, so a block knows its element type; none when omitted. */
79
86
  loadContentTypeAliases?(): Promise<Map<string, string>>
87
+ /** How URLs are built; Umbraco's defaults when omitted. */
88
+ loadUrlOptions?(): Promise<PublishedUrlOptions>
89
+ }
90
+
91
+ /** `HideTopLevelNodeFromPath`: true, Umbraco's default, leaves the top-level page out of paths. */
92
+ export interface PublishedUrlOptions {
93
+ hideTopLevel: boolean
94
+ }
95
+
96
+ const RTL_LANGUAGES = new Set([
97
+ 'ar',
98
+ 'arc',
99
+ 'ckb',
100
+ 'dv',
101
+ 'fa',
102
+ 'ha',
103
+ 'he',
104
+ 'khw',
105
+ 'ks',
106
+ 'ps',
107
+ 'sd',
108
+ 'ur',
109
+ 'yi',
110
+ ])
111
+
112
+ /** Whether a culture is written right to left, as .NET's `TextInfo.IsRightToLeft` says. Pure. */
113
+ export function isRightToLeft(culture: string | null | undefined): boolean {
114
+ if (!culture) return false
115
+ try {
116
+ const locale = new Intl.Locale(culture) as Intl.Locale & {
117
+ getTextInfo?: () => { direction?: string }
118
+ }
119
+ const direction = locale.getTextInfo?.().direction
120
+ if (direction) return direction === 'rtl'
121
+ return RTL_LANGUAGES.has(locale.language)
122
+ } catch {
123
+ return false
124
+ }
80
125
  }
81
126
 
82
127
  /**
@@ -99,6 +144,11 @@ export interface PublishedMediaNode {
99
144
  name: string
100
145
  mediaTypeAlias: string
101
146
  properties: PublishedProperty[]
147
+ /** Its place in the media tree; a root item's parent is -1. */
148
+ parentId?: number
149
+ sortOrder?: number
150
+ createDate?: Date
151
+ updateDate?: Date
102
152
  }
103
153
 
104
154
  /** The published tree in one culture. */
@@ -113,10 +163,18 @@ export interface CultureView {
113
163
  parentIdOf: Map<number, number>
114
164
  }
115
165
 
166
+ export interface DictionaryOutlineItem {
167
+ key: string
168
+ name: string
169
+ parentName: string | null
170
+ }
171
+
116
172
  export interface CacheSnapshot {
117
173
  /** Keyed by lowercase culture; `''` holds an invariant site's single view. */
118
174
  views: Map<string, CultureView>
119
175
  defaultCulture: string | null
176
+ /** `HideTopLevelNodeFromPath`, as this snapshot was built with it. */
177
+ hideTopLevel?: boolean
120
178
  /** Routes without a hostname, in the default culture. */
121
179
  byRoute: Map<string, PublishedContent>
122
180
  /** `<domain name>:<route below it>`, for requests that match a hostname. */
@@ -132,6 +190,8 @@ export interface CacheSnapshot {
132
190
  media: Map<string, PublishedMedia>
133
191
  /** Item key (lowercase) → culture (lowercase) → translation. */
134
192
  dictionary: Map<string, Map<string, string>>
193
+ /** Every dictionary item, for a choice list drawn from one item's children. */
194
+ dictionaryOutline: DictionaryOutlineItem[]
135
195
  /** Content type key (lowercase) → alias. */
136
196
  contentTypeAliases: Map<string, string>
137
197
  }
@@ -177,6 +237,7 @@ const EMPTY: CacheSnapshot = {
177
237
  languages: [],
178
238
  media: new Map(),
179
239
  dictionary: new Map(),
240
+ dictionaryOutline: [],
180
241
  contentTypeAliases: new Map(),
181
242
  }
182
243
 
@@ -222,6 +283,10 @@ export class PublishedCache {
222
283
  #snapshot: CacheSnapshot = EMPTY
223
284
  #loaded = false
224
285
  #options: PublishedCacheOptions
286
+ /** Moves on every invalidation, so a build that one overtook is not kept as current. */
287
+ #generation = 0
288
+ #building: { generation: number; done: Promise<CacheSnapshot> } | undefined
289
+ #snapshotGeneration = -1
225
290
 
226
291
  constructor(source: PublishedContentSource, options: PublishedCacheOptions = {}) {
227
292
  this.#source = source
@@ -231,14 +296,35 @@ export class PublishedCache {
231
296
  /** Dropped on publish or unpublish; the next read rebuilds it. */
232
297
  invalidate(): void {
233
298
  this.#loaded = false
299
+ this.#generation += 1
234
300
  }
235
301
 
302
+ /**
303
+ * The current snapshot, building it if it was dropped. Readers that arrive
304
+ * while it builds share that build rather than each starting one; a build
305
+ * an invalidation overtook still answers, but the next read builds again.
306
+ */
236
307
  async snapshot(): Promise<CacheSnapshot> {
237
- if (!this.#loaded) {
238
- this.#snapshot = await this.#build()
239
- this.#loaded = true
240
- }
241
- return this.#snapshot
308
+ if (this.#loaded) return this.#snapshot
309
+ const generation = this.#generation
310
+ if (this.#building?.generation === generation) return this.#building.done
311
+ const done = this.#build().then((snapshot) => {
312
+ // A slower, older build must not replace a newer one that finished first.
313
+ if (generation >= this.#snapshotGeneration) {
314
+ this.#snapshot = snapshot
315
+ this.#snapshotGeneration = generation
316
+ }
317
+ if (this.#generation === generation) this.#loaded = true
318
+ return snapshot
319
+ })
320
+ const building = { generation, done }
321
+ this.#building = building
322
+ void done
323
+ .finally(() => {
324
+ if (this.#building === building) this.#building = undefined
325
+ })
326
+ .catch(() => {})
327
+ return done
242
328
  }
243
329
 
244
330
  #view(snapshot: CacheSnapshot, culture: string | null | undefined): CultureView | undefined {
@@ -372,6 +458,42 @@ export class PublishedCache {
372
458
  return out
373
459
  }
374
460
 
461
+ /** Every published document a culture can see, in tree order: what a headless query scans. */
462
+ async contentIn(culture?: string | null): Promise<PublishedContent[]> {
463
+ const snapshot = await this.snapshot()
464
+ const view = this.#view(snapshot, culture)
465
+ if (!view) return []
466
+ const out: PublishedContent[] = []
467
+ const walk = (items: readonly PublishedContent[]) => {
468
+ for (const item of items) {
469
+ out.push(item)
470
+ walk(view.childrenOf.get(item.id) ?? [])
471
+ }
472
+ }
473
+ walk(view.roots)
474
+ return out
475
+ }
476
+
477
+ /** Every media item, in tree order. */
478
+ async allMedia(): Promise<PublishedMedia[]> {
479
+ return [...(await this.snapshot()).media.values()]
480
+ }
481
+
482
+ /** How URLs are built here: whether the top-level page is left out of paths. */
483
+ async hidesTopLevel(): Promise<boolean> {
484
+ return (await this.snapshot()).hideTopLevel ?? true
485
+ }
486
+
487
+ /** The culture a request gets when it names none. */
488
+ async defaultCulture(): Promise<string | null> {
489
+ return (await this.snapshot()).defaultCulture
490
+ }
491
+
492
+ /** Whether a culture has a view here: a language the site has. */
493
+ async hasCulture(culture: string): Promise<boolean> {
494
+ return (await this.snapshot()).views.has(viewKey(culture))
495
+ }
496
+
375
497
  /** The published children of a document, or of the tree root; keys, once each. */
376
498
  async childKeys(parentKey: string | null): Promise<string[]> {
377
499
  const snapshot = await this.snapshot()
@@ -405,7 +527,7 @@ export class PublishedCache {
405
527
  return {
406
528
  content: (key) => view?.byKey.get(key.toLowerCase()),
407
529
  media: (key) => snapshot.media.get(key.toLowerCase()),
408
- form: this.#options.form,
530
+ form: this.#formsIn(snapshot, view, culture ?? null),
409
531
  contentTypeAlias: (key) => snapshot.contentTypeAliases.get(key.toLowerCase()),
410
532
  urlOf: (content) => content.url,
411
533
  mediaUrlOf: (item) => (item as PublishedMedia).url,
@@ -472,6 +594,7 @@ export class PublishedCache {
472
594
  const domains = (await this.#source.loadDomains?.()) ?? []
473
595
  const languages = (await this.#source.loadLanguages?.()) ?? []
474
596
  const dictionary = new Map<string, Map<string, string>>()
597
+ const dictionaryOutline = (await this.#source.loadDictionaryOutline?.()) ?? []
475
598
  for (const [key, translations] of (await this.#source.loadDictionary?.()) ?? new Map())
476
599
  dictionary.set(
477
600
  key.toLowerCase(),
@@ -515,6 +638,7 @@ export class PublishedCache {
515
638
  const firstRootId = nodes
516
639
  .filter((node) => node.parentId <= 0)
517
640
  .sort((a, b) => a.sortOrder - b.sortOrder)[0]?.id
641
+ const { hideTopLevel } = (await this.#source.loadUrlOptions?.()) ?? { hideTopLevel: true }
518
642
 
519
643
  const byRoute = new Map<string, PublishedContent>()
520
644
  const byDomainRoute = new Map<string, PublishedContent>()
@@ -555,26 +679,38 @@ export class PublishedCache {
555
679
  )
556
680
  }
557
681
 
682
+ // Umbraco gathers a path from the page up and reverses it into root-first
683
+ // order only for a left-to-right culture; a right-to-left one keeps the
684
+ // page's own segment first.
685
+ const rtl = isRightToLeft(culture)
686
+ const inPathOrder = (segments: string[]) => (rtl ? [...segments].reverse() : segments)
687
+
558
688
  /**
559
689
  * Umbraco's default (`HideTopLevelNodeFromPath`): a top-level node's segment
560
690
  * 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.
691
+ * other is `/<segment>`. With it off, every path starts with its top-level
692
+ * page's segment, the first one's included. A route only exists if every
693
+ * ancestor is also published in the culture — an unpublished ancestor breaks
694
+ * the route rather than leaving a reachable orphan.
564
695
  */
565
696
  const routeOf = (node: PublishedNode): string | undefined => {
566
697
  if (node.parentId <= 0)
567
- return node.id === firstRootId ? '/' : joinPath([segmentOf.get(node.id) as string])
698
+ return node.id === firstRootId && hideTopLevel
699
+ ? '/'
700
+ : joinPath([segmentOf.get(node.id) as string])
568
701
  const segments: string[] = []
569
702
  let current: PublishedNode = node
570
703
  for (;;) {
571
704
  const parent = nodeById.get(current.parentId)
572
705
  if (!parent || !segmentOf.has(parent.id)) return undefined
573
706
  segments.unshift(segmentOf.get(current.id) as string)
574
- if (parent.parentId <= 0) break
707
+ if (parent.parentId <= 0) {
708
+ if (!hideTopLevel) segments.unshift(segmentOf.get(parent.id) as string)
709
+ break
710
+ }
575
711
  current = parent
576
712
  }
577
- return joinPath(segments)
713
+ return joinPath(inPathOrder(segments))
578
714
  }
579
715
 
580
716
  /** The route below the nearest document with a hostname for this culture, if there is one. */
@@ -585,7 +721,7 @@ export class PublishedCache {
585
721
  let current: PublishedNode | undefined = node
586
722
  while (current && segmentOf.has(current.id)) {
587
723
  const domain = domainFor(current.id, culture)
588
- if (domain) return { domain, route: joinPath(segments) }
724
+ if (domain) return { domain, route: joinPath(inPathOrder(segments)) }
589
725
  segments.unshift(segmentOf.get(current.id) as string)
590
726
  current = nodeById.get(current.parentId)
591
727
  }
@@ -657,6 +793,10 @@ export class PublishedCache {
657
793
  )
658
794
  if (isDefault && !byRoute.has(route.toLowerCase()))
659
795
  byRoute.set(route.toLowerCase(), content)
796
+ // With the top level shown the home page's URL is its own segment, but `/`
797
+ // still has to answer with it.
798
+ if (!hideTopLevel && isDefault && node.id === firstRootId && !domainRoute)
799
+ byRoute.set('/', content)
660
800
  // Every hostname on the rooting document serves its own culture's view;
661
801
  // one with no culture serves the default's
662
802
  if (domainRoute)
@@ -686,7 +826,11 @@ export class PublishedCache {
686
826
  // Read lazily: the map is filled just below, with this very context, so an
687
827
  // element picking another element resolves too.
688
828
  element: (key) => view.elements.get(key.toLowerCase()),
689
- form: this.#options.form,
829
+ form: this.#formsIn(
830
+ { dictionary, dictionaryOutline, languages, defaultCulture },
831
+ view,
832
+ culture,
833
+ ),
690
834
  contentTypeAlias: (key) => contentTypeAliases.get(key.toLowerCase()),
691
835
  urlOf: (content) => content.url,
692
836
  mediaUrlOf: (item) => item.url,
@@ -736,6 +880,7 @@ export class PublishedCache {
736
880
  return {
737
881
  views,
738
882
  defaultCulture,
883
+ hideTopLevel,
739
884
  byRoute,
740
885
  byDomainRoute,
741
886
  domains,
@@ -745,9 +890,76 @@ export class PublishedCache {
745
890
  languages,
746
891
  media,
747
892
  dictionary,
893
+ dictionaryOutline,
748
894
  contentTypeAliases,
749
895
  }
750
896
  }
897
+
898
+ /** Form lookups whose source-backed choices resolve against this view and culture. */
899
+ #formsIn(
900
+ snapshot: ChoiceSnapshot,
901
+ view: CultureView | undefined,
902
+ culture: string | null,
903
+ ): ((key: string) => SchemaForm | undefined) | undefined {
904
+ const lookup = this.#options.form
905
+ if (!lookup) return undefined
906
+ return (key) => {
907
+ const form = lookup(key)
908
+ return form
909
+ ? withResolvedChoices(form, (source) => resolveChoices(snapshot, view, culture, source))
910
+ : undefined
911
+ }
912
+ }
913
+
914
+ /** What a choice source offers now, in `culture`: for the designer, and for a submission. */
915
+ async choiceOptions(source: ChoiceSource, culture?: string | null): Promise<ChoiceOption[]> {
916
+ const snapshot = await this.snapshot()
917
+ return resolveChoices(snapshot, this.#view(snapshot, culture), culture ?? null, source)
918
+ }
919
+
920
+ /** The form with its source-backed choices resolved, as a page in `culture` would render it. */
921
+ async resolveForm(form: SchemaForm, culture?: string | null): Promise<SchemaForm> {
922
+ const snapshot = await this.snapshot()
923
+ const view = this.#view(snapshot, culture)
924
+ return withResolvedChoices(form, (source) =>
925
+ resolveChoices(snapshot, view, culture ?? null, source),
926
+ )
927
+ }
928
+ }
929
+
930
+ type ChoiceSnapshot = Pick<
931
+ CacheSnapshot,
932
+ 'dictionary' | 'dictionaryOutline' | 'languages' | 'defaultCulture'
933
+ >
934
+
935
+ /**
936
+ * A choice source's options: a published page's children by key and name, or a
937
+ * dictionary item's children by key and translation. Nothing when the parent is gone.
938
+ */
939
+ export function resolveChoices(
940
+ snapshot: ChoiceSnapshot,
941
+ view: CultureView | undefined,
942
+ culture: string | null,
943
+ source: ChoiceSource,
944
+ ): ChoiceOption[] {
945
+ if (source.kind === 'content') {
946
+ const parent = view?.byKey.get(source.parent.toLowerCase())
947
+ if (!parent || !view) return []
948
+ return (view.childrenOf.get(parent.id) ?? []).map((child) => ({
949
+ value: child.key,
950
+ label: child.name,
951
+ }))
952
+ }
953
+ if (source.kind === 'dictionary') {
954
+ const parent = source.parent.toLowerCase()
955
+ return snapshot.dictionaryOutline
956
+ .filter((item) => item.parentName?.toLowerCase() === parent)
957
+ .map((item) => ({
958
+ value: item.key,
959
+ label: translate(snapshot, item.name, culture) || item.name,
960
+ }))
961
+ }
962
+ return []
751
963
  }
752
964
 
753
965
  /**
@@ -813,7 +1025,11 @@ function descendantsOf(view: CultureView, content: PublishedContent): PublishedC
813
1025
  }
814
1026
 
815
1027
  /** 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 {
1028
+ export function translate(
1029
+ snapshot: Pick<CacheSnapshot, 'dictionary' | 'languages' | 'defaultCulture'>,
1030
+ key: string,
1031
+ culture: string | null,
1032
+ ): string {
817
1033
  const item = snapshot.dictionary.get(key.toLowerCase())
818
1034
  if (!item) return ''
819
1035
  const wanted = culture ?? snapshot.defaultCulture