@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/values.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * What templates get for structured property values: Umbraco's published value
3
+ * converters. A picker yields the content it points at, a link picker yields
4
+ * links with their URLs resolved, and block editors yield element models with
5
+ * the same `value()` API as a page.
6
+ */
7
+ import { keyFromReference, type SchemaForm } from '@crombolo/core'
8
+ import { RawHtml } from './html.ts'
9
+ import { ImageCropperValue, MediaWithCrops, PublishedMedia } from './media.ts'
10
+ import type { PublishedProperty } from './model.ts'
11
+
12
+ /** What conversion needs from the published cache. */
13
+ export interface ValueContext<Content = unknown, Media = unknown> {
14
+ content(key: string): Content | undefined
15
+ /**
16
+ * A form definition by key. Absent until the renderer is given one, which is
17
+ * what makes a `formPicker` resolve to the form rather than to its key.
18
+ */
19
+ form?(key: string): SchemaForm | undefined
20
+ /** Absent until the cache knows media. */
21
+ media?(key: string): Media | undefined
22
+ /** A published library element by key; absent until the cache knows them. */
23
+ element?(key: string): PublishedElement | undefined
24
+ /** A content type's alias by key, which is all a block records of its element type. */
25
+ contentTypeAlias?(key: string): string | undefined
26
+ /** A content URL, for links. */
27
+ urlOf(content: Content): string
28
+ mediaUrlOf?(media: Media): string
29
+ }
30
+
31
+ export interface Link {
32
+ name: string
33
+ url: string
34
+ target: string | null
35
+ type: 'document' | 'media' | 'external'
36
+ /** The linked item's key; null for an external link. */
37
+ key: string | null
38
+ }
39
+
40
+ export interface BlockItem {
41
+ contentKey: string
42
+ content: PublishedElement
43
+ settingsKey: string | null
44
+ settings: PublishedElement | null
45
+ }
46
+
47
+ export interface BlockGridArea {
48
+ key: string
49
+ items: BlockGridItem[]
50
+ }
51
+
52
+ export interface BlockGridItem extends BlockItem {
53
+ columnSpan: number | null
54
+ rowSpan: number | null
55
+ areas: BlockGridArea[]
56
+ }
57
+
58
+ const references = (value: unknown): string[] => {
59
+ if (Array.isArray(value)) return value.flatMap((v) => (typeof v === 'string' ? [v] : []))
60
+ if (typeof value === 'string')
61
+ return value
62
+ .split(',')
63
+ .map((s) => s.trim())
64
+ .filter(Boolean)
65
+ return []
66
+ }
67
+
68
+ /** One block's content or settings: an element with properties, but no URL or place in the tree. */
69
+ export class PublishedElement {
70
+ readonly key: string
71
+ readonly contentType: { key: string; alias: string | null }
72
+ readonly properties: PublishedProperty[]
73
+ #context: ValueContext | undefined
74
+
75
+ constructor(
76
+ init: {
77
+ key: string
78
+ contentTypeKey: string
79
+ contentTypeAlias?: string | null
80
+ properties: PublishedProperty[]
81
+ },
82
+ context?: ValueContext,
83
+ ) {
84
+ this.key = init.key
85
+ this.contentType = { key: init.contentTypeKey, alias: init.contentTypeAlias ?? null }
86
+ this.properties = init.properties
87
+ this.#context = context
88
+ }
89
+
90
+ value(alias: string): unknown {
91
+ const property = this.properties.find((p) => p.alias === alias)
92
+ if (!property) return null
93
+ return convertValue(property.editorAlias, property.value, this.#context, property.config)
94
+ }
95
+
96
+ text(alias: string): string {
97
+ const value = this.value(alias)
98
+ return value === null || value === undefined ? '' : String(value)
99
+ }
100
+
101
+ html(alias: string): RawHtml {
102
+ return new RawHtml(this.text(alias))
103
+ }
104
+ }
105
+
106
+ /** An element whose `value()` knows its properties, as `crombolo generate` types block content. */
107
+ export interface TypedElement<Properties> extends PublishedElement {
108
+ value<Alias extends keyof Properties & string>(alias: Alias): Properties[Alias]
109
+ }
110
+
111
+ interface BlockData {
112
+ key: string
113
+ contentTypeKey: string
114
+ values?: Array<{
115
+ alias: string
116
+ value: unknown
117
+ editorAlias?: string
118
+ culture?: string | null
119
+ segment?: string | null
120
+ }>
121
+ }
122
+
123
+ interface BlockLayout {
124
+ contentKey?: string
125
+ settingsKey?: string | null
126
+ columnSpan?: number
127
+ rowSpan?: number
128
+ areas?: Array<{ key: string; items?: BlockLayout[] }>
129
+ }
130
+
131
+ interface BlockValue {
132
+ layout?: Record<string, BlockLayout[] | undefined>
133
+ contentData?: BlockData[]
134
+ settingsData?: BlockData[]
135
+ }
136
+
137
+ function elementFrom(data: BlockData | undefined, context?: ValueContext): PublishedElement | null {
138
+ if (!data) return null
139
+ return new PublishedElement(
140
+ {
141
+ key: data.key,
142
+ contentTypeKey: data.contentTypeKey,
143
+ contentTypeAlias: context?.contentTypeAlias?.(data.contentTypeKey) ?? null,
144
+ properties: (data.values ?? []).map((v) => ({
145
+ alias: v.alias,
146
+ editorAlias: v.editorAlias ?? '',
147
+ culture: v.culture ?? null,
148
+ segment: v.segment ?? null,
149
+ value: v.value,
150
+ })),
151
+ },
152
+ context,
153
+ )
154
+ }
155
+
156
+ function blockItems<T extends BlockItem>(
157
+ value: unknown,
158
+ layoutAlias: string,
159
+ context: ValueContext | undefined,
160
+ extend: (layout: BlockLayout, item: BlockItem, build: (layouts: BlockLayout[]) => T[]) => T,
161
+ ): T[] {
162
+ if (!value || typeof value !== 'object') return []
163
+ const block = value as BlockValue
164
+ const contents = new Map((block.contentData ?? []).map((d) => [d.key, d]))
165
+ const settings = new Map((block.settingsData ?? []).map((d) => [d.key, d]))
166
+ const build = (layouts: BlockLayout[]): T[] =>
167
+ layouts.flatMap((layout) => {
168
+ const content = elementFrom(contents.get(layout.contentKey ?? ''), context)
169
+ if (!content) return []
170
+ const item: BlockItem = {
171
+ contentKey: content.key,
172
+ content,
173
+ settingsKey: layout.settingsKey ?? null,
174
+ settings: elementFrom(settings.get(layout.settingsKey ?? ''), context),
175
+ }
176
+ return [extend(layout, item, build)]
177
+ })
178
+ return build(block.layout?.[layoutAlias] ?? [])
179
+ }
180
+
181
+ /**
182
+ * Schemes a link picked in the backoffice may use on the front end.
183
+ *
184
+ * A link's URL is typed by an editor and ends up in an `href`, where escaping
185
+ * does nothing about the scheme: `javascript:` there runs for every visitor.
186
+ * Relative and fragment URLs have no scheme and are left alone.
187
+ */
188
+ const SAFE_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:'])
189
+
190
+ function safeExternalUrl(url: string): string {
191
+ const trimmed = url.trim()
192
+ if (trimmed === '') return ''
193
+ // No scheme: a path, a fragment or a protocol-relative URL, none of which can
194
+ // carry script. `new URL` needs a base to judge those, so they are let through
195
+ // without one rather than guessed at.
196
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)) return trimmed
197
+ try {
198
+ return SAFE_LINK_SCHEMES.has(new URL(trimmed).protocol) ? trimmed : ''
199
+ } catch {
200
+ return ''
201
+ }
202
+ }
203
+
204
+ const LOCAL_LINK = /\/?(?:\{|%7B)localLink:(?:umb:\/\/[a-z-]+\/)?([0-9a-fA-F-]{32,36})(?:\}|%7D)/g
205
+
206
+ /**
207
+ * Rich text links to pages and media are stored as `/{localLink:<key>}`, so a
208
+ * move or rename never breaks them; they become URLs when rendered. A link to
209
+ * something unpublished or gone becomes `#`.
210
+ */
211
+ export function resolveLocalLinks(markup: string, context?: ValueContext): string {
212
+ return markup.replace(LOCAL_LINK, (_, reference: string) => {
213
+ const key = keyFromReference(reference)
214
+ if (!key || !context) return '#'
215
+ const content = context.content(key)
216
+ if (content) return context.urlOf(content)
217
+ const media = context.media?.(key)
218
+ return media && context.mediaUrlOf ? context.mediaUrlOf(media) : '#'
219
+ })
220
+ }
221
+
222
+ /** A stored (editor-shaped) value as a template receives it. */
223
+ export function convertValue(
224
+ editorAlias: string,
225
+ value: unknown,
226
+ context?: ValueContext,
227
+ config: Record<string, unknown> = {},
228
+ ): unknown {
229
+ if (value === null || value === undefined) return value
230
+ switch (editorAlias) {
231
+ case 'Umbraco.MediaPicker3': {
232
+ const picks = (Array.isArray(value) ? value : []).flatMap((raw) => {
233
+ const pick = raw as Record<string, unknown>
234
+ const key = keyFromReference(pick.mediaKey)
235
+ const media = key ? context?.media?.(key) : undefined
236
+ return media instanceof PublishedMedia ? [new MediaWithCrops(media, pick)] : []
237
+ })
238
+ return config.multiple === false ? (picks[0] ?? null) : picks
239
+ }
240
+ case 'Umbraco.RichText':
241
+ return typeof value === 'string' ? resolveLocalLinks(value, context) : value
242
+ case 'Umbraco.ImageCropper':
243
+ return typeof value === 'object' || typeof value === 'string'
244
+ ? new ImageCropperValue(typeof value === 'string' ? { src: value } : (value as object))
245
+ : value
246
+ // A form definition is a file, so there is nothing in the content cache to
247
+ // resolve against: the lookup reads `schema/forms/`. Without one the raw key
248
+ // comes back, which is what a unit test sees.
249
+ case 'Crombolo.FormPicker': {
250
+ const key = typeof value === 'string' ? value.trim() : ''
251
+ if (!key) return null
252
+ return context?.form?.(key) ?? null
253
+ }
254
+ case 'Umbraco.ContentPicker': {
255
+ const key = keyFromReference(value)
256
+ return (key && context?.content(key)) ?? null
257
+ }
258
+ /**
259
+ * The Library's own picker. Umbraco stores a bare `Guid[]` here rather than the
260
+ * `umb://…` references a content picker stores, and always yields a collection
261
+ * even where the configuration allows one pick — so a template writes `[0]` for
262
+ * a single. An element the culture cannot see is simply absent from this
263
+ * culture's view, which is the drop Umbraco's converter does explicitly.
264
+ */
265
+ case 'Umbraco.ElementPicker':
266
+ return references(value).flatMap((reference) => {
267
+ const key = keyFromReference(reference)
268
+ const found = key ? context?.element?.(key) : undefined
269
+ return found ? [found] : []
270
+ })
271
+ case 'Umbraco.MultiNodeTreePicker':
272
+ return references(value).flatMap((reference) => {
273
+ const key = keyFromReference(reference)
274
+ const found = key ? (context?.content(key) ?? context?.media?.(key)) : undefined
275
+ return found ? [found] : []
276
+ })
277
+ case 'Umbraco.MultiUrlPicker': {
278
+ if (!Array.isArray(value)) return []
279
+ return value.flatMap((raw): Link[] => {
280
+ const link = raw as Record<string, unknown>
281
+ const key = keyFromReference(link.unique ?? link.udi)
282
+ const type = link.type === 'media' ? 'media' : key ? 'document' : 'external'
283
+ const queryString = typeof link.queryString === 'string' ? link.queryString : ''
284
+ let url: string | undefined
285
+ if (type === 'document' && key) {
286
+ const content = context?.content(key)
287
+ if (!content) return []
288
+ url = context?.urlOf(content)
289
+ } else if (type === 'media' && key) {
290
+ const media = context?.media?.(key)
291
+ if (!media) return []
292
+ url = context?.mediaUrlOf?.(media)
293
+ } else url = safeExternalUrl(typeof link.url === 'string' ? link.url : '')
294
+ return [
295
+ {
296
+ name: typeof link.name === 'string' ? link.name : '',
297
+ url: `${url ?? ''}${queryString}`,
298
+ target: typeof link.target === 'string' && link.target ? link.target : null,
299
+ type,
300
+ key: key ?? null,
301
+ },
302
+ ]
303
+ })
304
+ }
305
+ case 'Umbraco.BlockList':
306
+ return blockItems<BlockItem>(value, 'Umbraco.BlockList', context, (_, item) => item)
307
+ case 'Umbraco.BlockGrid':
308
+ return blockItems<BlockGridItem>(
309
+ value,
310
+ 'Umbraco.BlockGrid',
311
+ context,
312
+ (layout, item, build) => ({
313
+ ...item,
314
+ columnSpan: layout.columnSpan ?? null,
315
+ rowSpan: layout.rowSpan ?? null,
316
+ areas: (layout.areas ?? []).map((area) => ({
317
+ key: area.key,
318
+ items: build(area.items ?? []),
319
+ })),
320
+ }),
321
+ )
322
+ default:
323
+ return value
324
+ }
325
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.