@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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Redirect rules and matching a request against them.
3
+ *
4
+ * One shape carries both kinds: the rules a rename or a move records by itself,
5
+ * which always name a document, and the rules a site declares in its config,
6
+ * which may also name a path or an external URL. Umbraco has no configured
7
+ * redirects — its answer is a rewrite rule in front of the site — so that half
8
+ * of the vocabulary is ours.
9
+ */
10
+
11
+ /** How a rule's `pattern` is matched against a route. */
12
+ export type RedirectMatchKind = 'exact' | 'prefix' | 'regex'
13
+
14
+ /** What the rule's `target` names. */
15
+ export type RedirectTargetKind = 'document' | 'path' | 'url'
16
+
17
+ export interface RedirectRule {
18
+ /** Identity in the Management API and the database; absent until stored. */
19
+ key?: string
20
+ matchKind: RedirectMatchKind
21
+ /** The whole route, the subtree above it, or a regular expression's source. */
22
+ pattern: string
23
+ /**
24
+ * Set only on a rule a hostname scopes: the key of the document that hostname
25
+ * roots, so the rule survives the hostname being changed, and `pattern` is the
26
+ * route below that document. A configured rule leaves this null and matches
27
+ * the path of any request.
28
+ */
29
+ rootKey: string | null
30
+ culture: string | null
31
+ targetKind: RedirectTargetKind
32
+ /** A document key, or a path or URL in which `$1`…`$9` stand for the captures. */
33
+ target: string
34
+ statusCode: number
35
+ /** Configured rules are matched in this order, before any tracked rule. */
36
+ sortOrder: number
37
+ /** Configured in code, written by an administrator, or recorded by a rename. */
38
+ source: 'tracked' | 'config' | 'manual'
39
+ }
40
+
41
+ /** Where a configured redirect sends the visitor. */
42
+ export type RedirectTo = string | { document: string }
43
+
44
+ export interface RedirectOptions {
45
+ /** 301 by default, as a retired URL is normally gone for good. */
46
+ status?: 301 | 302 | 307 | 308
47
+ /** Restrict the rule to requests resolving in one culture. */
48
+ culture?: string
49
+ }
50
+
51
+ const ABSOLUTE = /^(https?:)?\/\//i
52
+
53
+ /** Trailing slashes carry no meaning in a route, and matching is case-insensitive. */
54
+ export function normaliseRedirectPath(path: string): string {
55
+ const trimmed = path.trim()
56
+ const withSlash = trimmed.startsWith('/') || ABSOLUTE.test(trimmed) ? trimmed : `/${trimmed}`
57
+ return (withSlash.replace(/\/+$/, '') || '/').toLowerCase()
58
+ }
59
+
60
+ /**
61
+ * A redirect for `crombolo.config.ts`.
62
+ *
63
+ * `from` is a route, a route ending in `/*` to match everything below it, or a
64
+ * regular expression. `to` is a route, an absolute URL, or `{ document }` — a
65
+ * document key, which is resolved when the request arrives and so follows the
66
+ * page when it is later renamed or moved.
67
+ */
68
+ export function redirect(
69
+ from: string | RegExp,
70
+ to: RedirectTo,
71
+ options: RedirectOptions = {},
72
+ ): RedirectRule {
73
+ const match =
74
+ from instanceof RegExp
75
+ ? { matchKind: 'regex' as const, pattern: from.source }
76
+ : from.endsWith('/*')
77
+ ? { matchKind: 'prefix' as const, pattern: normaliseRedirectPath(from.slice(0, -2)) }
78
+ : { matchKind: 'exact' as const, pattern: normaliseRedirectPath(from) }
79
+ const target =
80
+ typeof to === 'string'
81
+ ? ABSOLUTE.test(to.trim())
82
+ ? { targetKind: 'url' as const, target: to.trim() }
83
+ : { targetKind: 'path' as const, target: to.trim() }
84
+ : { targetKind: 'document' as const, target: to.document.toLowerCase() }
85
+ return {
86
+ ...match,
87
+ ...target,
88
+ rootKey: null,
89
+ culture: options.culture ?? null,
90
+ statusCode: options.status ?? 301,
91
+ sortOrder: 0,
92
+ source: 'config',
93
+ }
94
+ }
95
+
96
+ /** `$1`…`$9` from the captures, and `$$` for a literal dollar. */
97
+ function substitute(template: string, captures: readonly string[]): string {
98
+ return template.replace(/\$(\$|[1-9])/g, (_, token: string) =>
99
+ token === '$' ? '$' : (captures[Number(token) - 1] ?? ''),
100
+ )
101
+ }
102
+
103
+ /**
104
+ * The rule's target with its captures filled in, or undefined when the route
105
+ * does not match. A prefix rule offers the tail below the prefix as `$1`.
106
+ */
107
+ export function matchRedirect(rule: RedirectRule, route: string): string | undefined {
108
+ switch (rule.matchKind) {
109
+ case 'exact':
110
+ return route === rule.pattern ? rule.target : undefined
111
+ case 'prefix': {
112
+ // `/*` is the whole site — the rule for moving one somewhere else — and its
113
+ // prefix normalises to `/`, which would otherwise only match the home page.
114
+ if (rule.pattern === '/') return substitute(rule.target, [route.replace(/^\//, '')])
115
+ if (route !== rule.pattern && !route.startsWith(`${rule.pattern}/`)) return undefined
116
+ const tail = route.slice(rule.pattern.length).replace(/^\//, '')
117
+ return substitute(rule.target, [tail])
118
+ }
119
+ case 'regex': {
120
+ // The route reaching here is already lowercased, so a pattern written with
121
+ // capitals would never match without this flag.
122
+ const found = new RegExp(rule.pattern, 'i').exec(route)
123
+ return found
124
+ ? substitute(
125
+ rule.target,
126
+ found.slice(1).map((c) => c ?? ''),
127
+ )
128
+ : undefined
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Front-end rendering: resolve a URL to published content, find its template, and
3
+ * execute it.
4
+ *
5
+ * A template is a .tsx module whose default export is a function of PageProps.
6
+ * `export const layout = 'alias'` names the layout template, which is how Umbraco
7
+ * declares it too — in the file, not the database.
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { join, resolve } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+ import type { FormSubmissionState } from '@crombolo/core'
13
+ import { renderScope } from './context.ts'
14
+ import { RawHtml } from './html.ts'
15
+ import { type Child, renderToString } from './jsx-runtime.ts'
16
+ import type { PageProps, PublishedContent, RequestMember } from './model.ts'
17
+ import { type PublishedCache, translate, urlsIn } from './published-cache.ts'
18
+ import { absoluteUrl, type SeoSiteDefaults } from './seo.ts'
19
+ import type { ViewSnapshots } from './snapshots.ts'
20
+
21
+ export interface ComponentModule {
22
+ default: (props: PageProps) => Child | Promise<Child>
23
+ /** Alias of the layout template to wrap this one in. */
24
+ layout?: string
25
+ }
26
+
27
+ // biome-ignore lint/suspicious/noExplicitAny: an untyped page, as `PageProps` defaults to.
28
+ export interface LayoutProps<Properties = any> extends PageProps<Properties> {
29
+ children: RawHtml
30
+ }
31
+
32
+ export interface RenderOptions {
33
+ cache: PublishedCache
34
+ componentsDir: string
35
+ /**
36
+ * Content-addressed copies of `componentsDir` to import from.
37
+ *
38
+ * Without one the renderer imports straight from `componentsDir`, which is what a
39
+ * unit test wants and means an edited view is not picked up until the process
40
+ * restarts — see `snapshots.ts` for why.
41
+ */
42
+ snapshots?: ViewSnapshots
43
+ /** How long a page may take to render, layouts included; 0 for no limit. */
44
+ renderTimeoutMs?: number
45
+ }
46
+
47
+ export const DEFAULT_RENDER_TIMEOUT_MS = 10_000
48
+
49
+ export type RenderResult =
50
+ | { status: 'ok'; html: string; content: PublishedContent }
51
+ | { status: 'notFound' }
52
+ /** The page names another to send its visitors to (`umbracoRedirect`). */
53
+ | { status: 'redirect'; location: string; content: PublishedContent }
54
+ | { status: 'noTemplate'; content: PublishedContent }
55
+ | { status: 'error'; error: Error; content: PublishedContent }
56
+
57
+ const MAX_LAYOUT_DEPTH = 10
58
+
59
+ /**
60
+ * A whole document gets `<!DOCTYPE html>`, which JSX has no way to write and
61
+ * without which a browser renders in quirks mode. A page that writes its own,
62
+ * or renders something other than a document, is left as it is.
63
+ */
64
+ export function withDoctype(html: string): string {
65
+ return /^\s*<html[\s>]/i.test(html) ? `<!DOCTYPE html>${html}` : html
66
+ }
67
+ /** Umbraco's own guard against a login page that is itself protected. */
68
+ const MAX_ACCESS_SUBSTITUTIONS = 8
69
+
70
+ /**
71
+ * What a public-access check decides for a page: render it, or render another
72
+ * one in its place — the login page for a visitor who is not signed in, the
73
+ * error page for a member who may not see it. Umbraco rewrites rather than
74
+ * redirects, so the URL the visitor asked for is the URL they keep.
75
+ */
76
+ export type AccessDecision = { status: 'allowed' } | { status: 'substitute'; contentKey: string }
77
+
78
+ /** Per-request context: who is asking, and what they are allowed to see. */
79
+ export interface RenderRequest {
80
+ member?: RequestMember
81
+ access?: (content: PublishedContent) => Promise<AccessDecision>
82
+ /**
83
+ * A form submission the server has just handled, for this request's page.
84
+ *
85
+ * The endpoint redirects back here rather than answering with markup of its
86
+ * own, so the form reappears with its errors — or its thank-you — inside the
87
+ * layout it was rendered in.
88
+ */
89
+ submission?: FormSubmissionState
90
+ /** Scheme and host the visitor used, for absolute addresses; `http://localhost` when absent. */
91
+ origin?: string
92
+ /** The address requested, as the visitor sent it. */
93
+ url?: string
94
+ /** Site-wide search and sharing values. */
95
+ seo?: SeoSiteDefaults
96
+ /** Aborts when nobody is waiting for the page any more: the visitor disconnected. */
97
+ signal?: AbortSignal
98
+ }
99
+
100
+ /**
101
+ * Whether an alias is a path that stays inside the components tree.
102
+ *
103
+ * `@crombolo/server` has the same rule as `isSafeAlias`; it is written again
104
+ * here because render does not depend on server, and the two are checked
105
+ * against each other in the suite.
106
+ */
107
+ function isSafeComponentAlias(alias: string): boolean {
108
+ if (alias.length === 0 || alias.startsWith('/') || alias.includes('\\')) return false
109
+ if (/^[a-zA-Z]:/.test(alias)) return false
110
+ return alias.split('/').every((part) => /^[A-Za-z0-9_-]+$/.test(part))
111
+ }
112
+
113
+ export class Renderer {
114
+ #cache: PublishedCache
115
+ #componentsDir: string
116
+ #snapshots: ViewSnapshots | undefined
117
+ /** Keyed by the generation that holds the view, so one swap retires them all. */
118
+ #modules = new Map<string, ComponentModule>()
119
+ #importedFrom: string | undefined
120
+ #renderTimeoutMs: number
121
+
122
+ constructor(options: RenderOptions) {
123
+ this.#cache = options.cache
124
+ // Resolved to an absolute path: a dynamic import of a relative specifier
125
+ // resolves against this module, not the working directory.
126
+ this.#componentsDir = resolve(options.componentsDir)
127
+ this.#snapshots = options.snapshots
128
+ this.#renderTimeoutMs = options.renderTimeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS
129
+ }
130
+
131
+ /**
132
+ * Drops this renderer's own map of views.
133
+ *
134
+ * On its own this changes nothing about what renders: the module registry
135
+ * behind `import()` belongs to the runtime, so re-importing one path hands
136
+ * back the same module whatever this map holds. What makes an edited view be
137
+ * read is a snapshot at a new path.
138
+ */
139
+ invalidate(): void {
140
+ this.#modules.clear()
141
+ }
142
+
143
+ /**
144
+ * Where views are imported from now: a snapshot when there is one.
145
+ *
146
+ * A generation change clears this renderer's map. The runtime's registry keeps
147
+ * the old modules either way, but holding our own references to generations
148
+ * nobody renders would grow without limit.
149
+ */
150
+ #importDir(): string {
151
+ const dir = this.#snapshots?.directory() ?? this.#componentsDir
152
+ if (dir !== this.#importedFrom) {
153
+ this.#modules.clear()
154
+ this.#importedFrom = dir
155
+ }
156
+ return dir
157
+ }
158
+
159
+ /**
160
+ * The file a view is imported from, inside the current generation.
161
+ *
162
+ * Not the file anybody edits — that is `sourceFor`, and the two differ by the
163
+ * generation in between.
164
+ */
165
+ componentPath(alias: string): string | undefined {
166
+ // An alias is a path under `components/`, so it may carry folders — but it
167
+ // reaches here from the database, and a path from the database is a path
168
+ // from a request. Each segment is checked rather than the whole string, so
169
+ // `..`, an absolute path and a drive letter are all refused while
170
+ // `pages/homePage` resolves.
171
+ if (!isSafeComponentAlias(alias)) return undefined
172
+ const file = join(this.#importDir(), `${alias}.tsx`)
173
+ return existsSync(file) ? file : undefined
174
+ }
175
+
176
+ /**
177
+ * Rewrites snapshot paths back to `componentsDir`, for anything a person reads: an
178
+ * error, a log line, a stack trace. A snapshot names a directory nobody edits
179
+ * and a generation they cannot act on, so naming it would send them to the
180
+ * wrong file.
181
+ *
182
+ * Every generation, not just the current one: a stack can name a module loaded
183
+ * from the generation before a swap.
184
+ */
185
+ inSource(text: string): string {
186
+ const cacheDir = this.#snapshots?.cacheDir
187
+ if (!cacheDir) return text
188
+ const escaped = cacheDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
189
+ return text.replaceAll(new RegExp(`${escaped}[/\\\\][0-9a-f]+`, 'g'), this.#componentsDir)
190
+ }
191
+
192
+ /** Renders the page a request resolves to; `host` lets hostnames root and localise it. */
193
+ async render(route: string, host = '', request: RenderRequest = {}): Promise<RenderResult> {
194
+ const resolved = await this.#cache.resolve(host, route)
195
+ if (!resolved) return { status: 'notFound' }
196
+ const { culture } = resolved
197
+ if (resolved.redirect)
198
+ return { status: 'redirect', location: resolved.redirect, content: resolved.content }
199
+ const permitted = await this.#permitted(resolved.content, culture, request)
200
+ if (!permitted) return { status: 'notFound' }
201
+ const content = permitted
202
+ if (!content.componentAlias) return { status: 'noTemplate', content }
203
+
204
+ try {
205
+ const html = await this.#renderContent(content, content.componentAlias, culture, request)
206
+ return html === undefined
207
+ ? { status: 'noTemplate', content }
208
+ : { status: 'ok', html, content }
209
+ } catch (error) {
210
+ return { status: 'error', error: error as Error, content }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * The page to render in place of this one, following the access check until it
216
+ * is satisfied. Undefined means nothing can be shown — a rule naming a page
217
+ * that no longer exists, which is a 404 rather than an open door.
218
+ */
219
+ async #permitted(
220
+ content: PublishedContent,
221
+ culture: string | null,
222
+ request: RenderRequest,
223
+ ): Promise<PublishedContent | undefined> {
224
+ if (!request.access) return content
225
+ let current = content
226
+ for (let step = 0; step < MAX_ACCESS_SUBSTITUTIONS; step++) {
227
+ const decision = await request.access(current)
228
+ if (decision.status === 'allowed') return current
229
+ if (decision.contentKey.toLowerCase() === current.key.toLowerCase()) return undefined
230
+ const next = await this.#cache.byKey(decision.contentKey, culture)
231
+ if (!next) return undefined
232
+ current = next
233
+ }
234
+ return undefined
235
+ }
236
+
237
+ /**
238
+ * Renders a given model rather than a routed one: preview renders a draft this
239
+ * way, with the published tree for navigation.
240
+ */
241
+ async renderModel(
242
+ content: PublishedContent,
243
+ culture: string | null,
244
+ request: RenderRequest = {},
245
+ ): Promise<RenderResult> {
246
+ if (!content.componentAlias) return { status: 'noTemplate', content }
247
+ try {
248
+ const html = await this.#renderContent(content, content.componentAlias, culture, request)
249
+ return html === undefined
250
+ ? { status: 'noTemplate', content }
251
+ : { status: 'ok', html, content }
252
+ } catch (error) {
253
+ return { status: 'error', error: error as Error, content }
254
+ }
255
+ }
256
+
257
+ async #renderContent(
258
+ content: PublishedContent,
259
+ alias: string,
260
+ culture: string | null,
261
+ request: RenderRequest = {},
262
+ ): Promise<string | undefined> {
263
+ const deadline = new AbortController()
264
+ const signal = request.signal
265
+ ? AbortSignal.any([deadline.signal, request.signal])
266
+ : deadline.signal
267
+ const rendering = { alias }
268
+ const timer =
269
+ this.#renderTimeoutMs > 0
270
+ ? setTimeout(
271
+ () =>
272
+ deadline.abort(
273
+ new Error(
274
+ `Rendering '${rendering.alias}' took longer than ${this.#renderTimeoutMs}ms.`,
275
+ ),
276
+ ),
277
+ this.#renderTimeoutMs,
278
+ )
279
+ : undefined
280
+ const stopped = new Promise<never>((_, reject) => {
281
+ const fail = () => reject(signal.reason)
282
+ if (signal.aborted) fail()
283
+ else signal.addEventListener('abort', fail, { once: true })
284
+ })
285
+ stopped.catch(() => {})
286
+ const work = this.#renderChain(content, alias, culture, request, signal, rendering)
287
+ // Once the deadline has answered, a late failure has nobody left to tell.
288
+ work.catch(() => {})
289
+ try {
290
+ return await Promise.race([work, stopped])
291
+ } finally {
292
+ clearTimeout(timer)
293
+ }
294
+ }
295
+
296
+ async #renderChain(
297
+ content: PublishedContent,
298
+ alias: string,
299
+ culture: string | null,
300
+ request: RenderRequest,
301
+ signal: AbortSignal,
302
+ rendering: { alias: string },
303
+ ): Promise<string | undefined> {
304
+ const nav = await this.#cache.navigation(culture)
305
+ const snapshot = await this.#cache.snapshot()
306
+ const dictionary = (key: string) => translate(snapshot, key, culture)
307
+ const origin = (request.origin ?? 'http://localhost').replace(/\/$/, '')
308
+ const props: PageProps = {
309
+ model: content,
310
+ nav,
311
+ culture,
312
+ dictionary,
313
+ member: request.member,
314
+ submission: request.submission,
315
+ request: { origin, url: request.url ?? `${origin}/` },
316
+ urls: {
317
+ absolute: (target) => absoluteUrl(target.url, origin),
318
+ alternates: (target) => urlsIn(snapshot, target.key),
319
+ },
320
+ seo: request.seo ?? { siteName: '', indexable: true },
321
+ signal,
322
+ }
323
+
324
+ // Inside the page's scope, so any component can ask for it with `usePage()`.
325
+ return renderScope(props, () => this.#renderLayouts(alias, props, rendering))
326
+ }
327
+
328
+ async #renderLayouts(
329
+ alias: string,
330
+ props: PageProps,
331
+ rendering: { alias: string },
332
+ ): Promise<string | undefined> {
333
+ let currentAlias: string | undefined = alias
334
+ let body: RawHtml | undefined
335
+ let depth = 0
336
+
337
+ // Walk the layout chain outwards: the page renders first, then each layout
338
+ // wraps the result. A cycle would otherwise hang the request.
339
+ while (currentAlias && depth < MAX_LAYOUT_DEPTH) {
340
+ rendering.alias = currentAlias
341
+ const module = await this.#load(currentAlias)
342
+ if (!module) return depth === 0 || body === undefined ? undefined : withDoctype(body.value)
343
+ const rendered =
344
+ body === undefined
345
+ ? module.default(props)
346
+ : module.default({ ...props, children: body } as LayoutProps)
347
+ body = new RawHtml(await renderToString(rendered as Child))
348
+ currentAlias = module.layout
349
+ depth += 1
350
+ }
351
+ if (depth >= MAX_LAYOUT_DEPTH) {
352
+ throw new Error(
353
+ `Layout chain for '${alias}' is longer than ${MAX_LAYOUT_DEPTH}; is it a cycle?`,
354
+ )
355
+ }
356
+ return body === undefined ? undefined : withDoctype(body.value)
357
+ }
358
+
359
+ async #load(alias: string): Promise<ComponentModule | undefined> {
360
+ const file = this.componentPath(alias)
361
+ if (!file) return undefined
362
+ // The path carries the generation, so one entry per view per generation and
363
+ // a swap never answers from the generation before it.
364
+ const cached = this.#modules.get(file)
365
+ if (cached) return cached
366
+
367
+ const module = (await import(pathToFileURL(file).href)) as ComponentModule
368
+ if (typeof module.default !== 'function') {
369
+ throw new Error(`Template '${alias}' does not export a default function.`)
370
+ }
371
+ this.#modules.set(file, module)
372
+ return module
373
+ }
374
+ }