@opentf/web-cli 1.1.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 CHANGED
@@ -39,7 +39,10 @@ otfw serve # build, then server-render each route per request (SSR)
39
39
  Picks port 3000 (scanning upward), or `--port` for an explicit one. Phase 1: the page
40
40
  becomes interactive via the client bundle (CSR mount); hydration is a later phase.
41
41
 
42
- `OTFWC_BIN` overrides the compiler binary location (used for compiler development).
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.
43
46
 
44
47
  ## License
45
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentf/web-cli",
3
- "version": "1.1.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": {
package/src/build.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  readHtmlShell,
30
30
  runBlogFeed,
31
31
  runDocsSearchIndex,
32
+ runLlmsFiles,
32
33
  runLastUpdated,
33
34
  stampHydrateSentinel,
34
35
  } from "./shared.js";
@@ -36,17 +37,37 @@ import { fmtMs, step } from "./reporter.js";
36
37
 
37
38
  const hash = (s) => Bun.hash(s).toString(16).padStart(16, "0").slice(0, 8);
38
39
 
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="));
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
44
  if (flag) return flag.slice("--base-url=".length).replace(/\/+$/, "");
45
45
  if (config?.site?.url) return String(config.site.url).replace(/\/+$/, "");
46
46
  return "";
47
47
  }
48
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
+
49
67
  export async function runBuild(options = {}) {
68
+ const projectRoot = process.cwd();
69
+ const config = await loadConfig(projectRoot);
70
+ const baseUrl = requireBaseUrl(config);
50
71
  const { root, appDir, webEntry, otfwc, exclude } = loadProject();
51
72
  const t0 = performance.now();
52
73
 
@@ -63,7 +84,6 @@ export async function runBuild(options = {}) {
63
84
 
64
85
  // Docs generator: resolve `@opentf/web-docs/nav` to the build-time nav tree when
65
86
  // the project has a `docs` config block.
66
- const config = await loadConfig(root);
67
87
  const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
68
88
 
69
89
  const outDir = join(root, "dist");
@@ -145,7 +165,6 @@ export async function runBuild(options = {}) {
145
165
  // (so per-route files carry the same bundle + stylesheet links).
146
166
  let ssg = null;
147
167
  if (process.argv.includes("--ssg")) {
148
- const baseUrl = resolveBaseUrl(config);
149
168
  const { runPrerender } = await import("./prerender.js");
150
169
  // Per-page last-updated map (git/frontmatter) for the article:modified_time tag.
151
170
  const lastUpdated = await runLastUpdated(root, appDir, config, exclude);
@@ -185,13 +204,20 @@ export async function runBuild(options = {}) {
185
204
  searchStep.done(`Search index — ${search?.pages ?? 0} page(s)`);
186
205
  }
187
206
 
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.
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.
190
209
  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");
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");
195
221
  }
196
222
 
197
223
  console.log(`\n → dist/ ready in ${fmtMs(performance.now() - t0)}\n`);
package/src/prerender.js CHANGED
@@ -6,7 +6,13 @@
6
6
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
 
9
- import { buildServerBundle, injectHead, injectMarkup, withHtmlLang } from "./shared.js";
9
+ import {
10
+ buildServerBundle,
11
+ injectHead,
12
+ injectHydrationData,
13
+ injectMarkup,
14
+ withHtmlLang,
15
+ } from "./shared.js";
10
16
 
11
17
  // "/" → dist/index.html, "/post/1" → dist/post/1/index.html, "/fr/about" → dist/fr/about/index.html.
12
18
  function htmlPathFor(outDir, route) {
@@ -97,7 +103,8 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
97
103
  try {
98
104
  // Passing the localized URL pins router.locale (resolveLocale) and still
99
105
  // matches the locale-agnostic route table, so `t()` renders in `locale`.
100
- const { html, metadata } = (await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {} };
106
+ const { html, metadata, hydration } =
107
+ (await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {}, hydration: "" };
101
108
  const meta = i18nOn
102
109
  ? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
103
110
  : metadata;
@@ -108,7 +115,13 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
108
115
  if (iso) head += `\n<meta property="article:modified_time" content="${escapeXml(iso)}">`;
109
116
  const file = htmlPathFor(outDir, urlPath);
110
117
  mkdirSync(dirname(file), { recursive: true });
111
- writeFileSync(file, injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html));
118
+ writeFileSync(
119
+ file,
120
+ injectHydrationData(
121
+ injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
122
+ hydration,
123
+ ),
124
+ );
112
125
  rendered.push(urlPath);
113
126
  } catch (e) {
114
127
  failed.push(urlPath);
@@ -123,7 +136,10 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
123
136
  const result = await mod.renderRoute("/__otfw_404__");
124
137
  if (result) {
125
138
  const head = mod.renderHead({ robots: "noindex", ...result.metadata }, { baseUrl });
126
- writeFileSync(join(outDir, "404.html"), injectMarkup(injectHead(shellHtml, head), result.html));
139
+ writeFileSync(
140
+ join(outDir, "404.html"),
141
+ injectHydrationData(injectMarkup(injectHead(shellHtml, head), result.html), result.hydration),
142
+ );
127
143
  }
128
144
  } catch {
129
145
  /* no 404 page */
package/src/serve.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  buildServerBundle,
34
34
  discoverPages,
35
35
  injectHead,
36
+ injectHydrationData,
36
37
  injectMarkup,
37
38
  loadConfig,
38
39
  loadDocsPlugins,
@@ -189,7 +190,10 @@ export async function runServe() {
189
190
  : result.metadata;
190
191
  const head = mod.renderHead(meta, { path: url.pathname, baseUrl });
191
192
  const localizedShell = i18nOn ? withHtmlLang(shell, localeOf(url.pathname)) : shell;
192
- const html = injectMarkup(injectHead(localizedShell, head), result.html);
193
+ const html = injectHydrationData(
194
+ injectMarkup(injectHead(localizedShell, head), result.html),
195
+ result.hydration,
196
+ );
193
197
  // 200 for a real route; 404 when the path fell back to the registered 404 page.
194
198
  return new Response(html, {
195
199
  status: result.status ?? 200,
package/src/shared.js CHANGED
@@ -59,6 +59,20 @@ export function injectMarkup(shellHtml, markup) {
59
59
  return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, (_m, open, close) => `${open}${markup}${close}`);
60
60
  }
61
61
 
62
+ /**
63
+ * Embed the island hydration payload (`renderRoute().hydration`) as a
64
+ * `<script type="application/json" id="__otfw_h">` the client reads at upgrade so
65
+ * components resume from rich JS props (compiler-driven data hydration). Placed before
66
+ * `</body>`; a deferred module bundle runs after parse, so the payload is always in the
67
+ * DOM first. `json` is already `<`-escaped by the server collector, so it can't break out
68
+ * of the script. No-op for an empty payload. Uses a function replacer so `$` in the JSON
69
+ * stays literal.
70
+ */
71
+ export function injectHydrationData(shellHtml, json) {
72
+ if (!json) return shellHtml;
73
+ return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_h">${json}</script>`);
74
+ }
75
+
62
76
  /**
63
77
  * Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
64
78
  * the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
@@ -105,10 +119,45 @@ export function findUp(name, from) {
105
119
  }
106
120
  }
107
121
 
122
+ function hasLocalCompilerWorkspace(workspace) {
123
+ return !!workspace && existsSync(join(workspace, "crates", "otfw_cli", "Cargo.toml"));
124
+ }
125
+
126
+ export function resolveCompiler({
127
+ cliDir = dirname(fileURLToPath(import.meta.url)),
128
+ env = process.env,
129
+ resolvePackagedCompiler = otfwcPath,
130
+ findWorkspace = findUp,
131
+ ensure = ensureCompiler,
132
+ } = {}) {
133
+ if (env.OTFWC_BIN) return { otfwc: env.OTFWC_BIN, workspace: null };
134
+
135
+ let packagedError = null;
136
+ try {
137
+ return { otfwc: resolvePackagedCompiler(), workspace: null };
138
+ } catch (e) {
139
+ packagedError = e;
140
+ }
141
+
142
+ const installedPackage = cliDir.split(/[\\/]/).includes("node_modules");
143
+ const workspace = installedPackage ? null : findWorkspace("Cargo.toml", cliDir);
144
+ if (hasLocalCompilerWorkspace(workspace)) {
145
+ const otfwc = join(workspace, "target", "debug", "otfwc");
146
+ ensure(otfwc, workspace);
147
+ return { otfwc, workspace };
148
+ }
149
+
150
+ fail(
151
+ `${packagedError?.message ?? "cannot resolve @opentf/web-compiler prebuilt binary"}\n` +
152
+ ` No local otfwc compiler workspace was found to build from source.`,
153
+ );
154
+ }
155
+
108
156
  /**
109
157
  * Resolve the project and toolchain: the app being built (cwd), the runtime
110
158
  * package, the `otfwc` compiler, and the excluded routes. Exits with a clear
111
- * message on any hard failure. Builds the compiler on demand from the workspace.
159
+ * message on any hard failure. Source checkouts can build the compiler on demand
160
+ * from this repo's Cargo workspace; published installs use @opentf/web-compiler.
112
161
  */
113
162
  export function loadProject() {
114
163
  const root = process.cwd();
@@ -129,25 +178,9 @@ export function loadProject() {
129
178
  fail(`cannot resolve "@opentf/web" from ${root}\n add it to your dependencies.`);
130
179
  }
131
180
 
132
- // Locate the `otfwc` compiler. Published: the prebuilt binary from `@opentf/web-compiler`
133
- // (a dependency of this CLI). In this repo's own dev (a Cargo workspace is found
134
- // above the CLI): the cargo `target/debug` build, rebuilt on demand. `OTFWC_BIN`
135
- // overrides both.
136
- const cliDir = dirname(fileURLToPath(import.meta.url));
137
- const workspace = findUp("Cargo.toml", cliDir);
138
- let otfwc;
139
- if (process.env.OTFWC_BIN) {
140
- otfwc = process.env.OTFWC_BIN;
141
- } else if (workspace) {
142
- otfwc = join(workspace, "target", "debug", "otfwc");
143
- ensureCompiler(otfwc, workspace);
144
- } else {
145
- try {
146
- otfwc = otfwcPath();
147
- } catch (e) {
148
- fail(e.message);
149
- }
150
- }
181
+ // Locate the `otfwc` compiler. Explicit overrides win, then the packaged
182
+ // compiler binary, then this repo's local Rust workspace as a source fallback.
183
+ const { otfwc, workspace } = resolveCompiler();
151
184
 
152
185
  // Route directories to skip during discovery — comma-separated names in
153
186
  // EXCLUDE_ROUTES. None are excluded by default.
@@ -162,6 +195,19 @@ function ensureCompiler(otfwc, workspace) {
162
195
  if (existsSync(otfwc)) return;
163
196
  if (!workspace) fail(`otfwc compiler not found at ${otfwc}`);
164
197
  console.log("building compiler (cargo build -p otfw_cli)…");
198
+ const cargo = Bun.spawnSync(["cargo", "--version"], {
199
+ cwd: workspace,
200
+ stdout: "ignore",
201
+ stderr: "pipe",
202
+ });
203
+ if (cargo.error || cargo.exitCode !== 0) {
204
+ fail(
205
+ `cannot build otfwc because cargo is not available.\n` +
206
+ ` This source checkout needs Rust/Cargo to build ${otfwc}.\n` +
207
+ ` Install Rust in the build environment, set OTFWC_BIN to an existing otfwc binary,\n` +
208
+ ` or build with the published @opentf/web-cli package so @opentf/web-compiler can use its prebuilt binary.`,
209
+ );
210
+ }
165
211
  const b = Bun.spawnSync(["cargo", "build", "-p", "otfw_cli"], {
166
212
  cwd: workspace,
167
213
  stdout: "inherit",
@@ -271,43 +317,86 @@ export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
271
317
  }
272
318
 
273
319
  /**
274
- * Generate the blog RSS feed when the project has a `blog` config and a base URL.
275
- * Scans the post folders, renders RSS 2.0, and writes `<siteDir>/<blogDir>/rss.xml`.
276
- * A project-supplied `public/<blogDir>/rss.xml` takes precedence (it overwrites ours
277
- * on the public/ copy). Returns `{ path, url, count }` or null (no blog / no base URL
278
- * / package missing). Resolved from the app's `@opentf/web-docs`.
320
+ * Generate blog feeds when the project has a `blog` config and a base URL. Scans the
321
+ * post folders, renders RSS 2.0 + Atom 1.0, and writes them under
322
+ * `<siteDir>/<blogDir>/`. Project-supplied `public/<blogDir>/{rss,atom}.xml` files
323
+ * take precedence independently. Returns `{ paths, urls, count }` or null (no blog /
324
+ * no base URL / all feeds overridden / package missing). Resolved from the app's
325
+ * `@opentf/web-docs`.
279
326
  */
280
327
  export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
281
328
  const blog = config?.blog;
282
329
  if (!blog) return null;
283
330
  if (!baseUrl) {
284
- console.warn("⚠ blog RSS feed skipped: no site URL (pass --base-url or set otfw.config)");
331
+ console.warn("⚠ blog feeds skipped: no site URL (pass --base-url or set otfw.config)");
285
332
  return null;
286
333
  }
287
334
  const contentDir = blog.dir ?? "blog";
288
- if (existsSync(join(root, "public", contentDir, "rss.xml"))) return null; // honor override
289
335
  try {
290
336
  const entry = Bun.resolveSync("@opentf/web-docs/build", root);
291
- const { loadPosts, renderBlogFeed } = await import(pathToFileURL(entry).href);
337
+ const { loadPosts, renderAtomFeed, renderBlogFeed } = await import(pathToFileURL(entry).href);
292
338
  const posts = loadPosts({ appDir, contentDir, exclude });
293
- const feedPath = `/${contentDir}/rss.xml`;
294
339
  const title = blog.title || (config?.docs?.title ? `${config.docs.title} Blog` : "Blog");
295
- const xml = renderBlogFeed({
296
- posts,
297
- baseUrl,
298
- feedPath,
299
- channel: {
300
- title,
301
- description: blog.description || title,
302
- link: `/${contentDir}`,
340
+ const channel = {
341
+ title,
342
+ description: blog.description || title,
343
+ link: `/${contentDir}`,
344
+ };
345
+ const feeds = [
346
+ {
347
+ file: "rss.xml",
348
+ path: `/${contentDir}/rss.xml`,
349
+ render: renderBlogFeed,
303
350
  },
304
- });
305
- const out = join(siteDir, contentDir, "rss.xml");
306
- mkdirSync(dirname(out), { recursive: true });
307
- writeFileSync(out, xml);
308
- return { path: feedPath, url: baseUrl.replace(/\/+$/, "") + feedPath, count: posts.length };
351
+ {
352
+ file: "atom.xml",
353
+ path: `/${contentDir}/atom.xml`,
354
+ render: renderAtomFeed,
355
+ },
356
+ ];
357
+ const written = [];
358
+ for (const feed of feeds) {
359
+ if (existsSync(join(root, "public", contentDir, feed.file))) continue; // honor override
360
+ const out = join(siteDir, contentDir, feed.file);
361
+ mkdirSync(dirname(out), { recursive: true });
362
+ writeFileSync(out, feed.render({ posts, baseUrl, feedPath: feed.path, channel }));
363
+ written.push(feed.path);
364
+ }
365
+ if (!written.length) return null;
366
+ const origin = baseUrl.replace(/\/+$/, "");
367
+ return { paths: written, urls: written.map((path) => origin + path), count: posts.length };
368
+ } catch (e) {
369
+ console.warn(`⚠ blog feeds skipped: ${e?.message ?? e}`);
370
+ return null;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Generate `/llms.txt` and `/llms-full.txt` for docs/blog sites from the same
376
+ * filesystem route list used by the app build. Project-supplied public files override
377
+ * each output independently.
378
+ */
379
+ export async function runLlmsFiles(root, appDir, pages, config, siteDir, baseUrl) {
380
+ if (!config?.docs && !config?.blog) return null;
381
+ const outputs = [
382
+ { file: "llms.txt", render: "renderLlmsTxt" },
383
+ { file: "llms-full.txt", render: "renderLlmsFullTxt" },
384
+ ].filter((out) => !existsSync(join(root, "public", out.file)));
385
+ if (!outputs.length) return null;
386
+
387
+ try {
388
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
389
+ const mod = await import(pathToFileURL(entry).href);
390
+ const written = [];
391
+ for (const out of outputs) {
392
+ const render = mod[out.render];
393
+ if (typeof render !== "function") continue;
394
+ writeFileSync(join(siteDir, out.file), render({ appDir, pages, baseUrl, config }));
395
+ written.push(`/${out.file}`);
396
+ }
397
+ return written.length ? { paths: written } : null;
309
398
  } catch (e) {
310
- console.warn(`⚠ blog RSS feed skipped: ${e?.message ?? e}`);
399
+ console.warn(`⚠ llms.txt skipped: ${e?.message ?? e}`);
311
400
  return null;
312
401
  }
313
402
  }