@opentf/web-cli 1.0.0 → 1.1.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/src/dev.js CHANGED
@@ -1,12 +1,23 @@
1
- // `otfw dev` — the CSR dev server.
1
+ // `otfw dev` — the CSR dev server (on-demand / lazy-route).
2
2
  //
3
- // Drives Rolldown in watch mode (the `otfwc` compiler as a transform plugin) and
4
- // serves the project's index.html with WebSocket live-reload on rebuild. Tailwind
5
- // stylesheets are compiled on the fly (see ./tailwind.js). The project root is the
6
- // current working directory, like `vite` / `next dev`.
3
+ // Unlike a single eager bundle, this serves modules as the browser asks for them:
4
+ //
5
+ // /@fw.js — the runtime (`@opentf/web`), bundled once and shared by
6
+ // every chunk through an import map (so the router and signal
7
+ // registry are one instance).
8
+ // • /bundle.js — the app entry: `mountApp` + a route table whose loaders are
9
+ // `() => import("/__route/<id>.js")`. The route modules are
10
+ // *not* in this bundle.
11
+ // • /__route/<id>.js — one route (page or layout) compiled on first navigation,
12
+ // with `@opentf/web` external, then cached in memory.
13
+ //
14
+ // So startup compiles the entry + runtime only; each route's cost is paid the first
15
+ // time it's visited. The module graph (`otfwc graph`) drives HMR: on a file change
16
+ // it tells us exactly which cached chunks to drop before a reload. Tailwind/CSS and
17
+ // `public/` assets are served as before. Project root = cwd, like `vite`/`next dev`.
7
18
 
8
- import { watch } from "rolldown";
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { rolldown } from "rolldown";
20
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
10
21
  import { join } from "node:path";
11
22
 
12
23
  import { compileCss, usesTailwind } from "./tailwind.js";
@@ -17,14 +28,18 @@ import {
17
28
  cssPlugin,
18
29
  discoverPages,
19
30
  entrySource,
31
+ injectBeforeBody,
32
+ loadConfig,
33
+ loadDocsPlugins,
20
34
  loadProject,
35
+ moduleGraph,
21
36
  otfwPlugin,
37
+ readHtmlShell,
22
38
  } from "./shared.js";
23
39
 
24
40
  // Resolve the start port. An explicit `--port <n>` / `-p <n>` / `--port=<n>` is
25
41
  // honored exactly (fail fast if it's busy); with no flag we default to 3000 and
