@opentf/web 0.7.0 → 0.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.
@@ -0,0 +1,92 @@
1
+ // Types for the `@opentf/web/server` entry. The API-routes surface is fully typed
2
+ // (./api); the SSG string-builders and render helpers — authored in JS and mostly
3
+ // consumed by generated entry code — are declared with permissive signatures.
4
+
5
+ export * from "./api.js";
6
+
7
+ // ── Render / route API (render.js) ─────────────────────────────────────────────
8
+ export interface RenderResult {
9
+ html: string;
10
+ metadata: Record<string, unknown>;
11
+ hydration: unknown;
12
+ status?: number;
13
+ }
14
+ export function renderRoute(
15
+ pathname: string,
16
+ params?: Record<string, unknown> | null,
17
+ search?: string,
18
+ options?: { data?: unknown },
19
+ ): Promise<RenderResult | null>;
20
+ export function renderToString(pathname: string, search?: string): Promise<string>;
21
+ export function collectRoutePaths(): Promise<string[]>;
22
+
23
+ // ── <head> / metadata (head.js) ─────────────────────────────────────────────────
24
+ export function resolveMetadata(args?: {
25
+ route?: string;
26
+ entry?: unknown;
27
+ params?: Record<string, unknown>;
28
+ query?: Record<string, unknown>;
29
+ }): Promise<Record<string, unknown>>;
30
+ export function renderHead(meta?: Record<string, unknown>, opts?: { path?: string; baseUrl?: string }): string;
31
+ export function localeAlternateLinks(
32
+ routePath: string,
33
+ cfg?: { locales?: string[]; defaultLocale?: string },
34
+ localize?: (path: string, locale: string) => string,
35
+ ): Array<Record<string, string>>;
36
+
37
+ // ── Route loaders (loader.js) ───────────────────────────────────────────────────
38
+ import type { RouteParams } from "./api.js";
39
+
40
+ /** The context a route loader receives (docs/DATA.md). `request` is the live
41
+ * Request under serve/dev and undefined at SSG prerender; `locals` is reserved. */
42
+ export interface LoaderContext {
43
+ params: RouteParams;
44
+ query: Record<string, string>;
45
+ request?: Request;
46
+ locale: string | null;
47
+ locals: Record<string, unknown>;
48
+ }
49
+
50
+ /** A route loader — the default (or named `loader`) export of a `loader.{js,ts}`
51
+ * file. Returns the page's JSON-serializable data. */
52
+ export type Loader = (context: LoaderContext) => unknown | Promise<unknown>;
53
+
54
+ export interface LoaderMatch {
55
+ route: string;
56
+ params: RouteParams;
57
+ locale: string | null;
58
+ }
59
+
60
+ export interface LoaderRegistry {
61
+ routes: string[];
62
+ match(pathname: string): LoaderMatch | null;
63
+ load(m: LoaderMatch, ctx?: { request?: Request; query?: Record<string, string> }): Promise<unknown>;
64
+ loadSerialized(
65
+ m: LoaderMatch,
66
+ ctx?: { request?: Request; query?: Record<string, string> },
67
+ ): Promise<{ data: unknown; json: string }>;
68
+ handle(request: Request): Promise<Response | null>;
69
+ }
70
+
71
+ export function createLoaderRegistry(
72
+ loaderModules?: Record<string, { default?: Loader; loader?: Loader }>,
73
+ options?: { appDir?: string; i18n?: { locales?: string[]; defaultLocale?: string } },
74
+ ): LoaderRegistry;
75
+ export function loaderRouteFromPath(filePath: string, appDir?: string): string;
76
+ /** Throw "this page does not exist" from inside a loader (404 semantics). */
77
+ export function notFound(message?: string): never;
78
+ export function isNotFound(e: unknown): boolean;
79
+ export function serializeRouteData(value: unknown): string;
80
+
81
+ // ── SSG string-builder helpers (ssg-runtime.js) ─────────────────────────────────
82
+ export function defineSSG(tag: string, render: (...args: any[]) => string): void;
83
+ export function beginHydrationCollect(): void;
84
+ export function endHydrationCollect(): unknown;
85
+ export function escapeHtml(s: unknown): string;
86
+ export function escapeAttr(s: unknown): string;
87
+ export function clsx(value: unknown): string;
88
+ export function styleString(value: unknown): string;
89
+ export function attr(name: string, value: unknown): string;
90
+ export function ssgText(v: unknown): string;
91
+ export function ssgList<T>(arr: T[], fn: (item: T, index: number) => string): string;
92
+ export function ssgComponent(tag: string, props?: Record<string, unknown>, children?: unknown): string;
package/server/index.js CHANGED
@@ -5,4 +5,6 @@
5
5
  export * from "./ssg-runtime.js";
