@opentf/web-cli 1.1.0 → 1.3.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 +4 -1
- package/package.json +2 -2
- package/src/build.js +74 -14
- package/src/dev.js +141 -3
- package/src/prerender.js +47 -6
- package/src/serve.js +89 -23
- package/src/shared.js +473 -45
package/README.md
CHANGED
|
@@ -39,7 +39,10 @@ otfw serve # build, then server-render each route per request (SSR)
|
|
|
39
39
|
Picks port 3000 (scanning upward), or `--port` for an explicit one. Phase 1: the page
|
|
40
40
|
becomes interactive via the client bundle (CSR mount); hydration is a later phase.
|
|
41
41
|
|
|
42
|
-
`OTFWC_BIN` overrides the compiler binary location
|
|
42
|
+
`OTFWC_BIN` overrides the compiler binary location. Published installs under
|
|
43
|
+
`node_modules` use the packaged compiler, even when the app lives inside a Cargo
|
|
44
|
+
workspace; this repository's own source checkout may build and use
|
|
45
|
+
`target/debug/otfwc` for compiler development.
|
|
43
46
|
|
|
44
47
|
## License
|
|
45
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentf/web-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "OTF Web dev toolchain — the Rolldown-driven CSR dev server, production build, and SSG.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"test": "bun test tests/",
|
|
14
|
-
"test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/hydrate-browser.mjs"
|
|
14
|
+
"test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/build-data.mjs && bun tests/e2e/hydrate-browser.mjs"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"src"
|
package/src/build.js
CHANGED
|
@@ -14,13 +14,19 @@ import {
|
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { basename, join } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
17
18
|
|
|
18
19
|
import { compileCss, usesTailwind } from "./tailwind.js";
|
|
19
20
|
import {
|
|
20
21
|
EXTENSIONS,
|
|
22
|
+
assertNoRouteConflicts,
|
|
21
23
|
cssPlugin,
|
|
24
|
+
discoverLoaders,
|
|
22
25
|
discoverPages,
|
|
26
|
+
emitApiBundle,
|
|
27
|
+
emitLoaderBundle,
|
|
23
28
|
entrySource,
|
|
29
|
+
loaderRoutePath,
|
|
24
30
|
injectBeforeBody,
|
|
25
31
|
loadConfig,
|
|
26
32
|
loadDocsPlugins,
|
|
@@ -29,6 +35,7 @@ import {
|
|
|
29
35
|
readHtmlShell,
|
|
30
36
|
runBlogFeed,
|
|
31
37
|
runDocsSearchIndex,
|
|
38
|
+
runLlmsFiles,
|
|
32
39
|
runLastUpdated,
|
|
33
40
|
stampHydrateSentinel,
|
|
34
41
|
} from "./shared.js";
|
|
@@ -36,17 +43,37 @@ import { fmtMs, step } from "./reporter.js";
|
|
|
36
43
|
|
|
37
44
|
const hash = (s) => Bun.hash(s).toString(16).padStart(16, "0").slice(0, 8);
|
|
38
45
|
|
|
39
|
-
// Site origin for absolute canonical / sitemap URLs. Priority: `--base-url=`
|
|
40
|
-
// then `otfw.config` (`{ site: { url } }`)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const flag = process.argv.find((a) => a.startsWith("--base-url="));
|
|
46
|
+
// Site origin for absolute canonical / sitemap/feed URLs. Priority: `--base-url=`
|
|
47
|
+
// flag, then `otfw.config` (`{ site: { url } }`).
|
|
48
|
+
export function resolveBaseUrl(config, argv = process.argv) {
|
|
49
|
+
const flag = argv.find((a) => a.startsWith("--base-url="));
|
|
44
50
|
if (flag) return flag.slice("--base-url=".length).replace(/\/+$/, "");
|
|
45
51
|
if (config?.site?.url) return String(config.site.url).replace(/\/+$/, "");
|
|
46
52
|
return "";
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
export function buildRequiresBaseUrl(config, argv = process.argv) {
|
|
56
|
+
return argv.includes("--ssg") || !!config?.docs || !!config?.blog;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireBaseUrl(config, argv = process.argv) {
|
|
60
|
+
const baseUrl = resolveBaseUrl(config, argv);
|
|
61
|
+
if (baseUrl || !buildRequiresBaseUrl(config, argv)) return baseUrl;
|
|
62
|
+
console.error(
|
|
63
|
+
`✗ site.url is required for this production build.\n` +
|
|
64
|
+
` Add it to otfw.config.js:\n\n` +
|
|
65
|
+
` export default defineDocsConfig({\n` +
|
|
66
|
+
` site: { url: "https://example.com" }\n` +
|
|
67
|
+
` })\n\n` +
|
|
68
|
+
` Or pass --base-url=https://example.com`,
|
|
69
|
+
);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
export async function runBuild(options = {}) {
|
|
74
|
+
const projectRoot = process.cwd();
|
|
75
|
+
const config = await loadConfig(projectRoot);
|
|
76
|
+
const baseUrl = requireBaseUrl(config);
|
|
50
77
|
const { root, appDir, webEntry, otfwc, exclude } = loadProject();
|
|
51
78
|
const t0 = performance.now();
|
|
52
79
|
|
|
@@ -55,6 +82,7 @@ export async function runBuild(options = {}) {
|
|
|
55
82
|
console.error(`✗ no page.jsx files found under ${appDir}`);
|
|
56
83
|
process.exit(1);
|
|
57
84
|
}
|
|
85
|
+
assertNoRouteConflicts(appDir, exclude);
|
|
58
86
|
|
|
59
87
|
// Build the client for hydration when there will be server markup to adopt — the
|
|
60
88
|
// SSR server (`runBuild({ hydrate: true })`) and `--ssg` pre-rendered pages. A
|
|
@@ -63,7 +91,6 @@ export async function runBuild(options = {}) {
|
|
|
63
91
|
|
|
64
92
|
// Docs generator: resolve `@opentf/web-docs/nav` to the build-time nav tree when
|
|
65
93
|
// the project has a `docs` config block.
|
|
66
|
-
const config = await loadConfig(root);
|
|
67
94
|
const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
|
|
68
95
|
|
|
69
96
|
const outDir = join(root, "dist");
|
|
@@ -74,7 +101,9 @@ export async function runBuild(options = {}) {
|
|
|
74
101
|
const tmp = join(root, ".otfw");
|
|
75
102
|
mkdirSync(tmp, { recursive: true });
|
|
76
103
|
const entry = join(tmp, "entry.js");
|
|
77
|
-
|
|
104
|
+
const loaderFiles = discoverLoaders(appDir, exclude);
|
|
105
|
+
const loaderRoutes = loaderFiles.map((f) => loaderRoutePath(f, appDir));
|
|
106
|
+
writeFileSync(entry, entrySource(pages, appDir, undefined, config?.i18n, config?.nav, loaderRoutes));
|
|
78
107
|
|
|
79
108
|
console.log("\n OTF Web — production build\n");
|
|
80
109
|
|
|
@@ -141,11 +170,28 @@ export async function runBuild(options = {}) {
|
|
|
141
170
|
const chunks = result.output.filter((o) => o.type === "chunk").length;
|
|
142
171
|
buildStep.done(`Compiled ${pages.length} routes · bundled ${chunks} chunks`);
|
|
143
172
|
|
|
173
|
+
// Route loaders (docs/DATA.md): emit the server bundle to dist/server/loaders.js
|
|
174
|
+
// — `otfw serve` imports it per request, and the SSG pre-render below runs it at
|
|
175
|
+
// build time (so this must precede the SSG step).
|
|
176
|
+
let loaders = null;
|
|
177
|
+
if (loaderFiles.length > 0) {
|
|
178
|
+
const loaderStep = step("Bundling route loaders");
|
|
179
|
+
await emitLoaderBundle({
|
|
180
|
+
root,
|
|
181
|
+
appDir,
|
|
182
|
+
webEntry,
|
|
183
|
+
exclude,
|
|
184
|
+
i18n: config?.i18n,
|
|
185
|
+
outDir: join(outDir, "server"),
|
|
186
|
+
});
|
|
187
|
+
loaders = (await import(pathToFileURL(join(outDir, "server", "loaders.js")).href)).loaders;
|
|
188
|
+
loaderStep.done(`Route loaders — ${loaderFiles.length} → dist/server/loaders.js`);
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
// SSG: pre-render each route into static HTML using the shell we just composed
|
|
145
192
|
// (so per-route files carry the same bundle + stylesheet links).
|
|
146
193
|
let ssg = null;
|
|
147
194
|
if (process.argv.includes("--ssg")) {
|
|
148
|
-
const baseUrl = resolveBaseUrl(config);
|
|
149
195
|
const { runPrerender } = await import("./prerender.js");
|
|
150
196
|
// Per-page last-updated map (git/frontmatter) for the article:modified_time tag.
|
|
151
197
|
const lastUpdated = await runLastUpdated(root, appDir, config, exclude);
|
|
@@ -162,6 +208,7 @@ export async function runBuild(options = {}) {
|
|
|
162
208
|
docsPlugins,
|
|
163
209
|
lastUpdated,
|
|
164
210
|
i18n: config?.i18n,
|
|
211
|
+
loaders,
|
|
165
212
|
onCompile: (id) => ssgStep.update(`compiling ${basename(id)} (${++ssgCompiled})`),
|
|
166
213
|
onRender: (done, total) => ssgStep.update(`rendering ${done}/${total}`),
|
|
167
214
|
});
|
|
@@ -171,6 +218,12 @@ export async function runBuild(options = {}) {
|
|
|
171
218
|
);
|
|
172
219
|
}
|
|
173
220
|
|
|
221
|
+
// API routes (SPEC §11/§13): emit the server handler bundle to dist/server/api.js
|
|
222
|
+
// so a deploy adapter (Bun/Node/CF Workers) can serve /api/* in production.
|
|
223
|
+
const apiStep = step("Bundling API routes");
|
|
224
|
+
const api = await emitApiBundle({ root, appDir, webEntry, exclude, outDir: join(outDir, "server") });
|
|
225
|
+
apiStep.done(api ? `API routes — ${api.routes.length} → dist/server/api.js` : "API routes — none");
|
|
226
|
+
|
|
174
227
|
// Copy the public/ directory (static assets served at the root), if present.
|
|
175
228
|
const publicDir = join(root, "public");
|
|
176
229
|
if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
|
|
@@ -185,13 +238,20 @@ export async function runBuild(options = {}) {
|
|
|
185
238
|
searchStep.done(`Search index — ${search?.pages ?? 0} page(s)`);
|
|
186
239
|
}
|
|
187
240
|
|
|
188
|
-
// Blog RSS
|
|
189
|
-
// public/ copy so
|
|
241
|
+
// Blog RSS + Atom feeds (when a `blog` block is configured). Written
|
|
242
|
+
// after the public/ copy so project-supplied feed overrides aren't clobbered.
|
|
190
243
|
if (config?.blog) {
|
|
191
|
-
const feedStep = step("Generating
|
|
192
|
-
const feed = await runBlogFeed(root, appDir, config, outDir,
|
|
193
|
-
if (feed) feedStep.done(`
|
|
194
|
-
else feedStep.done("
|
|
244
|
+
const feedStep = step("Generating blog feeds");
|
|
245
|
+
const feed = await runBlogFeed(root, appDir, config, outDir, baseUrl, exclude);
|
|
246
|
+
if (feed) feedStep.done(`Blog feeds — ${feed.count} post(s) → ${feed.paths.join(", ")}`);
|
|
247
|
+
else feedStep.done("Blog feeds — skipped");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (config?.docs || config?.blog) {
|
|
251
|
+
const llmsStep = step("Generating LLM context");
|
|
252
|
+
const llms = await runLlmsFiles(root, appDir, pages, config, outDir, baseUrl);
|
|
253
|
+
if (llms) llmsStep.done(`LLM context — ${llms.paths.join(", ")}`);
|
|
254
|
+
else llmsStep.done("LLM context — skipped");
|
|
195
255
|
}
|
|
196
256
|
|
|
197
257
|
console.log(`\n → dist/ ready in ${fmtMs(performance.now() - t0)}\n`);
|
package/src/dev.js
CHANGED
|
@@ -18,16 +18,23 @@
|
|
|
18
18
|
|
|
19
19
|
import { rolldown } from "rolldown";
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
|
|
21
|
-
import { join } from "node:path";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
22
23
|
|
|
23
24
|
import { compileCss, usesTailwind } from "./tailwind.js";
|
|
24
25
|
import { overlayClient } from "./overlay.js";
|
|
25
26
|
import {
|
|
26
27
|
EXTENSIONS,
|
|
27
28
|
MIME,
|
|
29
|
+
assertNoRouteConflicts,
|
|
30
|
+
buildApiBundle,
|
|
31
|
+
buildLoaderBundle,
|
|
28
32
|
cssPlugin,
|
|
33
|
+
discoverApiRoutes,
|
|
34
|
+
discoverLoaders,
|
|
29
35
|
discoverPages,
|
|
30
36
|
entrySource,
|
|
37
|
+
loaderRoutePath,
|
|
31
38
|
injectBeforeBody,
|
|
32
39
|
loadConfig,
|
|
33
40
|
loadDocsPlugins,
|
|
@@ -97,6 +104,7 @@ export async function runDev() {
|
|
|
97
104
|
console.error(`✗ no page.jsx files found under ${appDir}`);
|
|
98
105
|
process.exit(1);
|
|
99
106
|
}
|
|
107
|
+
assertNoRouteConflicts(appDir, exclude);
|
|
100
108
|
|
|
101
109
|
const config = await loadConfig(root);
|
|
102
110
|
const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
|
|
@@ -104,7 +112,10 @@ export async function runDev() {
|
|
|
104
112
|
const devDir = join(root, ".dev");
|
|
105
113
|
mkdirSync(devDir, { recursive: true });
|
|
106
114
|
const entryFile = join(devDir, "entry.js");
|
|
107
|
-
|
|
115
|
+
const loaderRoutes = () => discoverLoaders(appDir, exclude).map((f) => loaderRoutePath(f, appDir));
|
|
116
|
+
const writeEntry = () =>
|
|
117
|
+
writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav, loaderRoutes()));
|
|
118
|
+
writeEntry();
|
|
108
119
|
|
|
109
120
|
let server;
|
|
110
121
|
const publish = (msg) => server?.publish("hmr", JSON.stringify(msg));
|
|
@@ -184,6 +195,88 @@ export async function runDev() {
|
|
|
184
195
|
return routeCache.get(file);
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
// API routes (SPEC §11) are plain server modules under app/api/. In dev they're
|
|
199
|
+
// built lazily on the first /api/* request and rebuilt after an edit (the watcher
|
|
200
|
+
// clears the cache). `bust` busts the ESM import cache so edits take effect.
|
|
201
|
+
let apiBundle = null;
|
|
202
|
+
let apiBuilt = false;
|
|
203
|
+
let apiVersion = 0;
|
|
204
|
+
async function getApi() {
|
|
205
|
+
if (!apiBuilt) {
|
|
206
|
+
try {
|
|
207
|
+
apiBundle = await buildApiBundle({ root, appDir, webEntry, exclude, bust: ++apiVersion });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error(`✗ API build failed: ${e?.message ?? e}`);
|
|
210
|
+
apiBundle = await brokenApiStub(e);
|
|
211
|
+
}
|
|
212
|
+
apiBuilt = true;
|
|
213
|
+
}
|
|
214
|
+
return apiBundle;
|
|
215
|
+
}
|
|
216
|
+
// A failed API build must not silently serve the SPA shell for endpoint URLs:
|
|
217
|
+
// stand in with a handler that 500s exactly the discovered endpoints (real
|
|
218
|
+
// matching semantics via createApiHandler) until the next successful rebuild.
|
|
219
|
+
async function brokenApiStub(err) {
|
|
220
|
+
try {
|
|
221
|
+
const serverApi = pathToFileURL(join(dirname(webEntry), "server", "index.js")).href;
|
|
222
|
+
const { createApiHandler } = await import(serverApi);
|
|
223
|
+
// Strip ANSI color codes — the bundler's terminal diagnostic goes into JSON here.
|
|
224
|
+
const msg = String(err?.message ?? err).replace(/\u001b\[[0-9;]*m/g, "");
|
|
225
|
+
const fail = () => Response.json({ error: `API routes failed to build: ${msg}` }, { status: 500 });
|
|
226
|
+
const stub = Object.fromEntries(
|
|
227
|
+
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].map((m) => [m, fail]),
|
|
228
|
+
);
|
|
229
|
+
const { routes } = discoverApiRoutes(appDir, exclude);
|
|
230
|
+
return {
|
|
231
|
+
handler: createApiHandler(Object.fromEntries(routes.map((f) => [f, stub])), {}, { appDir }),
|
|
232
|
+
routes,
|
|
233
|
+
cleanup: () => {},
|
|
234
|
+
};
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function invalidateApi() {
|
|
240
|
+
try {
|
|
241
|
+
apiBundle?.cleanup();
|
|
242
|
+
} catch {}
|
|
243
|
+
apiBundle = null;
|
|
244
|
+
apiBuilt = false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Route loaders (docs/DATA.md) are plain server modules like API routes, and get
|
|
248
|
+
// the same treatment: built lazily on the first `__data.json` request, rebuilt
|
|
249
|
+
// after an edit (the watcher clears the cache), ESM cache busted per rebuild.
|
|
250
|
+
let loaderBundle = null;
|
|
251
|
+
let loadersBuilt = false;
|
|
252
|
+
let loaderVersion = 0;
|
|
253
|
+
async function getLoaders() {
|
|
254
|
+
if (!loadersBuilt) {
|
|
255
|
+
try {
|
|
256
|
+
loaderBundle = await buildLoaderBundle({
|
|
257
|
+
root,
|
|
258
|
+
appDir,
|
|
259
|
+
webEntry,
|
|
260
|
+
exclude,
|
|
261
|
+
i18n: config?.i18n,
|
|
262
|
+
bust: ++loaderVersion,
|
|
263
|
+
});
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.error(`✗ loader build failed: ${e?.message ?? e}`);
|
|
266
|
+
loaderBundle = null;
|
|
267
|
+
}
|
|
268
|
+
loadersBuilt = true;
|
|
269
|
+
}
|
|
270
|
+
return loaderBundle;
|
|
271
|
+
}
|
|
272
|
+
function invalidateLoaders() {
|
|
273
|
+
try {
|
|
274
|
+
loaderBundle?.cleanup();
|
|
275
|
+
} catch {}
|
|
276
|
+
loaderBundle = null;
|
|
277
|
+
loadersBuilt = false;
|
|
278
|
+
}
|
|
279
|
+
|
|
187
280
|
// The module graph powers precise invalidation; build it in the background so it
|
|
188
281
|
// never blocks startup. Until it's ready, a change clears every cache (safe).
|
|
189
282
|
let graph = { has: () => false, affected: () => new Set() };
|
|
@@ -217,12 +310,30 @@ export async function runDev() {
|
|
|
217
310
|
const watcher = watch(appDir, { recursive: true }, (_evt, name) => {
|
|
218
311
|
if (!name) return;
|
|
219
312
|
const file = join(appDir, name);
|
|
313
|
+
// An API endpoint/middleware edit (any `route.*` / `_middleware.*` file) rebuilds
|
|
314
|
+
// the API bundle on the next request and triggers a reload.
|
|
315
|
+
if (/^(route|_middleware)\.(jsx?|tsx?)$/.test(name.split("/").pop())) {
|
|
316
|
+
invalidateApi();
|
|
317
|
+
publish({ type: "reload" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// A loader edit rebuilds the loader bundle on the next data request; a new or
|
|
321
|
+
// deleted loader.* also changes the route set baked into the entry, so the
|
|
322
|
+
// entry is rewritten and its cached chunk dropped (a stale set would make SPA
|
|
323
|
+
// nav skip the fetch — or fetch a 404).
|
|
324
|
+
if (/^loader\.(js|ts)$/.test(name.split("/").pop())) {
|
|
325
|
+
invalidateLoaders();
|
|
326
|
+
writeEntry();
|
|
327
|
+
entryCode = null;
|
|
328
|
+
publish({ type: "reload" });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
220
331
|
if (/\.(mdx|md|[jt]sx|css)$/.test(name)) {
|
|
221
332
|
if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(name.split("/").pop()) && !pages.includes(file)) {
|
|
222
333
|
const fresh = discoverPages(appDir, exclude);
|
|
223
334
|
pages.length = 0;
|
|
224
335
|
pages.push(...fresh);
|
|
225
|
-
|
|
336
|
+
writeEntry();
|
|
226
337
|
}
|
|
227
338
|
onChange(file);
|
|
228
339
|
}
|
|
@@ -234,6 +345,8 @@ export async function runDev() {
|
|
|
234
345
|
try {
|
|
235
346
|
watcher.close();
|
|
236
347
|
} catch {}
|
|
348
|
+
invalidateApi();
|
|
349
|
+
invalidateLoaders();
|
|
237
350
|
process.exit(0);
|
|
238
351
|
};
|
|
239
352
|
process.once("SIGINT", shutdown);
|
|
@@ -242,6 +355,8 @@ export async function runDev() {
|
|
|
242
355
|
try {
|
|
243
356
|
watcher.close();
|
|
244
357
|
} catch {}
|
|
358
|
+
invalidateApi();
|
|
359
|
+
invalidateLoaders();
|
|
245
360
|
});
|
|
246
361
|
|
|
247
362
|
// Import map (so `@opentf/web` resolves to the shared runtime chunk) + entry + the
|
|
@@ -303,6 +418,29 @@ export async function runDev() {
|
|
|
303
418
|
if (pathname.startsWith(ROUTE_PREFIX) && pathname.endsWith(".js")) {
|
|
304
419
|
return js(await serveRoute(fromRouteUrl(pathname)));
|
|
305
420
|
}
|
|
421
|
+
// API endpoints (route.* files, at any path — SPEC §11): a matched handler's
|
|
422
|
+
// Response wins; a miss falls through to static assets / the SPA shell, so pages
|
|
423
|
+
// and endpoints coexist. `getApi()` is O(1) after the first build (or a no-op
|
|
424
|
+
// when the app has no route.* files).
|
|
425
|
+
{
|
|
426
|
+
const api = await getApi();
|
|
427
|
+
const res = api ? await api.handler(req) : null;
|
|
428
|
+
if (res) return res;
|
|
429
|
+
}
|
|
430
|
+
// The route-loader data endpoint (docs/DATA.md): `<path>/__data.json` is a
|
|
431
|
+
// reserved suffix — answered here (SPA navigation fetches it) and never
|
|
432
|
+
// allowed to fall through to assets or the SPA shell, so a miss is a 404.
|
|
433
|
+
if (pathname === "/__data.json" || pathname.endsWith("/__data.json")) {
|
|
434
|
+
try {
|
|
435
|
+
const bundle = await getLoaders();
|
|
436
|
+
const res = bundle ? await bundle.loaders.handle(req) : null;
|
|
437
|
+
if (res) return res;
|
|
438
|
+
} catch (e) {
|
|
439
|
+
console.error(`✗ loader failed for ${pathname}: ${e?.message ?? e}`);
|
|
440
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
441
|
+
}
|
|
442
|
+
return Response.json(null, { status: 404 });
|
|
443
|
+
}
|
|
306
444
|
if (pathname !== "/") {
|
|
307
445
|
const asset = await serveStatic(pathname);
|
|
308
446
|
if (asset) return asset;
|
package/src/prerender.js
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
DATA_FILE,
|
|
11
|
+
buildServerBundle,
|
|
12
|
+
injectHead,
|
|
13
|
+
injectHydrationData,
|
|
14
|
+
injectMarkup,
|
|
15
|
+
injectRouteData,
|
|
16
|
+
withHtmlLang,
|
|
17
|
+
} from "./shared.js";
|
|
10
18
|
|
|
11
19
|
// "/" → dist/index.html, "/post/1" → dist/post/1/index.html, "/fr/about" → dist/fr/about/index.html.
|
|
12
20
|
function htmlPathFor(outDir, route) {
|
|
@@ -75,7 +83,7 @@ function escapeXml(s) {
|
|
|
75
83
|
* Pre-render the app to static HTML files under `outDir`. Returns
|
|
76
84
|
* `{ count, skipped, failed }`.
|
|
77
85
|
*/
|
|
78
|
-
export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, onCompile, onRender }) {
|
|
86
|
+
export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, loaders = null, onCompile, onRender }) {
|
|
79
87
|
const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n, onCompile });
|
|
80
88
|
|
|
81
89
|
// i18n (docs/I18N.md §6): pre-render each route once per locale. The default
|
|
@@ -95,9 +103,20 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
|
|
|
95
103
|
for (const locale of locales) {
|
|
96
104
|
const urlPath = localizeFor(path, locale, defaultLocale);
|
|
97
105
|
try {
|
|
106
|
+
// Route loader (docs/DATA.md): run it at build time with no `request` and
|
|
107
|
+
// an empty query (a query-dependent loader needs `otfw serve`). The result
|
|
108
|
+
// is threaded into the render (`router.data`), inlined into the HTML, and
|
|
109
|
+
// written as a sibling `__data.json` — per locale, since a localized
|
|
110
|
+
// loader can return locale-dependent data.
|
|
111
|
+
const m = loaders?.match(urlPath);
|
|
112
|
+
let data;
|
|
113
|
+
let dataJson = "";
|
|
114
|
+
if (m) ({ data, json: dataJson } = await loaders.loadSerialized(m, { query: {} }));
|
|
115
|
+
|
|
98
116
|
// Passing the localized URL pins router.locale (resolveLocale) and still
|
|
99
117
|
// matches the locale-agnostic route table, so `t()` renders in `locale`.
|
|
100
|
-
const { html, metadata
|
|
118
|
+
const { html, metadata, hydration } =
|
|
119
|
+
(await mod.renderRoute(urlPath, params, "", { data })) ?? { html: "", metadata: {}, hydration: "" };
|
|
101
120
|
const meta = i18nOn
|
|
102
121
|
? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
|
|
103
122
|
: metadata;
|
|
@@ -108,11 +127,30 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
|
|
|
108
127
|
if (iso) head += `\n<meta property="article:modified_time" content="${escapeXml(iso)}">`;
|
|
109
128
|
const file = htmlPathFor(outDir, urlPath);
|
|
110
129
|
mkdirSync(dirname(file), { recursive: true });
|
|
111
|
-
writeFileSync(
|
|
130
|
+
writeFileSync(
|
|
131
|
+
file,
|
|
132
|
+
injectRouteData(
|
|
133
|
+
injectHydrationData(
|
|
134
|
+
injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
|
|
135
|
+
hydration,
|
|
136
|
+
),
|
|
137
|
+
dataJson,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
if (m) {
|
|
141
|
+
// The static data file SPA navigation fetches on a plain static host —
|
|
142
|
+
// byte-identical to the inline payload ("null" when the loader returned
|
|
143
|
+
// undefined, so a 200 still parses as JSON).
|
|
144
|
+
const dataFile =
|
|
145
|
+
urlPath === "/" ? join(outDir, DATA_FILE) : join(outDir, urlPath, DATA_FILE);
|
|
146
|
+
mkdirSync(dirname(dataFile), { recursive: true });
|
|
147
|
+
writeFileSync(dataFile, dataJson || "null");
|
|
148
|
+
}
|
|
112
149
|
rendered.push(urlPath);
|
|
113
150
|
} catch (e) {
|
|
114
151
|
failed.push(urlPath);
|
|
115
|
-
|
|
152
|
+
const why = e?.otfwNotFound ? "loader called notFound()" : (e?.message ?? e);
|
|
153
|
+
console.error(`✗ pre-render failed for ${urlPath}: ${why}`);
|
|
116
154
|
}
|
|
117
155
|
}
|
|
118
156
|
}
|
|
@@ -123,7 +161,10 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
|
|
|
123
161
|
const result = await mod.renderRoute("/__otfw_404__");
|
|
124
162
|
if (result) {
|
|
125
163
|
const head = mod.renderHead({ robots: "noindex", ...result.metadata }, { baseUrl });
|
|
126
|
-
writeFileSync(
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(outDir, "404.html"),
|
|
166
|
+
injectHydrationData(injectMarkup(injectHead(shellHtml, head), result.html), result.hydration),
|
|
167
|
+
);
|
|
127
168
|
}
|
|
128
169
|
} catch {
|
|
129
170
|
/* no 404 page */
|
package/src/serve.js
CHANGED
|
@@ -16,24 +16,28 @@
|
|
|
16
16
|
// • anything else → SSR: render the route's markup + <head>, inject into the
|
|
17
17
|
// shell, return text/html.
|
|
18
18
|
//
|
|
19
|
-
// Hydration (Phase 2.
|
|
20
|
-
// `#app` carries the `data-otfw-hydrate` sentinel,
|
|
21
|
-
// server-rendered DOM on first paint (no rebuild/flash)
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// clean CSR mount. The per-request HTML is real either way (first
|
|
25
|
-
// content, SEO).
|
|
19
|
+
// Hydration (Phase 2, complete — docs/HYDRATION.md §4): the client build is the
|
|
20
|
+
// hydrate target and the shell's `#app` carries the `data-otfw-hydrate` sentinel,
|
|
21
|
+
// so the client *adopts* the server-rendered DOM on first paint (no rebuild/flash)
|
|
22
|
+
// — pages, layout chains, keyed lists, conditionals, and component islands with
|
|
23
|
+
// rich props (2.0/2.5/2.1a–d). A route the adopt walk can't handle falls back to
|
|
24
|
+
// a clean CSR mount per component. The per-request HTML is real either way (first
|
|
25
|
+
// paint, dynamic content, SEO).
|
|
26
26
|
|
|
27
27
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
28
28
|
import { join } from "node:path";
|
|
29
|
+
import { pathToFileURL } from "node:url";
|
|
29
30
|
|
|
30
31
|
import { runBuild } from "./build.js";
|
|
31
32
|
import {
|
|
32
33
|
MIME,
|
|
33
34
|
buildServerBundle,
|
|
35
|
+
discoverApiRoutes,
|
|
34
36
|
discoverPages,
|
|
35
37
|
injectHead,
|
|
38
|
+
injectHydrationData,
|
|
36
39
|
injectMarkup,
|
|
40
|
+
injectRouteData,
|
|
37
41
|
loadConfig,
|
|
38
42
|
loadDocsPlugins,
|
|
39
43
|
loadProject,
|
|
@@ -149,19 +153,37 @@ export async function runServe() {
|
|
|
149
153
|
|
|
150
154
|
const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n });
|
|
151
155
|
|
|
152
|
-
|
|
156
|
+
// API routes (SPEC §11): the client build already emitted the handler bundle to
|
|
157
|
+
// dist/server/api.js (build.js `emitApiBundle`). Import it and hold it live;
|
|
158
|
+
// `apiHandler(req)` returns a Response or null (no route matched).
|
|
159
|
+
const apiFile = join(distDir, "server", "api.js");
|
|
160
|
+
let api = null;
|
|
161
|
+
if (existsSync(apiFile)) {
|
|
162
|
+
const apiMod = await import(pathToFileURL(apiFile).href);
|
|
163
|
+
api = { handler: apiMod.apiHandler, routes: discoverApiRoutes(appDir, exclude).routes };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Route loaders (docs/DATA.md): the client build emitted the registry bundle to
|
|
167
|
+
// dist/server/loaders.js (build.js `emitLoaderBundle`). Import it and hold it
|
|
168
|
+
// live; per request it runs the matched page's loader (threaded into the render
|
|
169
|
+
// as `router.data`) and answers the `<path>/__data.json` endpoint.
|
|
170
|
+
const loadersFile = join(distDir, "server", "loaders.js");
|
|
171
|
+
const loaders = existsSync(loadersFile)
|
|
172
|
+
? (await import(pathToFileURL(loadersFile).href)).loaders
|
|
173
|
+
: null;
|
|
174
|
+
|
|
175
|
+
const cleanupAll = () => {
|
|
153
176
|
try {
|
|
154
177
|
cleanup();
|
|
155
178
|
} catch {}
|
|
179
|
+
};
|
|
180
|
+
const shutdown = () => {
|
|
181
|
+
cleanupAll();
|
|
156
182
|
process.exit(0);
|
|
157
183
|
};
|
|
158
184
|
process.once("SIGINT", shutdown);
|
|
159
185
|
process.once("SIGTERM", shutdown);
|
|
160
|
-
process.once("exit",
|
|
161
|
-
try {
|
|
162
|
-
cleanup();
|
|
163
|
-
} catch {}
|
|
164
|
-
});
|
|
186
|
+
process.once("exit", cleanupAll);
|
|
165
187
|
|
|
166
188
|
// Serve a built asset from dist/ (only regular files; a directory path falls
|
|
167
189
|
// through to SSR). `dist/` already contains hashed assets, copied public/ files,
|
|
@@ -176,20 +198,46 @@ export async function runServe() {
|
|
|
176
198
|
});
|
|
177
199
|
}
|
|
178
200
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
// The 404 response: the pre-built dist/404.html when present, else a bare body.
|
|
202
|
+
function renderNotFound() {
|
|
203
|
+
const notFound = join(distDir, "404.html");
|
|
204
|
+
const body = existsSync(notFound) ? readFileSync(notFound) : "<h1>404 — Not Found</h1>";
|
|
205
|
+
return new Response(body, { status: 404, headers: { "content-type": "text/html" } });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// SSR one navigation: run the route's loader (if any), render the markup +
|
|
209
|
+
// <head>, inject into the shell.
|
|
210
|
+
async function render(url, req) {
|
|
211
|
+
// Loader first — `notFound()` must resolve before anything renders. The check
|
|
212
|
+
// is a property (`otfwNotFound`), not instanceof: the loader bundle is its own
|
|
213
|
+
// module graph. On notFound, re-render as the unmatched sentinel path so the
|
|
214
|
+
// registered 404 page is served with HTTP 404 (same as a route miss). Any
|
|
215
|
+
// other loader throw propagates to the 500 branch in `fetch`.
|
|
216
|
+
let data;
|
|
217
|
+
let dataJson = "";
|
|
218
|
+
const m = loaders?.match(url.pathname);
|
|
219
|
+
if (m) {
|
|
220
|
+
try {
|
|
221
|
+
({ data, json: dataJson } = await loaders.loadSerialized(m, {
|
|
222
|
+
request: req,
|
|
223
|
+
query: Object.fromEntries(url.searchParams),
|
|
224
|
+
}));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (e?.otfwNotFound === true) return render(new URL("/__otfw_404__", url), req);
|
|
227
|
+
throw e;
|
|
228
|
+
}
|
|
186
229
|
}
|
|
230
|
+
const result = await mod.renderRoute(url.pathname, null, url.search, { data });
|
|
231
|
+
if (!result) return renderNotFound();
|
|
187
232
|
const meta = i18nOn
|
|
188
233
|
? { ...result.metadata, links: [...(result.metadata.links || []), ...alternatesFor(stripLocale(url.pathname))] }
|
|
189
234
|
: result.metadata;
|
|
190
235
|
const head = mod.renderHead(meta, { path: url.pathname, baseUrl });
|
|
191
236
|
const localizedShell = i18nOn ? withHtmlLang(shell, localeOf(url.pathname)) : shell;
|
|
192
|
-
const html =
|
|
237
|
+
const html = injectRouteData(
|
|
238
|
+
injectHydrationData(injectMarkup(injectHead(localizedShell, head), result.html), result.hydration),
|
|
239
|
+
dataJson,
|
|
240
|
+
);
|
|
193
241
|
// 200 for a real route; 404 when the path fell back to the registered 404 page.
|
|
194
242
|
return new Response(html, {
|
|
195
243
|
status: result.status ?? 200,
|
|
@@ -200,6 +248,22 @@ export async function runServe() {
|
|
|
200
248
|
const server = serve(startPort, explicitPort, {
|
|
201
249
|
async fetch(req) {
|
|
202
250
|
const url = new URL(req.url);
|
|
251
|
+
// API endpoints (route.* files, at any path — SPEC §11) are checked first —
|
|
252
|
+
// before the asset branch, matching `otfw dev` — so an endpoint path with a
|
|
253
|
+
// dotted segment (`/api/v1.0`) still resolves. A matched handler's Response
|
|
254
|
+
// wins; a miss falls through to assets / SSR, so pages and endpoints coexist.
|
|
255
|
+
if (api) {
|
|
256
|
+
const res = await api.handler(req);
|
|
257
|
+
if (res) return res;
|
|
258
|
+
}
|
|
259
|
+
// The route-loader data endpoint (docs/DATA.md): `<path>/__data.json` is a
|
|
260
|
+
// reserved suffix, answered before the asset branch below would swallow it
|
|
261
|
+
// (it has a file extension) and never falling through to SSR (a catch-all
|
|
262
|
+
// page would happily match the suffix as a path segment) — a miss is a 404.
|
|
263
|
+
if (url.pathname === "/__data.json" || url.pathname.endsWith("/__data.json")) {
|
|
264
|
+
const res = loaders ? await loaders.handle(req) : null;
|
|
265
|
+
return res ?? Response.json(null, { status: 404 });
|
|
266
|
+
}
|
|
203
267
|
// A path with a file extension is an asset request; serve it from dist/. A
|
|
204
268
|
// miss on a real asset is a 404 (don't fall through to the SSR shell).
|
|
205
269
|
if (/\.[a-z0-9]+$/i.test(url.pathname) && url.pathname !== "/") {
|
|
@@ -217,7 +281,7 @@ export async function runServe() {
|
|
|
217
281
|
}
|
|
218
282
|
}
|
|
219
283
|
try {
|
|
220
|
-
return await render(url);
|
|
284
|
+
return await render(url, req);
|
|
221
285
|
} catch (e) {
|
|
222
286
|
console.error(`✗ SSR failed for ${url.pathname}: ${e?.message ?? e}`);
|
|
223
287
|
return new Response(`<pre>SSR error: ${e?.message ?? e}</pre>`, {
|
|
@@ -229,6 +293,8 @@ export async function runServe() {
|
|
|
229
293
|
});
|
|
230
294
|
|
|
231
295
|
console.log(`\n OTF Web SSR server`);
|
|
232
|
-
|
|
296
|
+
const apiNote = api ? `, ${api.routes.length} API routes` : "";
|
|
297
|
+
const loaderNote = loaders ? `, ${loaders.routes.length} loaders` : "";
|
|
298
|
+
console.log(` → http://localhost:${server.port} (${pages.length} routes, server-rendered${apiNote}${loaderNote})`);
|
|
233
299
|
console.log(` ✓ ready in ${Date.now() - bootStart}ms\n`);
|
|
234
300
|
}
|
package/src/shared.js
CHANGED
|
@@ -59,6 +59,37 @@ export function injectMarkup(shellHtml, markup) {
|
|
|
59
59
|
return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, (_m, open, close) => `${open}${markup}${close}`);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Embed the island hydration payload (`renderRoute().hydration`) as a
|
|
64
|
+
* `<script type="application/json" id="__otfw_h">` the client reads at upgrade so
|
|
65
|
+
* components resume from rich JS props (compiler-driven data hydration). Placed before
|
|
66
|
+
* `</body>`; a deferred module bundle runs after parse, so the payload is always in the
|
|
67
|
+
* DOM first. `json` is already `<`-escaped by the server collector, so it can't break out
|
|
68
|
+
* of the script. No-op for an empty payload. Uses a function replacer so `$` in the JSON
|
|
69
|
+
* stays literal.
|
|
70
|
+
*/
|
|
71
|
+
export function injectHydrationData(shellHtml, json) {
|
|
72
|
+
if (!json) return shellHtml;
|
|
73
|
+
return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_h">${json}</script>`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The reserved per-route data filename/URL suffix (mirrors the runtime's
|
|
77
|
+
* `DATA_FILE` in `@opentf/web` runtime/route-data.js). */
|
|
78
|
+
export const DATA_FILE = "__data.json";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Embed a route loader's data (docs/DATA.md) as a `<script type="application/json"
|
|
82
|
+
* id="__otfw_data">` the client router reads on first paint (instead of fetching
|
|
83
|
+
* `<path>/__data.json`). A separate script from the island payload above — the two
|
|
84
|
+
* channels have independent shapes and readers. `json` comes `<`-escaped from
|
|
85
|
+
* `serializeRouteData`; no-op for an empty payload (route without a loader, or an
|
|
86
|
+
* undefined loader result).
|
|
87
|
+
*/
|
|
88
|
+
export function injectRouteData(shellHtml, json) {
|
|
89
|
+
if (!json) return shellHtml;
|
|
90
|
+
return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_data">${json}</script>`);
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
/**
|
|
63
94
|
* Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
|
|
64
95
|
* the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
|
|
@@ -105,10 +136,45 @@ export function findUp(name, from) {
|
|
|
105
136
|
}
|
|
106
137
|
}
|
|
107
138
|
|
|
139
|
+
function hasLocalCompilerWorkspace(workspace) {
|
|
140
|
+
return !!workspace && existsSync(join(workspace, "crates", "otfw_cli", "Cargo.toml"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveCompiler({
|
|
144
|
+
cliDir = dirname(fileURLToPath(import.meta.url)),
|
|
145
|
+
env = process.env,
|
|
146
|
+
resolvePackagedCompiler = otfwcPath,
|
|
147
|
+
findWorkspace = findUp,
|
|
148
|
+
ensure = ensureCompiler,
|
|
149
|
+
} = {}) {
|
|
150
|
+
if (env.OTFWC_BIN) return { otfwc: env.OTFWC_BIN, workspace: null };
|
|
151
|
+
|
|
152
|
+
let packagedError = null;
|
|
153
|
+
try {
|
|
154
|
+
return { otfwc: resolvePackagedCompiler(), workspace: null };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
packagedError = e;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const installedPackage = cliDir.split(/[\\/]/).includes("node_modules");
|
|
160
|
+
const workspace = installedPackage ? null : findWorkspace("Cargo.toml", cliDir);
|
|
161
|
+
if (hasLocalCompilerWorkspace(workspace)) {
|
|
162
|
+
const otfwc = join(workspace, "target", "debug", "otfwc");
|
|
163
|
+
ensure(otfwc, workspace);
|
|
164
|
+
return { otfwc, workspace };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fail(
|
|
168
|
+
`${packagedError?.message ?? "cannot resolve @opentf/web-compiler prebuilt binary"}\n` +
|
|
169
|
+
` No local otfwc compiler workspace was found to build from source.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
108
173
|
/**
|
|
109
174
|
* Resolve the project and toolchain: the app being built (cwd), the runtime
|
|
110
175
|
* package, the `otfwc` compiler, and the excluded routes. Exits with a clear
|
|
111
|
-
* message on any hard failure.
|
|
176
|
+
* message on any hard failure. Source checkouts can build the compiler on demand
|
|
177
|
+
* from this repo's Cargo workspace; published installs use @opentf/web-compiler.
|
|
112
178
|
*/
|
|
113
179
|
export function loadProject() {
|
|
114
180
|
const root = process.cwd();
|
|
@@ -129,25 +195,9 @@ export function loadProject() {
|
|
|
129
195
|
fail(`cannot resolve "@opentf/web" from ${root}\n add it to your dependencies.`);
|
|
130
196
|
}
|
|
131
197
|
|
|
132
|
-
// Locate the `otfwc` compiler.
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
// overrides both.
|
|
136
|
-
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
137
|
-
const workspace = findUp("Cargo.toml", cliDir);
|
|
138
|
-
let otfwc;
|
|
139
|
-
if (process.env.OTFWC_BIN) {
|
|
140
|
-
otfwc = process.env.OTFWC_BIN;
|
|
141
|
-
} else if (workspace) {
|
|
142
|
-
otfwc = join(workspace, "target", "debug", "otfwc");
|
|
143
|
-
ensureCompiler(otfwc, workspace);
|
|
144
|
-
} else {
|
|
145
|
-
try {
|
|
146
|
-
otfwc = otfwcPath();
|
|
147
|
-
} catch (e) {
|
|
148
|
-
fail(e.message);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
198
|
+
// Locate the `otfwc` compiler. Explicit overrides win, then the packaged
|
|
199
|
+
// compiler binary, then this repo's local Rust workspace as a source fallback.
|
|
200
|
+
const { otfwc, workspace } = resolveCompiler();
|
|
151
201
|
|
|
152
202
|
// Route directories to skip during discovery — comma-separated names in
|
|
153
203
|
// EXCLUDE_ROUTES. None are excluded by default.
|
|
@@ -162,6 +212,19 @@ function ensureCompiler(otfwc, workspace) {
|
|
|
162
212
|
if (existsSync(otfwc)) return;
|
|
163
213
|
if (!workspace) fail(`otfwc compiler not found at ${otfwc}`);
|
|
164
214
|
console.log("building compiler (cargo build -p otfw_cli)…");
|
|
215
|
+
const cargo = Bun.spawnSync(["cargo", "--version"], {
|
|
216
|
+
cwd: workspace,
|
|
217
|
+
stdout: "ignore",
|
|
218
|
+
stderr: "pipe",
|
|
219
|
+
});
|
|
220
|
+
if (cargo.error || cargo.exitCode !== 0) {
|
|
221
|
+
fail(
|
|
222
|
+
`cannot build otfwc because cargo is not available.\n` +
|
|
223
|
+
` This source checkout needs Rust/Cargo to build ${otfwc}.\n` +
|
|
224
|
+
` Install Rust in the build environment, set OTFWC_BIN to an existing otfwc binary,\n` +
|
|
225
|
+
` or build with the published @opentf/web-cli package so @opentf/web-compiler can use its prebuilt binary.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
165
228
|
const b = Bun.spawnSync(["cargo", "build", "-p", "otfw_cli"], {
|
|
166
229
|
cwd: workspace,
|
|
167
230
|
stdout: "inherit",
|
|
@@ -271,43 +334,86 @@ export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
|
|
|
271
334
|
}
|
|
272
335
|
|
|
273
336
|
/**
|
|
274
|
-
* Generate
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* / package missing). Resolved from the app's
|
|
337
|
+
* Generate blog feeds when the project has a `blog` config and a base URL. Scans the
|
|
338
|
+
* post folders, renders RSS 2.0 + Atom 1.0, and writes them under
|
|
339
|
+
* `<siteDir>/<blogDir>/`. Project-supplied `public/<blogDir>/{rss,atom}.xml` files
|
|
340
|
+
* take precedence independently. Returns `{ paths, urls, count }` or null (no blog /
|
|
341
|
+
* no base URL / all feeds overridden / package missing). Resolved from the app's
|
|
342
|
+
* `@opentf/web-docs`.
|
|
279
343
|
*/
|
|
280
344
|
export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
|
|
281
345
|
const blog = config?.blog;
|
|
282
346
|
if (!blog) return null;
|
|
283
347
|
if (!baseUrl) {
|
|
284
|
-
console.warn("⚠ blog
|
|
348
|
+
console.warn("⚠ blog feeds skipped: no site URL (pass --base-url or set otfw.config)");
|
|
285
349
|
return null;
|
|
286
350
|
}
|
|
287
351
|
const contentDir = blog.dir ?? "blog";
|
|
288
|
-
if (existsSync(join(root, "public", contentDir, "rss.xml"))) return null; // honor override
|
|
289
352
|
try {
|
|
290
353
|
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
291
|
-
const { loadPosts, renderBlogFeed } = await import(pathToFileURL(entry).href);
|
|
354
|
+
const { loadPosts, renderAtomFeed, renderBlogFeed } = await import(pathToFileURL(entry).href);
|
|
292
355
|
const posts = loadPosts({ appDir, contentDir, exclude });
|
|
293
|
-
const feedPath = `/${contentDir}/rss.xml`;
|
|
294
356
|
const title = blog.title || (config?.docs?.title ? `${config.docs.title} Blog` : "Blog");
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
357
|
+
const channel = {
|
|
358
|
+
title,
|
|
359
|
+
description: blog.description || title,
|
|
360
|
+
link: `/${contentDir}`,
|
|
361
|
+
};
|
|
362
|
+
const feeds = [
|
|
363
|
+
{
|
|
364
|
+
file: "rss.xml",
|
|
365
|
+
path: `/${contentDir}/rss.xml`,
|
|
366
|
+
render: renderBlogFeed,
|
|
303
367
|
},
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
368
|
+
{
|
|
369
|
+
file: "atom.xml",
|
|
370
|
+
path: `/${contentDir}/atom.xml`,
|
|
371
|
+
render: renderAtomFeed,
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
const written = [];
|
|
375
|
+
for (const feed of feeds) {
|
|
376
|
+
if (existsSync(join(root, "public", contentDir, feed.file))) continue; // honor override
|
|
377
|
+
const out = join(siteDir, contentDir, feed.file);
|
|
378
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
379
|
+
writeFileSync(out, feed.render({ posts, baseUrl, feedPath: feed.path, channel }));
|
|
380
|
+
written.push(feed.path);
|
|
381
|
+
}
|
|
382
|
+
if (!written.length) return null;
|
|
383
|
+
const origin = baseUrl.replace(/\/+$/, "");
|
|
384
|
+
return { paths: written, urls: written.map((path) => origin + path), count: posts.length };
|
|
385
|
+
} catch (e) {
|
|
386
|
+
console.warn(`⚠ blog feeds skipped: ${e?.message ?? e}`);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Generate `/llms.txt` and `/llms-full.txt` for docs/blog sites from the same
|
|
393
|
+
* filesystem route list used by the app build. Project-supplied public files override
|
|
394
|
+
* each output independently.
|
|
395
|
+
*/
|
|
396
|
+
export async function runLlmsFiles(root, appDir, pages, config, siteDir, baseUrl) {
|
|
397
|
+
if (!config?.docs && !config?.blog) return null;
|
|
398
|
+
const outputs = [
|
|
399
|
+
{ file: "llms.txt", render: "renderLlmsTxt" },
|
|
400
|
+
{ file: "llms-full.txt", render: "renderLlmsFullTxt" },
|
|
401
|
+
].filter((out) => !existsSync(join(root, "public", out.file)));
|
|
402
|
+
if (!outputs.length) return null;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
406
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
407
|
+
const written = [];
|
|
408
|
+
for (const out of outputs) {
|
|
409
|
+
const render = mod[out.render];
|
|
410
|
+
if (typeof render !== "function") continue;
|
|
411
|
+
writeFileSync(join(siteDir, out.file), render({ appDir, pages, baseUrl, config }));
|
|
412
|
+
written.push(`/${out.file}`);
|
|
413
|
+
}
|
|
414
|
+
return written.length ? { paths: written } : null;
|
|
309
415
|
} catch (e) {
|
|
310
|
-
console.warn(`⚠
|
|
416
|
+
console.warn(`⚠ llms.txt skipped: ${e?.message ?? e}`);
|
|
311
417
|
return null;
|
|
312
418
|
}
|
|
313
419
|
}
|
|
@@ -324,6 +430,325 @@ export function discoverPages(dir, exclude) {
|
|
|
324
430
|
return out;
|
|
325
431
|
}
|
|
326
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Discover file-based API routes anywhere under `app/` (SPEC §11). An endpoint is a
|
|
435
|
+
* `route.{js,ts}` file (the API analogue of `page.{jsx,tsx}`); its folder is the URL.
|
|
436
|
+
* Returns `{ routes, middleware }` — absolute paths to `route.*` handler modules and
|
|
437
|
+
* to `_middleware.{js,ts}` files (folder middleware) respectively.
|
|
438
|
+
*/
|
|
439
|
+
export function discoverApiRoutes(appDir, exclude = new Set()) {
|
|
440
|
+
const routes = [];
|
|
441
|
+
const middleware = [];
|
|
442
|
+
const walk = (dir) => {
|
|
443
|
+
if (!existsSync(dir)) return;
|
|
444
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
445
|
+
if (entry.isDirectory()) {
|
|
446
|
+
if (!exclude.has(entry.name)) walk(join(dir, entry.name));
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const name = entry.name;
|
|
450
|
+
const full = join(dir, name);
|
|
451
|
+
if (/^route\.(jsx?|tsx?)$/.test(name)) routes.push(full);
|
|
452
|
+
else if (/^_middleware\.(jsx?|tsx?)$/.test(name)) middleware.push(full);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
walk(appDir);
|
|
456
|
+
return { routes, middleware };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Strip the app-directory prefix from a route file path. With `appDir` the exact
|
|
461
|
+
* prefix is removed (unambiguous even for `app/app/...`); without it, fall back to
|
|
462
|
+
* the last complete `/app` path segment — the lookahead keeps folders that merely
|
|
463
|
+
* start with "app" (`/appointments`) intact.
|
|
464
|
+
*/
|
|
465
|
+
function stripAppPrefix(filePath, appDir) {
|
|
466
|
+
if (appDir) {
|
|
467
|
+
const base = appDir.replace(/\/+$/, "");
|
|
468
|
+
if (filePath.startsWith(base + "/")) return filePath.slice(base.length);
|
|
469
|
+
}
|
|
470
|
+
return filePath.replace(/^.*\/app(?=\/)/, "");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The page URL for a `.../app/<...>/page.{mdx,md,jsx,tsx}` file (folder = URL). */
|
|
474
|
+
export function pageRouteFromPath(filePath, appDir) {
|
|
475
|
+
const r = stripAppPrefix(filePath, appDir).replace(/\/page\.(mdx|md|jsx|tsx)$/, "");
|
|
476
|
+
return r === "" ? "/" : r;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The route URL for a `.../app/<...>/route.{js,ts}` file (folder = URL). */
|
|
480
|
+
function apiRoutePath(filePath, appDir) {
|
|
481
|
+
const r = stripAppPrefix(filePath, appDir).replace(/\/route\.(jsx?|tsx?)$/, "");
|
|
482
|
+
return r === "" ? "/" : r;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Discover route loaders anywhere under `app/` (docs/DATA.md). A loader is a
|
|
487
|
+
* `loader.{js,ts}` file sibling to a `page.*` — the data analogue of a `route.*`
|
|
488
|
+
* endpoint. Strictly `js|ts` (no `x` variants): loaders are plain server modules,
|
|
489
|
+
* never JSX. Returns absolute file paths.
|
|
490
|
+
*/
|
|
491
|
+
export function discoverLoaders(appDir, exclude = new Set()) {
|
|
492
|
+
const out = [];
|
|
493
|
+
const walk = (dir) => {
|
|
494
|
+
if (!existsSync(dir)) return;
|
|
495
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
496
|
+
if (entry.isDirectory()) {
|
|
497
|
+
if (!exclude.has(entry.name)) walk(join(dir, entry.name));
|
|
498
|
+
} else if (/^loader\.(js|ts)$/.test(entry.name)) out.push(join(dir, entry.name));
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
walk(appDir);
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The page route a `.../app/<...>/loader.{js,ts}` file feeds (folder = URL). */
|
|
506
|
+
export function loaderRoutePath(filePath, appDir) {
|
|
507
|
+
const r = stripAppPrefix(filePath, appDir).replace(/\/loader\.(js|ts)$/, "");
|
|
508
|
+
return r === "" ? "/" : r;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Detect folders that hold both a `page.*` and a `route.*` — they'd resolve to the
|
|
513
|
+
* same URL. Returns the conflicting `{ path, page, route }` entries (empty = none).
|
|
514
|
+
* A page and an endpoint cannot own the same path (as in Next.js's App Router).
|
|
515
|
+
*/
|
|
516
|
+
export function detectRouteConflicts(appDir, exclude = new Set()) {
|
|
517
|
+
const pageByRoute = new Map();
|
|
518
|
+
for (const p of discoverPages(appDir, exclude)) {
|
|
519
|
+
if (/\/page\.(mdx|md|jsx|tsx)$/.test(p)) pageByRoute.set(pageRouteFromPath(p, appDir), p);
|
|
520
|
+
}
|
|
521
|
+
const conflicts = [];
|
|
522
|
+
for (const r of discoverApiRoutes(appDir, exclude).routes) {
|
|
523
|
+
const path = apiRoutePath(r, appDir);
|
|
524
|
+
if (pageByRoute.has(path)) conflicts.push({ path, page: pageByRoute.get(path), route: r });
|
|
525
|
+
}
|
|
526
|
+
return conflicts;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Detect misplaced `loader.*` files (docs/DATA.md): a loader feeds the page in
|
|
531
|
+
* its folder, so one without a sibling `page.*` — including one placed next to a
|
|
532
|
+
* `route.*` endpoint — has nothing to feed. Returns `{ loader, route, reason }`.
|
|
533
|
+
*/
|
|
534
|
+
export function detectLoaderConflicts(appDir, exclude = new Set()) {
|
|
535
|
+
const conflicts = [];
|
|
536
|
+
for (const loader of discoverLoaders(appDir, exclude)) {
|
|
537
|
+
const dir = dirname(loader);
|
|
538
|
+
const hasPage = ["page.jsx", "page.tsx", "page.mdx", "page.md"].some((f) => existsSync(join(dir, f)));
|
|
539
|
+
if (hasPage) continue;
|
|
540
|
+
const hasRoute = ["route.js", "route.ts"].some((f) => existsSync(join(dir, f)));
|
|
541
|
+
conflicts.push({
|
|
542
|
+
loader,
|
|
543
|
+
route: loaderRoutePath(loader, appDir),
|
|
544
|
+
reason: hasRoute
|
|
545
|
+
? "sits next to a route.* API endpoint (endpoints take a Request; loaders feed pages)"
|
|
546
|
+
: "has no sibling page.* to feed",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return conflicts;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Print the route/page/loader conflicts and exit — shared by build, dev, and serve. */
|
|
553
|
+
export function assertNoRouteConflicts(appDir, exclude = new Set()) {
|
|
554
|
+
const conflicts = detectRouteConflicts(appDir, exclude);
|
|
555
|
+
if (conflicts.length > 0) {
|
|
556
|
+
const lines = conflicts.map((c) => ` ${c.path}\n page: ${c.page}\n route: ${c.route}`).join("\n");
|
|
557
|
+
fail(`a page and an API route cannot resolve to the same path:\n${lines}\n Move one to a different folder.`);
|
|
558
|
+
}
|
|
559
|
+
const loaderConflicts = detectLoaderConflicts(appDir, exclude);
|
|
560
|
+
if (loaderConflicts.length > 0) {
|
|
561
|
+
const lines = loaderConflicts.map((c) => ` ${c.route}\n loader: ${c.loader}\n ${c.reason}`).join("\n");
|
|
562
|
+
fail(`a loader.* file must sit next to the page.* it feeds:\n${lines}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Entry source for the API handler bundle: statically import every route +
|
|
568
|
+
* middleware module (keyed by absolute path so `createApiHandler` can derive the
|
|
569
|
+
* route from the path), then export the composed `Request → Response | null`
|
|
570
|
+
* handler. Mirrors `serverEntrySource` for the page/SSG bundle.
|
|
571
|
+
*/
|
|
572
|
+
export function apiEntrySource({ routes, middleware }, appDir) {
|
|
573
|
+
const rImports = routes.map((p, i) => `import * as r${i} from ${JSON.stringify(p)};`).join("\n");
|
|
574
|
+
const mImports = middleware.map((p, i) => `import * as m${i} from ${JSON.stringify(p)};`).join("\n");
|
|
575
|
+
const rMap = routes.map((p, i) => ` [${JSON.stringify(p)}]: r${i},`).join("\n");
|
|
576
|
+
const mMap = middleware.map((p, i) => ` [${JSON.stringify(p)}]: m${i},`).join("\n");
|
|
577
|
+
// `appDir` pins the exact prefix to strip from the keys — route derivation stays
|
|
578
|
+
// correct for folders whose name starts with "app" and for nested app/app dirs.
|
|
579
|
+
const opts = appDir ? `\n{ appDir: ${JSON.stringify(appDir)} },\n` : "\n";
|
|
580
|
+
return (
|
|
581
|
+
`import { createApiHandler } from "@opentf/web/server";\n` +
|
|
582
|
+
`${rImports}\n${mImports}\n` +
|
|
583
|
+
`export const apiHandler = createApiHandler(\n{\n${rMap}\n},\n{\n${mMap}\n},${opts});\n`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Build the API handler bundle and import it. Returns `{ handler, cleanup }`
|
|
589
|
+
* where `handler(request)` resolves to a `Response` or `null` (no API route
|
|
590
|
+
* matched). Route modules are *plain server code* — bundled as ESM without the
|
|
591
|
+
* `otfwc` DOM transform. Bare (npm / node builtin) imports stay external and are
|
|
592
|
+
* resolved at runtime from the project's node_modules, so server deps and native
|
|
593
|
+
* modules aren't dragged into the bundle. Returns `null` when there are no routes.
|
|
594
|
+
*/
|
|
595
|
+
export async function buildApiBundle({ root, appDir, webEntry, exclude, tmpName = ".otfw-api", bust }) {
|
|
596
|
+
const discovered = discoverApiRoutes(appDir, exclude);
|
|
597
|
+
if (discovered.routes.length === 0) return null;
|
|
598
|
+
|
|
599
|
+
const tmp = join(root, tmpName);
|
|
600
|
+
mkdirSync(tmp, { recursive: true });
|
|
601
|
+
const entry = join(tmp, "api-entry.js");
|
|
602
|
+
writeFileSync(entry, apiEntrySource(discovered, appDir));
|
|
603
|
+
|
|
604
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
605
|
+
// `bust` versions the emitted *filename* so `otfw dev` picks up handler edits on
|
|
606
|
+
// rebuild — Bun's ESM cache is keyed by file path and ignores a `?v=` query, so
|
|
607
|
+
// only a genuinely new path re-evaluates the module.
|
|
608
|
+
const outName = bust ? `api.${bust}.js` : "api.js";
|
|
609
|
+
await build({
|
|
610
|
+
input: entry,
|
|
611
|
+
platform: "node",
|
|
612
|
+
resolve: {
|
|
613
|
+
alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
|
|
614
|
+
extensions: EXTENSIONS,
|
|
615
|
+
},
|
|
616
|
+
// Keep npm/node builtins external — server handlers resolve them at runtime.
|
|
617
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
618
|
+
output: { dir: join(tmp, "out"), format: "esm", entryFileNames: outName },
|
|
619
|
+
checks: { pluginTimings: false },
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const mod = await import(pathToFileURL(join(tmp, "out", outName)).href);
|
|
623
|
+
return {
|
|
624
|
+
handler: mod.apiHandler,
|
|
625
|
+
routes: discovered.routes,
|
|
626
|
+
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Emit the API handler bundle to `outDir/api.js` for production/deploy (SPEC §13,
|
|
632
|
+
* `dist/server/`). Unlike `buildApiBundle` (which bundles to a temp dir and imports
|
|
633
|
+
* it for `dev`/`serve`), this writes a persistent, self-contained ESM module that
|
|
634
|
+
* exports `apiHandler` — the runtime dispatcher (`createApiHandler`) is bundled in;
|
|
635
|
+
* npm/node deps stay external and resolve at the deploy target. A deploy adapter
|
|
636
|
+
* (`server/adapters/`) imports this file and hands requests to `apiHandler`.
|
|
637
|
+
* Returns `{ routes }`, or `null` when the project has no API routes.
|
|
638
|
+
*/
|
|
639
|
+
export async function emitApiBundle({ root, appDir, webEntry, exclude, outDir }) {
|
|
640
|
+
const discovered = discoverApiRoutes(appDir, exclude);
|
|
641
|
+
if (discovered.routes.length === 0) return null;
|
|
642
|
+
|
|
643
|
+
const tmp = join(root, ".otfw-api-build");
|
|
644
|
+
mkdirSync(tmp, { recursive: true });
|
|
645
|
+
const entry = join(tmp, "api-entry.js");
|
|
646
|
+
writeFileSync(entry, apiEntrySource(discovered, appDir));
|
|
647
|
+
|
|
648
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
649
|
+
mkdirSync(outDir, { recursive: true });
|
|
650
|
+
await build({
|
|
651
|
+
input: entry,
|
|
652
|
+
platform: "node",
|
|
653
|
+
resolve: { alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry }, extensions: EXTENSIONS },
|
|
654
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
655
|
+
output: { dir: outDir, format: "esm", entryFileNames: "api.js" },
|
|
656
|
+
checks: { pluginTimings: false },
|
|
657
|
+
});
|
|
658
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
659
|
+
return { routes: discovered.routes };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Entry source for the loader bundle (docs/DATA.md): statically import every
|
|
664
|
+
* `loader.{js,ts}` module (keyed by absolute path so the registry derives each
|
|
665
|
+
* route from it) and export the composed registry. Mirrors `apiEntrySource`.
|
|
666
|
+
*/
|
|
667
|
+
export function loaderEntrySource(loaderFiles, appDir, i18n = null) {
|
|
668
|
+
const imports = loaderFiles.map((p, i) => `import * as l${i} from ${JSON.stringify(p)};`).join("\n");
|
|
669
|
+
const map = loaderFiles.map((p, i) => ` [${JSON.stringify(p)}]: l${i},`).join("\n");
|
|
670
|
+
const i18nOpt =
|
|
671
|
+
i18n && Array.isArray(i18n.locales) && i18n.locales.length
|
|
672
|
+
? `, i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })}`
|
|
673
|
+
: "";
|
|
674
|
+
const opts = `\n{ appDir: ${JSON.stringify(appDir ?? "")}${i18nOpt} },\n`;
|
|
675
|
+
return (
|
|
676
|
+
`import { createLoaderRegistry } from "@opentf/web/server";\n` +
|
|
677
|
+
`${imports}\n` +
|
|
678
|
+
`export const loaders = createLoaderRegistry(\n{\n${map}\n},${opts});\n`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Build the loader bundle and import it. Returns `{ loaders, files, cleanup }`
|
|
684
|
+
* where `loaders` is the registry (`match`/`load`/`loadSerialized`/`handle` —
|
|
685
|
+
* see `createLoaderRegistry`), or `null` when the app has no loader files.
|
|
686
|
+
* Loaders are *plain server code*, bundled exactly like the API handler bundle:
|
|
687
|
+
* no `otfwc` DOM transform, npm/node builtins external (DB drivers and native
|
|
688
|
+
* modules resolve at runtime from the project's node_modules).
|
|
689
|
+
*/
|
|
690
|
+
export async function buildLoaderBundle({ root, appDir, webEntry, exclude, i18n, tmpName = ".otfw-loaders", bust }) {
|
|
691
|
+
const files = discoverLoaders(appDir, exclude);
|
|
692
|
+
if (files.length === 0) return null;
|
|
693
|
+
|
|
694
|
+
const tmp = join(root, tmpName);
|
|
695
|
+
mkdirSync(tmp, { recursive: true });
|
|
696
|
+
const entry = join(tmp, "loaders-entry.js");
|
|
697
|
+
writeFileSync(entry, loaderEntrySource(files, appDir, i18n));
|
|
698
|
+
|
|
699
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
700
|
+
// Versioned filename, not a `?v=` query — Bun's ESM cache ignores the query for
|
|
701
|
+
// file URLs, so only a new path re-evaluates the rebuilt module (see buildApiBundle).
|
|
702
|
+
const outName = bust ? `loaders.${bust}.js` : "loaders.js";
|
|
703
|
+
await build({
|
|
704
|
+
input: entry,
|
|
705
|
+
platform: "node",
|
|
706
|
+
resolve: {
|
|
707
|
+
alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
|
|
708
|
+
extensions: EXTENSIONS,
|
|
709
|
+
},
|
|
710
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
711
|
+
output: { dir: join(tmp, "out"), format: "esm", entryFileNames: outName },
|
|
712
|
+
checks: { pluginTimings: false },
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const mod = await import(pathToFileURL(join(tmp, "out", outName)).href);
|
|
716
|
+
return {
|
|
717
|
+
loaders: mod.loaders,
|
|
718
|
+
files,
|
|
719
|
+
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Emit the loader bundle to `outDir/loaders.js` for production (`dist/server/`,
|
|
725
|
+
* next to `api.js`) — `otfw serve` imports it and runs loaders per request /
|
|
726
|
+
* serves the `<path>/__data.json` endpoint. Returns `{ files }`, or `null` when
|
|
727
|
+
* the app has no loader files.
|
|
728
|
+
*/
|
|
729
|
+
export async function emitLoaderBundle({ root, appDir, webEntry, exclude, i18n, outDir }) {
|
|
730
|
+
const files = discoverLoaders(appDir, exclude);
|
|
731
|
+
if (files.length === 0) return null;
|
|
732
|
+
|
|
733
|
+
const tmp = join(root, ".otfw-loaders-build");
|
|
734
|
+
mkdirSync(tmp, { recursive: true });
|
|
735
|
+
const entry = join(tmp, "loaders-entry.js");
|
|
736
|
+
writeFileSync(entry, loaderEntrySource(files, appDir, i18n));
|
|
737
|
+
|
|
738
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
739
|
+
mkdirSync(outDir, { recursive: true });
|
|
740
|
+
await build({
|
|
741
|
+
input: entry,
|
|
742
|
+
platform: "node",
|
|
743
|
+
resolve: { alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry }, extensions: EXTENSIONS },
|
|
744
|
+
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
|
|
745
|
+
output: { dir: outDir, format: "esm", entryFileNames: "loaders.js" },
|
|
746
|
+
checks: { pluginTimings: false },
|
|
747
|
+
});
|
|
748
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
749
|
+
return { files };
|
|
750
|
+
}
|
|
751
|
+
|
|
327
752
|
/** The optional `app/routeGuard.{js,ts}` path, or null. */
|
|
328
753
|
export function findGuard(appDir) {
|
|
329
754
|
return [join(appDir, "routeGuard.js"), join(appDir, "routeGuard.ts")].find(
|
|
@@ -339,7 +764,7 @@ export function findGuard(appDir) {
|
|
|
339
764
|
* The production build imports the file directly (Rolldown code-splits it); the dev
|
|
340
765
|
* server passes a `/__route/…` URL so the route compiles on first navigation.
|
|
341
766
|
*/
|
|
342
|
-
export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
|
|
767
|
+
export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null, loaderRoutes = []) {
|
|
343
768
|
const map = pages
|
|
344
769
|
.map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
|
|
345
770
|
.join("\n");
|
|
@@ -353,11 +778,14 @@ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, na
|
|
|
353
778
|
// Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
|
|
354
779
|
// (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
|
|
355
780
|
const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
|
|
781
|
+
// Route patterns with a server loader (docs/DATA.md) — the router fetches
|
|
782
|
+
// `<path>/__data.json` for these on navigation.
|
|
783
|
+
const loadersOpt = loaderRoutes.length ? `\n loaders: ${JSON.stringify(loaderRoutes)},` : "";
|
|
356
784
|
return (
|
|
357
785
|
`import { mountApp } from "@opentf/web";\n` +
|
|
358
786
|
(guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
|
|
359
787
|
`mountApp({\n pages: {\n${map}\n },\n` +
|
|
360
|
-
` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
|
|
788
|
+
` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}${loadersOpt}\n});\n`
|
|
361
789
|
);
|
|
362
790
|
}
|
|
363
791
|
|