@crombolo/render 0.15.0 → 0.17.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/package.json +2 -2
- package/src/context.ts +19 -2
- package/src/email.ts +52 -0
- package/src/index.ts +1 -0
- package/src/renderer.ts +52 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "TSX server rendering and the published-content model for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@crombolo/core": "0.
|
|
35
|
+
"@crombolo/core": "0.17.0",
|
|
36
36
|
"@types/react": "^19.3.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/context.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { SiteSearchPort } from './search.ts'
|
|
|
16
16
|
|
|
17
17
|
interface RenderScope {
|
|
18
18
|
page: PageProps | undefined
|
|
19
|
+
/** An e-mail template is rendering: there is no page to ask for. */
|
|
20
|
+
email?: boolean
|
|
19
21
|
/** The site search the server scoped to the person asking, when it provides one. */
|
|
20
22
|
search?: SiteSearchPort
|
|
21
23
|
contexts: ReadonlyMap<symbol, unknown>
|
|
@@ -37,6 +39,13 @@ export function renderScope<T>(page: PageProps, render: () => T, search?: SiteSe
|
|
|
37
39
|
return store.run({ page, search, contexts: new Map(), ids: { next: 0 } }, render)
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Runs an e-mail template's render: ids and context work, and there is no page. */
|
|
43
|
+
export function emailScope<T>(render: () => T): T {
|
|
44
|
+
return store.run({ page: undefined, email: true, contexts: new Map(), ids: { next: 0 } }, render)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const NO_PAGE_IN_EMAIL = 'an e-mail template has no page; read what it needs from its props.'
|
|
48
|
+
|
|
40
49
|
function within(caller: string): RenderScope {
|
|
41
50
|
const scope = store.getStore()
|
|
42
51
|
if (!scope)
|
|
@@ -53,7 +62,8 @@ function within(caller: string): RenderScope {
|
|
|
53
62
|
*/
|
|
54
63
|
// biome-ignore lint/suspicious/noExplicitAny: an untyped page, as `PageProps` defaults to.
|
|
55
64
|
export function usePage<Properties = any>(): PageProps<Properties> {
|
|
56
|
-
const { page } = within('usePage()')
|
|
65
|
+
const { page, email } = within('usePage()')
|
|
66
|
+
if (email) throw new Error(`usePage() was called while ${NO_PAGE_IN_EMAIL}`)
|
|
57
67
|
if (!page)
|
|
58
68
|
throw new Error('usePage() was called inside a context Provider rendered outside a page.')
|
|
59
69
|
return page as PageProps<Properties>
|
|
@@ -62,6 +72,7 @@ export function usePage<Properties = any>(): PageProps<Properties> {
|
|
|
62
72
|
/** The page and the site search for `search()`, which lives in `search.ts`. */
|
|
63
73
|
export function currentSiteSearch(): { page: PageProps; search: SiteSearchPort | undefined } {
|
|
64
74
|
const scope = within('search()')
|
|
75
|
+
if (scope.email) throw new Error(`search() was called while ${NO_PAGE_IN_EMAIL}`)
|
|
65
76
|
if (!scope.page)
|
|
66
77
|
throw new Error('search() was called inside a context Provider rendered outside a page.')
|
|
67
78
|
return { page: scope.page, search: scope.search }
|
|
@@ -112,7 +123,13 @@ export function createContext<T>(defaultValue: T): Context<T> {
|
|
|
112
123
|
const contexts = new Map(outer?.contexts)
|
|
113
124
|
contexts.set(key, value)
|
|
114
125
|
return store.run(
|
|
115
|
-
{
|
|
126
|
+
{
|
|
127
|
+
page: outer?.page,
|
|
128
|
+
email: outer?.email,
|
|
129
|
+
search: outer?.search,
|
|
130
|
+
contexts,
|
|
131
|
+
ids: outer?.ids ?? { next: 0 },
|
|
132
|
+
},
|
|
116
133
|
children,
|
|
117
134
|
)
|
|
118
135
|
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an e-mail template is given: the form, what was sent, and where from.
|
|
3
|
+
* `docs/forms.md`, "An e-mail template".
|
|
4
|
+
*
|
|
5
|
+
* An e-mail is not a page. There is no route, no member and no navigation, so a
|
|
6
|
+
* template reads everything from these props, and `usePage()` says so.
|
|
7
|
+
*/
|
|
8
|
+
import type { SchemaForm } from '@crombolo/core'
|
|
9
|
+
import type { RawHtml } from './html.ts'
|
|
10
|
+
|
|
11
|
+
/** One field of the form, in the form's order, as the e-mail may show it. */
|
|
12
|
+
export interface EmailField {
|
|
13
|
+
alias: string
|
|
14
|
+
caption: string
|
|
15
|
+
type: string
|
|
16
|
+
/** What was entered: a choice's label before its stored value, as `{alias}` shows it. */
|
|
17
|
+
values: string[]
|
|
18
|
+
answered: boolean
|
|
19
|
+
sensitive: boolean
|
|
20
|
+
/** Sensitive, and the workflow does not send sensitive answers: `values` is empty. */
|
|
21
|
+
leftOut: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EmailProps {
|
|
25
|
+
form: SchemaForm
|
|
26
|
+
fields: EmailField[]
|
|
27
|
+
/** What was entered for a field, joined as `{alias}` joins it; empty when nothing was. */
|
|
28
|
+
value(alias: string): string
|
|
29
|
+
entry: {
|
|
30
|
+
/** Absent for a form that stores no entries. */
|
|
31
|
+
id: string | null
|
|
32
|
+
/** When the workflow was queued: the submission, or the approval for `on = "approve"`. */
|
|
33
|
+
submittedAt: Date
|
|
34
|
+
}
|
|
35
|
+
site: { name: string; url: string }
|
|
36
|
+
/** The subject line, already substituted. */
|
|
37
|
+
subject: string
|
|
38
|
+
/** Aborts when the render has taken too long. */
|
|
39
|
+
signal: AbortSignal
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a layout around an e-mail template is given: the same props, and the template inside. */
|
|
43
|
+
export interface EmailLayoutProps extends EmailProps {
|
|
44
|
+
children: RawHtml
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type EmailRenderResult =
|
|
48
|
+
| { status: 'ok'; html: string }
|
|
49
|
+
/** No component answers to the alias in the views being served. */
|
|
50
|
+
| { status: 'missing' }
|
|
51
|
+
/** The message names the template and its line under `components/`, never a snapshot path. */
|
|
52
|
+
| { status: 'error'; message: string }
|
package/src/index.ts
CHANGED
package/src/renderer.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* declares it too — in the file, not the database.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
|
-
import { join, resolve } from 'node:path'
|
|
10
|
+
import { basename, join, resolve, sep } from 'node:path'
|
|
11
11
|
import { pathToFileURL } from 'node:url'
|
|
12
12
|
import type { FormSubmissionState } from '@crombolo/core'
|
|
13
|
-
import { renderScope } from './context.ts'
|
|
13
|
+
import { emailScope, renderScope } from './context.ts'
|
|
14
|
+
import type { EmailProps, EmailRenderResult } from './email.ts'
|
|
14
15
|
import { RawHtml } from './html.ts'
|
|
15
16
|
import { type Child, renderToString } from './jsx-runtime.ts'
|
|
16
17
|
import type { PageProps, PublishedContent, RequestMember } from './model.ts'
|
|
@@ -257,16 +258,56 @@ export class Renderer {
|
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Renders an e-mail template to HTML: resolved in the current views as a
|
|
263
|
+
* page's template is, under the same time limit, inside its layouts.
|
|
264
|
+
*
|
|
265
|
+
* Never throws. A failure comes back as a message a person can act on, naming
|
|
266
|
+
* the template and the line under `components/` it failed on.
|
|
267
|
+
*/
|
|
268
|
+
async renderEmail(alias: string, props: Omit<EmailProps, 'signal'>): Promise<EmailRenderResult> {
|
|
269
|
+
if (!this.componentPath(alias)) return { status: 'missing' }
|
|
270
|
+
try {
|
|
271
|
+
const html = await this.#withDeadline(alias, undefined, (signal, rendering) =>
|
|
272
|
+
emailScope(() => this.#renderLayouts(alias, { ...props, signal }, rendering)),
|
|
273
|
+
)
|
|
274
|
+
return html === undefined ? { status: 'missing' } : { status: 'ok', html }
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { status: 'error', message: this.#emailFailure(alias, error) }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `The e-mail template 'x' failed at components/x.tsx:12: <what it threw>`. */
|
|
281
|
+
#emailFailure(alias: string, error: unknown): string {
|
|
282
|
+
const root = `${this.#componentsDir}${sep}`
|
|
283
|
+
const shown = `${basename(this.#componentsDir)}/`
|
|
284
|
+
const readable = (text: string) => this.inSource(text).replaceAll(root, shown)
|
|
285
|
+
const message = readable(error instanceof Error ? error.message : String(error)).trim()
|
|
286
|
+
const stack = error instanceof Error ? readable(error.stack ?? '') : ''
|
|
287
|
+
const escaped = shown.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
288
|
+
const frame = new RegExp(`${escaped}[^\\s:()]+\\.[jt]sx?:\\d+`).exec(stack)
|
|
289
|
+
return `The e-mail template '${alias}' failed${frame ? ` at ${frame[0]}` : ''}: ${message}`
|
|
290
|
+
}
|
|
291
|
+
|
|
260
292
|
async #renderContent(
|
|
261
293
|
content: PublishedContent,
|
|
262
294
|
alias: string,
|
|
263
295
|
culture: string | null,
|
|
264
296
|
request: RenderRequest = {},
|
|
265
297
|
): Promise<string | undefined> {
|
|
298
|
+
return this.#withDeadline(alias, request.signal, (signal, rendering) =>
|
|
299
|
+
this.#renderChain(content, alias, culture, request, signal, rendering),
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Runs a render under the time limit; the signal it hands on aborts when that runs out. */
|
|
304
|
+
async #withDeadline<T>(
|
|
305
|
+
alias: string,
|
|
306
|
+
outer: AbortSignal | undefined,
|
|
307
|
+
render: (signal: AbortSignal, rendering: { alias: string }) => Promise<T>,
|
|
308
|
+
): Promise<T> {
|
|
266
309
|
const deadline = new AbortController()
|
|
267
|
-
const signal =
|
|
268
|
-
? AbortSignal.any([deadline.signal, request.signal])
|
|
269
|
-
: deadline.signal
|
|
310
|
+
const signal = outer ? AbortSignal.any([deadline.signal, outer]) : deadline.signal
|
|
270
311
|
const rendering = { alias }
|
|
271
312
|
const timer =
|
|
272
313
|
this.#renderTimeoutMs > 0
|
|
@@ -286,7 +327,7 @@ export class Renderer {
|
|
|
286
327
|
else signal.addEventListener('abort', fail, { once: true })
|
|
287
328
|
})
|
|
288
329
|
stopped.catch(() => {})
|
|
289
|
-
const work =
|
|
330
|
+
const work = render(signal, rendering)
|
|
290
331
|
// Once the deadline has answered, a late failure has nobody left to tell.
|
|
291
332
|
work.catch(() => {})
|
|
292
333
|
try {
|
|
@@ -328,9 +369,10 @@ export class Renderer {
|
|
|
328
369
|
return renderScope(props, () => this.#renderLayouts(alias, props, rendering), request.search)
|
|
329
370
|
}
|
|
330
371
|
|
|
331
|
-
|
|
372
|
+
/** Generic over its props: a page's, or an e-mail's. */
|
|
373
|
+
async #renderLayouts<Props extends object>(
|
|
332
374
|
alias: string,
|
|
333
|
-
props:
|
|
375
|
+
props: Props,
|
|
334
376
|
rendering: { alias: string },
|
|
335
377
|
): Promise<string | undefined> {
|
|
336
378
|
let currentAlias: string | undefined = alias
|
|
@@ -343,10 +385,8 @@ export class Renderer {
|
|
|
343
385
|
rendering.alias = currentAlias
|
|
344
386
|
const module = await this.#load(currentAlias)
|
|
345
387
|
if (!module) return depth === 0 || body === undefined ? undefined : withDoctype(body.value)
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
? module.default(props)
|
|
349
|
-
: module.default({ ...props, children: body } as LayoutProps)
|
|
388
|
+
const render = module.default as unknown as (props: object) => Child | Promise<Child>
|
|
389
|
+
const rendered = body === undefined ? render(props) : render({ ...props, children: body })
|
|
350
390
|
body = new RawHtml(await renderToString(rendered as Child))
|
|
351
391
|
currentAlias = module.layout
|
|
352
392
|
depth += 1
|