@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/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