@opentf/web-cli 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,49 @@
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. Published installs under
43
+ `node_modules` use the packaged compiler, even when the app lives inside a Cargo
44
+ workspace; this repository's own source checkout may build and use
45
+ `target/debug/otfwc` for compiler development.
46
+
47
+ ## License
48
+
49
+ 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.0.0",
3
+ "version": "1.2.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.1.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,53 @@ 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
+ runLlmsFiles,
33
+ runLastUpdated,
34
+ stampHydrateSentinel,
26
35
  } from "./shared.js";
36
+ import { fmtMs, step } from "./reporter.js";
27
37
 
28
38
  const hash = (s) => Bun.hash(s).toString(16).padStart(16, "0").slice(0, 8);
29
39
 
30
- export async function runBuild() {
40
+ // Site origin for absolute canonical / sitemap/feed URLs. Priority: `--base-url=`
41
+ // flag, then `otfw.config` (`{ site: { url } }`).
42
+ export function resolveBaseUrl(config, argv = process.argv) {
43
+ const flag = 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 function buildRequiresBaseUrl(config, argv = process.argv) {
50
+ return argv.includes("--ssg") || !!config?.docs || !!config?.blog;
51
+ }
52
+
53
+ function requireBaseUrl(config, argv = process.argv) {
54
+ const baseUrl = resolveBaseUrl(config, argv);
55
+ if (baseUrl || !buildRequiresBaseUrl(config, argv)) return baseUrl;
56
+ console.error(
57
+ `✗ site.url is required for this production build.\n` +
58
+ ` Add it to otfw.config.js:\n\n` +
59
+ ` export default defineDocsConfig({\n` +
60
+ ` site: { url: "https://example.com" }\n` +
61
+ ` })\n\n` +
62
+ ` Or pass --base-url=https://example.com`,
63
+ );
64
+ process.exit(1);
65
+ }
66
+
67
+ export async function runBuild(options = {}) {
68
+ const projectRoot = process.cwd();
69
+ const config = await loadConfig(projectRoot);
70
+ const baseUrl = requireBaseUrl(config);
31
71
  const { root, appDir, webEntry, otfwc, exclude } = loadProject();
32
72
  const t0 = performance.now();
33
73
 
@@ -37,6 +77,15 @@ export async function runBuild() {
37
77
  process.exit(1);
38
78
  }
39
79
 
80
+ // Build the client for hydration when there will be server markup to adopt — the
81
+ // SSR server (`runBuild({ hydrate: true })`) and `--ssg` pre-rendered pages. A
82
+ // plain CSR build mounts into an empty `#app`, so it keeps the leaner CSR bundle.
83
+ const hydrate = options.hydrate ?? process.argv.includes("--ssg");
84
+
85
+ // Docs generator: resolve `@opentf/web-docs/nav` to the build-time nav tree when
86
+ // the project has a `docs` config block.
87
+ const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
88
+
40
89
  const outDir = join(root, "dist");
41
90
  rmSync(outDir, { recursive: true, force: true });
42
91
  mkdirSync(join(outDir, "assets"), { recursive: true });
@@ -45,12 +94,27 @@ export async function runBuild() {
45
94
  const tmp = join(root, ".otfw");
46
95
  mkdirSync(tmp, { recursive: true });
47
96
  const entry = join(tmp, "entry.js");
48
- writeFileSync(entry, entrySource(pages, appDir));
97
+ writeFileSync(entry, entrySource(pages, appDir, undefined, config?.i18n, config?.nav));
98
+
99
+ console.log("\n OTF Web — production build\n");
49
100
 
101
+ // Phase 1: compile every route/component (jsx/mdx → native DOM) and bundle. The
102
+ // compiler runs as a synchronous subprocess per file, so the per-file `onResult`
103
+ // is what drives the live progress line.
104
+ const buildStep = step("Compiling routes & components");
105
+ let compiled = 0;
50
106
  const result = await build({
51
107
  input: entry,
52
108
  resolve: { alias: { "@opentf/web": webEntry }, extensions: EXTENSIONS },
53
- plugins: [otfwPlugin(otfwc, { failOnError: true }), cssPlugin()],
109
+ plugins: [
110
+ ...docsPlugins,
111
+ otfwPlugin(otfwc, {
112
+ failOnError: true,
113
+ target: hydrate ? "hydrate" : "csr",
114
+ onResult: (id) => buildStep.update(`${basename(id)} (${++compiled})`),
115
+ }),
116
+ cssPlugin(),
117
+ ],
54
118
  output: {
55
119
  dir: join(outDir, "assets"),
56
120
  format: "esm",
@@ -58,6 +122,9 @@ export async function runBuild() {
58
122
  chunkFileNames: "[name]-[hash].js",
59
123
  minify: true,
60
124
  },
125
+ // The compiler runs a subprocess per file, so it dominates plugin time by design;
126
+ // silence Rolldown's plugin-timing advisory rather than print it every build.
127
+ checks: { pluginTimings: false },
61
128
  });
62
129
  rmSync(tmp, { recursive: true, force: true });
63
130
 
@@ -65,20 +132,15 @@ export async function runBuild() {
65
132
  const bundleHref = `/assets/${entryChunk.fileName}`;
66
133
 
67
134
  // 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
- );
135
+ // compile + hash any local stylesheet links, and inject the bundle. When the
136
+ // client was built for hydration, stamp the `#app` sentinel so the client adopts
137
+ // the server markup (this shell is also the SSG pre-render template, so each
138
+ // pre-rendered page inherits the sentinel).
139
+ let html = readHtmlShell(root);
140
+ if (hydrate) html = stampHydrateSentinel(html);
80
141
 
81
142
  // Compile each local <link rel="stylesheet" href="/..."> and rewrite the href.
143
+ buildStep.update("styles");
82
144
  const links = [...html.matchAll(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/gi)];
83
145
  for (const [, href] of links) {
84
146
  if (!href.startsWith("/")) continue; // leave external/CDN links alone
@@ -93,34 +155,70 @@ export async function runBuild() {
93
155
  }
94
156
 
95
157
  const script = `<script type="module" src="${bundleHref}"></script>\n`;
96
- html = html.includes("</body>")
97
- ? html.replace("</body>", `${script}</body>`)
98
- : html + script;
158
+ html = injectBeforeBody(html, script);
99
159
  writeFileSync(join(outDir, "index.html"), html);
100
160
 
161
+ const chunks = result.output.filter((o) => o.type === "chunk").length;
162
+ buildStep.done(`Compiled ${pages.length} routes · bundled ${chunks} chunks`);
163
+
101
164
  // SSG: pre-render each route into static HTML using the shell we just composed
102
165
  // (so per-route files carry the same bundle + stylesheet links).
103
166
  let ssg = null;
104
167
  if (process.argv.includes("--ssg")) {
105
168
  const { runPrerender } = await import("./prerender.js");
106
- ssg = await runPrerender({ root, pages, webEntry, otfwc, shellHtml: html, outDir });
169
+ // Per-page last-updated map (git/frontmatter) for the article:modified_time tag.
170
+ const lastUpdated = await runLastUpdated(root, appDir, config, exclude);
171
+ const ssgStep = step("Pre-rendering pages");
172
+ let ssgCompiled = 0;
173
+ ssg = await runPrerender({
174
+ root,
175
+ pages,
176
+ webEntry,
177
+ otfwc,
178
+ shellHtml: html,
179
+ outDir,
180
+ baseUrl,
181
+ docsPlugins,
182
+ lastUpdated,
183
+ i18n: config?.i18n,
184
+ onCompile: (id) => ssgStep.update(`compiling ${basename(id)} (${++ssgCompiled})`),
185
+ onRender: (done, total) => ssgStep.update(`rendering ${done}/${total}`),
186
+ });
187
+ ssgStep.done(
188
+ `Pre-rendered ${ssg.count} page(s)` +
189
+ (ssg.skipped.length ? ` · ${ssg.skipped.length} dynamic route(s) skipped` : ""),
190
+ );
107
191
  }
108
192
 
109
193
  // Copy the public/ directory (static assets served at the root), if present.
110
194
  const publicDir = join(root, "public");
111
195
  if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
112
196
 
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
- : ""),
197
+ // Docs search: index the pre-rendered HTML with Pagefind (when SSG + opted in).
198
+ let search = null;
199
+ if (ssg && config?.docs?.search?.provider === "pagefind") {
200
+ const searchStep = step("Building search index");
201
+ search = await runDocsSearchIndex(root, config, outDir, (done, total) =>
202
+ searchStep.update(`${done}/${total} pages`),
123
203
  );
204
+ searchStep.done(`Search index — ${search?.pages ?? 0} page(s)`);
124
205
  }
125
- console.log("");
206
+
207
+ // Blog RSS + Atom feeds (when a `blog` block is configured). Written
208
+ // after the public/ copy so project-supplied feed overrides aren't clobbered.
209
+ if (config?.blog) {
210
+ const feedStep = step("Generating blog feeds");
211
+ const feed = await runBlogFeed(root, appDir, config, outDir, baseUrl, exclude);
212
+ if (feed) feedStep.done(`Blog feeds — ${feed.count} post(s) → ${feed.paths.join(", ")}`);
213
+ else feedStep.done("Blog feeds — skipped");
214
+ }
215
+
216
+ if (config?.docs || config?.blog) {
217
+ const llmsStep = step("Generating LLM context");
218
+ const llms = await runLlmsFiles(root, appDir, pages, config, outDir, baseUrl);
219
+ if (llms) llmsStep.done(`LLM context — ${llms.paths.join(", ")}`);
220
+ else llmsStep.done("LLM context — skipped");
221
+ }
222
+
223
+ console.log(`\n → dist/ ready in ${fmtMs(performance.now() - t0)}\n`);
126
224
  }
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
  }