@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/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @opentf/web-cli
|
|
2
|
+
|
|
3
|
+
The **OTF Web** dev toolchain — the `otfw` command. A [Rolldown](https://rolldown.rs)-driven
|
|
4
|
+
CSR dev server, a production build, and static pre-rendering (SSG), with the IR
|
|
5
|
+
compiler ([`@opentf/web-compiler`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/web-compiler))
|
|
6
|
+
running as a transform plugin. Runs on [Bun](https://bun.sh).
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bun add -d @opentf/web-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It depends on `@opentf/web-compiler` (the prebuilt compiler binary) and expects
|
|
15
|
+
`@opentf/web` in your project. The fastest way to a working setup is
|
|
16
|
+
[`@opentf/create-web`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/create-web).
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
The project root is the current working directory — its `index.html` plus a
|
|
21
|
+
file-based `app/` route tree (`page.jsx`, `layout.jsx`, `404.jsx`).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
otfw dev # start the dev server (watch + live reload)
|
|
25
|
+
otfw build # production bundle in dist/ (hashed, code-split, minified)
|
|
26
|
+
otfw build --ssg # build, then pre-render each static route to HTML
|
|
27
|
+
otfw serve # build, then server-render each route per request (SSR)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **`dev`** — bundles the route graph through Rolldown with the compiler as a
|
|
31
|
+
transform, serves it, and live-reloads on change. Picks port 3000, scanning
|
|
32
|
+
upward if it's taken; an explicit `--port` fails fast if busy.
|
|
33
|
+
- **`build`** — emits `dist/` from your `index.html`, compiling and hashing local
|
|
34
|
+
stylesheets (TailwindCSS v4 supported out of the box).
|
|
35
|
+
- **`build --ssg`** — additionally pre-renders each route with `getStaticPaths`
|
|
36
|
+
into static HTML.
|
|
37
|
+
- **`serve`** — builds `dist/`, then runs a per-request SSR server: assets are served
|
|
38
|
+
from `dist/` and every navigation is server-rendered through the same path SSG uses.
|
|
39
|
+
Picks port 3000 (scanning upward), or `--port` for an explicit one. Phase 1: the page
|
|
40
|
+
becomes interactive via the client bundle (CSR mount); hydration is a later phase.
|
|
41
|
+
|
|
42
|
+
`OTFWC_BIN` overrides the compiler binary location (used for compiler development).
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT © [Open Tech Foundation](https://github.com/Open-Tech-Foundation)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentf/web-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OTF Web dev toolchain — the Rolldown-driven CSR dev server, production build, and SSG.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/cli.js"
|
|
11
11
|
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bun test tests/",
|
|
14
|
+
"test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/hydrate-browser.mjs"
|
|
15
|
+
},
|
|
12
16
|
"files": [
|
|
13
17
|
"src"
|
|
14
18
|
],
|
|
@@ -16,7 +20,7 @@
|
|
|
16
20
|
"bun": ">=1.0.0"
|
|
17
21
|
},
|
|
18
22
|
"dependencies": {
|
|
19
|
-
"@opentf/web-compiler": "0.
|
|
23
|
+
"@opentf/web-compiler": "0.2.0",
|
|
20
24
|
"@tailwindcss/node": "4.2.2",
|
|
21
25
|
"@tailwindcss/oxide": "4.2.2",
|
|
22
26
|
"rolldown": "1.0.0-rc.17",
|
package/src/build.js
CHANGED
|
@@ -21,13 +21,32 @@ import {
|
|
|
21
21
|
cssPlugin,
|
|
22
22
|
discoverPages,
|
|
23
23
|
entrySource,
|
|
24
|
+
injectBeforeBody,
|
|
25
|
+
loadConfig,
|
|
26
|
+
loadDocsPlugins,
|
|
24
27
|
loadProject,
|
|
25
28
|
otfwPlugin,
|
|
29
|
+
readHtmlShell,
|
|
30
|
+
runBlogFeed,
|
|
31
|
+
runDocsSearchIndex,
|
|
32
|
+
runLastUpdated,
|
|
33
|
+
stampHydrateSentinel,
|
|
26
34
|
} from "./shared.js";
|
|
35
|
+
import { fmtMs, step } from "./reporter.js";
|
|
27
36
|
|
|
28
37
|
const hash = (s) => Bun.hash(s).toString(16).padStart(16, "0").slice(0, 8);
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
// Site origin for absolute canonical / sitemap URLs. Priority: `--base-url=` flag,
|
|
40
|
+
// then `otfw.config` (`{ site: { url } }`), else "" (relative canonicals, sitemap
|
|
41
|
+
// skipped with a warning).
|
|
42
|
+
function resolveBaseUrl(config) {
|
|
43
|
+
const flag = process.argv.find((a) => a.startsWith("--base-url="));
|
|
44
|
+
if (flag) return flag.slice("--base-url=".length).replace(/\/+$/, "");
|
|
45
|
+
if (config?.site?.url) return String(config.site.url).replace(/\/+$/, "");
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runBuild(options = {}) {
|
|
31
50
|
const { root, appDir, webEntry, otfwc, exclude } = loadProject();
|
|
32
51
|
const t0 = performance.now();
|
|
33
52
|
|
|
@@ -37,6 +56,16 @@ export async function runBuild() {
|
|
|
37
56
|
process.exit(1);
|
|
38
57
|
}
|
|
39
58
|
|
|
59
|
+
// Build the client for hydration when there will be server markup to adopt — the
|
|
60
|
+
// SSR server (`runBuild({ hydrate: true })`) and `--ssg` pre-rendered pages. A
|
|
61
|
+
// plain CSR build mounts into an empty `#app`, so it keeps the leaner CSR bundle.
|
|
62
|
+
const hydrate = options.hydrate ?? process.argv.includes("--ssg");
|
|
63
|
+
|
|
64
|
+
// Docs generator: resolve `@opentf/web-docs/nav` to the build-time nav tree when
|
|
65
|
+
// the project has a `docs` config block.
|
|
66
|
+
const config = await loadConfig(root);
|
|
67
|
+
const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
|
|
68
|
+
|
|
40
69
|
const outDir = join(root, "dist");
|
|
41
70
|
rmSync(outDir, { recursive: true, force: true });
|
|
42
71
|
mkdirSync(join(outDir, "assets"), { recursive: true });
|
|
@@ -45,12 +74,27 @@ export async function runBuild() {
|
|
|
45
74
|
const tmp = join(root, ".otfw");
|
|
46
75
|
mkdirSync(tmp, { recursive: true });
|
|
47
76
|
const entry = join(tmp, "entry.js");
|
|
48
|
-
writeFileSync(entry, entrySource(pages, appDir));
|
|
77
|
+
writeFileSync(entry, entrySource(pages, appDir, undefined, config?.i18n, config?.nav));
|
|
78
|
+
|
|
79
|
+
console.log("\n OTF Web — production build\n");
|
|
49
80
|
|
|
81
|
+
// Phase 1: compile every route/component (jsx/mdx → native DOM) and bundle. The
|
|
82
|
+
// compiler runs as a synchronous subprocess per file, so the per-file `onResult`
|
|
83
|
+
// is what drives the live progress line.
|
|
84
|
+
const buildStep = step("Compiling routes & components");
|
|
85
|
+
let compiled = 0;
|
|
50
86
|
const result = await build({
|
|
51
87
|
input: entry,
|
|
52
88
|
resolve: { alias: { "@opentf/web": webEntry }, extensions: EXTENSIONS },
|
|
53
|
-
plugins: [
|
|
89
|
+
plugins: [
|
|
90
|
+
...docsPlugins,
|
|
91
|
+
otfwPlugin(otfwc, {
|
|
92
|
+
failOnError: true,
|
|
93
|
+
target: hydrate ? "hydrate" : "csr",
|
|
94
|
+
onResult: (id) => buildStep.update(`${basename(id)} (${++compiled})`),
|
|
95
|
+
}),
|
|
96
|
+
cssPlugin(),
|
|
97
|
+
],
|
|
54
98
|
output: {
|
|
55
99
|
dir: join(outDir, "assets"),
|
|
56
100
|
format: "esm",
|
|
@@ -58,6 +102,9 @@ export async function runBuild() {
|
|
|
58
102
|
chunkFileNames: "[name]-[hash].js",
|
|
59
103
|
minify: true,
|
|
60
104
|
},
|
|
105
|
+
// The compiler runs a subprocess per file, so it dominates plugin time by design;
|
|
106
|
+
// silence Rolldown's plugin-timing advisory rather than print it every build.
|
|
107
|
+
checks: { pluginTimings: false },
|
|
61
108
|
});
|
|
62
109
|
rmSync(tmp, { recursive: true, force: true });
|
|
63
110
|
|
|
@@ -65,20 +112,15 @@ export async function runBuild() {
|
|
|
65
112
|
const bundleHref = `/assets/${entryChunk.fileName}`;
|
|
66
113
|
|
|
67
114
|
// Compose dist/index.html from the project shell: strip module entry scripts,
|
|
68
|
-
// compile + hash any local stylesheet links, and inject the bundle.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
);
|
|
115
|
+
// compile + hash any local stylesheet links, and inject the bundle. When the
|
|
116
|
+
// client was built for hydration, stamp the `#app` sentinel so the client adopts
|
|
117
|
+
// the server markup (this shell is also the SSG pre-render template, so each
|
|
118
|
+
// pre-rendered page inherits the sentinel).
|
|
119
|
+
let html = readHtmlShell(root);
|
|
120
|
+
if (hydrate) html = stampHydrateSentinel(html);
|
|
80
121
|
|
|
81
122
|
// Compile each local <link rel="stylesheet" href="/..."> and rewrite the href.
|
|
123
|
+
buildStep.update("styles");
|
|
82
124
|
const links = [...html.matchAll(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/gi)];
|
|
83
125
|
for (const [, href] of links) {
|
|
84
126
|
if (!href.startsWith("/")) continue; // leave external/CDN links alone
|
|
@@ -93,34 +135,64 @@ export async function runBuild() {
|
|
|
93
135
|
}
|
|
94
136
|
|
|
95
137
|
const script = `<script type="module" src="${bundleHref}"></script>\n`;
|
|
96
|
-
html = html
|
|
97
|
-
? html.replace("</body>", `${script}</body>`)
|
|
98
|
-
: html + script;
|
|
138
|
+
html = injectBeforeBody(html, script);
|
|
99
139
|
writeFileSync(join(outDir, "index.html"), html);
|
|
100
140
|
|
|
141
|
+
const chunks = result.output.filter((o) => o.type === "chunk").length;
|
|
142
|
+
buildStep.done(`Compiled ${pages.length} routes · bundled ${chunks} chunks`);
|
|
143
|
+
|
|
101
144
|
// SSG: pre-render each route into static HTML using the shell we just composed
|
|
102
145
|
// (so per-route files carry the same bundle + stylesheet links).
|
|
103
146
|
let ssg = null;
|
|
104
147
|
if (process.argv.includes("--ssg")) {
|
|
148
|
+
const baseUrl = resolveBaseUrl(config);
|
|
105
149
|
const { runPrerender } = await import("./prerender.js");
|
|
106
|
-
|
|
150
|
+
// Per-page last-updated map (git/frontmatter) for the article:modified_time tag.
|
|
151
|
+
const lastUpdated = await runLastUpdated(root, appDir, config, exclude);
|
|
152
|
+
const ssgStep = step("Pre-rendering pages");
|
|
153
|
+
let ssgCompiled = 0;
|
|
154
|
+
ssg = await runPrerender({
|
|
155
|
+
root,
|
|
156
|
+
pages,
|
|
157
|
+
webEntry,
|
|
158
|
+
otfwc,
|
|
159
|
+
shellHtml: html,
|
|
160
|
+
outDir,
|
|
161
|
+
baseUrl,
|
|
162
|
+
docsPlugins,
|
|
163
|
+
lastUpdated,
|
|
164
|
+
i18n: config?.i18n,
|
|
165
|
+
onCompile: (id) => ssgStep.update(`compiling ${basename(id)} (${++ssgCompiled})`),
|
|
166
|
+
onRender: (done, total) => ssgStep.update(`rendering ${done}/${total}`),
|
|
167
|
+
});
|
|
168
|
+
ssgStep.done(
|
|
169
|
+
`Pre-rendered ${ssg.count} page(s)` +
|
|
170
|
+
(ssg.skipped.length ? ` · ${ssg.skipped.length} dynamic route(s) skipped` : ""),
|
|
171
|
+
);
|
|
107
172
|
}
|
|
108
173
|
|
|
109
174
|
// Copy the public/ directory (static assets served at the root), if present.
|
|
110
175
|
const publicDir = join(root, "public");
|
|
111
176
|
if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
|
|
112
177
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
` → pre-rendered ${ssg.count} HTML file(s)` +
|
|
120
|
-
(ssg.skipped.length
|
|
121
|
-
? `; skipped ${ssg.skipped.length} dynamic route(s) without getStaticPaths`
|
|
122
|
-
: ""),
|
|
178
|
+
// Docs search: index the pre-rendered HTML with Pagefind (when SSG + opted in).
|
|
179
|
+
let search = null;
|
|
180
|
+
if (ssg && config?.docs?.search?.provider === "pagefind") {
|
|
181
|
+
const searchStep = step("Building search index");
|
|
182
|
+
search = await runDocsSearchIndex(root, config, outDir, (done, total) =>
|
|
183
|
+
searchStep.update(`${done}/${total} pages`),
|
|
123
184
|
);
|
|
185
|
+
searchStep.done(`Search index — ${search?.pages ?? 0} page(s)`);
|
|
124
186
|
}
|
|
125
|
-
|
|
187
|
+
|
|
188
|
+
// Blog RSS feed (when a `blog` block + site URL are configured). Written after the
|
|
189
|
+
// public/ copy so a project-supplied feed override isn't clobbered.
|
|
190
|
+
if (config?.blog) {
|
|
191
|
+
const feedStep = step("Generating RSS feed");
|
|
192
|
+
const feed = await runBlogFeed(root, appDir, config, outDir, resolveBaseUrl(config), exclude);
|
|
193
|
+
if (feed) feedStep.done(`RSS feed — ${feed.count} post(s) → ${feed.path}`);
|
|
194
|
+
else feedStep.done("RSS feed — skipped");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(`\n → dist/ ready in ${fmtMs(performance.now() - t0)}\n`);
|
|
126
198
|
}
|
package/src/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// otfw dev start the CSR dev server (watch + live-reload)
|
|
5
5
|
// otfw build produce a static production bundle in dist/
|
|
6
|
+
// otfw serve build, then run the per-request SSR server
|
|
6
7
|
//
|
|
7
8
|
// The project root is the current working directory (its index.html + app/),
|
|
8
9
|
// like `vite` / `next`.
|
|
@@ -20,6 +21,11 @@ switch (cmd) {
|
|
|
20
21
|
await runBuild();
|
|
21
22
|
break;
|
|
22
23
|
}
|
|
24
|
+
case "serve": {
|
|
25
|
+
const { runServe } = await import("./serve.js");
|
|
26
|
+
await runServe();
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
23
29
|
default: {
|
|
24
30
|
if (cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h") {
|
|
25
31
|
console.error(`unknown command: ${cmd}\n`);
|
|
@@ -28,6 +34,7 @@ switch (cmd) {
|
|
|
28
34
|
console.log("usage:");
|
|
29
35
|
console.log(" otfw dev start the dev server");
|
|
30
36
|
console.log(" otfw build build for production (dist/); --ssg to pre-render routes");
|
|
37
|
+
console.log(" otfw serve build, then run the per-request SSR server (--port to override)");
|
|
31
38
|
process.exit(cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h" ? 1 : 0);
|
|
32
39
|
}
|
|
33
40
|
}
|