@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/shared.js
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
// `otfwc` IR compiler as a Rolldown `transform` plugin, and let Rolldown link the
|
|
6
6
|
// module graph. This module holds everything they have in common.
|
|
7
7
|
|
|
8
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { build } from "rolldown";
|
|
11
13
|
|
|
12
14
|
import { otfwcPath } from "@opentf/web-compiler";
|
|
13
15
|
|
|
14
|
-
export const EXTENSIONS = [".jsx", ".tsx", ".js", ".ts"];
|
|
16
|
+
export const EXTENSIONS = [".jsx", ".tsx", ".js", ".ts", ".mdx", ".md"];
|
|
15
17
|
|
|
16
18
|
export const MIME = {
|
|
17
19
|
css: "text/css",
|
|
@@ -24,6 +26,88 @@ export const MIME = {
|
|
|
24
26
|
woff2: "font/woff2",
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
/** Fallback HTML shell used when a project has no `index.html`. */
|
|
30
|
+
export const DEFAULT_HTML_SHELL =
|
|
31
|
+
`<!doctype html><html lang="en"><head>\n` +
|
|
32
|
+
`<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">\n` +
|
|
33
|
+
`<title>OTF Web</title></head><body><div id="app"></div></body></html>`;
|
|
34
|
+
|
|
35
|
+
// The project's own module entry <script> is stripped so the toolchain injects
|
|
36
|
+
// its own bundle in its place.
|
|
37
|
+
const MODULE_ENTRY_RE = /<script\s+type=["']module["'][^>]*src=[^>]*>\s*<\/script>\s*/gi;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The project's `index.html` shell (or {@link DEFAULT_HTML_SHELL}) with the app's
|
|
41
|
+
* module entry script removed — the common starting point for `dev` and `build`.
|
|
42
|
+
*/
|
|
43
|
+
export function readHtmlShell(root) {
|
|
44
|
+
const indexPath = join(root, "index.html");
|
|
45
|
+
const html = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : DEFAULT_HTML_SHELL;
|
|
46
|
+
return html.replace(MODULE_ENTRY_RE, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Inject `snippet` just before `</body>` (or append when there is none). */
|
|
50
|
+
export function injectBeforeBody(html, snippet) {
|
|
51
|
+
// Function replacer so `$` in `snippet` is literal (String.replace treats `$1`,
|
|
52
|
+
// `$&`, … in a string replacement as back-references — e.g. a "$189.00" price).
|
|
53
|
+
return html.includes("</body>") ? html.replace("</body>", () => `${snippet}</body>`) : html + snippet;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Inject pre-rendered markup into the shell's empty `#app` container. */
|
|
57
|
+
export function injectMarkup(shellHtml, markup) {
|
|
58
|
+
// Function replacer: `markup` may contain `$` (currency, etc.) — keep it literal.
|
|
59
|
+
return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, (_m, open, close) => `${open}${markup}${close}`);
|
|
60
|
+
}
|
|
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
|
+
/**
|
|
77
|
+
* Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
|
|
78
|
+
* the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
|
|
79
|
+
* it (`runtime/router.js` `mountApp`). Used when the client bundle was built for the
|
|
80
|
+
* hydrate target and there is server markup to adopt (SSR, and SSG pre-render).
|
|
81
|
+
* Idempotent — a no-op if the sentinel is already present.
|
|
82
|
+
*/
|
|
83
|
+
export function stampHydrateSentinel(shellHtml) {
|
|
84
|
+
return shellHtml.replace(/<div id="app"([^>]*)>/, (m, attrs) =>
|
|
85
|
+
/\bdata-otfw-hydrate\b/.test(attrs) ? m : `<div id="app"${attrs} data-otfw-hydrate>`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Inject per-route `<head>` tags before `</head>`, dropping the shell's default
|
|
91
|
+
* `<title>` when the route supplies its own (so each page gets a unique,
|
|
92
|
+
* non-duplicated title). Shared by SSG pre-render and the SSR server.
|
|
93
|
+
*/
|
|
94
|
+
export function injectHead(shellHtml, headHtml) {
|
|
95
|
+
if (!headHtml) return shellHtml;
|
|
96
|
+
let out = shellHtml;
|
|
97
|
+
if (/<title[\s>]/i.test(headHtml)) out = out.replace(/<title>[\s\S]*?<\/title>\s*/i, "");
|
|
98
|
+
// Function replacer so `$` in `headHtml` (e.g. a price in a meta description) is literal.
|
|
99
|
+
return out.replace(/<\/head>/i, () => `${headHtml}\n</head>`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Set/replace the shell's `<html lang>` for a localized page (i18n; docs/I18N.md §6). */
|
|
103
|
+
export function withHtmlLang(shellHtml, locale) {
|
|
104
|
+
if (!locale) return shellHtml;
|
|
105
|
+
if (/<html[^>]*\slang=/i.test(shellHtml)) {
|
|
106
|
+
return shellHtml.replace(/(<html[^>]*\slang=)(["'])[^"']*\2/i, `$1$2${locale}$2`);
|
|
107
|
+
}
|
|
108
|
+
return shellHtml.replace(/<html\b/i, `<html lang="${locale}"`);
|
|
109
|
+
}
|
|
110
|
+
|
|
27
111
|
/** Nearest ancestor directory of `from` (inclusive) that contains `name`. */
|
|
28
112
|
export function findUp(name, from) {
|
|
29
113
|
let dir = from;
|
|
@@ -35,10 +119,45 @@ export function findUp(name, from) {
|
|
|
35
119
|
}
|
|
36
120
|
}
|
|
37
121
|
|
|
122
|
+
function hasLocalCompilerWorkspace(workspace) {
|
|
123
|
+
return !!workspace && existsSync(join(workspace, "crates", "otfw_cli", "Cargo.toml"));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveCompiler({
|
|
127
|
+
cliDir = dirname(fileURLToPath(import.meta.url)),
|
|
128
|
+
env = process.env,
|
|
129
|
+
resolvePackagedCompiler = otfwcPath,
|
|
130
|
+
findWorkspace = findUp,
|
|
131
|
+
ensure = ensureCompiler,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (env.OTFWC_BIN) return { otfwc: env.OTFWC_BIN, workspace: null };
|
|
134
|
+
|
|
135
|
+
let packagedError = null;
|
|
136
|
+
try {
|
|
137
|
+
return { otfwc: resolvePackagedCompiler(), workspace: null };
|
|
138
|
+
} catch (e) {
|
|
139
|
+
packagedError = e;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const installedPackage = cliDir.split(/[\\/]/).includes("node_modules");
|
|
143
|
+
const workspace = installedPackage ? null : findWorkspace("Cargo.toml", cliDir);
|
|
144
|
+
if (hasLocalCompilerWorkspace(workspace)) {
|
|
145
|
+
const otfwc = join(workspace, "target", "debug", "otfwc");
|
|
146
|
+
ensure(otfwc, workspace);
|
|
147
|
+
return { otfwc, workspace };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fail(
|
|
151
|
+
`${packagedError?.message ?? "cannot resolve @opentf/web-compiler prebuilt binary"}\n` +
|
|
152
|
+
` No local otfwc compiler workspace was found to build from source.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
38
156
|
/**
|
|
39
157
|
* Resolve the project and toolchain: the app being built (cwd), the runtime
|
|
40
158
|
* package, the `otfwc` compiler, and the excluded routes. Exits with a clear
|
|
41
|
-
* message on any hard failure.
|
|
159
|
+
* message on any hard failure. Source checkouts can build the compiler on demand
|
|
160
|
+
* from this repo's Cargo workspace; published installs use @opentf/web-compiler.
|
|
42
161
|
*/
|
|
43
162
|
export function loadProject() {
|
|
44
163
|
const root = process.cwd();
|
|
@@ -59,30 +178,14 @@ export function loadProject() {
|
|
|
59
178
|
fail(`cannot resolve "@opentf/web" from ${root}\n add it to your dependencies.`);
|
|
60
179
|
}
|
|
61
180
|
|
|
62
|
-
// Locate the `otfwc` compiler.
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
// overrides both.
|
|
66
|
-
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
67
|
-
const workspace = findUp("Cargo.toml", cliDir);
|
|
68
|
-
let otfwc;
|
|
69
|
-
if (process.env.OTFWC_BIN) {
|
|
70
|
-
otfwc = process.env.OTFWC_BIN;
|
|
71
|
-
} else if (workspace) {
|
|
72
|
-
otfwc = join(workspace, "target", "debug", "otfwc");
|
|
73
|
-
ensureCompiler(otfwc, workspace);
|
|
74
|
-
} else {
|
|
75
|
-
try {
|
|
76
|
-
otfwc = otfwcPath();
|
|
77
|
-
} catch (e) {
|
|
78
|
-
fail(e.message);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
181
|
+
// Locate the `otfwc` compiler. Explicit overrides win, then the packaged
|
|
182
|
+
// compiler binary, then this repo's local Rust workspace as a source fallback.
|
|
183
|
+
const { otfwc, workspace } = resolveCompiler();
|
|
81
184
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
185
|
+
// Route directories to skip during discovery — comma-separated names in
|
|
186
|
+
// EXCLUDE_ROUTES. None are excluded by default.
|
|
84
187
|
const exclude = new Set(
|
|
85
|
-
(process.env.EXCLUDE_ROUTES ?? "
|
|
188
|
+
(process.env.EXCLUDE_ROUTES ?? "").split(",").filter(Boolean),
|
|
86
189
|
);
|
|
87
190
|
|
|
88
191
|
return { root, appDir, webEntry, otfwc, workspace, exclude };
|
|
@@ -92,6 +195,19 @@ function ensureCompiler(otfwc, workspace) {
|
|
|
92
195
|
if (existsSync(otfwc)) return;
|
|
93
196
|
if (!workspace) fail(`otfwc compiler not found at ${otfwc}`);
|
|
94
197
|
console.log("building compiler (cargo build -p otfw_cli)…");
|
|
198
|
+
const cargo = Bun.spawnSync(["cargo", "--version"], {
|
|
199
|
+
cwd: workspace,
|
|
200
|
+
stdout: "ignore",
|
|
201
|
+
stderr: "pipe",
|
|
202
|
+
});
|
|
203
|
+
if (cargo.error || cargo.exitCode !== 0) {
|
|
204
|
+
fail(
|
|
205
|
+
`cannot build otfwc because cargo is not available.\n` +
|
|
206
|
+
` This source checkout needs Rust/Cargo to build ${otfwc}.\n` +
|
|
207
|
+
` Install Rust in the build environment, set OTFWC_BIN to an existing otfwc binary,\n` +
|
|
208
|
+
` or build with the published @opentf/web-cli package so @opentf/web-compiler can use its prebuilt binary.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
95
211
|
const b = Bun.spawnSync(["cargo", "build", "-p", "otfw_cli"], {
|
|
96
212
|
cwd: workspace,
|
|
97
213
|
stdout: "inherit",
|
|
@@ -100,6 +216,191 @@ function ensureCompiler(otfwc, workspace) {
|
|
|
100
216
|
if (b.exitCode !== 0) process.exit(b.exitCode);
|
|
101
217
|
}
|
|
102
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Load the optional project config (`otfw.config.{json,js,mjs}`) as a plain object.
|
|
221
|
+
* Read by the build for the site URL (SEO) and the `docs` block (docs generator).
|
|
222
|
+
*/
|
|
223
|
+
export async function loadConfig(root) {
|
|
224
|
+
const json = join(root, "otfw.config.json");
|
|
225
|
+
if (existsSync(json)) {
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(readFileSync(json, "utf8")) ?? {};
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.warn(`⚠ could not parse otfw.config.json: ${e?.message ?? e}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const name of ["otfw.config.js", "otfw.config.mjs"]) {
|
|
233
|
+
const p = join(root, name);
|
|
234
|
+
if (existsSync(p)) {
|
|
235
|
+
try {
|
|
236
|
+
return (await import(pathToFileURL(p).href)).default ?? {};
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.warn(`⚠ could not load ${name}: ${e?.message ?? e}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The docs navigation Rolldown plugin, when the project opts into the docs
|
|
247
|
+
* generator (a `docs` block in otfw.config). Resolved from `@opentf/web-docs`
|
|
248
|
+
* (the app's own dependency); returns null when docs aren't configured or the
|
|
249
|
+
* package isn't installed, so the core toolchain stays untouched for normal apps.
|
|
250
|
+
*/
|
|
251
|
+
export async function loadDocsPlugins(root, appDir, config, exclude = new Set()) {
|
|
252
|
+
const docs = config?.docs;
|
|
253
|
+
const blog = config?.blog;
|
|
254
|
+
if (!docs && !blog) return [];
|
|
255
|
+
try {
|
|
256
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
257
|
+
const { docsNavPlugin, blogPostsPlugin, lastUpdatedPlugin } = await import(
|
|
258
|
+
pathToFileURL(entry).href
|
|
259
|
+
);
|
|
260
|
+
const plugins = [];
|
|
261
|
+
// Resolves `@opentf/web-docs/nav` to a section map — one generated tree per top-level
|
|
262
|
+
// folder under app/. Any folder with a DocsLayout becomes a section automatically.
|
|
263
|
+
if (docs) plugins.push(docsNavPlugin({ appDir, exclude }));
|
|
264
|
+
// Resolves `@opentf/web-docs/posts` to the generated post list.
|
|
265
|
+
if (blog) plugins.push(blogPostsPlugin({ appDir, contentDir: blog.dir ?? "blog", exclude }));
|
|
266
|
+
// Resolves `@opentf/web-docs/updated` to the per-page last-updated map (every route),
|
|
267
|
+
// when last-updated is turned on anywhere.
|
|
268
|
+
if (wantsLastUpdated(config)) plugins.push(lastUpdatedPlugin({ appDir, exclude }));
|
|
269
|
+
return plugins;
|
|
270
|
+
} catch (e) {
|
|
271
|
+
console.warn(
|
|
272
|
+
`⚠ docs/blog config present but @opentf/web-docs could not be loaded: ${e?.message ?? e}`,
|
|
273
|
+
);
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Whether "last updated" tracking is enabled anywhere (`docs`/`blog` `lastUpdated`). */
|
|
279
|
+
export function wantsLastUpdated(config) {
|
|
280
|
+
return Boolean(config?.docs?.lastUpdated || config?.blog?.lastUpdated);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Build the `{ [routePath]: ISO }` last-updated map for every page (the same map the
|
|
285
|
+
* `@opentf/web-docs/updated` virtual module exposes). Used by the SSG step to emit
|
|
286
|
+
* `article:modified_time`. Returns `{}` when last-updated is off or the package can't
|
|
287
|
+
* be loaded.
|
|
288
|
+
*/
|
|
289
|
+
export async function runLastUpdated(root, appDir, config, exclude = new Set()) {
|
|
290
|
+
if (!wantsLastUpdated(config)) return {};
|
|
291
|
+
try {
|
|
292
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
293
|
+
const { loadLastUpdated } = await import(pathToFileURL(entry).href);
|
|
294
|
+
return loadLastUpdated({ appDir, exclude });
|
|
295
|
+
} catch (e) {
|
|
296
|
+
console.warn(`⚠ last-updated map skipped: ${e?.message ?? e}`);
|
|
297
|
+
return {};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Run Pagefind over the built site when the docs config opts into it
|
|
303
|
+
* (`docs.search.provider === "pagefind"`). Indexes the pre-rendered HTML in `siteDir`
|
|
304
|
+
* and writes `<siteDir>/pagefind/`. No-op (returns null) otherwise. Resolved from the
|
|
305
|
+
* app's `@opentf/web-docs` so the hook ships with the docs package.
|
|
306
|
+
*/
|
|
307
|
+
export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
|
|
308
|
+
if (config?.docs?.search?.provider !== "pagefind") return null;
|
|
309
|
+
try {
|
|
310
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
311
|
+
const { indexWithPagefind } = await import(pathToFileURL(entry).href);
|
|
312
|
+
return await indexWithPagefind({ siteDir, onProgress });
|
|
313
|
+
} catch (e) {
|
|
314
|
+
console.warn(`⚠ Pagefind indexing skipped: ${e?.message ?? e}`);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Generate blog feeds when the project has a `blog` config and a base URL. Scans the
|
|
321
|
+
* post folders, renders RSS 2.0 + Atom 1.0, and writes them under
|
|
322
|
+
* `<siteDir>/<blogDir>/`. Project-supplied `public/<blogDir>/{rss,atom}.xml` files
|
|
323
|
+
* take precedence independently. Returns `{ paths, urls, count }` or null (no blog /
|
|
324
|
+
* no base URL / all feeds overridden / package missing). Resolved from the app's
|
|
325
|
+
* `@opentf/web-docs`.
|
|
326
|
+
*/
|
|
327
|
+
export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
|
|
328
|
+
const blog = config?.blog;
|
|
329
|
+
if (!blog) return null;
|
|
330
|
+
if (!baseUrl) {
|
|
331
|
+
console.warn("⚠ blog feeds skipped: no site URL (pass --base-url or set otfw.config)");
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const contentDir = blog.dir ?? "blog";
|
|
335
|
+
try {
|
|
336
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
337
|
+
const { loadPosts, renderAtomFeed, renderBlogFeed } = await import(pathToFileURL(entry).href);
|
|
338
|
+
const posts = loadPosts({ appDir, contentDir, exclude });
|
|
339
|
+
const title = blog.title || (config?.docs?.title ? `${config.docs.title} Blog` : "Blog");
|
|
340
|
+
const channel = {
|
|
341
|
+
title,
|
|
342
|
+
description: blog.description || title,
|
|
343
|
+
link: `/${contentDir}`,
|
|
344
|
+
};
|
|
345
|
+
const feeds = [
|
|
346
|
+
{
|
|
347
|
+
file: "rss.xml",
|
|
348
|
+
path: `/${contentDir}/rss.xml`,
|
|
349
|
+
render: renderBlogFeed,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
file: "atom.xml",
|
|
353
|
+
path: `/${contentDir}/atom.xml`,
|
|
354
|
+
render: renderAtomFeed,
|
|
355
|
+
},
|
|
356
|
+
];
|
|
357
|
+
const written = [];
|
|
358
|
+
for (const feed of feeds) {
|
|
359
|
+
if (existsSync(join(root, "public", contentDir, feed.file))) continue; // honor override
|
|
360
|
+
const out = join(siteDir, contentDir, feed.file);
|
|
361
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
362
|
+
writeFileSync(out, feed.render({ posts, baseUrl, feedPath: feed.path, channel }));
|
|
363
|
+
written.push(feed.path);
|
|
364
|
+
}
|
|
365
|
+
if (!written.length) return null;
|
|
366
|
+
const origin = baseUrl.replace(/\/+$/, "");
|
|
367
|
+
return { paths: written, urls: written.map((path) => origin + path), count: posts.length };
|
|
368
|
+
} catch (e) {
|
|
369
|
+
console.warn(`⚠ blog feeds skipped: ${e?.message ?? e}`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Generate `/llms.txt` and `/llms-full.txt` for docs/blog sites from the same
|
|
376
|
+
* filesystem route list used by the app build. Project-supplied public files override
|
|
377
|
+
* each output independently.
|
|
378
|
+
*/
|
|
379
|
+
export async function runLlmsFiles(root, appDir, pages, config, siteDir, baseUrl) {
|
|
380
|
+
if (!config?.docs && !config?.blog) return null;
|
|
381
|
+
const outputs = [
|
|
382
|
+
{ file: "llms.txt", render: "renderLlmsTxt" },
|
|
383
|
+
{ file: "llms-full.txt", render: "renderLlmsFullTxt" },
|
|
384
|
+
].filter((out) => !existsSync(join(root, "public", out.file)));
|
|
385
|
+
if (!outputs.length) return null;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
389
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
390
|
+
const written = [];
|
|
391
|
+
for (const out of outputs) {
|
|
392
|
+
const render = mod[out.render];
|
|
393
|
+
if (typeof render !== "function") continue;
|
|
394
|
+
writeFileSync(join(siteDir, out.file), render({ appDir, pages, baseUrl, config }));
|
|
395
|
+
written.push(`/${out.file}`);
|
|
396
|
+
}
|
|
397
|
+
return written.length ? { paths: written } : null;
|
|
398
|
+
} catch (e) {
|
|
399
|
+
console.warn(`⚠ llms.txt skipped: ${e?.message ?? e}`);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
103
404
|
/** Discover file-based routes under `app/`: every page/layout and the 404. */
|
|
104
405
|
export function discoverPages(dir, exclude) {
|
|
105
406
|
const out = [];
|
|
@@ -107,7 +408,7 @@ export function discoverPages(dir, exclude) {
|
|
|
107
408
|
if (entry.isDirectory() && exclude.has(entry.name)) continue;
|
|
108
409
|
const full = `${dir}/${entry.name}`;
|
|
109
410
|
if (entry.isDirectory()) out.push(...discoverPages(full, exclude));
|
|
110
|
-
else if (/^(page|layout|404)\.[jt]sx$/.test(entry.name)) out.push(full);
|
|
411
|
+
else if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(entry.name)) out.push(full);
|
|
111
412
|
}
|
|
112
413
|
return out;
|
|
113
414
|
}
|
|
@@ -122,20 +423,192 @@ export function findGuard(appDir) {
|
|
|
122
423
|
/**
|
|
123
424
|
* The app entry source: hand `mountApp` a route map of lazy `() => import()`
|
|
124
425
|
* loaders (so each route code-splits into its own chunk) plus the optional guard.
|
|
426
|
+
*
|
|
427
|
+
* `loaderUrl(filePath)` maps each route file to the specifier its loader imports.
|
|
428
|
+
* The production build imports the file directly (Rolldown code-splits it); the dev
|
|
429
|
+
* server passes a `/__route/…` URL so the route compiles on first navigation.
|
|
125
430
|
*/
|
|
126
|
-
export function entrySource(pages, appDir) {
|
|
431
|
+
export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
|
|
127
432
|
const map = pages
|
|
128
|
-
.map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(p)}),`)
|
|
433
|
+
.map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
|
|
129
434
|
.join("\n");
|
|
130
435
|
const guard = findGuard(appDir);
|
|
436
|
+
// Thread the i18n config (otfw.config) into `mountApp` so the client router knows
|
|
437
|
+
// the locales and `router.locale` resolves from the URL prefix (docs/I18N.md §6).
|
|
438
|
+
const i18nOpt =
|
|
439
|
+
i18n && Array.isArray(i18n.locales) && i18n.locales.length
|
|
440
|
+
? `\n i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })},`
|
|
441
|
+
: "";
|
|
442
|
+
// Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
|
|
443
|
+
// (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
|
|
444
|
+
const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
|
|
131
445
|
return (
|
|
132
446
|
`import { mountApp } from "@opentf/web";\n` +
|
|
133
447
|
(guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
|
|
134
448
|
`mountApp({\n pages: {\n${map}\n },\n` +
|
|
135
|
-
` target: document.getElementById("app"),${guard ? "\n guard," : ""}\n});\n`
|
|
449
|
+
` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
|
|
136
450
|
);
|
|
137
451
|
}
|
|
138
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Crawl the module graph via `otfwc graph` and return a queryable handle. Used by
|
|
455
|
+
* the dev server to invalidate precisely: `affected(file)` is every module that
|
|
456
|
+
* transitively imports `file` (so editing it rebuilds exactly those route chunks).
|
|
457
|
+
* `roots` are the entries to crawl from (the route/layout files + the runtime).
|
|
458
|
+
*/
|
|
459
|
+
export async function moduleGraph(otfwc, webEntry, roots) {
|
|
460
|
+
const proc = Bun.spawn([otfwc, "graph", `--web=${webEntry}`, ...roots], {
|
|
461
|
+
stdout: "pipe",
|
|
462
|
+
stderr: "pipe",
|
|
463
|
+
});
|
|
464
|
+
proc.unref(); // a short read-only crawl — never let it hold up shutdown
|
|
465
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
466
|
+
if (exitCode !== 0) {
|
|
467
|
+
console.error(`✗ otfwc graph failed:\n${await new Response(proc.stderr).text()}`);
|
|
468
|
+
return { affected: () => new Set(), has: () => false };
|
|
469
|
+
}
|
|
470
|
+
let modules = [];
|
|
471
|
+
try {
|
|
472
|
+
modules = JSON.parse(stdout).modules;
|
|
473
|
+
} catch {
|
|
474
|
+
return { affected: () => new Set(), has: () => false };
|
|
475
|
+
}
|
|
476
|
+
// Reverse adjacency: target → importers, for transitive-dependents queries.
|
|
477
|
+
const importers = new Map();
|
|
478
|
+
for (const m of modules) {
|
|
479
|
+
for (const dep of m.deps) {
|
|
480
|
+
if (dep.external) continue;
|
|
481
|
+
if (!importers.has(dep.target)) importers.set(dep.target, []);
|
|
482
|
+
importers.get(dep.target).push(m.id);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const ids = new Set(modules.map((m) => m.id));
|
|
486
|
+
return {
|
|
487
|
+
has: (id) => ids.has(id),
|
|
488
|
+
affected(file) {
|
|
489
|
+
const out = new Set();
|
|
490
|
+
const queue = [file];
|
|
491
|
+
while (queue.length) {
|
|
492
|
+
const id = queue.shift();
|
|
493
|
+
if (out.has(id)) continue;
|
|
494
|
+
out.add(id);
|
|
495
|
+
for (const importer of importers.get(id) || []) {
|
|
496
|
+
if (!out.has(importer)) queue.push(importer);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Start a long-lived `otfwc serve` process and talk to it over a framed
|
|
506
|
+
* stdin/stdout protocol (see crates/otfw_cli/src/main.rs `serve`). One process
|
|
507
|
+
* compiles every module, so the toolchain pays the binary-startup cost once
|
|
508
|
+
* instead of spawning a subprocess per file — the dominant dev-server cost.
|
|
509
|
+
*
|
|
510
|
+
* `compile(id, source, component, target)` resolves to the emitted JS or rejects
|
|
511
|
+
* with the compiler diagnostic (`target` is `"csr"` | `"ssg"` | `"hydrate"`).
|
|
512
|
+
* Requests are serialized through a FIFO queue: the server
|
|
513
|
+
* is single-threaded, replies arrive in request order, so the head of the queue
|
|
514
|
+
* always pairs with the next frame. The child is killed when this process exits.
|
|
515
|
+
*/
|
|
516
|
+
export function startCompilerServer(otfwc) {
|
|
517
|
+
const proc = Bun.spawn([otfwc, "serve"], {
|
|
518
|
+
stdin: "pipe",
|
|
519
|
+
stdout: "pipe",
|
|
520
|
+
stderr: "inherit",
|
|
521
|
+
});
|
|
522
|
+
// Don't let the long-lived child keep the event loop open: a one-shot `otfw build`
|
|
523
|
+
// must exit once the bundle is written (the dev server stays alive on its own via
|
|
524
|
+
// `Bun.serve`). The `exit` handler below still tears the child down, and the child
|
|
525
|
+
// also sees EOF on its stdin pipe when we go.
|
|
526
|
+
proc.unref();
|
|
527
|
+
const reader = proc.stdout.getReader();
|
|
528
|
+
const enc = new TextEncoder();
|
|
529
|
+
const dec = new TextDecoder();
|
|
530
|
+
const queue = []; // { resolve, reject } in request order
|
|
531
|
+
let buf = new Uint8Array(0);
|
|
532
|
+
let pumping = false;
|
|
533
|
+
let dead = false;
|
|
534
|
+
|
|
535
|
+
const append = (a, b) => {
|
|
536
|
+
const out = new Uint8Array(a.length + b.length);
|
|
537
|
+
out.set(a);
|
|
538
|
+
out.set(b, a.length);
|
|
539
|
+
return out;
|
|
540
|
+
};
|
|
541
|
+
const die = (err) => {
|
|
542
|
+
dead = true;
|
|
543
|
+
while (queue.length) queue.shift().reject(err);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Drain reply frames as they arrive, resolving queued requests in order. A frame
|
|
547
|
+
// is `<status> <byteLen>\n` followed by exactly `byteLen` bytes of payload.
|
|
548
|
+
async function pump() {
|
|
549
|
+
if (pumping) return;
|
|
550
|
+
pumping = true;
|
|
551
|
+
try {
|
|
552
|
+
while (queue.length) {
|
|
553
|
+
let nl = buf.indexOf(10);
|
|
554
|
+
while (nl === -1) {
|
|
555
|
+
const { value, done } = await reader.read();
|
|
556
|
+
if (done) return die(new Error("otfwc serve exited"));
|
|
557
|
+
buf = append(buf, value);
|
|
558
|
+
nl = buf.indexOf(10);
|
|
559
|
+
}
|
|
560
|
+
const [status, lenStr] = dec.decode(buf.subarray(0, nl)).split(" ");
|
|
561
|
+
const len = Number(lenStr);
|
|
562
|
+
while (buf.length < nl + 1 + len) {
|
|
563
|
+
const { value, done } = await reader.read();
|
|
564
|
+
if (done) return die(new Error("otfwc serve exited"));
|
|
565
|
+
buf = append(buf, value);
|
|
566
|
+
}
|
|
567
|
+
const payload = dec.decode(buf.subarray(nl + 1, nl + 1 + len));
|
|
568
|
+
buf = buf.slice(nl + 1 + len);
|
|
569
|
+
const job = queue.shift();
|
|
570
|
+
if (status === "OK") job.resolve(payload);
|
|
571
|
+
else job.reject(new Error(payload));
|
|
572
|
+
}
|
|
573
|
+
} finally {
|
|
574
|
+
pumping = false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function compile(id, source, component, target = "csr") {
|
|
579
|
+
if (dead) return Promise.reject(new Error("otfwc serve is not running"));
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
queue.push({ resolve, reject });
|
|
582
|
+
const idB = enc.encode(id);
|
|
583
|
+
const srcB = enc.encode(source);
|
|
584
|
+
proc.stdin.write(enc.encode(`${idB.length} ${srcB.length} ${component ? 1 : 0} ${target}\n`));
|
|
585
|
+
proc.stdin.write(idB);
|
|
586
|
+
proc.stdin.write(srcB);
|
|
587
|
+
proc.stdin.flush();
|
|
588
|
+
pump();
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const close = () => {
|
|
593
|
+
if (dead) return;
|
|
594
|
+
dead = true;
|
|
595
|
+
try {
|
|
596
|
+
proc.stdin.end();
|
|
597
|
+
} catch {}
|
|
598
|
+
try {
|
|
599
|
+
proc.kill();
|
|
600
|
+
} catch {}
|
|
601
|
+
};
|
|
602
|
+
// Clean up the child whenever this process exits — for any reason and at any exit
|
|
603
|
+
// code. We deliberately don't trap SIGINT/SIGTERM here: a helper shouldn't dictate
|
|
604
|
+
// the whole process's exit code (that's the command's call), and the child also
|
|
605
|
+
// receives the terminal's group signal directly. The `exit` hook is enough to avoid
|
|
606
|
+
// leaking it. (One-shot builds exit when done; the dev server until it's stopped.)
|
|
607
|
+
process.once("exit", close);
|
|
608
|
+
|
|
609
|
+
return { compile, close };
|
|
610
|
+
}
|
|
611
|
+
|
|
139
612
|
/**
|
|
140
613
|
* Rolldown plugin: compile `.jsx`/`.tsx` through the `otfwc` IR compiler. Page /
|
|
141
614
|
* layout / 404 modules become factories; everything else a Custom Element. On a
|
|
@@ -143,23 +616,27 @@ export function entrySource(pages, appDir) {
|
|
|
143
616
|
* build) unless `failOnError` is set (production builds should fail loudly).
|
|
144
617
|
* `onResult(id, errorMessageOrNull)` is called per module so the dev server can
|
|
145
618
|
* push compile diagnostics to the error overlay and clear them once fixed.
|
|
619
|
+
*
|
|
620
|
+
* Compilation runs through one persistent `otfwc serve` process per plugin instance
|
|
621
|
+
* (see `startCompilerServer`). `target` picks the codegen backend: `"csr"` (the live
|
|
622
|
+
* DOM build), `"ssg"` (HTML-string renderers), or `"hydrate"` (the dual module — a
|
|
623
|
+
* CSR build factory plus an adopt factory for first-paint hydration).
|
|
146
624
|
*/
|
|
147
625
|
export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr" } = {}) {
|
|
626
|
+
const server = startCompilerServer(otfwc);
|
|
148
627
|
return {
|
|
149
628
|
name: "otfw",
|
|
150
|
-
transform(code, id) {
|
|
151
|
-
if (!/\.[jt]sx$/.test(id)) return null;
|
|
152
|
-
const base = id.split("/").pop().replace(/\.[jt]sx$/, "");
|
|
629
|
+
async transform(code, id) {
|
|
630
|
+
if (!/\.(mdx|md|[jt]sx)$/.test(id)) return null;
|
|
631
|
+
const base = id.split("/").pop().replace(/\.(mdx|md|[jt]sx)$/, "");
|
|
153
632
|
const isPage = base === "page" || base === "layout" || base === "404";
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (proc.exitCode !== 0) {
|
|
162
|
-
const msg = proc.stderr.toString();
|
|
633
|
+
try {
|
|
634
|
+
const out = await server.compile(id, code, !isPage, target);
|
|
635
|
+
onResult?.(id, null);
|
|
636
|
+
// Side effects (e.g. customElements.define) must survive bundling.
|
|
637
|
+
return { code: out, moduleSideEffects: true };
|
|
638
|
+
} catch (e) {
|
|
639
|
+
const msg = e?.message ?? String(e);
|
|
163
640
|
onResult?.(id, msg);
|
|
164
641
|
if (failOnError) {
|
|
165
642
|
this.error(`otfwc failed for ${id}:\n${msg}`);
|
|
@@ -172,9 +649,6 @@ export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr
|
|
|
172
649
|
` return pre; }`;
|
|
173
650
|
return { code: stub, moduleSideEffects: true };
|
|
174
651
|
}
|
|
175
|
-
onResult?.(id, null);
|
|
176
|
-
// Side effects (e.g. customElements.define) must survive bundling.
|
|
177
|
-
return { code: proc.stdout.toString(), moduleSideEffects: true };
|
|
178
652
|
},
|
|
179
653
|
};
|
|
180
654
|
}
|
|
@@ -200,6 +674,78 @@ export function cssPlugin() {
|
|
|
200
674
|
};
|
|
201
675
|
}
|
|
202
676
|
|
|
677
|
+
// Generated server entry: eager-import every page module (so `registerRoutes`
|
|
678
|
+
// sees real namespaces, enabling `getStaticPaths`) and re-export the render API.
|
|
679
|
+
// Shared by the SSG pre-render and the SSR server — both render through the same
|
|
680
|
+
// SSG-compiled bundle (ARCHITECTURE.md §6: "SSR … shares the SSG path").
|
|
681
|
+
export function serverEntrySource(pages, i18n = null) {
|
|
682
|
+
const imports = pages.map((p, i) => `import * as p${i} from ${JSON.stringify(p)};`).join("\n");
|
|
683
|
+
const map = pages.map((p, i) => ` [${JSON.stringify(p)}]: p${i},`).join("\n");
|
|
684
|
+
// i18n: the server-side router needs the locales too — so renderRoute can strip
|
|
685
|
+
// a `/fr/...` prefix to match the route table and set router.locale for `t()`
|
|
686
|
+
// (docs/I18N.md §6). Without this the prefix wouldn't match and pages would
|
|
687
|
+
// render in the default locale (or 404).
|
|
688
|
+
const i18nOn = i18n && Array.isArray(i18n.locales) && i18n.locales.length;
|
|
689
|
+
const named = i18nOn ? "registerRoutes, configureI18n" : "registerRoutes";
|
|
690
|
+
const i18nCall = i18nOn
|
|
691
|
+
? `configureI18n(${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })});\n`
|
|
692
|
+
: "";
|
|
693
|
+
return (
|
|
694
|
+
`${imports}\n` +
|
|
695
|
+
`import { ${named} } from "@opentf/web";\n` +
|
|
696
|
+
`export { renderRoute, renderHead, collectRoutePaths } from "@opentf/web/server";\n` +
|
|
697
|
+
i18nCall +
|
|
698
|
+
`registerRoutes({\n${map}\n});\n`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Build the server render bundle (the compiler's SSG backend — HTML-string
|
|
704
|
+
* renderers) and import it. Returns `{ mod, cleanup }`, where `mod` exposes
|
|
705
|
+
* `renderRoute` / `renderHead` / `collectRoutePaths` and `cleanup()` removes the
|
|
706
|
+
* temp build dir. The SSG pre-render calls `cleanup()` immediately after rendering
|
|
707
|
+
* every route; the SSR server keeps the module live and cleans up on shutdown.
|
|
708
|
+
*/
|
|
709
|
+
export async function buildServerBundle({
|
|
710
|
+
root,
|
|
711
|
+
pages,
|
|
712
|
+
webEntry,
|
|
713
|
+
otfwc,
|
|
714
|
+
docsPlugins = [],
|
|
715
|
+
i18n = null,
|
|
716
|
+
onCompile,
|
|
717
|
+
tmpName = ".otfw-ssg",
|
|
718
|
+
}) {
|
|
719
|
+
const tmp = join(root, tmpName);
|
|
720
|
+
mkdirSync(tmp, { recursive: true });
|
|
721
|
+
const entry = join(tmp, "ssg-entry.js");
|
|
722
|
+
writeFileSync(entry, serverEntrySource(pages, i18n));
|
|
723
|
+
|
|
724
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
725
|
+
await build({
|
|
726
|
+
input: entry,
|
|
727
|
+
resolve: {
|
|
728
|
+
alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
|
|
729
|
+
extensions: EXTENSIONS,
|
|
730
|
+
},
|
|
731
|
+
plugins: [
|
|
732
|
+
...docsPlugins,
|
|
733
|
+
otfwPlugin(otfwc, { failOnError: true, target: "ssg", onResult: (id) => onCompile?.(id) }),
|
|
734
|
+
cssPlugin(),
|
|
735
|
+
],
|
|
736
|
+
output: { dir: join(tmp, "out"), format: "esm", entryFileNames: "server.js" },
|
|
737
|
+
checks: { pluginTimings: false },
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// The runtime defines `class … extends HTMLElement` at load (for CSR custom
|
|
741
|
+
// elements). Server render never instantiates them, but the base class must
|
|
742
|
+
// exist so the class definitions evaluate. A bare stub suffices — no DOM
|
|
743
|
+
// (customElements stays undefined, so elements self-register only in the browser).
|
|
744
|
+
globalThis.HTMLElement ??= class {};
|
|
745
|
+
const mod = await import(pathToFileURL(join(tmp, "out", "server.js")).href);
|
|
746
|
+
return { mod, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
|
|
747
|
+
}
|
|
748
|
+
|
|
203
749
|
function fail(msg) {
|
|
204
750
|
console.error(`✗ ${msg}`);
|
|
205
751
|
process.exit(1);
|