@opentf/web-cli 1.0.0 → 1.2.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 +49 -0
- package/package.json +6 -2
- package/src/build.js +128 -30
- package/src/cli.js +7 -0
- package/src/dev.js +195 -97
- package/src/prerender.js +124 -57
- package/src/reporter.js +73 -0
- package/src/serve.js +238 -0
- package/src/shared.js +591 -45
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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.
|
|
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(
|
|
80
|
-
const
|
|
81
|
-
writeFileSync(
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
191
|
-
if (pathname
|
|
192
|
-
|
|
193
|
-
|
|
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)
|
|
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,153 @@
|
|
|
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 {
|
|
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 {
|
|
9
|
+
import {
|
|
10
|
+
buildServerBundle,
|
|
11
|
+
injectHead,
|
|
12
|
+
injectHydrationData,
|
|
13
|
+
injectMarkup,
|
|
14
|
+
withHtmlLang,
|
|
15
|
+
} from "./shared.js";
|
|
12
16
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
);
|
|
17
|
+
// "/" → dist/index.html, "/post/1" → dist/post/1/index.html, "/fr/about" → dist/fr/about/index.html.
|
|
18
|
+
function htmlPathFor(outDir, route) {
|
|
19
|
+
return route === "/" ? join(outDir, "index.html") : join(outDir, route, "index.html");
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
// Prefix a locale-agnostic route with `locale` (bare for the default) — the URL form
|
|
23
|
+
// that both selects the output file and, passed to renderRoute, pins router.locale.
|
|
24
|
+
function localizeFor(path, locale, defaultLocale) {
|
|
25
|
+
if (!locale || locale === defaultLocale) return path;
|
|
26
|
+
return path === "/" ? `/${locale}` : `/${locale}${path}`;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
// "
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// `rel="alternate" hreflang` descriptors for a route across all locales (+ x-default),
|
|
30
|
+
// merged into metadata.links so renderHead emits them (docs/I18N.md §6).
|
|
31
|
+
function alternatesFor(path, locales, defaultLocale) {
|
|
32
|
+
const links = locales.map((l) => ({
|
|
33
|
+
rel: "alternate",
|
|
34
|
+
hreflang: l,
|
|
35
|
+
href: localizeFor(path, l, defaultLocale),
|
|
36
|
+
}));
|
|
37
|
+
links.push({ rel: "alternate", hreflang: "x-default", href: localizeFor(path, defaultLocale, defaultLocale) });
|
|
38
|
+
return links;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Absolute URL for sitemap/robots; "" if no base URL configured.
|
|
42
|
+
function absoluteUrl(baseUrl, path) {
|
|
43
|
+
if (!baseUrl) return "";
|
|
44
|
+
return baseUrl.replace(/\/+$/, "") + (path === "/" ? "/" : path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Emit sitemap.xml (absolute <loc> per rendered path) — requires a base URL. A
|
|
48
|
+
// project-supplied public/sitemap.xml takes precedence (it overwrites ours on copy).
|
|
49
|
+
function writeSitemap(outDir, publicDir, baseUrl, paths) {
|
|
50
|
+
if (existsSync(join(publicDir, "sitemap.xml"))) return false;
|
|
51
|
+
if (!baseUrl) {
|
|
52
|
+
console.warn("⚠ sitemap.xml skipped: no site URL (pass --base-url or set otfw.config)");
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const urls = paths
|
|
56
|
+
.map((p) => ` <url><loc>${escapeXml(absoluteUrl(baseUrl, p))}</loc></url>`)
|
|
57
|
+
.join("\n");
|
|
58
|
+
const xml =
|
|
59
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
60
|
+
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
61
|
+
writeFileSync(join(outDir, "sitemap.xml"), xml);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Emit a permissive robots.txt referencing the sitemap (honor a public/ override).
|
|
66
|
+
function writeRobots(outDir, publicDir, baseUrl, hasSitemap) {
|
|
67
|
+
if (existsSync(join(publicDir, "robots.txt"))) return false;
|
|
68
|
+
let body = "User-agent: *\nAllow: /\n";
|
|
69
|
+
if (baseUrl && hasSitemap) body += `Sitemap: ${absoluteUrl(baseUrl, "/sitemap.xml")}\n`;
|
|
70
|
+
writeFileSync(join(outDir, "robots.txt"), body);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escapeXml(s) {
|
|
75
|
+
return String(s).replace(/[&<>'"]/g, (c) =>
|
|
76
|
+
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === "'" ? "'" : """,
|
|
77
|
+
);
|
|
34
78
|
}
|
|
35
79
|
|
|
36
80
|
/**
|
|
37
81
|
* Pre-render the app to static HTML files under `outDir`. Returns
|
|
38
82
|
* `{ count, skipped, failed }`.
|
|
39
83
|
*/
|
|
40
|
-
export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir }) {
|
|
41
|
-
const
|
|
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
|
-
});
|
|
84
|
+
export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, onCompile, onRender }) {
|
|
85
|
+
const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n, onCompile });
|
|
56
86
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
const
|
|
87
|
+
// i18n (docs/I18N.md §6): pre-render each route once per locale. The default
|
|
88
|
+
// locale is emitted bare (dist/<route>/index.html), others under their prefix
|
|
89
|
+
// (dist/<locale>/<route>/index.html). A single `[null]` means i18n is off — the
|
|
90
|
+
// loop collapses to the original one-render-per-route behavior.
|
|
91
|
+
const i18nOn = !!(i18n && Array.isArray(i18n.locales) && i18n.locales.length);
|
|
92
|
+
const locales = i18nOn ? i18n.locales : [null];
|
|
93
|
+
const defaultLocale = i18nOn ? i18n.defaultLocale : null;
|
|
63
94
|
|
|
64
95
|
const { paths, skipped } = await mod.collectRoutePaths();
|
|
65
96
|
const failed = [];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
const rendered = []; // concrete paths that produced an HTML file (for the sitemap)
|
|
98
|
+
let renderedCount = 0;
|
|
99
|
+
for (const { path, params } of paths) {
|
|
100
|
+
onRender?.(++renderedCount, paths.length);
|
|
101
|
+
for (const locale of locales) {
|
|
102
|
+
const urlPath = localizeFor(path, locale, defaultLocale);
|
|
103
|
+
try {
|
|
104
|
+
// Passing the localized URL pins router.locale (resolveLocale) and still
|
|
105
|
+
// matches the locale-agnostic route table, so `t()` renders in `locale`.
|
|
106
|
+
const { html, metadata, hydration } =
|
|
107
|
+
(await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {}, hydration: "" };
|
|
108
|
+
const meta = i18nOn
|
|
109
|
+
? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
|
|
110
|
+
: metadata;
|
|
111
|
+
let head = mod.renderHead(meta, { path: urlPath, baseUrl });
|
|
112
|
+
// SEO: expose the page's last-updated time (git/frontmatter) as Open Graph's
|
|
113
|
+
// article:modified_time so crawlers see when the content actually changed.
|
|
114
|
+
const iso = lastUpdated[path];
|
|
115
|
+
if (iso) head += `\n<meta property="article:modified_time" content="${escapeXml(iso)}">`;
|
|
116
|
+
const file = htmlPathFor(outDir, urlPath);
|
|
117
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
118
|
+
writeFileSync(
|
|
119
|
+
file,
|
|
120
|
+
injectHydrationData(
|
|
121
|
+
injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
|
|
122
|
+
hydration,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
rendered.push(urlPath);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
failed.push(urlPath);
|
|
128
|
+
console.error(`✗ pre-render failed for ${urlPath}: ${e?.message ?? e}`);
|
|
129
|
+
}
|
|
75
130
|
}
|
|
76
131
|
}
|
|
77
132
|
|
|
78
|
-
// 404: any unmatched path resolves to the registered 404 page.
|
|
133
|
+
// 404: any unmatched path resolves to the registered 404 page. Mark noindex so
|
|
134
|
+
// crawlers don't index the error page, and keep it out of the sitemap.
|
|
79
135
|
try {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
136
|
+
const result = await mod.renderRoute("/__otfw_404__");
|
|
137
|
+
if (result) {
|
|
138
|
+
const head = mod.renderHead({ robots: "noindex", ...result.metadata }, { baseUrl });
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(outDir, "404.html"),
|
|
141
|
+
injectHydrationData(injectMarkup(injectHead(shellHtml, head), result.html), result.hydration),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
82
144
|
} catch {
|
|
83
145
|
/* no 404 page */
|
|
84
146
|
}
|
|
85
147
|
|
|
86
|
-
|
|
87
|
-
|
|
148
|
+
// Crawl infrastructure: sitemap.xml + robots.txt (honoring public/ overrides).
|
|
149
|
+
const publicDir = join(root, "public");
|
|
150
|
+
const hasSitemap = writeSitemap(outDir, publicDir, baseUrl, rendered);
|
|
151
|
+
writeRobots(outDir, publicDir, baseUrl, hasSitemap);
|
|
152
|
+
|
|
153
|
+
cleanup();
|
|
154
|
+
return { count: rendered.length, skipped, failed };
|
|
88
155
|
}
|
package/src/reporter.js
ADDED
|
@@ -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
|
+
}
|