@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
|
@@ -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
|
+
}
|