@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 +47 -0
- package/src/build.js +126 -0
- package/src/cli.js +33 -0
- package/src/dev.js +220 -0
- package/src/overlay.js +66 -0
- package/src/prerender.js +88 -0
- package/src/shared.js +206 -0
- package/src/tailwind.js +43 -0
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
|
+
})();`;
|
package/src/prerender.js
ADDED
|
@@ -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
|
+
}
|
package/src/tailwind.js
ADDED
|
@@ -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
|
+
}
|