@opentf/web-cli 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/package.json +6 -2
- package/src/build.js +102 -30
- package/src/cli.js +7 -0
- package/src/dev.js +195 -97
- package/src/prerender.js +108 -57
- package/src/reporter.js +73 -0
- package/src/serve.js +234 -0
- package/src/shared.js +482 -25
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,74 @@ 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
|
+
* Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
|
|
64
|
+
* the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
|
|
65
|
+
* it (`runtime/router.js` `mountApp`). Used when the client bundle was built for the
|
|
66
|
+
* hydrate target and there is server markup to adopt (SSR, and SSG pre-render).
|
|
67
|
+
* Idempotent — a no-op if the sentinel is already present.
|
|
68
|
+
*/
|
|
69
|
+
export function stampHydrateSentinel(shellHtml) {
|
|
70
|
+
return shellHtml.replace(/<div id="app"([^>]*)>/, (m, attrs) =>
|
|
71
|
+
/\bdata-otfw-hydrate\b/.test(attrs) ? m : `<div id="app"${attrs} data-otfw-hydrate>`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Inject per-route `<head>` tags before `</head>`, dropping the shell's default
|
|
77
|
+
* `<title>` when the route supplies its own (so each page gets a unique,
|
|
78
|
+
* non-duplicated title). Shared by SSG pre-render and the SSR server.
|
|
79
|
+
*/
|
|
80
|
+
export function injectHead(shellHtml, headHtml) {
|
|
81
|
+
if (!headHtml) return shellHtml;
|
|
82
|
+
let out = shellHtml;
|
|
83
|
+
if (/<title[\s>]/i.test(headHtml)) out = out.replace(/<title>[\s\S]*?<\/title>\s*/i, "");
|
|
84
|
+
// Function replacer so `$` in `headHtml` (e.g. a price in a meta description) is literal.
|
|
85
|
+
return out.replace(/<\/head>/i, () => `${headHtml}\n</head>`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Set/replace the shell's `<html lang>` for a localized page (i18n; docs/I18N.md §6). */
|
|
89
|
+
export function withHtmlLang(shellHtml, locale) {
|
|
90
|
+
if (!locale) return shellHtml;
|
|
91
|
+
if (/<html[^>]*\slang=/i.test(shellHtml)) {
|
|
92
|
+
return shellHtml.replace(/(<html[^>]*\slang=)(["'])[^"']*\2/i, `$1$2${locale}$2`);
|
|
93
|
+
}
|
|
94
|
+
return shellHtml.replace(/<html\b/i, `<html lang="${locale}"`);
|
|
95
|
+
}
|
|
96
|
+
|
|
27
97
|
/** Nearest ancestor directory of `from` (inclusive) that contains `name`. */
|
|
28
98
|
export function findUp(name, from) {
|
|
29
99
|
let dir = from;
|
|
@@ -79,10 +149,10 @@ export function loadProject() {
|
|
|
79
149
|
}
|
|
80
150
|
}
|
|
81
151
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
152
|
+
// Route directories to skip during discovery — comma-separated names in
|
|
153
|
+
// EXCLUDE_ROUTES. None are excluded by default.
|
|
84
154
|
const exclude = new Set(
|
|
85
|
-
(process.env.EXCLUDE_ROUTES ?? "
|
|
155
|
+
(process.env.EXCLUDE_ROUTES ?? "").split(",").filter(Boolean),
|
|
86
156
|
);
|
|
87
157
|
|
|
88
158
|
return { root, appDir, webEntry, otfwc, workspace, exclude };
|
|
@@ -100,6 +170,148 @@ function ensureCompiler(otfwc, workspace) {
|
|
|
100
170
|
if (b.exitCode !== 0) process.exit(b.exitCode);
|
|
101
171
|
}
|
|
102
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Load the optional project config (`otfw.config.{json,js,mjs}`) as a plain object.
|
|
175
|
+
* Read by the build for the site URL (SEO) and the `docs` block (docs generator).
|
|
176
|
+
*/
|
|
177
|
+
export async function loadConfig(root) {
|
|
178
|
+
const json = join(root, "otfw.config.json");
|
|
179
|
+
if (existsSync(json)) {
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(readFileSync(json, "utf8")) ?? {};
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.warn(`⚠ could not parse otfw.config.json: ${e?.message ?? e}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const name of ["otfw.config.js", "otfw.config.mjs"]) {
|
|
187
|
+
const p = join(root, name);
|
|
188
|
+
if (existsSync(p)) {
|
|
189
|
+
try {
|
|
190
|
+
return (await import(pathToFileURL(p).href)).default ?? {};
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn(`⚠ could not load ${name}: ${e?.message ?? e}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The docs navigation Rolldown plugin, when the project opts into the docs
|
|
201
|
+
* generator (a `docs` block in otfw.config). Resolved from `@opentf/web-docs`
|
|
202
|
+
* (the app's own dependency); returns null when docs aren't configured or the
|
|
203
|
+
* package isn't installed, so the core toolchain stays untouched for normal apps.
|
|
204
|
+
*/
|
|
205
|
+
export async function loadDocsPlugins(root, appDir, config, exclude = new Set()) {
|
|
206
|
+
const docs = config?.docs;
|
|
207
|
+
const blog = config?.blog;
|
|
208
|
+
if (!docs && !blog) return [];
|
|
209
|
+
try {
|
|
210
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
211
|
+
const { docsNavPlugin, blogPostsPlugin, lastUpdatedPlugin } = await import(
|
|
212
|
+
pathToFileURL(entry).href
|
|
213
|
+
);
|
|
214
|
+
const plugins = [];
|
|
215
|
+
// Resolves `@opentf/web-docs/nav` to a section map — one generated tree per top-level
|
|
216
|
+
// folder under app/. Any folder with a DocsLayout becomes a section automatically.
|
|
217
|
+
if (docs) plugins.push(docsNavPlugin({ appDir, exclude }));
|
|
218
|
+
// Resolves `@opentf/web-docs/posts` to the generated post list.
|
|
219
|
+
if (blog) plugins.push(blogPostsPlugin({ appDir, contentDir: blog.dir ?? "blog", exclude }));
|
|
220
|
+
// Resolves `@opentf/web-docs/updated` to the per-page last-updated map (every route),
|
|
221
|
+
// when last-updated is turned on anywhere.
|
|
222
|
+
if (wantsLastUpdated(config)) plugins.push(lastUpdatedPlugin({ appDir, exclude }));
|
|
223
|
+
return plugins;
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.warn(
|
|
226
|
+
`⚠ docs/blog config present but @opentf/web-docs could not be loaded: ${e?.message ?? e}`,
|
|
227
|
+
);
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether "last updated" tracking is enabled anywhere (`docs`/`blog` `lastUpdated`). */
|
|
233
|
+
export function wantsLastUpdated(config) {
|
|
234
|
+
return Boolean(config?.docs?.lastUpdated || config?.blog?.lastUpdated);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build the `{ [routePath]: ISO }` last-updated map for every page (the same map the
|
|
239
|
+
* `@opentf/web-docs/updated` virtual module exposes). Used by the SSG step to emit
|
|
240
|
+
* `article:modified_time`. Returns `{}` when last-updated is off or the package can't
|
|
241
|
+
* be loaded.
|
|
242
|
+
*/
|
|
243
|
+
export async function runLastUpdated(root, appDir, config, exclude = new Set()) {
|
|
244
|
+
if (!wantsLastUpdated(config)) return {};
|
|
245
|
+
try {
|
|
246
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
247
|
+
const { loadLastUpdated } = await import(pathToFileURL(entry).href);
|
|
248
|
+
return loadLastUpdated({ appDir, exclude });
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.warn(`⚠ last-updated map skipped: ${e?.message ?? e}`);
|
|
251
|
+
return {};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Run Pagefind over the built site when the docs config opts into it
|
|
257
|
+
* (`docs.search.provider === "pagefind"`). Indexes the pre-rendered HTML in `siteDir`
|
|
258
|
+
* and writes `<siteDir>/pagefind/`. No-op (returns null) otherwise. Resolved from the
|
|
259
|
+
* app's `@opentf/web-docs` so the hook ships with the docs package.
|
|
260
|
+
*/
|
|
261
|
+
export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
|
|
262
|
+
if (config?.docs?.search?.provider !== "pagefind") return null;
|
|
263
|
+
try {
|
|
264
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
265
|
+
const { indexWithPagefind } = await import(pathToFileURL(entry).href);
|
|
266
|
+
return await indexWithPagefind({ siteDir, onProgress });
|
|
267
|
+
} catch (e) {
|
|
268
|
+
console.warn(`⚠ Pagefind indexing skipped: ${e?.message ?? e}`);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Generate the blog RSS feed when the project has a `blog` config and a base URL.
|
|
275
|
+
* Scans the post folders, renders RSS 2.0, and writes `<siteDir>/<blogDir>/rss.xml`.
|
|
276
|
+
* A project-supplied `public/<blogDir>/rss.xml` takes precedence (it overwrites ours
|
|
277
|
+
* on the public/ copy). Returns `{ path, url, count }` or null (no blog / no base URL
|
|
278
|
+
* / package missing). Resolved from the app's `@opentf/web-docs`.
|
|
279
|
+
*/
|
|
280
|
+
export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
|
|
281
|
+
const blog = config?.blog;
|
|
282
|
+
if (!blog) return null;
|
|
283
|
+
if (!baseUrl) {
|
|
284
|
+
console.warn("⚠ blog RSS feed skipped: no site URL (pass --base-url or set otfw.config)");
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const contentDir = blog.dir ?? "blog";
|
|
288
|
+
if (existsSync(join(root, "public", contentDir, "rss.xml"))) return null; // honor override
|
|
289
|
+
try {
|
|
290
|
+
const entry = Bun.resolveSync("@opentf/web-docs/build", root);
|
|
291
|
+
const { loadPosts, renderBlogFeed } = await import(pathToFileURL(entry).href);
|
|
292
|
+
const posts = loadPosts({ appDir, contentDir, exclude });
|
|
293
|
+
const feedPath = `/${contentDir}/rss.xml`;
|
|
294
|
+
const title = blog.title || (config?.docs?.title ? `${config.docs.title} Blog` : "Blog");
|
|
295
|
+
const xml = renderBlogFeed({
|
|
296
|
+
posts,
|
|
297
|
+
baseUrl,
|
|
298
|
+
feedPath,
|
|
299
|
+
channel: {
|
|
300
|
+
title,
|
|
301
|
+
description: blog.description || title,
|
|
302
|
+
link: `/${contentDir}`,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const out = join(siteDir, contentDir, "rss.xml");
|
|
306
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
307
|
+
writeFileSync(out, xml);
|
|
308
|
+
return { path: feedPath, url: baseUrl.replace(/\/+$/, "") + feedPath, count: posts.length };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
console.warn(`⚠ blog RSS feed skipped: ${e?.message ?? e}`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
103
315
|
/** Discover file-based routes under `app/`: every page/layout and the 404. */
|
|
104
316
|
export function discoverPages(dir, exclude) {
|
|
105
317
|
const out = [];
|
|
@@ -107,7 +319,7 @@ export function discoverPages(dir, exclude) {
|
|
|
107
319
|
if (entry.isDirectory() && exclude.has(entry.name)) continue;
|
|
108
320
|
const full = `${dir}/${entry.name}`;
|
|
109
321
|
if (entry.isDirectory()) out.push(...discoverPages(full, exclude));
|
|
110
|
-
else if (/^(page|layout|404)\.[jt]sx$/.test(entry.name)) out.push(full);
|
|
322
|
+
else if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(entry.name)) out.push(full);
|
|
111
323
|
}
|
|
112
324
|
return out;
|
|
113
325
|
}
|
|
@@ -122,20 +334,192 @@ export function findGuard(appDir) {
|
|
|
122
334
|
/**
|
|
123
335
|
* The app entry source: hand `mountApp` a route map of lazy `() => import()`
|
|
124
336
|
* loaders (so each route code-splits into its own chunk) plus the optional guard.
|
|
337
|
+
*
|
|
338
|
+
* `loaderUrl(filePath)` maps each route file to the specifier its loader imports.
|
|
339
|
+
* The production build imports the file directly (Rolldown code-splits it); the dev
|
|
340
|
+
* server passes a `/__route/…` URL so the route compiles on first navigation.
|
|
125
341
|
*/
|
|
126
|
-
export function entrySource(pages, appDir) {
|
|
342
|
+
export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
|
|
127
343
|
const map = pages
|
|
128
|
-
.map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(p)}),`)
|
|
344
|
+
.map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
|
|
129
345
|
.join("\n");
|
|
130
346
|
const guard = findGuard(appDir);
|
|
347
|
+
// Thread the i18n config (otfw.config) into `mountApp` so the client router knows
|
|
348
|
+
// the locales and `router.locale` resolves from the URL prefix (docs/I18N.md §6).
|
|
349
|
+
const i18nOpt =
|
|
350
|
+
i18n && Array.isArray(i18n.locales) && i18n.locales.length
|
|
351
|
+
? `\n i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })},`
|
|
352
|
+
: "";
|
|
353
|
+
// Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
|
|
354
|
+
// (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
|
|
355
|
+
const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
|
|
131
356
|
return (
|
|
132
357
|
`import { mountApp } from "@opentf/web";\n` +
|
|
133
358
|
(guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
|
|
134
359
|
`mountApp({\n pages: {\n${map}\n },\n` +
|
|
135
|
-
` target: document.getElementById("app"),${guard ? "\n guard," : ""}\n});\n`
|
|
360
|
+
` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
|
|
136
361
|
);
|
|
137
362
|
}
|
|
138
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Crawl the module graph via `otfwc graph` and return a queryable handle. Used by
|
|
366
|
+
* the dev server to invalidate precisely: `affected(file)` is every module that
|
|
367
|
+
* transitively imports `file` (so editing it rebuilds exactly those route chunks).
|
|
368
|
+
* `roots` are the entries to crawl from (the route/layout files + the runtime).
|
|
369
|
+
*/
|
|
370
|
+
export async function moduleGraph(otfwc, webEntry, roots) {
|
|
371
|
+
const proc = Bun.spawn([otfwc, "graph", `--web=${webEntry}`, ...roots], {
|
|
372
|
+
stdout: "pipe",
|
|
373
|
+
stderr: "pipe",
|
|
374
|
+
});
|
|
375
|
+
proc.unref(); // a short read-only crawl — never let it hold up shutdown
|
|
376
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
377
|
+
if (exitCode !== 0) {
|
|
378
|
+
console.error(`✗ otfwc graph failed:\n${await new Response(proc.stderr).text()}`);
|
|
379
|
+
return { affected: () => new Set(), has: () => false };
|
|
380
|
+
}
|
|
381
|
+
let modules = [];
|
|
382
|
+
try {
|
|
383
|
+
modules = JSON.parse(stdout).modules;
|
|
384
|
+
} catch {
|
|
385
|
+
return { affected: () => new Set(), has: () => false };
|
|
386
|
+
}
|
|
387
|
+
// Reverse adjacency: target → importers, for transitive-dependents queries.
|
|
388
|
+
const importers = new Map();
|
|
389
|
+
for (const m of modules) {
|
|
390
|
+
for (const dep of m.deps) {
|
|
391
|
+
if (dep.external) continue;
|
|
392
|
+
if (!importers.has(dep.target)) importers.set(dep.target, []);
|
|
393
|
+
importers.get(dep.target).push(m.id);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const ids = new Set(modules.map((m) => m.id));
|
|
397
|
+
return {
|
|
398
|
+
has: (id) => ids.has(id),
|
|
399
|
+
affected(file) {
|
|
400
|
+
const out = new Set();
|
|
401
|
+
const queue = [file];
|
|
402
|
+
while (queue.length) {
|
|
403
|
+
const id = queue.shift();
|
|
404
|
+
if (out.has(id)) continue;
|
|
405
|
+
out.add(id);
|
|
406
|
+
for (const importer of importers.get(id) || []) {
|
|
407
|
+
if (!out.has(importer)) queue.push(importer);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Start a long-lived `otfwc serve` process and talk to it over a framed
|
|
417
|
+
* stdin/stdout protocol (see crates/otfw_cli/src/main.rs `serve`). One process
|
|
418
|
+
* compiles every module, so the toolchain pays the binary-startup cost once
|
|
419
|
+
* instead of spawning a subprocess per file — the dominant dev-server cost.
|
|
420
|
+
*
|
|
421
|
+
* `compile(id, source, component, target)` resolves to the emitted JS or rejects
|
|
422
|
+
* with the compiler diagnostic (`target` is `"csr"` | `"ssg"` | `"hydrate"`).
|
|
423
|
+
* Requests are serialized through a FIFO queue: the server
|
|
424
|
+
* is single-threaded, replies arrive in request order, so the head of the queue
|
|
425
|
+
* always pairs with the next frame. The child is killed when this process exits.
|
|
426
|
+
*/
|
|
427
|
+
export function startCompilerServer(otfwc) {
|
|
428
|
+
const proc = Bun.spawn([otfwc, "serve"], {
|
|
429
|
+
stdin: "pipe",
|
|
430
|
+
stdout: "pipe",
|
|
431
|
+
stderr: "inherit",
|
|
432
|
+
});
|
|
433
|
+
// Don't let the long-lived child keep the event loop open: a one-shot `otfw build`
|
|
434
|
+
// must exit once the bundle is written (the dev server stays alive on its own via
|
|
435
|
+
// `Bun.serve`). The `exit` handler below still tears the child down, and the child
|
|
436
|
+
// also sees EOF on its stdin pipe when we go.
|
|
437
|
+
proc.unref();
|
|
438
|
+
const reader = proc.stdout.getReader();
|
|
439
|
+
const enc = new TextEncoder();
|
|
440
|
+
const dec = new TextDecoder();
|
|
441
|
+
const queue = []; // { resolve, reject } in request order
|
|
442
|
+
let buf = new Uint8Array(0);
|
|
443
|
+
let pumping = false;
|
|
444
|
+
let dead = false;
|
|
445
|
+
|
|
446
|
+
const append = (a, b) => {
|
|
447
|
+
const out = new Uint8Array(a.length + b.length);
|
|
448
|
+
out.set(a);
|
|
449
|
+
out.set(b, a.length);
|
|
450
|
+
return out;
|
|
451
|
+
};
|
|
452
|
+
const die = (err) => {
|
|
453
|
+
dead = true;
|
|
454
|
+
while (queue.length) queue.shift().reject(err);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Drain reply frames as they arrive, resolving queued requests in order. A frame
|
|
458
|
+
// is `<status> <byteLen>\n` followed by exactly `byteLen` bytes of payload.
|
|
459
|
+
async function pump() {
|
|
460
|
+
if (pumping) return;
|
|
461
|
+
pumping = true;
|
|
462
|
+
try {
|
|
463
|
+
while (queue.length) {
|
|
464
|
+
let nl = buf.indexOf(10);
|
|
465
|
+
while (nl === -1) {
|
|
466
|
+
const { value, done } = await reader.read();
|
|
467
|
+
if (done) return die(new Error("otfwc serve exited"));
|
|
468
|
+
buf = append(buf, value);
|
|
469
|
+
nl = buf.indexOf(10);
|
|
470
|
+
}
|
|
471
|
+
const [status, lenStr] = dec.decode(buf.subarray(0, nl)).split(" ");
|
|
472
|
+
const len = Number(lenStr);
|
|
473
|
+
while (buf.length < nl + 1 + len) {
|
|
474
|
+
const { value, done } = await reader.read();
|
|
475
|
+
if (done) return die(new Error("otfwc serve exited"));
|
|
476
|
+
buf = append(buf, value);
|
|
477
|
+
}
|
|
478
|
+
const payload = dec.decode(buf.subarray(nl + 1, nl + 1 + len));
|
|
479
|
+
buf = buf.slice(nl + 1 + len);
|
|
480
|
+
const job = queue.shift();
|
|
481
|
+
if (status === "OK") job.resolve(payload);
|
|
482
|
+
else job.reject(new Error(payload));
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
pumping = false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function compile(id, source, component, target = "csr") {
|
|
490
|
+
if (dead) return Promise.reject(new Error("otfwc serve is not running"));
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
queue.push({ resolve, reject });
|
|
493
|
+
const idB = enc.encode(id);
|
|
494
|
+
const srcB = enc.encode(source);
|
|
495
|
+
proc.stdin.write(enc.encode(`${idB.length} ${srcB.length} ${component ? 1 : 0} ${target}\n`));
|
|
496
|
+
proc.stdin.write(idB);
|
|
497
|
+
proc.stdin.write(srcB);
|
|
498
|
+
proc.stdin.flush();
|
|
499
|
+
pump();
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const close = () => {
|
|
504
|
+
if (dead) return;
|
|
505
|
+
dead = true;
|
|
506
|
+
try {
|
|
507
|
+
proc.stdin.end();
|
|
508
|
+
} catch {}
|
|
509
|
+
try {
|
|
510
|
+
proc.kill();
|
|
511
|
+
} catch {}
|
|
512
|
+
};
|
|
513
|
+
// Clean up the child whenever this process exits — for any reason and at any exit
|
|
514
|
+
// code. We deliberately don't trap SIGINT/SIGTERM here: a helper shouldn't dictate
|
|
515
|
+
// the whole process's exit code (that's the command's call), and the child also
|
|
516
|
+
// receives the terminal's group signal directly. The `exit` hook is enough to avoid
|
|
517
|
+
// leaking it. (One-shot builds exit when done; the dev server until it's stopped.)
|
|
518
|
+
process.once("exit", close);
|
|
519
|
+
|
|
520
|
+
return { compile, close };
|
|
521
|
+
}
|
|
522
|
+
|
|
139
523
|
/**
|
|
140
524
|
* Rolldown plugin: compile `.jsx`/`.tsx` through the `otfwc` IR compiler. Page /
|
|
141
525
|
* layout / 404 modules become factories; everything else a Custom Element. On a
|
|
@@ -143,23 +527,27 @@ export function entrySource(pages, appDir) {
|
|
|
143
527
|
* build) unless `failOnError` is set (production builds should fail loudly).
|
|
144
528
|
* `onResult(id, errorMessageOrNull)` is called per module so the dev server can
|
|
145
529
|
* push compile diagnostics to the error overlay and clear them once fixed.
|
|
530
|
+
*
|
|
531
|
+
* Compilation runs through one persistent `otfwc serve` process per plugin instance
|
|
532
|
+
* (see `startCompilerServer`). `target` picks the codegen backend: `"csr"` (the live
|
|
533
|
+
* DOM build), `"ssg"` (HTML-string renderers), or `"hydrate"` (the dual module — a
|
|
534
|
+
* CSR build factory plus an adopt factory for first-paint hydration).
|
|
146
535
|
*/
|
|
147
536
|
export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr" } = {}) {
|
|
537
|
+
const server = startCompilerServer(otfwc);
|
|
148
538
|
return {
|
|
149
539
|
name: "otfw",
|
|
150
|
-
transform(code, id) {
|
|
151
|
-
if (!/\.[jt]sx$/.test(id)) return null;
|
|
152
|
-
const base = id.split("/").pop().replace(/\.[jt]sx$/, "");
|
|
540
|
+
async transform(code, id) {
|
|
541
|
+
if (!/\.(mdx|md|[jt]sx)$/.test(id)) return null;
|
|
542
|
+
const base = id.split("/").pop().replace(/\.(mdx|md|[jt]sx)$/, "");
|
|
153
543
|
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();
|
|
544
|
+
try {
|
|
545
|
+
const out = await server.compile(id, code, !isPage, target);
|
|
546
|
+
onResult?.(id, null);
|
|
547
|
+
// Side effects (e.g. customElements.define) must survive bundling.
|
|
548
|
+
return { code: out, moduleSideEffects: true };
|
|
549
|
+
} catch (e) {
|
|
550
|
+
const msg = e?.message ?? String(e);
|
|
163
551
|
onResult?.(id, msg);
|
|
164
552
|
if (failOnError) {
|
|
165
553
|
this.error(`otfwc failed for ${id}:\n${msg}`);
|
|
@@ -172,9 +560,6 @@ export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr
|
|
|
172
560
|
` return pre; }`;
|
|
173
561
|
return { code: stub, moduleSideEffects: true };
|
|
174
562
|
}
|
|
175
|
-
onResult?.(id, null);
|
|
176
|
-
// Side effects (e.g. customElements.define) must survive bundling.
|
|
177
|
-
return { code: proc.stdout.toString(), moduleSideEffects: true };
|
|
178
563
|
},
|
|
179
564
|
};
|
|
180
565
|
}
|
|
@@ -200,6 +585,78 @@ export function cssPlugin() {
|
|
|
200
585
|
};
|
|
201
586
|
}
|
|
202
587
|
|
|
588
|
+
// Generated server entry: eager-import every page module (so `registerRoutes`
|
|
589
|
+
// sees real namespaces, enabling `getStaticPaths`) and re-export the render API.
|
|
590
|
+
// Shared by the SSG pre-render and the SSR server — both render through the same
|
|
591
|
+
// SSG-compiled bundle (ARCHITECTURE.md §6: "SSR … shares the SSG path").
|
|
592
|
+
export function serverEntrySource(pages, i18n = null) {
|
|
593
|
+
const imports = pages.map((p, i) => `import * as p${i} from ${JSON.stringify(p)};`).join("\n");
|
|
594
|
+
const map = pages.map((p, i) => ` [${JSON.stringify(p)}]: p${i},`).join("\n");
|
|
595
|
+
// i18n: the server-side router needs the locales too — so renderRoute can strip
|
|
596
|
+
// a `/fr/...` prefix to match the route table and set router.locale for `t()`
|
|
597
|
+
// (docs/I18N.md §6). Without this the prefix wouldn't match and pages would
|
|
598
|
+
// render in the default locale (or 404).
|
|
599
|
+
const i18nOn = i18n && Array.isArray(i18n.locales) && i18n.locales.length;
|
|
600
|
+
const named = i18nOn ? "registerRoutes, configureI18n" : "registerRoutes";
|
|
601
|
+
const i18nCall = i18nOn
|
|
602
|
+
? `configureI18n(${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })});\n`
|
|
603
|
+
: "";
|
|
604
|
+
return (
|
|
605
|
+
`${imports}\n` +
|
|
606
|
+
`import { ${named} } from "@opentf/web";\n` +
|
|
607
|
+
`export { renderRoute, renderHead, collectRoutePaths } from "@opentf/web/server";\n` +
|
|
608
|
+
i18nCall +
|
|
609
|
+
`registerRoutes({\n${map}\n});\n`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Build the server render bundle (the compiler's SSG backend — HTML-string
|
|
615
|
+
* renderers) and import it. Returns `{ mod, cleanup }`, where `mod` exposes
|
|
616
|
+
* `renderRoute` / `renderHead` / `collectRoutePaths` and `cleanup()` removes the
|
|
617
|
+
* temp build dir. The SSG pre-render calls `cleanup()` immediately after rendering
|
|
618
|
+
* every route; the SSR server keeps the module live and cleans up on shutdown.
|
|
619
|
+
*/
|
|
620
|
+
export async function buildServerBundle({
|
|
621
|
+
root,
|
|
622
|
+
pages,
|
|
623
|
+
webEntry,
|
|
624
|
+
otfwc,
|
|
625
|
+
docsPlugins = [],
|
|
626
|
+
i18n = null,
|
|
627
|
+
onCompile,
|
|
628
|
+
tmpName = ".otfw-ssg",
|
|
629
|
+
}) {
|
|
630
|
+
const tmp = join(root, tmpName);
|
|
631
|
+
mkdirSync(tmp, { recursive: true });
|
|
632
|
+
const entry = join(tmp, "ssg-entry.js");
|
|
633
|
+
writeFileSync(entry, serverEntrySource(pages, i18n));
|
|
634
|
+
|
|
635
|
+
const serverApi = join(dirname(webEntry), "server", "index.js");
|
|
636
|
+
await build({
|
|
637
|
+
input: entry,
|
|
638
|
+
resolve: {
|
|
639
|
+
alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
|
|
640
|
+
extensions: EXTENSIONS,
|
|
641
|
+
},
|
|
642
|
+
plugins: [
|
|
643
|
+
...docsPlugins,
|
|
644
|
+
otfwPlugin(otfwc, { failOnError: true, target: "ssg", onResult: (id) => onCompile?.(id) }),
|
|
645
|
+
cssPlugin(),
|
|
646
|
+
],
|
|
647
|
+
output: { dir: join(tmp, "out"), format: "esm", entryFileNames: "server.js" },
|
|
648
|
+
checks: { pluginTimings: false },
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// The runtime defines `class … extends HTMLElement` at load (for CSR custom
|
|
652
|
+
// elements). Server render never instantiates them, but the base class must
|
|
653
|
+
// exist so the class definitions evaluate. A bare stub suffices — no DOM
|
|
654
|
+
// (customElements stays undefined, so elements self-register only in the browser).
|
|
655
|
+
globalThis.HTMLElement ??= class {};
|
|
656
|
+
const mod = await import(pathToFileURL(join(tmp, "out", "server.js")).href);
|
|
657
|
+
return { mod, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
|
|
658
|
+
}
|
|
659
|
+
|
|
203
660
|
function fail(msg) {
|
|
204
661
|
console.error(`✗ ${msg}`);
|
|
205
662
|
process.exit(1);
|