@crombolo/render 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }