@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 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.0.0",
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.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,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
- export async function runBuild() {
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: [otfwPlugin(otfwc, { failOnError: true }), cssPlugin()],
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
- 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
- );
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.includes("</body>")
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
- ssg = await runPrerender({ root, pages, webEntry, otfwc, shellHtml: html, outDir });
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
- 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
- : ""),
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
- console.log("");
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
  }