@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/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/html.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** HTML serialisation primitives for the TSX renderer. */
|
|
2
|
+
|
|
3
|
+
/** Markup that must not be escaped, for a rich-text property value. */
|
|
4
|
+
export class RawHtml {
|
|
5
|
+
readonly value: string
|
|
6
|
+
constructor(value: string) {
|
|
7
|
+
this.value = value
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const raw = (value: string): RawHtml => new RawHtml(value)
|
|
12
|
+
|
|
13
|
+
const ESCAPES: Record<string, string> = {
|
|
14
|
+
'&': '&',
|
|
15
|
+
'<': '<',
|
|
16
|
+
'>': '>',
|
|
17
|
+
'"': '"',
|
|
18
|
+
"'": ''',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function escapeHtml(value: string): string {
|
|
22
|
+
return value.replace(/[&<>"']/g, (character) => ESCAPES[character] as string)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Elements that must not be given a closing tag. */
|
|
26
|
+
export const VOID_ELEMENTS = new Set([
|
|
27
|
+
'area',
|
|
28
|
+
'base',
|
|
29
|
+
'br',
|
|
30
|
+
'col',
|
|
31
|
+
'embed',
|
|
32
|
+
'hr',
|
|
33
|
+
'img',
|
|
34
|
+
'input',
|
|
35
|
+
'link',
|
|
36
|
+
'meta',
|
|
37
|
+
'param',
|
|
38
|
+
'source',
|
|
39
|
+
'track',
|
|
40
|
+
'wbr',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
/** SVG presentation attributes React spells in camelCase; each is written hyphenated. */
|
|
44
|
+
const HYPHENATED_SVG = [
|
|
45
|
+
'alignmentBaseline',
|
|
46
|
+
'arabicForm',
|
|
47
|
+
'baselineShift',
|
|
48
|
+
'capHeight',
|
|
49
|
+
'clipPath',
|
|
50
|
+
'clipRule',
|
|
51
|
+
'colorInterpolation',
|
|
52
|
+
'colorInterpolationFilters',
|
|
53
|
+
'colorProfile',
|
|
54
|
+
'colorRendering',
|
|
55
|
+
'dominantBaseline',
|
|
56
|
+
'enableBackground',
|
|
57
|
+
'fillOpacity',
|
|
58
|
+
'fillRule',
|
|
59
|
+
'floodColor',
|
|
60
|
+
'floodOpacity',
|
|
61
|
+
'fontFamily',
|
|
62
|
+
'fontSize',
|
|
63
|
+
'fontSizeAdjust',
|
|
64
|
+
'fontStretch',
|
|
65
|
+
'fontStyle',
|
|
66
|
+
'fontVariant',
|
|
67
|
+
'fontWeight',
|
|
68
|
+
'glyphName',
|
|
69
|
+
'glyphOrientationHorizontal',
|
|
70
|
+
'glyphOrientationVertical',
|
|
71
|
+
'horizAdvX',
|
|
72
|
+
'horizOriginX',
|
|
73
|
+
'imageRendering',
|
|
74
|
+
'letterSpacing',
|
|
75
|
+
'lightingColor',
|
|
76
|
+
'markerEnd',
|
|
77
|
+
'markerMid',
|
|
78
|
+
'markerStart',
|
|
79
|
+
'overlinePosition',
|
|
80
|
+
'overlineThickness',
|
|
81
|
+
'paintOrder',
|
|
82
|
+
'pointerEvents',
|
|
83
|
+
'renderingIntent',
|
|
84
|
+
'shapeRendering',
|
|
85
|
+
'stopColor',
|
|
86
|
+
'stopOpacity',
|
|
87
|
+
'strikethroughPosition',
|
|
88
|
+
'strikethroughThickness',
|
|
89
|
+
'strokeDasharray',
|
|
90
|
+
'strokeDashoffset',
|
|
91
|
+
'strokeLinecap',
|
|
92
|
+
'strokeLinejoin',
|
|
93
|
+
'strokeMiterlimit',
|
|
94
|
+
'strokeOpacity',
|
|
95
|
+
'strokeWidth',
|
|
96
|
+
'textAnchor',
|
|
97
|
+
'textDecoration',
|
|
98
|
+
'textRendering',
|
|
99
|
+
'transformOrigin',
|
|
100
|
+
'underlinePosition',
|
|
101
|
+
'underlineThickness',
|
|
102
|
+
'unicodeBidi',
|
|
103
|
+
'unicodeRange',
|
|
104
|
+
'unitsPerEm',
|
|
105
|
+
'vAlphabetic',
|
|
106
|
+
'vHanging',
|
|
107
|
+
'vIdeographic',
|
|
108
|
+
'vMathematical',
|
|
109
|
+
'vectorEffect',
|
|
110
|
+
'vertAdvY',
|
|
111
|
+
'vertOriginX',
|
|
112
|
+
'vertOriginY',
|
|
113
|
+
'wordSpacing',
|
|
114
|
+
'writingMode',
|
|
115
|
+
'xHeight',
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const hyphenate = (name: string) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Props whose attribute is spelled differently, as React's server renderer
|
|
122
|
+
* writes them. A camelCase HTML attribute such as `tabIndex` is left alone:
|
|
123
|
+
* HTML attribute names are case-insensitive, so it already works.
|
|
124
|
+
*/
|
|
125
|
+
const ATTRIBUTE_NAMES: Record<string, string> = {
|
|
126
|
+
className: 'class',
|
|
127
|
+
htmlFor: 'for',
|
|
128
|
+
httpEquiv: 'http-equiv',
|
|
129
|
+
acceptCharset: 'accept-charset',
|
|
130
|
+
panose1: 'panose-1',
|
|
131
|
+
xlinkActuate: 'xlink:actuate',
|
|
132
|
+
xlinkArcrole: 'xlink:arcrole',
|
|
133
|
+
xlinkHref: 'xlink:href',
|
|
134
|
+
xlinkRole: 'xlink:role',
|
|
135
|
+
xlinkShow: 'xlink:show',
|
|
136
|
+
xlinkTitle: 'xlink:title',
|
|
137
|
+
xlinkType: 'xlink:type',
|
|
138
|
+
xmlBase: 'xml:base',
|
|
139
|
+
xmlLang: 'xml:lang',
|
|
140
|
+
xmlSpace: 'xml:space',
|
|
141
|
+
xmlnsXlink: 'xmlns:xlink',
|
|
142
|
+
...Object.fromEntries(HYPHENATED_SVG.map((name) => [name, hyphenate(name)])),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Props that are never attributes. */
|
|
146
|
+
const NOT_ATTRIBUTES = new Set([
|
|
147
|
+
'children',
|
|
148
|
+
'key',
|
|
149
|
+
'ref',
|
|
150
|
+
'setInnerHTML',
|
|
151
|
+
'dangerouslySetInnerHTML',
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
/** Attributes whose value is the string "true" or "false" rather than presence. */
|
|
155
|
+
const BOOLEANISH = new Set([
|
|
156
|
+
'contenteditable',
|
|
157
|
+
'draggable',
|
|
158
|
+
'spellcheck',
|
|
159
|
+
'autoreverse',
|
|
160
|
+
'externalresourcesrequired',
|
|
161
|
+
'focusable',
|
|
162
|
+
'preservealpha',
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
const isBooleanish = (attribute: string) =>
|
|
166
|
+
attribute.startsWith('aria-') ||
|
|
167
|
+
attribute.startsWith('data-') ||
|
|
168
|
+
BOOLEANISH.has(attribute.toLowerCase())
|
|
169
|
+
|
|
170
|
+
/** The characters an attribute name may hold, as React checks them. */
|
|
171
|
+
const NAME_START =
|
|
172
|
+
':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD'
|
|
173
|
+
const VALID_ATTRIBUTE_NAME = new RegExp(
|
|
174
|
+
`^[${NAME_START}][${NAME_START}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
/** CSS properties whose number stays a number rather than gaining `px`. */
|
|
178
|
+
const UNITLESS = new Set([
|
|
179
|
+
'animationIterationCount',
|
|
180
|
+
'aspectRatio',
|
|
181
|
+
'borderImageOutset',
|
|
182
|
+
'borderImageSlice',
|
|
183
|
+
'borderImageWidth',
|
|
184
|
+
'boxFlex',
|
|
185
|
+
'boxFlexGroup',
|
|
186
|
+
'boxOrdinalGroup',
|
|
187
|
+
'columnCount',
|
|
188
|
+
'columns',
|
|
189
|
+
'flex',
|
|
190
|
+
'flexGrow',
|
|
191
|
+
'flexPositive',
|
|
192
|
+
'flexShrink',
|
|
193
|
+
'flexNegative',
|
|
194
|
+
'flexOrder',
|
|
195
|
+
'gridArea',
|
|
196
|
+
'gridRow',
|
|
197
|
+
'gridRowEnd',
|
|
198
|
+
'gridRowSpan',
|
|
199
|
+
'gridRowStart',
|
|
200
|
+
'gridColumn',
|
|
201
|
+
'gridColumnEnd',
|
|
202
|
+
'gridColumnSpan',
|
|
203
|
+
'gridColumnStart',
|
|
204
|
+
'fontWeight',
|
|
205
|
+
'lineClamp',
|
|
206
|
+
'lineHeight',
|
|
207
|
+
'opacity',
|
|
208
|
+
'order',
|
|
209
|
+
'orphans',
|
|
210
|
+
'scale',
|
|
211
|
+
'tabSize',
|
|
212
|
+
'widows',
|
|
213
|
+
'zIndex',
|
|
214
|
+
'zoom',
|
|
215
|
+
'fillOpacity',
|
|
216
|
+
'floodOpacity',
|
|
217
|
+
'stopOpacity',
|
|
218
|
+
'strokeDasharray',
|
|
219
|
+
'strokeDashoffset',
|
|
220
|
+
'strokeMiterlimit',
|
|
221
|
+
'strokeOpacity',
|
|
222
|
+
'strokeWidth',
|
|
223
|
+
])
|
|
224
|
+
|
|
225
|
+
const unitless = (property: string) =>
|
|
226
|
+
UNITLESS.has(property) ||
|
|
227
|
+
UNITLESS.has(
|
|
228
|
+
property.replace(/^(Webkit|Moz|ms|O)([A-Z])/, (_, _prefix, first: string) =>
|
|
229
|
+
first.toLowerCase(),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
/** A React style object as CSS: `{ marginTop: 4 }` is `margin-top:4px`. */
|
|
234
|
+
export function styleToCss(style: Record<string, unknown>): string {
|
|
235
|
+
const declarations: string[] = []
|
|
236
|
+
for (const [property, value] of Object.entries(style)) {
|
|
237
|
+
if (value === null || value === undefined || typeof value === 'boolean' || value === '')
|
|
238
|
+
continue
|
|
239
|
+
const custom = property.startsWith('--')
|
|
240
|
+
const name = custom ? property : hyphenate(property).replace(/^ms-/, '-ms-')
|
|
241
|
+
const text =
|
|
242
|
+
typeof value === 'number' && value !== 0 && !custom && !unitless(property)
|
|
243
|
+
? `${value}px`
|
|
244
|
+
: String(value).trim()
|
|
245
|
+
declarations.push(`${name}:${text}`)
|
|
246
|
+
}
|
|
247
|
+
return declarations.join(';')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether a value would print as `[object …]`: something with no text of its own. */
|
|
251
|
+
const opaqueObject = (value: object) =>
|
|
252
|
+
!Array.isArray(value) && String(value).startsWith('[object ')
|
|
253
|
+
|
|
254
|
+
export function serializeAttributes(props: Record<string, unknown>, tag = 'element'): string {
|
|
255
|
+
const parts: string[] = []
|
|
256
|
+
for (const [name, value] of Object.entries(props)) {
|
|
257
|
+
if (NOT_ATTRIBUTES.has(name) || value === null || value === undefined) continue
|
|
258
|
+
if (!VALID_ATTRIBUTE_NAME.test(name))
|
|
259
|
+
throw new Error(
|
|
260
|
+
`<${tag}> was given ${JSON.stringify(name)} as an attribute, which is not a valid attribute name.`,
|
|
261
|
+
)
|
|
262
|
+
if (typeof value === 'function')
|
|
263
|
+
throw new Error(
|
|
264
|
+
`The "${name}" attribute of <${tag}> is a function. Pages are rendered to static HTML, so ` +
|
|
265
|
+
'it can never run. Render an element to mount on, and load your client code from /scripts/.',
|
|
266
|
+
)
|
|
267
|
+
const attribute = ATTRIBUTE_NAMES[name] ?? name
|
|
268
|
+
if (typeof value === 'boolean') {
|
|
269
|
+
if (isBooleanish(attribute)) parts.push(`${attribute}="${value}"`)
|
|
270
|
+
else if (value) parts.push(attribute)
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (name === 'style' && typeof value === 'object') {
|
|
274
|
+
const css = styleToCss(value as Record<string, unknown>)
|
|
275
|
+
if (css) parts.push(`style="${escapeHtml(css)}"`)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === 'symbol' || (typeof value === 'object' && opaqueObject(value)))
|
|
279
|
+
throw new Error(
|
|
280
|
+
`The "${name}" attribute of <${tag}> is ${typeof value === 'symbol' ? 'a symbol' : 'an object'}, which has no text to write. Convert it to a string first.`,
|
|
281
|
+
)
|
|
282
|
+
parts.push(`${attribute}="${escapeHtml(String(value))}"`)
|
|
283
|
+
}
|
|
284
|
+
return parts.length > 0 ? ` ${parts.join(' ')}` : ''
|
|
285
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './context.ts'
|
|
2
|
+
export * from './form-script.ts'
|
|
3
|
+
export * from './forms.ts'
|
|
4
|
+
export * from './html.ts'
|
|
5
|
+
export * from './jsx-attributes.ts'
|
|
6
|
+
export * from './jsx-runtime.ts'
|
|
7
|
+
export * from './media.ts'
|
|
8
|
+
export * from './model.ts'
|
|
9
|
+
export * from './published-cache.ts'
|
|
10
|
+
export * from './redirects.ts'
|
|
11
|
+
export * from './renderer.ts'
|
|
12
|
+
export * from './seo.ts'
|
|
13
|
+
export * from './sitemap.ts'
|
|
14
|
+
export * from './snapshots.ts'
|
|
15
|
+
export * from './url-segment.ts'
|
|
16
|
+
export * from './values.ts'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attributes each tag accepts, for type checking a view.
|
|
3
|
+
*
|
|
4
|
+
* React's DOM types are the source, adapted to how this runtime renders: to a
|
|
5
|
+
* string, once, on the server. So children may be async, a style object is
|
|
6
|
+
* CSS, an event handler is refused (nothing could run it), and the HTML
|
|
7
|
+
* spellings (`class`, `for`, `charset`) are accepted beside React's.
|
|
8
|
+
*/
|
|
9
|
+
import type * as React from 'react'
|
|
10
|
+
import type { Child, InnerHTML } from './jsx-runtime.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `style` object: React's typed CSS properties, plus custom properties. A
|
|
14
|
+
* `null` leaves the property out, as it does an attribute.
|
|
15
|
+
*/
|
|
16
|
+
export type CSSProperties = {
|
|
17
|
+
[Property in keyof React.CSSProperties]: React.CSSProperties[Property] | null
|
|
18
|
+
} & {
|
|
19
|
+
[custom: `--${string}`]: string | number | null | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Attributes a site adds to every element, for one these types do not know yet:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* declare module '@crombolo/crombolo' {
|
|
27
|
+
* interface ExtraAttributes { popover?: 'auto' | 'manual' }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
// biome-ignore lint/suspicious/noEmptyInterface: empty so that a site can merge into it.
|
|
32
|
+
export interface ExtraAttributes {}
|
|
33
|
+
|
|
34
|
+
/** What this runtime does differently from React, on every element. */
|
|
35
|
+
export interface RuntimeAttributes {
|
|
36
|
+
children?: Child
|
|
37
|
+
style?: string | CSSProperties
|
|
38
|
+
/** Markup to write inside the element, escaped unless `dangerously` is set. */
|
|
39
|
+
setInnerHTML?: InnerHTML
|
|
40
|
+
/** React's spelling, written verbatim. */
|
|
41
|
+
dangerouslySetInnerHTML?: { __html: string | Promise<string> }
|
|
42
|
+
class?: string
|
|
43
|
+
for?: string
|
|
44
|
+
/** Dropped when rendered. */
|
|
45
|
+
ref?: unknown
|
|
46
|
+
/** A page is static HTML, so a handler could never run. */
|
|
47
|
+
[handler: `on${Capitalize<string>}`]: never
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Props React declares that mean nothing to a string renderer, or that are typed again above. */
|
|
51
|
+
type Replaced =
|
|
52
|
+
| 'children'
|
|
53
|
+
| 'style'
|
|
54
|
+
| 'ref'
|
|
55
|
+
| 'dangerouslySetInnerHTML'
|
|
56
|
+
| 'suppressContentEditableWarning'
|
|
57
|
+
| 'suppressHydrationWarning'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* React's props for one tag, without its handlers and the props replaced above.
|
|
61
|
+
* Each also takes `null`, which leaves the attribute out — and is what
|
|
62
|
+
* `model.value()` returns for an empty property.
|
|
63
|
+
*/
|
|
64
|
+
type ReactAttributes<Props> = {
|
|
65
|
+
[Name in keyof Props as Name extends Replaced | `on${Capitalize<string>}` ? never : Name]:
|
|
66
|
+
| Props[Name]
|
|
67
|
+
| null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Each attribute again in lower case, as HTML writes it: `charset`, `tabindex`. */
|
|
71
|
+
type HtmlSpellings<Props> = {
|
|
72
|
+
[Name in keyof Props as Lowercase<Name & string>]?: Props[Name]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type Attributes<Props> = ReactAttributes<Props> &
|
|
76
|
+
HtmlSpellings<ReactAttributes<Props>> &
|
|
77
|
+
RuntimeAttributes &
|
|
78
|
+
ExtraAttributes
|
|
79
|
+
|
|
80
|
+
/** Every tag React knows, with its attributes. */
|
|
81
|
+
export type KnownElements = {
|
|
82
|
+
[Tag in keyof React.JSX.IntrinsicElements]: Attributes<React.JSX.IntrinsicElements[Tag]>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A custom element (`<my-widget>`): its attributes are its own, so any is
|
|
87
|
+
* accepted, and the runtime's still apply.
|
|
88
|
+
*/
|
|
89
|
+
export interface CustomElementAttributes extends RuntimeAttributes {
|
|
90
|
+
[attribute: string]: unknown
|
|
91
|
+
}
|