26
- // scan upward for a free port. (No PORT env: this is a dev tool, and OpenTF's
27
- // production output is static — there's no long-running server to take PORT.)
42
+ // scan upward for a free port.
28
43
  function resolvePort() {
29
44
  const argv = process.argv.slice(3); // args after `dev`
30
45
  for (let i = 0; i < argv.length; i++) {
@@ -61,7 +76,15 @@ function serve(start, explicit, options) {
61
76
  process.exit(1);
62
77
  }
63
78
 
79
+ // A route file ↔ its `/__route/<id>.js` URL. The id is the file path, base64url so
80
+ // it survives a URL path segment; decoding recovers the absolute path to compile.
81
+ const ROUTE_PREFIX = "/__route/";
82
+ const toRouteUrl = (file) => `${ROUTE_PREFIX}${Buffer.from(file).toString("base64url")}.js`;
83
+ const fromRouteUrl = (pathname) =>
84
+ Buffer.from(pathname.slice(ROUTE_PREFIX.length, -".js".length), "base64url").toString("utf8");
85
+
64
86
  export async function runDev() {
87
+ const bootStart = Date.now();
65
88
  const { root, appDir, webEntry, otfwc, exclude } = loadProject();
66
89
  const { port: startPort, explicit: explicitPort } = resolvePort();
67
90
  if (!Number.isInteger(startPort) || startPort < 1 || startPort > 65535) {
@@ -75,105 +98,195 @@ export async function runDev() {
75
98
  process.exit(1);
76
99
  }
77
100
 
101
+ const config = await loadConfig(root);
102
+ const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
103
+
78
104
  const devDir = join(root, ".dev");
79
- mkdirSync(join(devDir, "csr"), { recursive: true });
80
- const entry = join(devDir, "entry.js");
81
- writeFileSync(entry, entrySource(pages, appDir));
105
+ mkdirSync(devDir, { recursive: true });
106
+ const entryFile = join(devDir, "entry.js");
107
+ writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav));
82
108
 
83
- // WebSocket HMR: clients on the "hmr" topic get JSON messages — a successful
84
- // rebuild sends { type: "reload" }; a compile/build failure sends
85
- // { type: "error" } so the injected overlay shows it over the last good page.
86
109
  let server;
87
110
  const publish = (msg) => server?.publish("hmr", JSON.stringify(msg));
88
- // Per-module compile diagnostics (keyed by id); a clean build clears them.
89
111
  const compileErrors = new Map();
90
112
 
91
- const watcher = watch({
92
- input: entry,
93
- resolve: { alias: { "@opentf/web": webEntry }, extensions: EXTENSIONS },
94
- plugins: [
95
- otfwPlugin(otfwc, {
96
- onResult: (id, err) => (err ? compileErrors.set(id, err) : compileErrors.delete(id)),
97
- }),
98
- cssPlugin(),
99
- ],
100
- output: {
101
- dir: join(devDir, "csr"),
102
- format: "esm",
103
- entryFileNames: "bundle.js",
104
- },
113
+ // One persistent compiler (one `otfwc serve` child) shared by every build below.
114
+ const otfw = otfwPlugin(otfwc, {
115
+ onResult: (id, err) => (err ? compileErrors.set(id, err) : compileErrors.delete(id)),
105
116
  });
106
- watcher.on("event", (e) => {
107
- if (e.code === "BUNDLE_END") {
108
- e.result?.close?.();
109
- if (compileErrors.size > 0) {
110
- const [id, message] = [...compileErrors][0];
111
- console.error(`✗ ${compileErrors.size} compile error(s)`);
112
- publish({ type: "error", kind: "compile", id, message });
113
- } else {
114
- console.log(`✓ bundled in ${e.duration}ms`);
115
- publish({ type: "reload" });
117
+ const css = cssPlugin();
118
+ const plugins = [...docsPlugins, otfw, css];
119
+
120
+ // Bundle `input` to a single ESM string in memory (no disk). `external` ids are
121
+ // left as bare imports (resolved by the browser via the import map / route URLs).
122
+ // `alias` lets the runtime build resolve its own `@opentf/web` self-imports.
123
+ async function bundle({ input, external, alias }) {
124
+ const b = await rolldown({
125
+ input,
126
+ resolve: { alias: alias || {}, extensions: EXTENSIONS },
127
+ external,
128
+ plugins,
129
+ });
130
+ try {
131
+ const { output } = await b.generate({ format: "esm", codeSplitting: false });
132
+ return output[0].code;
133
+ } finally {
134
+ await b.close();
135
+ }
136
+ }
137
+
138
+ // The shared runtime, bundled once. Its components compile to `import … from
139
+ // "@opentf/web"`; aliasing that back to the entry keeps them inside this bundle.
140
+ async function buildFramework() {
141
+ return bundle({ input: webEntry, alias: { "@opentf/web": webEntry } });
142
+ }
143
+ // The app entry: route loaders point at `/__route/…` (external — fetched lazily)
144
+ // and `@opentf/web` is external (→ import map → /@fw.js).
145
+ async function buildEntry() {
146
+ return bundle({
147
+ input: entryFile,
148
+ external: (id) => id === "@opentf/web" || id.startsWith(ROUTE_PREFIX),
149
+ });
150
+ }
151
+ // One route module (page or layout) + its components/web-docs, runtime external.
152
+ async function buildRoute(file) {
153
+ return bundle({ input: file, external: ["@opentf/web"] });
154
+ }
155
+
156
+ // Everything is built on first request and cached (Vite-style), so startup does no
157
+ // compilation at all. `null` means "needs (re)building".
158
+ let fwCode = null;
159
+ let entryCode = null;
160
+ const routeCache = new Map(); // route file → compiled chunk
161
+
162
+ // A build error becomes a thrown-on-load stub so the overlay shows it in place.
163
+ const errorStub = (id, msg) =>
164
+ `throw new Error(${JSON.stringify(`Compile error in ${id}\n\n${msg}`)});`;
165
+
166
+ // Compile `file` (framework / entry / route) on demand, surfacing a compile error
167
+ // as a thrown-on-load stub. `build` is the matching builder.
168
+ async function compileOnce(file, build) {
169
+ try {
170
+ const code = await build(file);
171
+ compileErrors.delete(file);
172
+ return code;
173
+ } catch (e) {
174
+ const msg = e?.message ?? String(e);
175
+ compileErrors.set(file, msg);
176
+ publish({ type: "error", kind: "compile", id: file, message: msg });
177
+ return errorStub(file, msg);
178
+ }
179
+ }
180
+ const serveFramework = async () => (fwCode ??= await compileOnce(webEntry, buildFramework));
181
+ const serveEntry = async () => (entryCode ??= await compileOnce(entryFile, buildEntry));
182
+ async function serveRoute(file) {
183
+ if (!routeCache.has(file)) routeCache.set(file, await compileOnce(file, buildRoute));
184
+ return routeCache.get(file);
185
+ }
186
+
187
+ // The module graph powers precise invalidation; build it in the background so it
188
+ // never blocks startup. Until it's ready, a change clears every cache (safe).
189
+ let graph = { has: () => false, affected: () => new Set() };
190
+ const refreshGraph = () =>
191
+ moduleGraph(otfwc, webEntry, [...pages, webEntry]).then((g) => (graph = g));
192
+ refreshGraph();
193
+
194
+ // On a change, drop only the cached chunks the edited file reaches (its graph
195
+ // dependents); a reload then rebuilds them on demand. An unknown file (e.g. a
196
+ // workspace package we treat as external) clears everything to be safe.
197
+ function onChange(file) {
198
+ refreshGraph();
199
+ const hit = graph.has(file) ? graph.affected(file) : null;
200
+ if (!hit) {
201
+ fwCode = entryCode = null;
202
+ routeCache.clear();
203
+ } else {
204
+ if (hit.has(webEntry)) fwCode = null;
205
+ if (file === entryFile || pages.includes(file)) entryCode = null;
206
+ for (const f of [...routeCache.keys()]) if (hit.has(f)) routeCache.delete(f);
207
+ }
208
+ if (compileErrors.size > 0) {
209
+ const [id, message] = [...compileErrors][0];
210
+ publish({ type: "error", kind: "compile", id, message });
211
+ } else {
212
+ publish({ type: "reload" });
213
+ }
214
+ }
215
+
216
+ // Watch app sources; a new page/layout file regenerates the route table.
217
+ const watcher = watch(appDir, { recursive: true }, (_evt, name) => {
218
+ if (!name) return;
219
+ const file = join(appDir, name);
220
+ if (/\.(mdx|md|[jt]sx|css)$/.test(name)) {
221
+ if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(name.split("/").pop()) && !pages.includes(file)) {
222
+ const fresh = discoverPages(appDir, exclude);
223
+ pages.length = 0;
224
+ pages.push(...fresh);
225
+ writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav));
116
226
  }
117
- } else if (e.code === "ERROR") {
118
- const message = e.error?.message ?? String(e.error);
119
- console.error("✗ build error:\n", message);
120
- publish({ type: "error", kind: "build", message, stack: e.error?.stack });
227
+ onChange(file);
121
228
  }
122
229
  });
230
+ // Ctrl+C is the normal way to stop a dev server, so treat it as a clean shutdown
231
+ // (exit 0) rather than the conventional 130 — otherwise `bun run dev` reports it as
232
+ // a failure. The compiler child is torn down by its own `exit` hook.
233
+ const shutdown = () => {
234
+ try {
235
+ watcher.close();
236
+ } catch {}
237
+ process.exit(0);
238
+ };
239
+ process.once("SIGINT", shutdown);
240
+ process.once("SIGTERM", shutdown);
241
+ process.once("exit", () => {
242
+ try {
243
+ watcher.close();
244
+ } catch {}
245
+ });
123
246
 
124
- const indexPath = join(root, "index.html");
125
-
126
- // Injected into the served HTML: our bundle + the dev error overlay / reload
127
- // client (reconnects and reloads once the server is back after a restart).
247
+ // Import map (so `@opentf/web` resolves to the shared runtime chunk) + entry + the
248
+ // dev overlay / reload client. The import map must precede the module script.
128
249
  const injected =
250
+ `<script type="importmap">{"imports":{"@opentf/web":"/@fw.js"}}</script>\n` +
129
251
  `<script type="module" src="/bundle.js"></script>\n` +
130
252
  `<script>${overlayClient}</script>\n`;
131
253
 
132
- // Use the project's index.html as the shell, stripping any module entry script
133
- // (the app would be double-loaded) and injecting our bundle + reload client.
134
- function buildHtml() {
135
- let html;
136
- if (existsSync(indexPath)) {
137
- html = readFileSync(indexPath, "utf8").replace(
138
- /<script\s+type=["']module["'][^>]*src=[^>]*>\s*<\/script>\s*/gi,
139
- "",
140
- );
141
- } else {
142
- html = `<!doctype html><html lang="en"><head>
143
- <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
144
- <title>OTF Web</title></head><body><div id="app"></div></body></html>`;
145
- }
146
- return html.includes("</body>")
147
- ? html.replace("</body>", `${injected}</body>`)
148
- : html + injected;
149
- }
254
+ const buildHtml = () => injectBeforeBody(readHtmlShell(root), injected);
150
255
 
151
- // Serve a static file from the project root (no traversal outside it). Tailwind
152
- // entry stylesheets are compiled on request.
153
256
  async function serveStatic(pathname) {
154
- const file = join(root, pathname);
155
- if (!file.startsWith(root) || !existsSync(file)) return null;
257
+ // Only serve regular files — a request whose path is a directory (e.g. a route
258
+ // like `/blog` that also exists as `public/blog/`) must fall through to the SPA
259
+ // shell, not try to read the directory.
260
+ const isFile = (f) => existsSync(f) && statSync(f).isFile();
261
+ let file = join(root, pathname);
262
+ if (!file.startsWith(root)) return null;
263
+ if (!isFile(file)) {
264
+ const fromPublic = join(root, "public", pathname);
265
+ if (!fromPublic.startsWith(join(root, "public") + "/") || !isFile(fromPublic)) return null;
266
+ file = fromPublic;
267
+ }
156
268
  const ext = pathname.split(".").pop();
157
269
  if (ext === "css") {
158
270
  const source = readFileSync(file, "utf8");
159
- const css = usesTailwind(source)
271
+ const out = usesTailwind(source)
160
272
  ? await compileCss(file, source, root).catch((err) => {
161
273
  console.error(`✗ tailwind failed for ${pathname}:\n${err?.message ?? err}`);
162
274
  return source;
163
275
  })
164
276
  : source;
165
- return new Response(css, { headers: { "content-type": "text/css" } });
277
+ return new Response(out, { headers: { "content-type": "text/css" } });
166
278
  }
167
279
  return new Response(readFileSync(file), {
168
280
  headers: { "content-type": MIME[ext] ?? "application/octet-stream" },
169
281
  });
170
282
  }
171
283
 
284
+ const js = (code) => new Response(code, { headers: { "content-type": "text/javascript" } });
285
+
172
286
  server = serve(startPort, explicitPort, {
173
287
  websocket: {
174
288
  open: (ws) => {
175
289
  ws.subscribe("hmr");
176
- // A client connecting while a compile error is outstanding sees it now.
177
290
  if (compileErrors.size > 0) {
178
291
  const [id, message] = [...compileErrors][0];
179
292
  ws.send(JSON.stringify({ type: "error", kind: "compile", id, message }));
@@ -183,38 +296,23 @@ export async function runDev() {
183
296
  async fetch(req, srv) {
184
297
  const { pathname } = new URL(req.url);
185
298
  if (pathname === "/__hmr") {
186
- return srv.upgrade(req)
187
- ? undefined
188
- : new Response("upgrade failed", { status: 400 });
299
+ return srv.upgrade(req) ? undefined : new Response("upgrade failed", { status: 400 });
189
300
  }
190
- // Built output: the entry bundle and code-split chunks live in .dev/csr.
191
- if (pathname.endsWith(".js")) {
192
- const built = join(devDir, "csr", pathname);
193
- if (built.startsWith(join(devDir, "csr") + "/") && existsSync(built)) {
194
- return new Response(readFileSync(built), {
195
- headers: { "content-type": "text/javascript" },
196
- });
197
- }
198
- if (pathname === "/bundle.js") {
199
- return new Response("// building…", {
200
- headers: { "content-type": "text/javascript" },
201
- });
202
- }
301
+ if (pathname === "/@fw.js") return js(await serveFramework());
302
+ if (pathname === "/bundle.js") return js(await serveEntry());
303
+ if (pathname.startsWith(ROUTE_PREFIX) && pathname.endsWith(".js")) {
304
+ return js(await serveRoute(fromRouteUrl(pathname)));
203
305
  }
204
- // Static assets referenced by index.html (css, public/, etc).
205
306
  if (pathname !== "/") {
206
307
  const asset = await serveStatic(pathname);
207
308
  if (asset) return asset;
208
- if (/\.[a-z0-9]+$/i.test(pathname)) {
209
- return new Response("not found", { status: 404 });
210
- }
309
+ if (/\.[a-z0-9]+$/i.test(pathname)) return new Response("not found", { status: 404 });
211
310
  }
212
- return new Response(buildHtml(), {
213
- headers: { "content-type": "text/html" },
214
- });
311
+ return new Response(buildHtml(), { headers: { "content-type": "text/html" } });
215
312
  },
216
313
  });
217
314
 
218
315
  console.log(`\n OTF Web dev server`);
219
- console.log(` → http://localhost:${server.port} (${pages.length} routes)\n`);
316
+ console.log(` → http://localhost:${server.port} (${pages.length} routes, on-demand)`);
317
+ console.log(` ✓ ready in ${Date.now() - bootStart}ms — routes compile on first visit\n`);
220
318
  }
package/src/prerender.js CHANGED
@@ -3,86 +3,137 @@
3
3
  // render each route to a static HTML file. No DOM — the SSG output is pure string
4
4
  // concatenation, so no effects/lifecycle run.
5
5
 
6
- import { build } from "rolldown";
7
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
7
  import { dirname, join } from "node:path";
9
- import { pathToFileURL } from "node:url";
10
8
 
11
- import { EXTENSIONS, cssPlugin, otfwPlugin } from "./shared.js";
9
+ import { buildServerBundle, injectHead, injectMarkup, withHtmlLang } from "./shared.js";
12
10
 
13
- // Generated server entry: eager-import every page module (so registerRoutes sees
14
- // real namespaces, enabling getStaticPaths) and re-export the render API.
15
- function serverEntrySource(pages) {
16
- const imports = pages.map((p, i) => `import * as p${i} from ${JSON.stringify(p)};`).join("\n");
17
- const map = pages.map((p, i) => ` [${JSON.stringify(p)}]: p${i},`).join("\n");
18
- return (
19
- `${imports}\n` +
20
- `import { registerRoutes } from "@opentf/web";\n` +
21
- `export { renderToString, collectRoutePaths } from "@opentf/web/server";\n` +
22
- `registerRoutes({\n${map}\n});\n`
23
- );
11
+ // "/" dist/index.html, "/post/1" dist/post/1/index.html, "/fr/about" dist/fr/about/index.html.
12
+ function htmlPathFor(outDir, route) {
13
+ return route === "/" ? join(outDir, "index.html") : join(outDir, route, "index.html");
24
14
  }
25
15
 
26
- // Inject pre-rendered markup into the shell's #app container.
27
- function injectMarkup(shellHtml, markup) {
28
- return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, `$1${markup}$2`);
16
+ // Prefix a locale-agnostic route with `locale` (bare for the default) the URL form
17
+ // that both selects the output file and, passed to renderRoute, pins router.locale.
18
+ function localizeFor(path, locale, defaultLocale) {
19
+ if (!locale || locale === defaultLocale) return path;
20
+ return path === "/" ? `/${locale}` : `/${locale}${path}`;
29
21
  }
30
22
 
31
- // "/" dist/index.html, "/post/1" dist/post/1/index.html.
32
- function htmlPathFor(outDir, route) {
33
- return route === "/" ? join(outDir, "index.html") : join(outDir, route, "index.html");
23
+ // `rel="alternate" hreflang` descriptors for a route across all locales (+ x-default),
24
+ // merged into metadata.links so renderHead emits them (docs/I18N.md §6).
25
+ function alternatesFor(path, locales, defaultLocale) {
26
+ const links = locales.map((l) => ({
27
+ rel: "alternate",
28
+ hreflang: l,
29
+ href: localizeFor(path, l, defaultLocale),
30
+ }));
31
+ links.push({ rel: "alternate", hreflang: "x-default", href: localizeFor(path, defaultLocale, defaultLocale) });
32
+ return links;
33
+ }
34
+
35
+ // Absolute URL for sitemap/robots; "" if no base URL configured.
36
+ function absoluteUrl(baseUrl, path) {
37
+ if (!baseUrl) return "";
38
+ return baseUrl.replace(/\/+$/, "") + (path === "/" ? "/" : path);
39
+ }
40
+
41
+ // Emit sitemap.xml (absolute <loc> per rendered path) — requires a base URL. A
42
+ // project-supplied public/sitemap.xml takes precedence (it overwrites ours on copy).
43
+ function writeSitemap(outDir, publicDir, baseUrl, paths) {
44
+ if (existsSync(join(publicDir, "sitemap.xml"))) return false;
45
+ if (!baseUrl) {
46
+ console.warn("⚠ sitemap.xml skipped: no site URL (pass --base-url or set otfw.config)");
47
+ return false;
48
+ }
49
+ const urls = paths
50
+ .map((p) => ` <url><loc>${escapeXml(absoluteUrl(baseUrl, p))}</loc></url>`)
51
+ .join("\n");
52
+ const xml =
53
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
54
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
55
+ writeFileSync(join(outDir, "sitemap.xml"), xml);
56
+ return true;
57
+ }
58
+
59
+ // Emit a permissive robots.txt referencing the sitemap (honor a public/ override).
60
+ function writeRobots(outDir, publicDir, baseUrl, hasSitemap) {
61
+ if (existsSync(join(publicDir, "robots.txt"))) return false;
62
+ let body = "User-agent: *\nAllow: /\n";
63
+ if (baseUrl && hasSitemap) body += `Sitemap: ${absoluteUrl(baseUrl, "/sitemap.xml")}\n`;
64
+ writeFileSync(join(outDir, "robots.txt"), body);
65
+ return true;
66
+ }
67
+
68
+ function escapeXml(s) {
69
+ return String(s).replace(/[&<>'"]/g, (c) =>
70
+ c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "'" ? "&apos;" : "&quot;",
71
+ );
34
72
  }
35
73
 
36
74
  /**
37
75
  * Pre-render the app to static HTML files under `outDir`. Returns
38
76
  * `{ count, skipped, failed }`.
39
77
  */
40
- export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir }) {
41
- const tmp = join(root, ".otfw-ssg");
42
- mkdirSync(tmp, { recursive: true });
43
- const entry = join(tmp, "ssg-entry.js");
44
- writeFileSync(entry, serverEntrySource(pages));
45
-
46
- const serverApi = join(dirname(webEntry), "server", "index.js");
47
- await build({
48
- input: entry,
49
- resolve: {
50
- alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
51
- extensions: EXTENSIONS,
52
- },
53
- plugins: [otfwPlugin(otfwc, { failOnError: true, target: "ssg" }), cssPlugin()],
54
- output: { dir: join(tmp, "out"), format: "esm", entryFileNames: "server.js" },
55
- });
78
+ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, onCompile, onRender }) {
79
+ const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n, onCompile });
56
80
 
57
- // The runtime defines `class extends HTMLElement` at load (for CSR custom
58
- // elements). SSG never instantiates them, but the base class must exist so the
59
- // class definitions evaluate. A bare stub suffices no DOM (customElements
60
- // stays undefined, so the elements self-register only in the browser).
61
- globalThis.HTMLElement ??= class {};
62
- const mod = await import(pathToFileURL(join(tmp, "out", "server.js")).href);
81
+ // i18n (docs/I18N.md §6): pre-render each route once per locale. The default
82
+ // locale is emitted bare (dist/<route>/index.html), others under their prefix
83
+ // (dist/<locale>/<route>/index.html). A single `[null]` means i18n is off — the
84
+ // loop collapses to the original one-render-per-route behavior.
85
+ const i18nOn = !!(i18n && Array.isArray(i18n.locales) && i18n.locales.length);
86
+ const locales = i18nOn ? i18n.locales : [null];
87
+ const defaultLocale = i18nOn ? i18n.defaultLocale : null;
63
88
 
64
89
  const { paths, skipped } = await mod.collectRoutePaths();
65
90
  const failed = [];
66
- for (const route of paths) {
67
- try {
68
- const markup = (await mod.renderToString(route)) ?? "";
69
- const file = htmlPathFor(outDir, route);
70
- mkdirSync(dirname(file), { recursive: true });
71
- writeFileSync(file, injectMarkup(shellHtml, markup));
72
- } catch (e) {
73
- failed.push(route);
74
- console.error(`✗ pre-render failed for ${route}: ${e?.message ?? e}`);
91
+ const rendered = []; // concrete paths that produced an HTML file (for the sitemap)
92
+ let renderedCount = 0;
93
+ for (const { path, params } of paths) {
94
+ onRender?.(++renderedCount, paths.length);
95
+ for (const locale of locales) {
96
+ const urlPath = localizeFor(path, locale, defaultLocale);
97
+ try {
98
+ // Passing the localized URL pins router.locale (resolveLocale) and still
99
+ // matches the locale-agnostic route table, so `t()` renders in `locale`.
100
+ const { html, metadata } = (await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {} };
101
+ const meta = i18nOn
102
+ ? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
103
+ : metadata;
104
+ let head = mod.renderHead(meta, { path: urlPath, baseUrl });
105
+ // SEO: expose the page's last-updated time (git/frontmatter) as Open Graph's
106
+ // article:modified_time so crawlers see when the content actually changed.
107
+ const iso = lastUpdated[path];
108
+ if (iso) head += `\n<meta property="article:modified_time" content="${escapeXml(iso)}">`;
109
+ const file = htmlPathFor(outDir, urlPath);
110
+ mkdirSync(dirname(file), { recursive: true });
111
+ writeFileSync(file, injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html));
112
+ rendered.push(urlPath);
113
+ } catch (e) {
114
+ failed.push(urlPath);
115
+ console.error(`✗ pre-render failed for ${urlPath}: ${e?.message ?? e}`);
116
+ }
75
117
  }
76
118
  }
77
119
 
78
- // 404: any unmatched path resolves to the registered 404 page.
120
+ // 404: any unmatched path resolves to the registered 404 page. Mark noindex so
121
+ // crawlers don't index the error page, and keep it out of the sitemap.
79
122
  try {
80
- const notFound = await mod.renderToString("/__otfw_404__");
81
- if (notFound != null) writeFileSync(join(outDir, "404.html"), injectMarkup(shellHtml, notFound));
123
+ const result = await mod.renderRoute("/__otfw_404__");
124
+ if (result) {
125
+ const head = mod.renderHead({ robots: "noindex", ...result.metadata }, { baseUrl });
126
+ writeFileSync(join(outDir, "404.html"), injectMarkup(injectHead(shellHtml, head), result.html));
127
+ }
82
128
  } catch {
83
129
  /* no 404 page */
84
130
  }
85
131
 
86
- rmSync(tmp, { recursive: true, force: true });
87
- return { count: paths.length - failed.length, skipped, failed };
132
+ // Crawl infrastructure: sitemap.xml + robots.txt (honoring public/ overrides).
133
+ const publicDir = join(root, "public");
134
+ const hasSitemap = writeSitemap(outDir, publicDir, baseUrl, rendered);
135
+ writeRobots(outDir, publicDir, baseUrl, hasSitemap);
136
+
137
+ cleanup();
138
+ return { count: rendered.length, skipped, failed };
88
139
  }
@@ -0,0 +1,73 @@
1
+ // Pretty, TTY-aware build reporter. Each phase is a "step" that shows an animated
2
+ // spinner with live detail while it runs, then collapses to a green ✅ line with the
3
+ // elapsed time when done. On a non-interactive stream (CI, piped logs) the spinner and
4
+ // in-place updates are skipped — only the final ✅/✗ line per step is printed — so logs
5
+ // stay clean and free of carriage returns.
6
+
7
+ const TTY = !!process.stdout.isTTY;
8
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+
10
+ const paint = (code, s) => (TTY ? `\x1b[${code}m${s}\x1b[0m` : s);
11
+ const dim = (s) => paint(2, s);
12
+ const cyan = (s) => paint(36, s);
13
+ const red = (s) => paint(31, s);
14
+
15
+ /** Humanize a millisecond duration: 940ms, 5.2s. */
16
+ export function fmtMs(n) {
17
+ return n < 1000 ? `${Math.round(n)}ms` : `${(n / 1000).toFixed(1)}s`;
18
+ }
19
+
20
+ class Step {
21
+ constructor(label) {
22
+ this.label = label;
23
+ this.detail = "";
24
+ this.t0 = performance.now();
25
+ this.frame = 0;
26
+ this.timer = null;
27
+ if (TTY) {
28
+ this.render();
29
+ this.timer = setInterval(() => this.render(), 80);
30
+ } else {
31
+ process.stdout.write(` ${dim("•")} ${label}…\n`);
32
+ }
33
+ }
34
+
35
+ render() {
36
+ const spinner = cyan(FRAMES[(this.frame = (this.frame + 1) % FRAMES.length)]);
37
+ const detail = this.detail ? " " + dim(this.detail) : "";
38
+ process.stdout.write(`\r\x1b[K ${spinner} ${this.label}${detail}`);
39
+ }
40
+
41
+ /**
42
+ * Update the live detail (e.g. the current file or an `N/total` count) and repaint
43
+ * immediately. The immediate repaint matters because compilation runs synchronous
44
+ * subprocesses that block the event loop — the `setInterval` tick can't fire during
45
+ * them, so each `update()` is what actually advances the line.
46
+ */
47
+ update(detail) {
48
+ this.detail = detail || "";
49
+ if (TTY) this.render();
50
+ }
51
+
52
+ _stop() {
53
+ if (this.timer) clearInterval(this.timer);
54
+ if (TTY) process.stdout.write("\r\x1b[K");
55
+ }
56
+
57
+ /** Finish the step: green ✅ with `msg` (defaults to the label) and elapsed time. */
58
+ done(msg) {
59
+ this._stop();
60
+ process.stdout.write(` ✅ ${msg || this.label} ${dim(fmtMs(performance.now() - this.t0))}\n`);
61
+ }
62
+
63
+ /** Mark the step failed: red ✗ with `msg`. */
64
+ fail(msg) {
65
+ this._stop();
66
+ process.stdout.write(` ${red("✗")} ${msg || this.label}\n`);
67
+ }
68
+ }
69
+
70
+ /** Start a build phase. Returns a `Step` you drive with `.update()` then `.done()`. */
71
+ export function step(label) {
72
+ return new Step(label);
73
+ }