@crombolo/render 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/render",
3
- "version": "0.16.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.16.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
- { page: outer?.page, search: outer?.search, contexts, ids: outer?.ids ?? { next: 0 } },
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
@@ -1,4 +1,5 @@
1
1
  export * from './context.ts'
2
+ export * from './email.ts'
2
3
  export * from './excerpt.ts'
3
4
  export * from './form-script.ts'
4
5
  export * from './forms.ts'
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 = request.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 = this.#renderChain(content, alias, culture, request, signal, rendering)
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
- async #renderLayouts(
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: PageProps,
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 rendered =
347
- body === undefined
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