@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/redirects.ts
ADDED
|
@@ -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
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|