@crombolo/render 0.11.0 → 0.12.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 +2 -2
- package/src/form-script.ts +143 -6
- package/src/forms.ts +75 -25
- package/src/media.ts +12 -0
- package/src/published-cache.ts +232 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.
|
|
35
|
+
"@crombolo/core": "0.12.1",
|
|
36
36
|
"@types/react": "^19.3.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/form-script.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
form.addEventListener('
|
|
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<
|
|
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<
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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:
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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. */
|
package/src/published-cache.ts
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* navigation resolve within the culture of the page that asks.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import
|
|
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 (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.#
|
|
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>`.
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
|
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)
|
|
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.#
|
|
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(
|
|
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
|