@opentf/web-cli 1.0.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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@opentf/web-cli",
3
+ "version": "1.0.0",
4
+ "description": "OTF Web dev toolchain — the Rolldown-driven CSR dev server, production build, and SSG.",
5
+ "type": "module",
6
+ "bin": {
7
+ "otfw": "./src/cli.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/cli.js"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "engines": {
16
+ "bun": ">=1.0.0"
17
+ },
18
+ "dependencies": {
19
+ "@opentf/web-compiler": "0.1.0",
20
+ "@tailwindcss/node": "4.2.2",
21
+ "@tailwindcss/oxide": "4.2.2",
22
+ "rolldown": "1.0.0-rc.17",
23
+ "tailwindcss": "4.2.2"
24
+ },
25
+ "peerDependencies": {
26
+ "@opentf/web": "*"
27
+ },
28
+ "keywords": [
29
+ "opentf",
30
+ "web",
31
+ "dev-server",
32
+ "ssg",
33
+ "rolldown",
34
+ "toolchain"
35
+ ],
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/Open-Tech-Foundation/Web-App-Framework.git",
39
+ "directory": "packages/web-cli"
40
+ },
41
+ "homepage": "https://github.com/Open-Tech-Foundation/Web-App-Framework#readme",
42
+ "bugs": "https://github.com/Open-Tech-Foundation/Web-App-Framework/issues",
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
package/src/build.js ADDED
@@ -0,0 +1,126 @@
1
+ // `otfw build` — the production build.
2
+ //
3
+ // One-shot Rolldown bundle (minified, content-hashed, code-split per route) with
4
+ // the `otfwc` compiler as a transform plugin, Tailwind stylesheets compiled to
5
+ // hashed CSS files, and a static `dist/` emitted from the project's index.html.
6
+
7
+ import { build } from "rolldown";
8
+ import {
9
+ cpSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { basename, join } from "node:path";
17
+
18
+ import { compileCss, usesTailwind } from "./tailwind.js";
19
+ import {
20
+ EXTENSIONS,
21
+ cssPlugin,
22
+ discoverPages,
23
+ entrySource,
24
+ loadProject,
25
+ otfwPlugin,
26
+ } from "./shared.js";
27
+
28
+ const hash = (s) => Bun.hash(s).toString(16).padStart(16, "0").slice(0, 8);
29
+
30
+ export async function runBuild() {
31
+ const { root, appDir, webEntry, otfwc, exclude } = loadProject();
32
+ const t0 = performance.now();
33
+
34
+ const pages = discoverPages(appDir, exclude);
35
+ if (pages.length === 0) {
36
+ console.error(`✗ no page.jsx files found under ${appDir}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const outDir = join(root, "dist");
41
+ rmSync(outDir, { recursive: true, force: true });
42
+ mkdirSync(join(outDir, "assets"), { recursive: true });
43
+
44
+ // Write the app entry into a temp dir, then bundle it.
45
+ const tmp = join(root, ".otfw");
46
+ mkdirSync(tmp, { recursive: true });
47
+ const entry = join(tmp, "entry.js");
48
+ writeFileSync(entry, entrySource(pages, appDir));
49
+
50
+ const result = await build({
51
+ input: entry,
52
+ resolve: { alias: { "@opentf/web": webEntry }, extensions: EXTENSIONS },
53
+ plugins: [otfwPlugin(otfwc, { failOnError: true }), cssPlugin()],
54
+ output: {
55
+ dir: join(outDir, "assets"),
56
+ format: "esm",
57
+ entryFileNames: "bundle-[hash].js",
58
+ chunkFileNames: "[name]-[hash].js",
59
+ minify: true,
60
+ },
61
+ });
62
+ rmSync(tmp, { recursive: true, force: true });
63
+
64
+ const entryChunk = result.output.find((o) => o.type === "chunk" && o.isEntry);
65
+ const bundleHref = `/assets/${entryChunk.fileName}`;
66
+
67
+ // Compose dist/index.html from the project shell: strip module entry scripts,
68
+ // compile + hash any local stylesheet links, and inject the bundle.
69
+ const indexPath = join(root, "index.html");
70
+ let html = existsSync(indexPath)
71
+ ? readFileSync(indexPath, "utf8")
72
+ : `<!doctype html><html lang="en"><head>
73
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
74
+ <title>OTF Web</title></head><body><div id="app"></div></body></html>`;
75
+
76
+ html = html.replace(
77
+ /<script\s+type=["']module["'][^>]*src=[^>]*>\s*<\/script>\s*/gi,
78
+ "",
79
+ );
80
+
81
+ // Compile each local <link rel="stylesheet" href="/..."> and rewrite the href.
82
+ const links = [...html.matchAll(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/gi)];
83
+ for (const [, href] of links) {
84
+ if (!href.startsWith("/")) continue; // leave external/CDN links alone
85
+ const src = join(root, href);
86
+ if (!existsSync(src)) continue;
87
+ const raw = readFileSync(src, "utf8");
88
+ const css = usesTailwind(raw) ? await compileCss(src, raw, root) : raw;
89
+ const name = basename(href).replace(/\.css$/, "");
90
+ const out = `${name}-${hash(css)}.css`;
91
+ writeFileSync(join(outDir, "assets", out), css);
92
+ html = html.replaceAll(href, `/assets/${out}`);
93
+ }
94
+
95
+ const script = `<script type="module" src="${bundleHref}"></script>\n`;
96
+ html = html.includes("</body>")
97
+ ? html.replace("</body>", `${script}</body>`)
98
+ : html + script;
99
+ writeFileSync(join(outDir, "index.html"), html);
100
+
101
+ // SSG: pre-render each route into static HTML using the shell we just composed
102
+ // (so per-route files carry the same bundle + stylesheet links).
103
+ let ssg = null;
104
+ if (process.argv.includes("--ssg")) {
105
+ const { runPrerender } = await import("./prerender.js");
106
+ ssg = await runPrerender({ root, pages, webEntry, otfwc, shellHtml: html, outDir });
107
+ }
108
+
109
+ // Copy the public/ directory (static assets served at the root), if present.
110
+ const publicDir = join(root, "public");
111
+ if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
112
+
113
+ const chunks = result.output.filter((o) => o.type === "chunk").length;
114
+ const ms = Math.round(performance.now() - t0);
115
+ console.log(`\n OTF Web build`);
116
+ console.log(` → dist/ (${pages.length} routes, ${chunks} chunks) in ${ms}ms`);
117
+ if (ssg) {
118
+ console.log(
119
+ ` → pre-rendered ${ssg.count} HTML file(s)` +
120
+ (ssg.skipped.length
121
+ ? `; skipped ${ssg.skipped.length} dynamic route(s) without getStaticPaths`
122
+ : ""),
123
+ );
124
+ }
125
+ console.log("");
126
+ }
package/src/cli.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bun
2
+ // `otfw` — the OTF Web toolchain CLI.
3
+ //
4
+ // otfw dev start the CSR dev server (watch + live-reload)
5
+ // otfw build produce a static production bundle in dist/
6
+ //
7
+ // The project root is the current working directory (its index.html + app/),
8
+ // like `vite` / `next`.
9
+
10
+ const cmd = process.argv[2];
11
+
12
+ switch (cmd) {
13
+ case "dev": {
14
+ const { runDev } = await import("./dev.js");
15
+ await runDev();
16
+ break;
17
+ }
18
+ case "build": {
19
+ const { runBuild } = await import("./build.js");
20
+ await runBuild();
21
+ break;
22
+ }
23
+ default: {
24
+ if (cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h") {
25
+ console.error(`unknown command: ${cmd}\n`);
26
+ }
27
+ console.log("otfw — OTF Web toolchain");
28
+ console.log("usage:");
29
+ console.log(" otfw dev start the dev server");
30
+ console.log(" otfw build build for production (dist/); --ssg to pre-render routes");
31
+ process.exit(cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h" ? 1 : 0);
32
+ }
33
+ }
package/src/dev.js ADDED
@@ -0,0 +1,220 @@
1
+ // `otfw dev` — the CSR dev server.
2
+ //
3
+ // Drives Rolldown in watch mode (the `otfwc` compiler as a transform plugin) and
4
+ // serves the project's index.html with WebSocket live-reload on rebuild. Tailwind
5
+ // stylesheets are compiled on the fly (see ./tailwind.js). The project root is the
6
+ // current working directory, like `vite` / `next dev`.
7
+
8
+ import { watch } from "rolldown";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { compileCss, usesTailwind } from "./tailwind.js";
13
+ import { overlayClient } from "./overlay.js";
14
+ import {
15
+ EXTENSIONS,
16
+ MIME,
17
+ cssPlugin,
18
+ discoverPages,
19
+ entrySource,
20
+ loadProject,
21
+ otfwPlugin,
22
+ } from "./shared.js";
23
+
24
+ // Resolve the start port. An explicit `--port <n>` / `-p <n>` / `--port=<n>` is
25
+ // honored exactly (fail fast if it's busy); with no flag we default to 3000 and
26
+ // scan upward for a free port. (No PORT env: this is a dev tool, and OpenTF's
27
+ // production output is static — there's no long-running server to take PORT.)
28
+ function resolvePort() {
29
+ const argv = process.argv.slice(3); // args after `dev`
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if ((a === "--port" || a === "-p") && argv[i + 1]) {
33
+ return { port: Number(argv[i + 1]), explicit: true };
34
+ }
35
+ if (a.startsWith("--port=")) {
36
+ return { port: Number(a.slice("--port=".length)), explicit: true };
37
+ }
38
+ }
39
+ return { port: 3000, explicit: false };
40
+ }
41
+
42
+ // Start `Bun.serve`. An explicit port is tried once (busy → fail fast); otherwise
43
+ // scan upward from `start` for the first free port (EADDRINUSE → next).
44
+ function serve(start, explicit, options) {
45
+ const end = explicit ? start : start + 99;
46
+ for (let port = start; port <= end; port++) {
47
+ try {
48
+ return Bun.serve({ ...options, port });
49
+ } catch (e) {
50
+ if (e?.code === "EADDRINUSE") {
51
+ if (explicit) {
52
+ console.error(`✗ port ${port} is already in use (pass a different --port)`);
53
+ process.exit(1);
54
+ }
55
+ continue;
56
+ }
57
+ throw e;
58
+ }
59
+ }
60
+ console.error(`✗ no free port found in ${start}–${end}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ export async function runDev() {
65
+ const { root, appDir, webEntry, otfwc, exclude } = loadProject();
66
+ const { port: startPort, explicit: explicitPort } = resolvePort();
67
+ if (!Number.isInteger(startPort) || startPort < 1 || startPort > 65535) {
68
+ console.error(`✗ invalid --port value: ${startPort}`);
69
+ process.exit(1);
70
+ }
71
+
72
+ const pages = discoverPages(appDir, exclude);
73
+ if (pages.length === 0) {
74
+ console.error(`✗ no page.jsx files found under ${appDir}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ const devDir = join(root, ".dev");
79
+ mkdirSync(join(devDir, "csr"), { recursive: true });
80
+ const entry = join(devDir, "entry.js");
81
+ writeFileSync(entry, entrySource(pages, appDir));
82
+
83
+ // WebSocket HMR: clients on the "hmr" topic get JSON messages — a successful
84
+ // rebuild sends { type: "reload" }; a compile/build failure sends
85
+ // { type: "error" } so the injected overlay shows it over the last good page.
86
+ let server;
87
+ const publish = (msg) => server?.publish("hmr", JSON.stringify(msg));
88
+ // Per-module compile diagnostics (keyed by id); a clean build clears them.
89
+ const compileErrors = new Map();
90
+
91
+ const watcher = watch({
92
+ input: entry,
93
+ resolve: { alias: { "@opentf/web": webEntry }, extensions: EXTENSIONS },
94
+ plugins: [
95
+ otfwPlugin(otfwc, {
96
+ onResult: (id, err) => (err ? compileErrors.set(id, err) : compileErrors.delete(id)),
97
+ }),
98
+ cssPlugin(),
99
+ ],
100
+ output: {
101
+ dir: join(devDir, "csr"),
102
+ format: "esm",
103
+ entryFileNames: "bundle.js",
104
+ },
105
+ });
106
+ watcher.on("event", (e) => {
107
+ if (e.code === "BUNDLE_END") {
108
+ e.result?.close?.();
109
+ if (compileErrors.size > 0) {
110
+ const [id, message] = [...compileErrors][0];
111
+ console.error(`✗ ${compileErrors.size} compile error(s)`);
112
+ publish({ type: "error", kind: "compile", id, message });
113
+ } else {
114
+ console.log(`✓ bundled in ${e.duration}ms`);
115
+ publish({ type: "reload" });
116
+ }
117
+ } else if (e.code === "ERROR") {
118
+ const message = e.error?.message ?? String(e.error);
119
+ console.error("✗ build error:\n", message);
120
+ publish({ type: "error", kind: "build", message, stack: e.error?.stack });
121
+ }
122
+ });
123
+
124
+ const indexPath = join(root, "index.html");
125
+
126
+ // Injected into the served HTML: our bundle + the dev error overlay / reload
127
+ // client (reconnects and reloads once the server is back after a restart).
128
+ const injected =
129
+ `<script type="module" src="/bundle.js"></script>\n` +
130
+ `<script>${overlayClient}</script>\n`;
131
+
132
+ // Use the project's index.html as the shell, stripping any module entry script
133
+ // (the app would be double-loaded) and injecting our bundle + reload client.
134
+ function buildHtml() {
135
+ let html;
136
+ if (existsSync(indexPath)) {
137
+ html = readFileSync(indexPath, "utf8").replace(
138
+ /<script\s+type=["']module["'][^>]*src=[^>]*>\s*<\/script>\s*/gi,
139
+ "",
140
+ );
141
+ } else {
142
+ html = `<!doctype html><html lang="en"><head>
143
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <title>OTF Web</title></head><body><div id="app"></div></body></html>`;
145
+ }
146
+ return html.includes("</body>")
147
+ ? html.replace("</body>", `${injected}</body>`)
148
+ : html + injected;
149
+ }
150
+
151
+ // Serve a static file from the project root (no traversal outside it). Tailwind
152
+ // entry stylesheets are compiled on request.
153
+ async function serveStatic(pathname) {
154
+ const file = join(root, pathname);
155
+ if (!file.startsWith(root) || !existsSync(file)) return null;
156
+ const ext = pathname.split(".").pop();
157
+ if (ext === "css") {
158
+ const source = readFileSync(file, "utf8");
159
+ const css = usesTailwind(source)
160
+ ? await compileCss(file, source, root).catch((err) => {
161
+ console.error(`✗ tailwind failed for ${pathname}:\n${err?.message ?? err}`);
162
+ return source;
163
+ })
164
+ : source;
165
+ return new Response(css, { headers: { "content-type": "text/css" } });
166
+ }
167
+ return new Response(readFileSync(file), {
168
+ headers: { "content-type": MIME[ext] ?? "application/octet-stream" },
169
+ });
170
+ }
171
+
172
+ server = serve(startPort, explicitPort, {
173
+ websocket: {
174
+ open: (ws) => {
175
+ ws.subscribe("hmr");
176
+ // A client connecting while a compile error is outstanding sees it now.
177
+ if (compileErrors.size > 0) {
178
+ const [id, message] = [...compileErrors][0];
179
+ ws.send(JSON.stringify({ type: "error", kind: "compile", id, message }));
180
+ }
181
+ },
182
+ },
183
+ async fetch(req, srv) {
184
+ const { pathname } = new URL(req.url);
185
+ if (pathname === "/__hmr") {
186
+ return srv.upgrade(req)
187
+ ? undefined
188
+ : new Response("upgrade failed", { status: 400 });
189
+ }
190
+ // Built output: the entry bundle and code-split chunks live in .dev/csr.
191
+ if (pathname.endsWith(".js")) {
192
+ const built = join(devDir, "csr", pathname);
193
+ if (built.startsWith(join(devDir, "csr") + "/") && existsSync(built)) {
194
+ return new Response(readFileSync(built), {
195
+ headers: { "content-type": "text/javascript" },
196
+ });
197
+ }
198
+ if (pathname === "/bundle.js") {
199
+ return new Response("// building…", {
200
+ headers: { "content-type": "text/javascript" },
201
+ });
202
+ }
203
+ }
204
+ // Static assets referenced by index.html (css, public/, etc).
205
+ if (pathname !== "/") {
206
+ const asset = await serveStatic(pathname);
207
+ if (asset) return asset;
208
+ if (/\.[a-z0-9]+$/i.test(pathname)) {
209
+ return new Response("not found", { status: 404 });
210
+ }
211
+ }
212
+ return new Response(buildHtml(), {
213
+ headers: { "content-type": "text/html" },
214
+ });
215
+ },
216
+ });
217
+
218
+ console.log(`\n OTF Web dev server`);
219
+ console.log(` → http://localhost:${server.port} (${pages.length} routes)\n`);
220
+ }
package/src/overlay.js ADDED
@@ -0,0 +1,66 @@
1
+ // The dev-mode error overlay client. Injected as an inline <script> into the
2
+ // served HTML, so it is intentionally a self-contained IIFE with no imports.
3
+ //
4
+ // It surfaces two error sources, Next.js-style:
5
+ // • compile errors — pushed from the dev server over the HMR WebSocket
6
+ // ({ type: "error" }); a successful rebuild sends { type: "reload" }.
7
+ // • runtime errors — the runtime's `otfw:error` window event (render/effect/
8
+ // mount/route), plus uncaught `error` / `unhandledrejection`.
9
+ // `otfw:error-clear` (a good navigation) and a successful reload dismiss it.
10
+
11
+ export const overlayClient = `(() => {
12
+ const C = { bg:"rgba(8,8,12,.86)", panel:"#161618", line:"#2c2c30", red:"#ff5c5c", head:"#2a1416", text:"#ececf0", dim:"#9aa0a6" };
13
+ let root, titleEl, msgEl, stackEl, fileEl;
14
+ const css = (el, s) => { el.style.cssText = s; return el; };
15
+ function ensure() {
16
+ if (root) return;
17
+ root = css(document.createElement("div"), "position:fixed;inset:0;z-index:2147483647;background:"+C.bg+";display:none;align-items:center;justify-content:center;padding:6vh 4vw;font-family:ui-monospace,SFMono-Regular,Menlo,monospace");
18
+ const panel = css(document.createElement("div"), "max-width:980px;width:100%;max-height:88vh;display:flex;flex-direction:column;background:"+C.panel+";border:1px solid "+C.line+";border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.55);overflow:hidden");
19
+ const bar = css(document.createElement("div"), "display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid "+C.line+";background:"+C.head);
20
+ const badge = css(document.createElement("span"), "font-weight:700;font-size:11px;letter-spacing:.6px;color:#fff;background:"+C.red+";padding:3px 8px;border-radius:6px");
21
+ badge.textContent = "ERROR";
22
+ titleEl = css(document.createElement("span"), "color:"+C.red+";font-weight:600;font-size:14px");
23
+ const spacer = css(document.createElement("span"), "flex:1");
24
+ fileEl = css(document.createElement("span"), "color:"+C.dim+";font-size:12px;max-width:42%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap");
25
+ const close = css(document.createElement("button"), "border:0;background:transparent;color:"+C.dim+";cursor:pointer;font-size:18px;line-height:1;padding:0 2px");
26
+ close.textContent = "\\u2715"; close.title = "Dismiss (Esc)"; close.onclick = hide;
27
+ bar.append(badge, titleEl, spacer, fileEl, close);
28
+ msgEl = css(document.createElement("div"), "padding:16px 18px;color:"+C.text+";font-size:14px;white-space:pre-wrap;line-height:1.55");
29
+ stackEl = css(document.createElement("pre"), "margin:0;padding:0 18px 18px;color:"+C.dim+";font-size:12.5px;white-space:pre-wrap;overflow:auto;flex:1");
30
+ panel.append(bar, msgEl, stackEl);
31
+ root.appendChild(panel);
32
+ root.addEventListener("click", (e) => { if (e.target === root) hide(); });
33
+ document.body.appendChild(root);
34
+ }
35
+ function show(o) {
36
+ ensure();
37
+ titleEl.textContent = o.title || "Error";
38
+ fileEl.textContent = o.file || "";
39
+ msgEl.textContent = o.message != null ? String(o.message) : "";
40
+ stackEl.textContent = o.stack || "";
41
+ root.style.display = "flex";
42
+ }
43
+ function hide() { if (root) root.style.display = "none"; }
44
+
45
+ window.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
46
+ window.addEventListener("error", (e) => show({ title:"Runtime error", message:e.message, stack:e.error && e.error.stack, file:e.filename }));
47
+ window.addEventListener("unhandledrejection", (e) => { const r = e.reason || {}; show({ title:"Unhandled promise rejection", message:r.message || String(r), stack:r.stack }); });
48
+ window.addEventListener("otfw:error", (e) => {
49
+ const d = e.detail || {}, ctx = d.context || {}, err = d.error || {};
50
+ const title = (ctx.phase ? "Error during " + ctx.phase : "Runtime error") + (ctx.component ? " \\u2014 <" + ctx.component + ">" : "");
51
+ show({ title, message: err.message || String(err), stack: err.stack, file: ctx.path });
52
+ });
53
+ window.addEventListener("otfw:error-clear", hide);
54
+
55
+ const url = (location.protocol === "https:" ? "wss" : "ws") + "://" + location.host + "/__hmr";
56
+ const connect = () => {
57
+ const ws = new WebSocket(url);
58
+ ws.onmessage = (ev) => {
59
+ let m; try { m = JSON.parse(ev.data); } catch { m = { type: "reload" }; }
60
+ if (m.type === "reload") location.reload();
61
+ else if (m.type === "error") show({ title:"Compile error", message:m.message, file:m.id, stack:m.stack });
62
+ };
63
+ ws.onclose = () => setTimeout(connect, 1000);
64
+ };
65
+ connect();
66
+ })();`;
@@ -0,0 +1,88 @@
1
+ // SSG pre-render (ARCHITECTURE.md §6): build a *server* bundle of the app with the
2
+ // compiler's SSG backend (HTML-string renderers), then run it in plain Bun to
3
+ // render each route to a static HTML file. No DOM — the SSG output is pure string
4
+ // concatenation, so no effects/lifecycle run.
5
+
6
+ import { build } from "rolldown";
7
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ import { EXTENSIONS, cssPlugin, otfwPlugin } from "./shared.js";
12
+
13
+ // Generated server entry: eager-import every page module (so registerRoutes sees
14
+ // real namespaces, enabling getStaticPaths) and re-export the render API.
15
+ function serverEntrySource(pages) {
16
+ const imports = pages.map((p, i) => `import * as p${i} from ${JSON.stringify(p)};`).join("\n");
17
+ const map = pages.map((p, i) => ` [${JSON.stringify(p)}]: p${i},`).join("\n");
18
+ return (
19
+ `${imports}\n` +
20
+ `import { registerRoutes } from "@opentf/web";\n` +
21
+ `export { renderToString, collectRoutePaths } from "@opentf/web/server";\n` +
22
+ `registerRoutes({\n${map}\n});\n`
23
+ );
24
+ }
25
+
26
+ // Inject pre-rendered markup into the shell's #app container.
27
+ function injectMarkup(shellHtml, markup) {
28
+ return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, `$1${markup}$2`);
29
+ }
30
+
31
+ // "/" → dist/index.html, "/post/1" → dist/post/1/index.html.
32
+ function htmlPathFor(outDir, route) {
33
+ return route === "/" ? join(outDir, "index.html") : join(outDir, route, "index.html");
34
+ }
35
+
36
+ /**
37
+ * Pre-render the app to static HTML files under `outDir`. Returns
38
+ * `{ count, skipped, failed }`.
39
+ */
40
+ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir }) {
41
+ const tmp = join(root, ".otfw-ssg");
42
+ mkdirSync(tmp, { recursive: true });
43
+ const entry = join(tmp, "ssg-entry.js");
44
+ writeFileSync(entry, serverEntrySource(pages));
45
+
46
+ const serverApi = join(dirname(webEntry), "server", "index.js");
47
+ await build({
48
+ input: entry,
49
+ resolve: {
50
+ alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
51
+ extensions: EXTENSIONS,
52
+ },
53
+ plugins: [otfwPlugin(otfwc, { failOnError: true, target: "ssg" }), cssPlugin()],
54
+ output: { dir: join(tmp, "out"), format: "esm", entryFileNames: "server.js" },
55
+ });
56
+
57
+ // The runtime defines `class … extends HTMLElement` at load (for CSR custom
58
+ // elements). SSG never instantiates them, but the base class must exist so the
59
+ // class definitions evaluate. A bare stub suffices — no DOM (customElements
60
+ // stays undefined, so the elements self-register only in the browser).
61
+ globalThis.HTMLElement ??= class {};
62
+ const mod = await import(pathToFileURL(join(tmp, "out", "server.js")).href);
63
+
64
+ const { paths, skipped } = await mod.collectRoutePaths();
65
+ const failed = [];
66
+ for (const route of paths) {
67
+ try {
68
+ const markup = (await mod.renderToString(route)) ?? "";
69
+ const file = htmlPathFor(outDir, route);
70
+ mkdirSync(dirname(file), { recursive: true });
71
+ writeFileSync(file, injectMarkup(shellHtml, markup));
72
+ } catch (e) {
73
+ failed.push(route);
74
+ console.error(`✗ pre-render failed for ${route}: ${e?.message ?? e}`);
75
+ }
76
+ }
77
+
78
+ // 404: any unmatched path resolves to the registered 404 page.
79
+ try {
80
+ const notFound = await mod.renderToString("/__otfw_404__");
81
+ if (notFound != null) writeFileSync(join(outDir, "404.html"), injectMarkup(shellHtml, notFound));
82
+ } catch {
83
+ /* no 404 page */
84
+ }
85
+
86
+ rmSync(tmp, { recursive: true, force: true });
87
+ return { count: paths.length - failed.length, skipped, failed };
88
+ }
package/src/shared.js ADDED
@@ -0,0 +1,206 @@
1
+ // Shared plumbing for the OTF Web toolchain (`otfw dev` / `otfw build`).
2
+ //
3
+ // Both commands treat the current working directory as the project root (its
4
+ // `index.html` + `app/`), resolve `@opentf/web` via node resolution, run the
5
+ // `otfwc` IR compiler as a Rolldown `transform` plugin, and let Rolldown link the
6
+ // module graph. This module holds everything they have in common.
7
+
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { otfwcPath } from "@opentf/web-compiler";
13
+
14
+ export const EXTENSIONS = [".jsx", ".tsx", ".js", ".ts"];
15
+
16
+ export const MIME = {
17
+ css: "text/css",
18
+ js: "text/javascript",
19
+ json: "application/json",
20
+ svg: "image/svg+xml",
21
+ png: "image/png",
22
+ jpg: "image/jpeg",
23
+ ico: "image/x-icon",
24
+ woff2: "font/woff2",
25
+ };
26
+
27
+ /** Nearest ancestor directory of `from` (inclusive) that contains `name`. */
28
+ export function findUp(name, from) {
29
+ let dir = from;
30
+ while (true) {
31
+ if (existsSync(join(dir, name))) return dir;
32
+ const parent = dirname(dir);
33
+ if (parent === dir) return null;
34
+ dir = parent;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Resolve the project and toolchain: the app being built (cwd), the runtime
40
+ * package, the `otfwc` compiler, and the excluded routes. Exits with a clear
41
+ * message on any hard failure. Builds the compiler on demand from the workspace.
42
+ */
43
+ export function loadProject() {
44
+ const root = process.cwd();
45
+
46
+ const appDir = join(root, "app");
47
+ if (!existsSync(appDir)) {
48
+ fail(
49
+ `no app/ directory in ${root}\n run otfw from your project root (the folder with index.html and app/).`,
50
+ );
51
+ }
52
+
53
+ // Resolve the runtime the way the bundler will, so it works as an installed
54
+ // dependency or a workspace package — no hardcoded path.
55
+ let webEntry;
56
+ try {
57
+ webEntry = Bun.resolveSync("@opentf/web", root);
58
+ } catch {
59
+ fail(`cannot resolve "@opentf/web" from ${root}\n add it to your dependencies.`);
60
+ }
61
+
62
+ // Locate the `otfwc` compiler. Published: the prebuilt binary from `@opentf/web-compiler`
63
+ // (a dependency of this CLI). In this repo's own dev (a Cargo workspace is found
64
+ // above the CLI): the cargo `target/debug` build, rebuilt on demand. `OTFWC_BIN`
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
+ }
81
+
82
+ // forms-demo depends on @opentf/web-form, which is not yet ported to the new
83
+ // runtime (it lands with the Project Graph). Override with EXCLUDE_ROUTES.
84
+ const exclude = new Set(
85
+ (process.env.EXCLUDE_ROUTES ?? "forms-demo").split(",").filter(Boolean),
86
+ );
87
+
88
+ return { root, appDir, webEntry, otfwc, workspace, exclude };
89
+ }
90
+
91
+ function ensureCompiler(otfwc, workspace) {
92
+ if (existsSync(otfwc)) return;
93
+ if (!workspace) fail(`otfwc compiler not found at ${otfwc}`);
94
+ console.log("building compiler (cargo build -p otfw_cli)…");
95
+ const b = Bun.spawnSync(["cargo", "build", "-p", "otfw_cli"], {
96
+ cwd: workspace,
97
+ stdout: "inherit",
98
+ stderr: "inherit",
99
+ });
100
+ if (b.exitCode !== 0) process.exit(b.exitCode);
101
+ }
102
+
103
+ /** Discover file-based routes under `app/`: every page/layout and the 404. */
104
+ export function discoverPages(dir, exclude) {
105
+ const out = [];
106
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
107
+ if (entry.isDirectory() && exclude.has(entry.name)) continue;
108
+ const full = `${dir}/${entry.name}`;
109
+ if (entry.isDirectory()) out.push(...discoverPages(full, exclude));
110
+ else if (/^(page|layout|404)\.[jt]sx$/.test(entry.name)) out.push(full);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** The optional `app/routeGuard.{js,ts}` path, or null. */
116
+ export function findGuard(appDir) {
117
+ return [join(appDir, "routeGuard.js"), join(appDir, "routeGuard.ts")].find(
118
+ existsSync,
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The app entry source: hand `mountApp` a route map of lazy `() => import()`
124
+ * loaders (so each route code-splits into its own chunk) plus the optional guard.
125
+ */
126
+ export function entrySource(pages, appDir) {
127
+ const map = pages
128
+ .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(p)}),`)
129
+ .join("\n");
130
+ const guard = findGuard(appDir);
131
+ return (
132
+ `import { mountApp } from "@opentf/web";\n` +
133
+ (guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
134
+ `mountApp({\n pages: {\n${map}\n },\n` +
135
+ ` target: document.getElementById("app"),${guard ? "\n guard," : ""}\n});\n`
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Rolldown plugin: compile `.jsx`/`.tsx` through the `otfwc` IR compiler. Page /
141
+ * layout / 404 modules become factories; everything else a Custom Element. On a
142
+ * compile error it emits a diagnostic stub (so one bad route doesn't sink the
143
+ * build) unless `failOnError` is set (production builds should fail loudly).
144
+ * `onResult(id, errorMessageOrNull)` is called per module so the dev server can
145
+ * push compile diagnostics to the error overlay and clear them once fixed.
146
+ */
147
+ export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr" } = {}) {
148
+ return {
149
+ name: "otfw",
150
+ transform(code, id) {
151
+ if (!/\.[jt]sx$/.test(id)) return null;
152
+ const base = id.split("/").pop().replace(/\.[jt]sx$/, "");
153
+ const isPage = base === "page" || base === "layout" || base === "404";
154
+ const args = ["build"];
155
+ if (!isPage) args.push("--component");
156
+ if (target === "ssg") args.push("--target=ssg");
157
+ args.push("--stdin", id);
158
+ const proc = Bun.spawnSync([otfwc, ...args], {
159
+ stdin: new TextEncoder().encode(code),
160
+ });
161
+ if (proc.exitCode !== 0) {
162
+ const msg = proc.stderr.toString();
163
+ onResult?.(id, msg);
164
+ if (failOnError) {
165
+ this.error(`otfwc failed for ${id}:\n${msg}`);
166
+ }
167
+ console.error(`✗ otfwc failed for ${id}:\n${msg}`);
168
+ const stub =
169
+ `export default function () { const pre = document.createElement("pre");` +
170
+ ` pre.style.cssText = "color:#f87171;padding:1rem;white-space:pre-wrap";` +
171
+ ` pre.textContent = ${JSON.stringify(`Compile error in ${id}\n\n${msg}`)};` +
172
+ ` return pre; }`;
173
+ return { code: stub, moduleSideEffects: true };
174
+ }
175
+ onResult?.(id, null);
176
+ // Side effects (e.g. customElements.define) must survive bundling.
177
+ return { code: proc.stdout.toString(), moduleSideEffects: true };
178
+ },
179
+ };
180
+ }
181
+
182
+ /**
183
+ * CSS plugin: `import "./x.css"` injects a <style>; `*.module.css` resolves to an
184
+ * identity class-name map (`styles.foo` → "foo"). Dev-grade CSS Modules.
185
+ */
186
+ export function cssPlugin() {
187
+ return {
188
+ name: "css",
189
+ transform(code, id) {
190
+ if (!id.endsWith(".css")) return null;
191
+ const inject =
192
+ `const __s = document.createElement("style");` +
193
+ ` __s.textContent = ${JSON.stringify(code)};` +
194
+ ` document.head.appendChild(__s);`;
195
+ const out = id.endsWith(".module.css")
196
+ ? `${inject}\nexport default new Proxy({}, { get: (_, k) => k });`
197
+ : `${inject}\nexport default ${JSON.stringify(code)};`;
198
+ return { code: out, moduleSideEffects: true };
199
+ },
200
+ };
201
+ }
202
+
203
+ function fail(msg) {
204
+ console.error(`✗ ${msg}`);
205
+ process.exit(1);
206
+ }
@@ -0,0 +1,43 @@
1
+ // Tailwind CSS v4 compilation for the dev server.
2
+ //
3
+ // We drive Tailwind's engine directly: `@tailwindcss/node` resolves `@import
4
+ // "tailwindcss"` and the theme/utility layers, and `@tailwindcss/oxide` scans the
5
+ // project for class-name candidates. This is the same engine the editor tooling
6
+ // uses, invoked as a library — no separate Tailwind config or build step.
7
+
8
+ import { compile } from "@tailwindcss/node";
9
+ import { Scanner } from "@tailwindcss/oxide";
10
+ import { dirname } from "node:path";
11
+
12
+ // A stylesheet uses Tailwind if it pulls in the framework (v4 `@import
13
+ // "tailwindcss"`) or any legacy `@tailwind` directive. Plain CSS is served as-is.
14
+ export function usesTailwind(source) {
15
+ return /@import\s+["']tailwindcss["']|@tailwind\b/.test(source);
16
+ }
17
+
18
+ /**
19
+ * Compile a Tailwind entry stylesheet to plain CSS, scanning `base` (the project
20
+ * root) for the utility classes actually used.
21
+ *
22
+ * @param {string} cssPath absolute path to the entry `.css`
23
+ * @param {string} source its contents
24
+ * @param {string} base project root to scan for class-name candidates
25
+ * @returns {Promise<string>} the generated CSS
26
+ */
27
+ export async function compileCss(cssPath, source, base) {
28
+ const compiler = await compile(source, {
29
+ base: dirname(cssPath),
30
+ onDependency: () => {},
31
+ });
32
+ // Decide what to scan: `source(none)` disables scanning, `source(dir)` narrows
33
+ // it, otherwise scan the whole project root. Always include the compiler's own
34
+ // declared sources.
35
+ const roots =
36
+ compiler.root === "none"
37
+ ? []
38
+ : compiler.root === null
39
+ ? [{ base, pattern: "**/*", negated: false }]
40
+ : [{ ...compiler.root, negated: false }];
41
+ const scanner = new Scanner({ sources: roots.concat(compiler.sources) });
42
+ return compiler.build(scanner.scan());
43
+ }