6
6
  export * from "./head.js";
7
7
  export * from "./render.js";
8
+ export * from "./api.js";
9
+ export * from "./loader.js";
8
10
  import "./builtins.js";
@@ -0,0 +1,199 @@
1
+ // Route loaders — the server half of data fetching (docs/DATA.md, SPEC §8). A
2
+ // loader is a `loader.{js,ts}` file sibling to a `page.*` (the data analogue of
3
+ // a `route.*` API endpoint): a *plain server module* whose default export (or
4
+ // named `loader` export, the Phase B compiler convention) returns the page's
5
+ // JSON-serializable data:
6
+ //
7
+ // export default async function loader({ params, query, request, locale, locals }) {
8
+ // return db.todos.list();
9
+ // }
10
+ //
11
+ // `createLoaderRegistry` turns a discovered `{ absFilePath: namespace }` map into
12
+ // a matcher + runner shared by every consumer: SSG prerender (build time, no
13
+ // `request`), the SSR server (per request), the dev server, and the
14
+ // `<path>/__data.json` HTTP endpoint SPA navigation fetches from (`handle`).
15
+ //
16
+ // Matching duplicates the `[param]` / `[...rest]` compile/decode approach of the
17
+ // API handler (api.js) — like api.js itself mirrors the page router — so params
18
+ // behave identically across pages, endpoints, and loaders.
19
+
20
+ import { DATA_FILE } from "../runtime/route-data.js";
21
+
22
+ /** See `stripAppDir` in api.js — exact-prefix strip with a `/app` segment fallback. */
23
+ function stripAppDir(filePath, appDir) {
24
+ if (appDir) {
25
+ const base = appDir.replace(/\/+$/, "");
26
+ if (filePath.startsWith(base + "/")) return filePath.slice(base.length);
27
+ }
28
+ return filePath.replace(/^.*\/app(?=\/)/, "");
29
+ }
30
+
31
+ /** The page route a `.../app/<...>/loader.{js,ts}` file feeds (folder = URL):
32
+ * `app/todos/loader.js` → `/todos`, `app/loader.js` → `/`. */
33
+ export function loaderRouteFromPath(filePath, appDir) {
34
+ const r = stripAppDir(filePath, appDir).replace(/\/loader\.(js|ts)$/, "");
35
+ return r === "" ? "/" : r;
36
+ }
37
+
38
+ /** Compile a route with `[param]` / `[...rest]` segments to a matcher regex (see api.js). */
39
+ function compilePattern(route) {
40
+ const src = route
41
+ .split(/(\[\.\.\.[^\]]+\]|\[[^\]]+\])/)
42
+ .map((part) => {
43
+ if (part.startsWith("[...")) return `(?<${part.slice(4, -1)}>.+)`;
44
+ if (part.startsWith("[")) return `(?<${part.slice(1, -1)}>[^/]+)`;
45
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ })
47
+ .join("");
48
+ return new RegExp(`^${src}/?$`);
49
+ }
50
+
51
+ const decodeParam = (s) => {
52
+ try {
53
+ return decodeURIComponent(s);
54
+ } catch {
55
+ return s;
56
+ }
57
+ };
58
+
59
+ const normalize = (p) => (p || "/").replace(/(.)\/+$/, "$1");
60
+
61
+ /**
62
+ * Signal "this page does not exist for these params" from inside a loader — the
63
+ * SSR server responds 404 (rendering the 404 page), the data endpoint returns a
64
+ * 404 the client maps to `undefined`, and SSG records the path as failed.
65
+ */
66
+ export function notFound(message = "Not Found") {
67
+ const e = new Error(message);
68
+ e.name = "NotFoundError";
69
+ // A marker property, not a class: the loader bundle and the server bundle are
70
+ // separate module graphs (each bundles its own copy of this file), so an
71
+ // `instanceof` check would fail across the boundary.
72
+ e.otfwNotFound = true;
73
+ throw e;
74
+ }
75
+
76
+ /** Was `e` thrown by {@link notFound}? (Property check — see the note there.) */
77
+ export function isNotFound(e) {
78
+ return e?.otfwNotFound === true;
79
+ }
80
+
81
+ /**
82
+ * Serialize loader data for embedding: the inline `#__otfw_data` script and the
83
+ * static `__data.json` file must carry identical bytes. `<` is escaped so the
84
+ * payload can never close the inline `<script>` (same rule as the island-props
85
+ * collector in ssg-runtime.js). `undefined` (no data) serializes to `""` — the
86
+ * toolchain skips injection entirely.
87
+ */
88
+ export function serializeRouteData(value) {
89
+ const json = JSON.stringify(value);
90
+ return json === undefined ? "" : json.replace(/</g, "\\u003c");
91
+ }
92
+
93
+ /**
94
+ * Build the loader registry from a discovered `{ absFilePath: namespace }` map.
95
+ *
96
+ * @param {Record<string, object>} loaderModules each module's default (or `loader`)
97
+ * export is the loader function.
98
+ * @param {{ appDir?: string, i18n?: { locales?: string[], defaultLocale?: string } }} [options]
99
+ * `appDir` pins exact route derivation; `i18n` lets `match` strip a leading
100
+ * non-default locale prefix (`/fr/todos` matches the `/todos` loader with
101
+ * `locale: "fr"` in the context).
102
+ * @returns {{ routes: string[],
103
+ * match: (pathname: string) => object | null,
104
+ * load: (m: object, ctx?: object) => Promise<unknown>,
105
+ * loadSerialized: (m: object, ctx?: object) => Promise<{ data: unknown, json: string }>,
106
+ * handle: (request: Request) => Promise<Response | null> }}
107
+ */
108
+ export function createLoaderRegistry(loaderModules = {}, { appDir, i18n } = {}) {
109
+ const entries = [];
110
+ for (const file in loaderModules) {
111
+ const ns = loaderModules[file];
112
+ const fn = ns.default ?? ns.loader;
113
+ if (typeof fn !== "function") continue; // a loader file without a loader export is inert
114
+ const route = loaderRouteFromPath(file, appDir);
115
+ entries.push({ route, pattern: compilePattern(route), fn, dynamic: route.includes("[") });
116
+ }
117
+ // Static before dynamic, longer before shorter — most specific wins (see api.js).
118
+ entries.sort((a, b) => a.dynamic - b.dynamic || b.route.length - a.route.length);
119
+
120
+ const i18nOn = !!(i18n && Array.isArray(i18n.locales) && i18n.locales.length);
121
+ const defaultLocale = i18nOn ? (i18n.defaultLocale ?? i18n.locales[0]) : null;
122
+ const nonDefault = i18nOn ? new Set(i18n.locales.filter((l) => l !== defaultLocale)) : null;
123
+
124
+ /** Match a *page* pathname (no `__data.json` suffix) → `{ route, params, locale }` or null. */
125
+ function match(pathname) {
126
+ let path = normalize(pathname);
127
+ let locale = defaultLocale;
128
+ if (nonDefault) {
129
+ const m = path.match(/^\/([^/]+)(\/.*|)$/);
130
+ if (m && nonDefault.has(m[1])) {
131
+ locale = m[1];
132
+ path = m[2] || "/";
133
+ }
134
+ }
135
+ for (const e of entries) {
136
+ const m = path.match(e.pattern);
137
+ if (!m) continue;
138
+ const params = { ...(m.groups || {}) };
139
+ for (const k in params) {
140
+ params[k] = e.route.includes(`[...${k}]`)
141
+ ? params[k].split("/").map(decodeParam)
142
+ : decodeParam(params[k]);
143
+ }
144
+ return { route: e.route, params, locale, fn: e.fn };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /** Run a matched loader. `request` is the live Request under serve/dev and
150
+ * `undefined` at SSG prerender; `locals` is reserved (middleware is future work). */
151
+ async function load(m, { request, query = {} } = {}) {
152
+ return m.fn({ params: m.params, query, request, locale: m.locale, locals: {} });
153
+ }
154
+
155
+ /** {@link load}, plus the serialized form — escaping lives in one place. */
156
+ async function loadSerialized(m, ctx) {
157
+ const data = await load(m, ctx);
158
+ return { data, json: serializeRouteData(data) };
159
+ }
160
+
161
+ /**
162
+ * The `<path>/__data.json` HTTP endpoint (docs/DATA.md): a `Response` for any
163
+ * data URL — including 404 on a miss, so the reserved suffix never falls
164
+ * through to SSR — or `null` for every other path.
165
+ */
166
+ async function handle(request) {
167
+ const url = new URL(request.url);
168
+ const pathname = normalize(url.pathname);
169
+ let pagePath = null;
170
+ if (pathname === `/${DATA_FILE}`) pagePath = "/";
171
+ else if (pathname.endsWith(`/${DATA_FILE}`)) {
172
+ pagePath = pathname.slice(0, -(DATA_FILE.length + 1)) || "/";
173
+ }
174
+ if (pagePath === null) return null;
175
+
176
+ const method = request.method.toUpperCase();
177
+ if (method !== "GET" && method !== "HEAD") {
178
+ return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
179
+ }
180
+ const headers = { "content-type": "application/json", "cache-control": "no-store" };
181
+ const m = match(pagePath);
182
+ if (!m) return new Response("null", { status: 404, headers });
183
+ try {
184
+ const { json } = await loadSerialized(m, {
185
+ request,
186
+ query: Object.fromEntries(url.searchParams),
187
+ });
188
+ // `json || "null"` — an undefined loader result still yields valid JSON
189
+ // (the client maps a 404 to undefined; a 200 must parse).
190
+ return new Response(method === "HEAD" ? null : json || "null", { status: 200, headers });
191
+ } catch (e) {
192
+ if (isNotFound(e)) return new Response("null", { status: 404, headers });
193
+ console.error(`✗ loader ${pagePath}:`, e?.stack ?? e);
194
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
195
+ }
196
+ }
197
+
198
+ return { routes: entries.map((e) => e.route), match, load, loadSerialized, handle };
199
+ }
package/server/render.js CHANGED
@@ -24,17 +24,19 @@ import { beginHydrationCollect, endHydrationCollect } from "./ssg-runtime.js";
24
24
  * server uses it; SSG ignores it), and `hydration` — the JSON island-props payload the
25
25
  * toolchain embeds so the client resumes from rich data (`""` when nothing needs it).
26
26
  * `params` (from `getStaticPaths`) override the matched route's params for dynamic
27
- * routes. Returns `null` if there's no match and no 404 page.
27
+ * routes. `options.data` is the route's loader result (run by the caller — serve /
28
+ * prerender own the loader bundle) and is exposed to the page as `router.data`.
29
+ * Returns `null` if there's no match and no 404 page.
28
30
  */
29
- export async function renderRoute(pathname, params = null, search = "") {
31
+ export async function renderRoute(pathname, params = null, search = "", { data } = {}) {
30
32
  const real = matchRoute(pathname);
31
33
  const match =
32
34
  real || (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
33
35
  if (!match) return null;
34
36
  if (params) match.params = params;
35
37
 
36
- // Let a page reading `router.params`/`pathname`/`query` resolve to this route.
37
- setRouteState({ pathname, search, params: match.params });
38
+ // Let a page reading `router.params`/`pathname`/`query`/`data` resolve to this route.
39
+ setRouteState({ pathname, search, params: match.params, data });
38
40
 
39
41
  const query = Object.fromEntries(new URLSearchParams(search));
40
42
  const props = { params: match.params, query };