@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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * A JSX runtime that renders straight to an HTML string.
3
+ *
4
+ * Templates are ordinary .tsx files executed by Bun, so there is no virtual DOM
5
+ * and no hydration: a template is a function from a model to markup. Configured
6
+ * through `jsxImportSource` in tsconfig.
7
+ *
8
+ * A component may be async. Rendering stays synchronous until it meets a
9
+ * promise, and from there hands back a promise of the markup instead, so a tree
10
+ * without one renders exactly as it always has.
11
+ */
12
+ import { escapeHtml, RawHtml, serializeAttributes, VOID_ELEMENTS } from './html.ts'
13
+ import type { CustomElementAttributes, KnownElements } from './jsx-attributes.ts'
14
+
15
+ export type Child =
16
+ | string
17
+ | number
18
+ | boolean
19
+ | null
20
+ | undefined
21
+ | RawHtml
22
+ | Promise<Child>
23
+ | Child[]
24
+
25
+ /** What `jsx()` returns: markup, or a promise of it once an async component is involved. */
26
+ export type Html = RawHtml | Promise<RawHtml>
27
+
28
+ /**
29
+ * The value of the `setInnerHTML` attribute: markup, and whether to emit it
30
+ * verbatim.
31
+ *
32
+ * React's `dangerouslySetInnerHTML` is always verbatim, and its name is the only
33
+ * thing standing between a value and an injection. Making it a flag moves that
34
+ * decision into the value, so a template that forgets it escapes rather than
35
+ * trusting whatever arrived.
36
+ */
37
+ export interface InnerHTML {
38
+ __html: string | Promise<string>
39
+ /** Emit the markup as-is. Escaped when absent. */
40
+ dangerously?: boolean
41
+ }
42
+
43
+ export interface ElementProps extends Record<string, unknown> {
44
+ children?: Child
45
+ setInnerHTML?: InnerHTML
46
+ /** React's spelling, always verbatim. `setInnerHTML` escapes unless told not to. */
47
+ dangerouslySetInnerHTML?: { __html: string | Promise<string> }
48
+ }
49
+
50
+ export type Component = (props: ElementProps) => Child
51
+
52
+ export const Fragment = Symbol.for('crombolo.fragment')
53
+
54
+ export function isPromise(value: unknown): value is Promise<unknown> {
55
+ return typeof (value as { then?: unknown } | null | undefined)?.then === 'function'
56
+ }
57
+
58
+ const REACT_ELEMENTS = new Set([
59
+ Symbol.for('react.element'),
60
+ Symbol.for('react.transitional.element'),
61
+ ])
62
+
63
+ /** Refuses what has no markup of its own, saying what to do instead. */
64
+ function refuseChild(child: unknown): never {
65
+ if (typeof child === 'function')
66
+ throw new Error(
67
+ 'A function is not valid as a child. Call it, or render it as a component: <Name />.',
68
+ )
69
+ if (typeof child === 'symbol') throw new Error('A symbol is not valid as a child.')
70
+ if (REACT_ELEMENTS.has((child as { $$typeof?: symbol }).$$typeof as symbol))
71
+ throw new Error(
72
+ "A React element was rendered in a page template. Templates render with Crombolo's own " +
73
+ 'JSX, to static HTML, so a React component cannot run here. Render an element to mount ' +
74
+ 'it on, and load your client code from /scripts/.',
75
+ )
76
+ const found =
77
+ Object.getPrototypeOf(child) === Object.prototype
78
+ ? `object with keys {${Object.keys(child as object).join(', ')}}`
79
+ : Object.prototype.toString.call(child)
80
+ throw new Error(
81
+ `Objects are not valid as a child (found: ${found}). Render a list as an array, and a value as text.`,
82
+ )
83
+ }
84
+
85
+ function renderChild(child: Child): string | Promise<string> {
86
+ if (child === null || child === undefined || child === false || child === true) return ''
87
+ if (child instanceof RawHtml) return child.value
88
+ if (isPromise(child))
89
+ return (child as Promise<unknown>).then((value) => renderChild(value as Child))
90
+ if (Array.isArray(child)) {
91
+ const parts = child.map(renderChild)
92
+ return parts.some(isPromise)
93
+ ? Promise.all(parts).then((done) => done.join(''))
94
+ : (parts as string[]).join('')
95
+ }
96
+ if (typeof child === 'string' || typeof child === 'number' || typeof child === 'bigint')
97
+ return escapeHtml(String(child))
98
+ return refuseChild(child)
99
+ }
100
+
101
+ function toHtml(content: string | Promise<string>, wrap = (inner: string) => inner): Html {
102
+ if (!isPromise(content)) return new RawHtml(wrap(content))
103
+ const html = content.then((inner) => new RawHtml(wrap(inner)))
104
+ // Marked handled so an element built but never rendered does not surface as an
105
+ // unhandled rejection; whoever renders it still receives the error.
106
+ html.catch(() => {})
107
+ return html
108
+ }
109
+
110
+ function innerHtml(type: string, props: ElementProps): string | Promise<string> {
111
+ const verbatim = props.dangerouslySetInnerHTML
112
+ if (verbatim) {
113
+ if (props.setInnerHTML)
114
+ throw new Error(`<${type}> was given both setInnerHTML and dangerouslySetInnerHTML.`)
115
+ if (props.children !== undefined && props.children !== null)
116
+ throw new Error(`<${type}> was given both children and dangerouslySetInnerHTML.`)
117
+ return verbatim.__html
118
+ }
119
+ const inner = props.setInnerHTML
120
+ if (!inner) return renderChild(props.children)
121
+ const output = (html: string) => (inner.dangerously ? html : escapeHtml(html))
122
+ return isPromise(inner.__html) ? inner.__html.then(output) : output(inner.__html)
123
+ }
124
+
125
+ /**
126
+ * An `<option>` that keeps its value until it is written, so the `<select>`
127
+ * holding it can mark it selected. JSX builds the options before the select,
128
+ * so by then a finished string could only be edited, not asked.
129
+ */
130
+ class OptionHtml extends RawHtml {
131
+ readonly optionValue: string | undefined
132
+ readonly #props: ElementProps
133
+ readonly #inner: string
134
+
135
+ constructor(props: ElementProps, inner: string) {
136
+ super(`<option${serializeAttributes(props, 'option')}>${inner}</option>`)
137
+ this.#props = props
138
+ this.#inner = inner
139
+ // Without a value attribute an option submits its text, so that is what matches.
140
+ this.optionValue =
141
+ props.value !== undefined && props.value !== null
142
+ ? String(props.value)
143
+ : textOf(props.children)
144
+ }
145
+
146
+ /** This option as the select's value says it should be. */
147
+ chosen(selected: boolean): OptionHtml {
148
+ return new OptionHtml({ ...this.#props, selected }, this.#inner)
149
+ }
150
+ }
151
+
152
+ /** An `<optgroup>` that keeps its options, so the select can reach through it. */
153
+ class OptgroupHtml extends RawHtml {
154
+ readonly #props: ElementProps
155
+
156
+ constructor(props: ElementProps, inner: string) {
157
+ super(`<optgroup${serializeAttributes(props, 'optgroup')}>${inner}</optgroup>`)
158
+ this.#props = props
159
+ }
160
+
161
+ get options(): Child {
162
+ return this.#props.children
163
+ }
164
+
165
+ /** This group, written again with its options as the select chose them. */
166
+ withOptions(children: Child): Html {
167
+ return optgroup({ ...this.#props, children })
168
+ }
169
+ }
170
+
171
+ function optgroup(props: ElementProps): Html {
172
+ const inner = renderChild(props.children)
173
+ if (!isPromise(inner)) return new OptgroupHtml(props, inner)
174
+ const group = inner.then((done) => new OptgroupHtml(props, done))
175
+ group.catch(() => {})
176
+ return group
177
+ }
178
+
179
+ /** The plain text of children that are only text, as an option's implicit value. */
180
+ function textOf(children: Child): string | undefined {
181
+ if (typeof children === 'string' || typeof children === 'number') return String(children)
182
+ if (Array.isArray(children)) {
183
+ const parts = children.map(textOf)
184
+ return parts.every((part) => part !== undefined) ? parts.join('') : undefined
185
+ }
186
+ return undefined
187
+ }
188
+
189
+ /** Marks the options among `children` whose value was chosen, noting which values it found. */
190
+ function chooseOptions(children: Child, chosen: Set<string>, found: Set<string>): Child {
191
+ if (children instanceof OptionHtml) {
192
+ const selected = children.optionValue !== undefined && chosen.has(children.optionValue)
193
+ if (selected) found.add(children.optionValue as string)
194
+ return children.chosen(selected)
195
+ }
196
+ if (children instanceof OptgroupHtml)
197
+ return children.withOptions(chooseOptions(children.options, chosen, found))
198
+ if (Array.isArray(children)) return children.map((child) => chooseOptions(child, chosen, found))
199
+ if (isPromise(children))
200
+ return (children as Promise<unknown>).then((resolved) =>
201
+ chooseOptions(resolved as Child, chosen, found),
202
+ )
203
+ return children
204
+ }
205
+
206
+ /** React's controlled form values, as the attributes and content the HTML spells them with. */
207
+ function formControl(type: string, props: ElementProps): ElementProps {
208
+ if (type === 'input') {
209
+ const { defaultValue, defaultChecked, ...rest } = props
210
+ return {
211
+ ...rest,
212
+ ...(rest.value === undefined && defaultValue !== undefined ? { value: defaultValue } : {}),
213
+ ...(rest.checked === undefined && defaultChecked !== undefined
214
+ ? { checked: defaultChecked }
215
+ : {}),
216
+ }
217
+ }
218
+ if (type === 'textarea') {
219
+ const { value, defaultValue, ...rest } = props
220
+ const text = value ?? defaultValue
221
+ if (text === undefined || text === null) return rest
222
+ if (rest.children !== undefined && rest.children !== null)
223
+ throw new Error('<textarea> was given both a value and children; use the value alone.')
224
+ return { ...rest, children: String(text) }
225
+ }
226
+ return props
227
+ }
228
+
229
+ function select(props: ElementProps): Html {
230
+ const { value, defaultValue, ...rest } = props
231
+ const wanted = value ?? defaultValue
232
+ if (wanted === undefined || wanted === null) return jsx('select', rest)
233
+ const chosen = new Set((Array.isArray(wanted) ? wanted : [wanted]).map(String))
234
+ const found = new Set<string>()
235
+ const children = chooseOptions(rest.children, chosen, found)
236
+ const check = (html: RawHtml) => {
237
+ const missing = [...chosen].filter((v) => !found.has(v))
238
+ if (missing.length > 0)
239
+ throw new Error(
240
+ `<select> was given the value ${JSON.stringify(missing[0])}, but no <option> it can see ` +
241
+ 'has it. An option inside a fragment or returned from a component is written before ' +
242
+ 'the select can ask it, so put `selected` on that option instead.',
243
+ )
244
+ return html
245
+ }
246
+ const html = jsx('select', { ...rest, children })
247
+ return isPromise(html) ? html.then(check) : check(html)
248
+ }
249
+
250
+ export function jsx(type: string | Component | typeof Fragment, props: ElementProps | null): Html {
251
+ const actual = props ?? {}
252
+
253
+ if (type === Fragment) return toHtml(renderChild(actual.children))
254
+
255
+ if (typeof type === 'function') return toHtml(renderChild(type(actual)))
256
+
257
+ if (type === 'select' && (actual.value != null || actual.defaultValue != null))
258
+ return select(actual)
259
+
260
+ for (const [name, value] of Object.entries(actual)) {
261
+ if (name !== 'children' && name !== 'setInnerHTML' && isPromise(value))
262
+ throw new Error(
263
+ `The "${name}" attribute of <${type}> is a promise. Await it before rendering the element.`,
264
+ )
265
+ }
266
+ const attributes = formControl(type, actual)
267
+ if (type === 'optgroup') return optgroup(attributes)
268
+ const open = `<${type}${serializeAttributes(attributes, type)}`
269
+ if (VOID_ELEMENTS.has(type)) return new RawHtml(`${open} />`)
270
+ const inner = innerHtml(type, attributes)
271
+ if (type === 'option') {
272
+ if (!isPromise(inner)) return new OptionHtml(attributes, inner)
273
+ const option = inner.then((done) => new OptionHtml(attributes, done))
274
+ option.catch(() => {})
275
+ return option
276
+ }
277
+ return toHtml(inner, (done) => `${open}>${done}</${type}>`)
278
+ }
279
+
280
+ export const jsxs = jsx
281
+ export const jsxDEV = jsx
282
+
283
+ /**
284
+ * What a view's `.tsx` compiles against: each tag with its own attributes (see
285
+ * `jsx-attributes.ts`), and markup out. `@crombolo/crombolo/loose` is the same runtime
286
+ * with any attribute on any tag, for a site not ready to be checked.
287
+ */
288
+ export declare namespace JSX {
289
+ /** What a JSX expression is: markup, or a promise of it once anything in it is async. */
290
+ type Element = Html
291
+ /**
292
+ * What may stand in a JSX tag position. Without this, TypeScript requires a
293
+ * component's declared return type to be assignable to `Element`, so a
294
+ * component returning plain text or a list would be rejected.
295
+ *
296
+ * The props are `never` rather than `ElementProps` so that a component
297
+ * declaring its own prop type still fits: parameters are contravariant, so
298
+ * pinning them here would reject every component that takes anything more
299
+ * specific than `ElementProps` — which is all of them. Props are checked
300
+ * against the component's own signature regardless; this constrains only what
301
+ * may stand in the tag position.
302
+ */
303
+ type ElementType = string | ((props: never) => Child)
304
+ interface IntrinsicElements extends KnownElements {
305
+ [custom: `${string}-${string}`]: CustomElementAttributes
306
+ }
307
+ interface IntrinsicAttributes {
308
+ key?: string | number | bigint | null
309
+ }
310
+ interface ElementChildrenAttribute {
311
+ children: unknown
312
+ }
313
+ }
314
+
315
+ /** Renders a template's return value to HTML: a string, or a promise of one when anything in it is async. */
316
+ export function renderToString(child: Child): string | Promise<string> {
317
+ return renderChild(child)
318
+ }
319
+
320
+ /**
321
+ * `jsx()` for markup that must be ready now: the built-in components, whose
322
+ * return types promise `RawHtml`. Refuses a promise rather than handing one on.
323
+ */
324
+ export function element(
325
+ type: string | Component | typeof Fragment,
326
+ props: ElementProps | null,
327
+ ): RawHtml {
328
+ const html = jsx(type, props)
329
+ if (isPromise(html))
330
+ throw new Error(
331
+ `<${typeof type === 'string' ? type : 'component'}> was given an async child, which this element cannot wait for.`,
332
+ )
333
+ return html
334
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The same JSX runtime with unchecked attributes: any attribute on any tag, and
3
+ * a JSX expression typed as text. For a site that is upgrading and has views
4
+ * the checked types would flag; set `"jsxImportSource": "@crombolo/crombolo/loose"`.
5
+ * What renders is identical either way.
6
+ */
7
+ import type { Child, ElementProps } from '../jsx-runtime.ts'
8
+
9
+ export type { Child, ElementProps } from '../jsx-runtime.ts'
10
+ export { Fragment, jsx, jsxDEV, jsxs } from '../jsx-runtime.ts'
11
+
12
+ export declare namespace JSX {
13
+ type Element = string
14
+ type ElementType = string | ((props: never) => Child)
15
+ interface IntrinsicElements {
16
+ [tag: string]: ElementProps
17
+ }
18
+ interface ElementChildrenAttribute {
19
+ children: unknown
20
+ }
21
+ }
package/src/media.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Media as templates see it: a published media item with its file URL, and
3
+ * what the Media Picker and Image Cropper yield — the item with its crops,
4
+ * each crop reachable as a URL the media route resizes and cuts.
5
+ */
6
+ import type { PublishedProperty } from './model.ts'
7
+
8
+ export interface Crop {
9
+ alias: string
10
+ width: number
11
+ height: number
12
+ /** Fractions trimmed from each edge, as the cropper stores them; null means "around the focal point". */
13
+ coordinates: { x1: number; y1: number; x2: number; y2: number } | null
14
+ }
15
+
16
+ export interface FocalPoint {
17
+ left: number
18
+ top: number
19
+ }
20
+
21
+ /** A crop URL: a named crop, or any size around the focal point. */
22
+ export type CropRequest =
23
+ | string
24
+ | { width?: number; height?: number; mode?: string; format?: string }
25
+
26
+ /** `url` with the query that yields `request`, given the crops and focal point in force. */
27
+ export function cropUrl(
28
+ url: string,
29
+ crops: readonly Crop[],
30
+ focalPoint: FocalPoint | null,
31
+ request: CropRequest,
32
+ ): string | null {
33
+ const params = new URLSearchParams()
34
+ if (typeof request === 'string') {
35
+ const crop = crops.find((c) => c.alias === request)
36
+ if (!crop) return null
37
+ if (crop.coordinates)
38
+ params.set(
39
+ 'cc',
40
+ [crop.coordinates.x1, crop.coordinates.y1, crop.coordinates.x2, crop.coordinates.y2].join(
41
+ ',',
42
+ ),
43
+ )
44
+ else if (focalPoint) params.set('rxy', `${focalPoint.left},${focalPoint.top}`)
45
+ params.set('width', String(crop.width))
46
+ params.set('height', String(crop.height))
47
+ } else {
48
+ if (focalPoint && request.width && request.height)
49
+ params.set('rxy', `${focalPoint.left},${focalPoint.top}`)
50
+ if (request.width) params.set('width', String(request.width))
51
+ if (request.height) params.set('height', String(request.height))
52
+ if (request.mode) params.set('rmode', request.mode.toLowerCase())
53
+ if (request.format) params.set('format', request.format)
54
+ }
55
+ const query = params.toString()
56
+ return query ? `${url}?${query}` : url
57
+ }
58
+
59
+ const numberOr = (value: unknown, fallback: number) => {
60
+ const n = Number(value)
61
+ return Number.isFinite(n) ? n : fallback
62
+ }
63
+
64
+ /** Crops as the cropper stores them, tolerating partial data. */
65
+ export function readCrops(value: unknown): Crop[] {
66
+ if (!Array.isArray(value)) return []
67
+ return value.flatMap((raw): Crop[] => {
68
+ const crop = raw as Record<string, unknown>
69
+ if (typeof crop.alias !== 'string') return []
70
+ const c = crop.coordinates as Record<string, unknown> | null | undefined
71
+ return [
72
+ {
73
+ alias: crop.alias,
74
+ width: numberOr(crop.width, 0),
75
+ height: numberOr(crop.height, 0),
76
+ coordinates: c
77
+ ? {
78
+ x1: numberOr(c.x1, 0),
79
+ y1: numberOr(c.y1, 0),
80
+ x2: numberOr(c.x2, 0),
81
+ y2: numberOr(c.y2, 0),
82
+ }
83
+ : null,
84
+ },
85
+ ]
86
+ })
87
+ }
88
+
89
+ export function readFocalPoint(value: unknown): FocalPoint | null {
90
+ const f = value as Record<string, unknown> | null | undefined
91
+ if (!f) return null
92
+ return { left: numberOr(f.left, 0.5), top: numberOr(f.top, 0.5) }
93
+ }
94
+
95
+ /** An Image Cropper value: the file, its crops and focal point. `toString()` is its URL. */
96
+ export class ImageCropperValue {
97
+ readonly src: string
98
+ readonly crops: Crop[]
99
+ readonly focalPoint: FocalPoint | null
100
+
101
+ constructor(value: { src?: unknown; crops?: unknown; focalPoint?: unknown }) {
102
+ this.src = typeof value.src === 'string' ? value.src : ''
103
+ this.crops = readCrops(value.crops)
104
+ this.focalPoint = readFocalPoint(value.focalPoint)
105
+ }
106
+
107
+ get url(): string {
108
+ return this.src
109
+ }
110
+
111
+ cropUrl(request: CropRequest): string | null {
112
+ return this.src ? cropUrl(this.src, this.crops, this.focalPoint, request) : null
113
+ }
114
+
115
+ toString(): string {
116
+ return this.src
117
+ }
118
+ }
119
+
120
+ /** One published media item. */
121
+ export class PublishedMedia {
122
+ readonly key: string
123
+ readonly id: number
124
+ readonly name: string
125
+ readonly mediaType: { alias: string }
126
+ readonly properties: PublishedProperty[]
127
+
128
+ constructor(init: {
129
+ key: string
130
+ id: number
131
+ name: string
132
+ mediaTypeAlias: string
133
+ properties: PublishedProperty[]
134
+ }) {
135
+ this.key = init.key
136
+ this.id = init.id
137
+ this.name = init.name
138
+ this.mediaType = { alias: init.mediaTypeAlias }
139
+ this.properties = init.properties
140
+ }
141
+
142
+ /** The file's URL: what `umbracoFile` holds. */
143
+ get url(): string {
144
+ const file = this.properties.find((p) => p.alias === 'umbracoFile')?.value
145
+ if (typeof file === 'string') return file
146
+ const src = (file as { src?: unknown } | null | undefined)?.src
147
+ return typeof src === 'string' ? src : ''
148
+ }
149
+
150
+ /** The file as an Image Cropper value, for its crops and focal point. */
151
+ get image(): ImageCropperValue {
152
+ const file = this.properties.find((p) => p.alias === 'umbracoFile')?.value
153
+ return new ImageCropperValue(
154
+ typeof file === 'string' ? { src: file } : ((file as object | null | undefined) ?? {}),
155
+ )
156
+ }
157
+
158
+ value(alias: string): unknown {
159
+ return this.properties.find((p) => p.alias === alias)?.value ?? null
160
+ }
161
+
162
+ text(alias: string): string {
163
+ const value = this.value(alias)
164
+ return value === null || value === undefined ? '' : String(value)
165
+ }
166
+ }
167
+
168
+ /**
169
+ * A Media Picker entry: the media item, with the crops and focal point this
170
+ * pick overrides (Umbraco's `MediaWithCrops`).
171
+ */
172
+ export class MediaWithCrops {
173
+ readonly key: string
174
+ readonly media: PublishedMedia
175
+ readonly crops: Crop[]
176
+ readonly focalPoint: FocalPoint | null
177
+
178
+ constructor(
179
+ media: PublishedMedia,
180
+ local: { key?: unknown; crops?: unknown; focalPoint?: unknown },
181
+ ) {
182
+ this.media = media
183
+ this.key = typeof local.key === 'string' ? local.key : media.key
184
+ const own = media.image
185
+ const localCrops = readCrops(local.crops)
186
+ // The pick's own crop wins; the media's crops fill in the rest.
187
+ this.crops = [
188
+ ...localCrops,
189
+ ...own.crops.filter((c) => !localCrops.some((l) => l.alias === c.alias)),
190
+ ]
191
+ this.focalPoint = readFocalPoint(local.focalPoint) ?? own.focalPoint
192
+ }
193
+
194
+ get name(): string {
195
+ return this.media.name
196
+ }
197
+
198
+ get url(): string {
199
+ return this.media.url
200
+ }
201
+
202
+ cropUrl(request: CropRequest): string | null {
203
+ return this.url ? cropUrl(this.url, this.crops, this.focalPoint, request) : null
204
+ }
205
+ }