@ultimat3/render 27.8.1 → 27.9.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/README.md CHANGED
@@ -26,6 +26,14 @@ export const config = defineRoute({
26
26
  export function Page(props: { data: Post }) { /* the SAME object meta was given */ }
27
27
  ```
28
28
 
29
+ `prerender()` answers the pages to build: a bare string (a one-param route), a params object, or an
30
+ entry — `{ params, lastmod?, locales? }` (`PrerenderEntry`) — mixed freely. The build reads `params`
31
+ alone and renders every routed locale; `lastmod` (a `Date` or ISO 8601 with a zone) and `locales`
32
+ are what the page says of itself for the sitemap: its `<lastmod>`, and the locales it is listed and
33
+ cross-linked in. `enumeratePrerender()` answers params, `enumeratePrerenderEntries()` the entries.
34
+ A key outside the three, a `lastmod` that is neither a string nor a `Date`, or `locales` that is
35
+ not a list of names is `X_PRERENDER_FAILED`, naming the route file.
36
+
29
37
  ## `load` is the one server-side data seam
30
38
 
31
39
  Optional, and the only way a page gets data. It runs **once per render** and the result is handed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "27.8.1",
3
+ "version": "27.9.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,11 +40,11 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/cache": "27.8.1",
44
- "@ultimat3/core": "27.8.1",
45
- "@ultimat3/http": "27.8.1",
46
- "@ultimat3/i18n": "27.8.1",
47
- "@ultimat3/seo": "27.8.1",
43
+ "@ultimat3/cache": "27.9.0",
44
+ "@ultimat3/core": "27.9.0",
45
+ "@ultimat3/http": "27.9.0",
46
+ "@ultimat3/i18n": "27.9.0",
47
+ "@ultimat3/seo": "27.9.0",
48
48
  "sass": "1.105.1"
49
49
  }
50
50
  }
package/src/index.ts CHANGED
@@ -182,7 +182,9 @@ export {
182
182
  export type {
183
183
  InvalidateMode,
184
184
  LoadRequirement,
185
+ PrerenderEntry,
185
186
  PrerenderFn,
187
+ PrerenderItem,
186
188
  RenderResult,
187
189
  RevalidateConfig,
188
190
  RouteAlternate,
@@ -0,0 +1,108 @@
1
+ // Single responsibility: what a route's `prerender()` answered, normalised — every page as its
2
+ // params, plus what the page said about itself for a sitemap (`lastmod`, `locales`).
3
+
4
+ import { renderCauseValue, renderThrowable } from '@ultimat3/core';
5
+ import { PrerenderFailedError } from './errors';
6
+ import type { RouteEntry } from './registry';
7
+ import type { PrerenderEntry, RouteParams } from './route';
8
+
9
+ const ENTRY_KEYS: readonly string[] = ['params', 'lastmod', 'locales'];
10
+
11
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value);
13
+
14
+ /**
15
+ * Is `item` the ENTRY form — `{ params: { … }, lastmod?, locales? }` — rather than a params object?
16
+ * Decided by `params` holding an object: a param VALUE is always a string, so `{ params: 'a' }` on a
17
+ * route whose own dynamic segment is `[params]` stays the plain params it has always been.
18
+ */
19
+ const isEntry = (item: Readonly<Record<string, unknown>>): boolean => isRecord(item['params']);
20
+
21
+ function entryOf(entry: RouteEntry, item: Readonly<Record<string, unknown>>): PrerenderEntry {
22
+ const refuse = (problem: string): never => {
23
+ throw new PrerenderFailedError(
24
+ `prerender() for ${entry.path} returned an entry ${problem}`,
25
+ `x build --target static --json # after prerender in ${entry.file} returns { params, lastmod?: Date | ISO 8601 string, locales?: string[] }`,
26
+ );
27
+ };
28
+ const extra = Object.keys(item).find((key) => !ENTRY_KEYS.includes(key));
29
+ if (extra !== undefined) {
30
+ refuse(`with the key ${JSON.stringify(extra)}, which is not one of ${ENTRY_KEYS.join(', ')}`);
31
+ }
32
+ const { lastmod, locales } = item;
33
+ if (lastmod !== undefined && typeof lastmod !== 'string' && !(lastmod instanceof Date)) {
34
+ refuse(`whose lastmod is ${renderCauseValue(lastmod)}, neither a Date nor an ISO 8601 string`);
35
+ }
36
+ if (
37
+ locales !== undefined &&
38
+ !(Array.isArray(locales) && locales.every((locale) => typeof locale === 'string'))
39
+ ) {
40
+ refuse(`whose locales is ${renderCauseValue(locales)}, not a list of locale names`);
41
+ }
42
+ return {
43
+ params: item['params'] as RouteParams,
44
+ ...(lastmod === undefined ? {} : { lastmod: lastmod as string | Date }),
45
+ ...(locales === undefined ? {} : { locales: locales as readonly string[] }),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Every page `prerender()` enumerates, as an entry. A bare string fills the route's one dynamic
51
+ * param; a params object is an entry with nothing to add. `lastmod` is carried AS WRITTEN — whether
52
+ * it names an instant is the sitemap's question (`@ultimat3/seo`, `X_SITEMAP_ENTRY_INVALID`).
53
+ */
54
+ export async function enumeratePrerenderEntries(
55
+ entry: RouteEntry,
56
+ ): Promise<readonly PrerenderEntry[]> {
57
+ const prerender = entry.config.prerender;
58
+ if (prerender === undefined) {
59
+ return entry.pattern.keys.length === 0 ? [{ params: {} }] : [];
60
+ }
61
+
62
+ let produced: unknown;
63
+ try {
64
+ produced = await prerender();
65
+ } catch (error) {
66
+ throw new PrerenderFailedError(
67
+ // `renderThrowable`, never `.message`/`String()`: `prerender` is app code and may throw a
68
+ // value whose read raises in turn — this frame is what makes the build failure a coded one.
69
+ `prerender() for ${entry.path} threw: ${renderThrowable(error)}`,
70
+ `fix prerender in ${entry.file} — it runs at build time with no request context`,
71
+ );
72
+ }
73
+
74
+ if (!Array.isArray(produced)) {
75
+ throw new PrerenderFailedError(
76
+ `prerender() for ${entry.path} returned ${typeof produced}, expected an array`,
77
+ `return an array of params from prerender in ${entry.file}`,
78
+ );
79
+ }
80
+
81
+ const keys = entry.pattern.keys;
82
+ return produced.map((item: unknown): PrerenderEntry => {
83
+ if (typeof item === 'string') {
84
+ const only = keys[0];
85
+ if (keys.length !== 1 || only === undefined) {
86
+ throw new PrerenderFailedError(
87
+ `prerender() for ${entry.path} returned the bare string ${renderCauseValue(item)} ` +
88
+ `but the route has ${keys.length} dynamic params (${keys.join(', ')})`,
89
+ `return objects from prerender in ${entry.file}, e.g. { ${keys.join(': …, ')}: … }`,
90
+ );
91
+ }
92
+ return { params: { [only]: item } };
93
+ }
94
+ // A `null` reached `Object.hasOwn` in `static-path.ts` and left the build as a bare `TypeError`.
95
+ if (!isRecord(item)) {
96
+ throw new PrerenderFailedError(
97
+ `prerender() for ${entry.path} returned ${renderCauseValue(item)} as one of its items, which is neither a params object nor a string`,
98
+ `return an array of params objects (or strings, for a one-param route) from prerender in ${entry.file}`,
99
+ );
100
+ }
101
+ return isEntry(item) ? entryOf(entry, item) : { params: item as RouteParams };
102
+ });
103
+ }
104
+
105
+ /** The params of every page `prerender()` enumerates — what a build renders, one document each. */
106
+ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly RouteParams[]> {
107
+ return (await enumeratePrerenderEntries(entry)).map((page) => page.params);
108
+ }
@@ -4,12 +4,16 @@
4
4
  * the precache revision in `sw.js`, and the asset filename suffix.
5
5
  */
6
6
 
7
- import { BUILD_ID_HEADER, renderCauseValue, renderThrowable, useContext } from '@ultimat3/core';
7
+ import { BUILD_ID_HEADER, renderThrowable, useContext } from '@ultimat3/core';
8
8
  import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
+ import { enumeratePrerender } from './prerender-enumerate';
9
10
  import type { RouteEntry } from './registry';
10
11
  import type { RenderResult, RouteParams } from './route';
11
12
  import { filePathOf, filledSegments, urlPathOf } from './static-path';
12
13
 
14
+ // Re-exported where it has always been imported from; the enumeration is `prerender-enumerate.ts`'s.
15
+ export { enumeratePrerender } from './prerender-enumerate';
16
+
13
17
  export interface StaticArtifact {
14
18
  readonly path: string;
15
19
  readonly params: RouteParams;
@@ -61,59 +65,6 @@ export function assertNoPerRequestState(file: string): void {
61
65
  }
62
66
  }
63
67
 
64
- /** Normalize `prerender()` output. A bare string fills the route's one dynamic param. */
65
- export async function enumeratePrerender(entry: RouteEntry): Promise<readonly RouteParams[]> {
66
- const prerender = entry.config.prerender;
67
- if (prerender === undefined) {
68
- return entry.pattern.keys.length === 0 ? [{}] : [];
69
- }
70
-
71
- let produced: readonly (string | RouteParams)[];
72
- try {
73
- produced = await prerender();
74
- } catch (error) {
75
- throw new PrerenderFailedError(
76
- // `renderThrowable`, never `.message`/`String()`: `prerender` is app code and may throw a
77
- // value whose read raises in turn — this frame is what makes the build failure a coded one.
78
- `prerender() for ${entry.path} threw: ${renderThrowable(error)}`,
79
- `fix prerender in ${entry.file} — it runs at build time with no request context`,
80
- );
81
- }
82
-
83
- if (!Array.isArray(produced)) {
84
- throw new PrerenderFailedError(
85
- `prerender() for ${entry.path} returned ${typeof produced}, expected an array`,
86
- `return an array of params from prerender in ${entry.file}`,
87
- );
88
- }
89
-
90
- const keys = entry.pattern.keys;
91
- return produced.map((item: unknown) => {
92
- if (typeof item !== 'string') return paramsOf(entry, item);
93
- const only = keys[0];
94
- if (keys.length !== 1 || only === undefined) {
95
- throw new PrerenderFailedError(
96
- `prerender() for ${entry.path} returned the bare string ${renderCauseValue(item)} ` +
97
- `but the route has ${keys.length} dynamic params (${keys.join(', ')})`,
98
- `return objects from prerender in ${entry.file}, e.g. { ${keys.join(': …, ')}: … }`,
99
- );
100
- }
101
- return { [only]: item };
102
- });
103
- }
104
-
105
- /**
106
- * One `prerender()` item that is not a bare string: a params object, or a coded refusal. A `null`
107
- * reached `Object.hasOwn` in `static-path.ts` and left the build as a bare `TypeError`.
108
- */
109
- function paramsOf(entry: RouteEntry, item: unknown): RouteParams {
110
- if (typeof item === 'object' && item !== null && !Array.isArray(item)) return item as RouteParams;
111
- throw new PrerenderFailedError(
112
- `prerender() for ${entry.path} returned ${renderCauseValue(item)} as one of its items, which is neither a params object nor a string`,
113
- `return an array of params objects (or strings, for a one-param route) from prerender in ${entry.file}`,
114
- );
115
- }
116
-
117
68
  export interface StaticBuildOptions {
118
69
  readonly buildId: string;
119
70
  /** Extension-less output files get `/index.html` appended. */
package/src/route.ts CHANGED
@@ -186,10 +186,29 @@ export type RouteMetaAsyncFn<TData = RouteData> = (
186
186
  ctx: RouteMetaContext<TData>,
187
187
  ) => Promise<RouteMeta>;
188
188
 
189
- /** Returns the params to build at deploy time. Bare strings fill a single dynamic param. */
190
- export type PrerenderFn = () =>
191
- | readonly (string | RouteParams)[]
192
- | Promise<readonly (string | RouteParams)[]>;
189
+ /**
190
+ * One page `prerender()` answers, with what the page says about itself for the SITEMAP. The build
191
+ * reads `params` alone: every routed locale's document is still rendered.
192
+ */
193
+ export interface PrerenderEntry {
194
+ readonly params: RouteParams;
195
+ /**
196
+ * When this page last changed — the sitemap's `<lastmod>` for it. A `Date`, or ISO 8601 with an
197
+ * explicit zone (`row.updatedAt`); a moment in the future is written as now.
198
+ */
199
+ readonly lastmod?: string | Date;
200
+ /**
201
+ * The locales this page is LISTED in: one sitemap `<url>` each, cross-linked with hreflang among
202
+ * themselves only. Omitted: every routed locale. Empty: built, and listed nowhere.
203
+ */
204
+ readonly locales?: readonly string[];
205
+ }
206
+
207
+ /** One item of a `prerender()` answer: a bare string, a params object, or an entry. */
208
+ export type PrerenderItem = string | RouteParams | PrerenderEntry;
209
+
210
+ /** Returns the pages to build at deploy time. Bare strings fill a single dynamic param. */
211
+ export type PrerenderFn = () => readonly PrerenderItem[] | Promise<readonly PrerenderItem[]>;
193
212
 
194
213
  /**
195
214
  * The rule that makes the no-`load` fallback true: a route that loads nothing renders
package/src/server.ts CHANGED
@@ -36,10 +36,10 @@ export {
36
36
  stylesheetsRevision,
37
37
  transformTsx,
38
38
  } from './module-loader';
39
+ export { enumeratePrerenderEntries } from './prerender-enumerate';
39
40
  // ---- the render pipeline: one entry point per mode ----------------------------------------------
40
41
  export type { RenderHtmlOptions } from './render-html';
41
42
  export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
42
-
43
43
  export {
44
44
  DEFAULT_ISR_REGENERATE_DEADLINE_MS,
45
45
  invalidateAndRevalidate,