@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +36 -4
- package/src/context.ts +112 -0
- package/src/form-script.ts +112 -0
- package/src/forms.ts +557 -0
- package/src/html.ts +285 -0
- package/src/index.ts +16 -0
- package/src/jsx-attributes.ts +91 -0
- package/src/jsx-runtime.ts +334 -0
- package/src/loose/jsx-runtime.ts +21 -0
- package/src/media.ts +205 -0
- package/src/model.ts +303 -0
- package/src/published-cache.ts +865 -0
- package/src/redirects.ts +131 -0
- package/src/renderer.ts +374 -0
- package/src/seo.ts +209 -0
- package/src/sitemap.ts +99 -0
- package/src/snapshots.ts +398 -0
- package/src/url-segment.ts +34 -0
- package/src/values.ts +325 -0
- package/README.md +0 -3
package/src/model.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model a template sees.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately close to Umbraco's `IPublishedContent`: small, with no `parent` or
|
|
5
|
+
* `children` properties — navigation is a separate service, which is what keeps
|
|
6
|
+
* the published cache cheap. `value()` carries the ergonomics: culture is
|
|
7
|
+
* ambient, so `model.value('title')` returns the current request's culture.
|
|
8
|
+
*/
|
|
9
|
+
import type { FormSubmissionState } from '@crombolo/core'
|
|
10
|
+
import { RawHtml } from './html.ts'
|
|
11
|
+
import { MediaWithCrops } from './media.ts'
|
|
12
|
+
import type { SeoSiteDefaults } from './seo.ts'
|
|
13
|
+
import { NAVI_HIDE_ALIAS } from './url-segment.ts'
|
|
14
|
+
import { convertValue, type ValueContext } from './values.ts'
|
|
15
|
+
|
|
16
|
+
export type FallbackStrategy = 'none' | 'defaultValue' | 'language' | 'ancestors'
|
|
17
|
+
|
|
18
|
+
export interface ValueOptions {
|
|
19
|
+
culture?: string | null
|
|
20
|
+
segment?: string | null
|
|
21
|
+
fallback?: FallbackStrategy
|
|
22
|
+
default?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The property aliases a page's interface names; any string for an untyped page. */
|
|
26
|
+
export type AliasOf<Properties> = keyof Properties & string
|
|
27
|
+
|
|
28
|
+
/** A property's type from the page's interface; `unknown` for an untyped page. */
|
|
29
|
+
export type ValueOf<Properties, Alias extends keyof Properties> = 0 extends 1 & Properties
|
|
30
|
+
? unknown
|
|
31
|
+
: Properties[Alias]
|
|
32
|
+
|
|
33
|
+
export interface PublishedProperty {
|
|
34
|
+
alias: string
|
|
35
|
+
editorAlias: string
|
|
36
|
+
culture: string | null
|
|
37
|
+
segment: string | null
|
|
38
|
+
value: unknown
|
|
39
|
+
/** The data type's configuration, for converters that depend on it. */
|
|
40
|
+
config?: Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PublishedContentInit {
|
|
44
|
+
key: string
|
|
45
|
+
id: number
|
|
46
|
+
name: string
|
|
47
|
+
contentTypeAlias: string
|
|
48
|
+
urlSegment: string
|
|
49
|
+
url: string
|
|
50
|
+
level: number
|
|
51
|
+
path: string
|
|
52
|
+
sortOrder: number
|
|
53
|
+
createDate: Date
|
|
54
|
+
updateDate: Date
|
|
55
|
+
componentAlias: string | null
|
|
56
|
+
cultures: string[]
|
|
57
|
+
properties: PublishedProperty[]
|
|
58
|
+
/** The culture in scope for this request; null for an invariant site. */
|
|
59
|
+
variationCulture: string | null
|
|
60
|
+
/** Fallback chain for `language` fallback, nearest first. */
|
|
61
|
+
languageFallback?: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A published page. `Properties` is the page's interface from `crombolo generate`;
|
|
66
|
+
* given one, `value()` and its siblings accept only that type's aliases and
|
|
67
|
+
* `value()` returns the property's type. Without one, any alias is accepted and
|
|
68
|
+
* `value()` returns `unknown`.
|
|
69
|
+
*
|
|
70
|
+
* The default is `any` rather than an index type so that a typed page fits
|
|
71
|
+
* wherever a plain `PublishedContent` is accepted: `nav.children(model)`, a
|
|
72
|
+
* component's own props.
|
|
73
|
+
*/
|
|
74
|
+
// biome-ignore lint/suspicious/noExplicitAny: the default that lets every typed page in; see above.
|
|
75
|
+
export class PublishedContent<Properties = any> {
|
|
76
|
+
readonly key: string
|
|
77
|
+
readonly id: number
|
|
78
|
+
readonly name: string
|
|
79
|
+
readonly contentType: { alias: string }
|
|
80
|
+
readonly urlSegment: string
|
|
81
|
+
readonly url: string
|
|
82
|
+
readonly level: number
|
|
83
|
+
readonly path: string
|
|
84
|
+
readonly sortOrder: number
|
|
85
|
+
readonly createDate: Date
|
|
86
|
+
readonly updateDate: Date
|
|
87
|
+
readonly componentAlias: string | null
|
|
88
|
+
readonly cultures: string[]
|
|
89
|
+
readonly properties: PublishedProperty[]
|
|
90
|
+
#culture: string | null
|
|
91
|
+
#fallbackChain: string[]
|
|
92
|
+
#ancestors: PublishedContent[] = []
|
|
93
|
+
#context: ValueContext<PublishedContent> | undefined
|
|
94
|
+
|
|
95
|
+
constructor(init: PublishedContentInit) {
|
|
96
|
+
this.key = init.key
|
|
97
|
+
this.id = init.id
|
|
98
|
+
this.name = init.name
|
|
99
|
+
this.contentType = { alias: init.contentTypeAlias }
|
|
100
|
+
this.urlSegment = init.urlSegment
|
|
101
|
+
this.url = init.url
|
|
102
|
+
this.level = init.level
|
|
103
|
+
this.path = init.path
|
|
104
|
+
this.sortOrder = init.sortOrder
|
|
105
|
+
this.createDate = init.createDate
|
|
106
|
+
this.updateDate = init.updateDate
|
|
107
|
+
this.componentAlias = init.componentAlias
|
|
108
|
+
this.cultures = init.cultures
|
|
109
|
+
this.properties = init.properties
|
|
110
|
+
this.#culture = init.variationCulture
|
|
111
|
+
this.#fallbackChain = init.languageFallback ?? []
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** False when the page is marked `umbracoNaviHide`, so menus leave it out. */
|
|
115
|
+
get isVisible(): boolean {
|
|
116
|
+
const hidden = this.#read(NAVI_HIDE_ALIAS, {})
|
|
117
|
+
return !(hidden === true || hidden === 1 || hidden === '1' || hidden === 'true')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The culture in scope: the request's for content that varies by culture, else null. */
|
|
121
|
+
get culture(): string | null {
|
|
122
|
+
return this.#culture
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** How pickers, links and blocks resolve what they point at; the cache supplies it. */
|
|
126
|
+
withValueContext(context: ValueContext<PublishedContent> | undefined): this {
|
|
127
|
+
this.#context = context
|
|
128
|
+
return this
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#convert(property: PublishedProperty): unknown {
|
|
132
|
+
return convertValue(
|
|
133
|
+
property.editorAlias,
|
|
134
|
+
property.value,
|
|
135
|
+
this.#context as ValueContext,
|
|
136
|
+
property.config,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A Media Picker's picks as a list, whether the picker allows one or many. */
|
|
141
|
+
media(alias: AliasOf<Properties>, options: ValueOptions = {}): MediaWithCrops[] {
|
|
142
|
+
const value = this.#read(alias, options)
|
|
143
|
+
if (Array.isArray(value))
|
|
144
|
+
return value.filter((v): v is MediaWithCrops => v instanceof MediaWithCrops)
|
|
145
|
+
return value instanceof MediaWithCrops ? [value] : []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Ancestors, nearest first; used by the `ancestors` fallback strategy. */
|
|
149
|
+
withAncestors(ancestors: PublishedContent[]): this {
|
|
150
|
+
this.#ancestors = ancestors
|
|
151
|
+
return this
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getProperty(alias: AliasOf<Properties>): PublishedProperty | undefined {
|
|
155
|
+
return this.properties.find((property) => property.alias === alias)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
hasValue(alias: AliasOf<Properties>, options: ValueOptions = {}): boolean {
|
|
159
|
+
const found = this.#find(alias, options)
|
|
160
|
+
return found !== undefined && found.value !== null && found.value !== ''
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolves a property value: exact match first, then the fallback strategy,
|
|
165
|
+
* then the raw value regardless of culture — the same order Umbraco uses.
|
|
166
|
+
*/
|
|
167
|
+
value<Alias extends AliasOf<Properties>>(
|
|
168
|
+
alias: Alias,
|
|
169
|
+
options: ValueOptions = {},
|
|
170
|
+
): ValueOf<Properties, Alias> {
|
|
171
|
+
return this.#read(alias, options) as ValueOf<Properties, Alias>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#read(alias: string, options: ValueOptions): unknown {
|
|
175
|
+
const exact = this.#find(alias, options)
|
|
176
|
+
if (exact && exact.value !== null && exact.value !== '') return this.#convert(exact)
|
|
177
|
+
|
|
178
|
+
switch (options.fallback) {
|
|
179
|
+
case 'defaultValue':
|
|
180
|
+
return options.default ?? null
|
|
181
|
+
case 'language': {
|
|
182
|
+
for (const culture of this.#fallbackChain) {
|
|
183
|
+
const found = this.#find(alias, { ...options, culture })
|
|
184
|
+
if (found && found.value !== null && found.value !== '') return this.#convert(found)
|
|
185
|
+
}
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
case 'ancestors': {
|
|
189
|
+
for (const ancestor of this.#ancestors) {
|
|
190
|
+
const found = ancestor.#read(alias, { ...options, fallback: 'none' })
|
|
191
|
+
if (found !== null && found !== undefined && found !== '') return found
|
|
192
|
+
}
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (options.default !== undefined) return options.default
|
|
200
|
+
return exact?.value ?? null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `value()` coerced to a string, for direct use in markup. */
|
|
204
|
+
text(alias: AliasOf<Properties>, options: ValueOptions = {}): string {
|
|
205
|
+
const value = this.#read(alias, options)
|
|
206
|
+
return value === null || value === undefined ? '' : String(value)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A rich-text value, marked so the renderer does not escape it. */
|
|
210
|
+
html(alias: AliasOf<Properties>, options: ValueOptions = {}): RawHtml {
|
|
211
|
+
return new RawHtml(this.text(alias, options))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#find(alias: string, options: ValueOptions): PublishedProperty | undefined {
|
|
215
|
+
const culture = options.culture === undefined ? this.#culture : options.culture
|
|
216
|
+
const segment = options.segment ?? null
|
|
217
|
+
const matches = this.properties.filter((property) => property.alias === alias)
|
|
218
|
+
return (
|
|
219
|
+
matches.find(
|
|
220
|
+
(property) =>
|
|
221
|
+
(property.culture ?? null) === (culture ?? null) &&
|
|
222
|
+
(property.segment ?? null) === segment,
|
|
223
|
+
) ??
|
|
224
|
+
// An invariant value satisfies a culture-scoped read.
|
|
225
|
+
matches.find((property) => property.culture === null && property.segment === null)
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The member signed in on this request. `roles` are member group **names**, the
|
|
232
|
+
* same currency public-access rules deal in, so a template can ask
|
|
233
|
+
* `member?.roles.includes('Subscribers')`.
|
|
234
|
+
*/
|
|
235
|
+
export interface RequestMember {
|
|
236
|
+
key: string
|
|
237
|
+
name: string
|
|
238
|
+
username: string
|
|
239
|
+
email: string
|
|
240
|
+
roles: string[]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** What a template receives. */
|
|
244
|
+
/**
|
|
245
|
+
* What a template receives. Name the page's interface from `crombolo generate`
|
|
246
|
+
* to have its properties checked: `PageProps<HomePage>`.
|
|
247
|
+
*/
|
|
248
|
+
// biome-ignore lint/suspicious/noExplicitAny: an untyped page, as `PublishedContent` defaults to.
|
|
249
|
+
export interface PageProps<Properties = any> {
|
|
250
|
+
model: PublishedContent<Properties>
|
|
251
|
+
/** Navigation, kept out of the model exactly as Umbraco does. */
|
|
252
|
+
nav: Navigation
|
|
253
|
+
culture: string | null
|
|
254
|
+
/** A dictionary item in the request's culture, then its fallback languages; '' when missing. */
|
|
255
|
+
dictionary: (key: string) => string
|
|
256
|
+
/** The signed-in member, or undefined for an anonymous visitor. */
|
|
257
|
+
member: RequestMember | undefined
|
|
258
|
+
/**
|
|
259
|
+
* What just happened to a form submission on this page, when one did. Passed
|
|
260
|
+
* to `<Form submission={…} />` so a refused submission comes back inside the
|
|
261
|
+
* page's own layout rather than on a bare error page.
|
|
262
|
+
*/
|
|
263
|
+
submission: FormSubmissionState | undefined
|
|
264
|
+
/** The address requested, and the scheme and host it came in on. */
|
|
265
|
+
request: { origin: string; url: string }
|
|
266
|
+
urls: {
|
|
267
|
+
/** A page's address with scheme and host; undefined for one with no address here. */
|
|
268
|
+
absolute(content: PublishedContent): string | undefined
|
|
269
|
+
/** A page's address in each language it is published in. */
|
|
270
|
+
alternates(content: PublishedContent): Array<{
|
|
271
|
+
culture: string | null
|
|
272
|
+
url: string
|
|
273
|
+
isDefault: boolean
|
|
274
|
+
}>
|
|
275
|
+
}
|
|
276
|
+
/** Site-wide search and sharing values, for `<SeoHead>`. */
|
|
277
|
+
seo: SeoSiteDefaults
|
|
278
|
+
/**
|
|
279
|
+
* Aborts when the render runs out of time or the visitor disconnects. Pass it
|
|
280
|
+
* to anything an async component waits on: `fetch(url, { signal })`.
|
|
281
|
+
*/
|
|
282
|
+
signal: AbortSignal
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface NavigationOptions {
|
|
286
|
+
/** Leave out pages marked `umbracoNaviHide`. */
|
|
287
|
+
visibleOnly?: boolean
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface Navigation {
|
|
291
|
+
parent(content: PublishedContent): PublishedContent | undefined
|
|
292
|
+
children(content: PublishedContent, options?: NavigationOptions): PublishedContent[]
|
|
293
|
+
ancestors(content: PublishedContent): PublishedContent[]
|
|
294
|
+
root(options?: NavigationOptions): PublishedContent[]
|
|
295
|
+
/**
|
|
296
|
+
* Published content by key, in the request's culture.
|
|
297
|
+
*
|
|
298
|
+
* The only lookup that does not start from the page being rendered, which is
|
|
299
|
+
* what a query rooted at a document somebody picked needs — the template
|
|
300
|
+
* query builder writes `nav.byKey('…')` for exactly that.
|
|
301
|
+
*/
|
|
302
|
+
byKey(key: string): PublishedContent | undefined
|
|
303
|
+
}
|