From f4b12ece0908110cd8b07004553feb6eae99d738 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 14 Aug 2026 23:54:27 -0400 Subject: [PATCH 1/4] feat: replace starlight-markdown with clean MDX-to-markdown emitter --- astro.config.mjs | 2 - package.json | 5 +- pnpm-lock.yaml | 33 +++++---- src/lib/mdx-to-markdown.test.ts | 81 +++++++++++++++++++++++ src/lib/mdx-to-markdown.ts | 114 ++++++++++++++++++++++++++++++++ src/pages/[...path]/index.md.ts | 36 ++++++++++ 6 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 src/lib/mdx-to-markdown.test.ts create mode 100644 src/lib/mdx-to-markdown.ts create mode 100644 src/pages/[...path]/index.md.ts diff --git a/astro.config.mjs b/astro.config.mjs index 97d9800..1010dc9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,7 +3,6 @@ import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import ecTwoSlash from "expressive-code-twoslash"; import topics from "starlight-sidebar-topics"; -import starlightMarkdown from "starlight-markdown"; import mermaid from "astro-mermaid"; import { fileURLToPath } from "node:url"; @@ -104,7 +103,6 @@ export default defineConfig({ { icon: 'github', label: 'GitHub', href: 'https://bomb.sh/on/github' }, ], plugins: [ - starlightMarkdown(), topics([ { label: "Clack", diff --git a/package.json b/package.json index a075fc4..e15025d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "astro build && cp public/_headers dist/_headers", "preview": "astro preview", "astro": "astro", + "test": "node --test \"src/**/*.test.ts\"", "snapshot": "node --experimental-strip-types ./scripts/snapshot.ts", "generate:docs-index": "node --experimental-strip-types ./scripts/generate-docs-index.ts" }, @@ -31,11 +32,13 @@ "expressive-code-twoslash": "^0.5.3", "mermaid": "^11.16.0", "sharp": "^0.33.5", - "starlight-markdown": "^0.1.5", "starlight-sidebar-topics": "^0.6.2" }, "devDependencies": { "astro-vtbot": "^3.1.0", + "remark": "^15.0.1", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", "tinyexec": "^1.0.2", "wrangler": "^4.97.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c08d42..ae77ed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,9 +59,6 @@ importers: sharp: specifier: ^0.33.5 version: 0.33.5 - starlight-markdown: - specifier: ^0.1.5 - version: 0.1.5(astro@5.16.6(@types/node@22.19.3)(rollup@4.55.1)(typescript@5.8.2)) starlight-sidebar-topics: specifier: ^0.6.2 version: 0.6.2(@astrojs/starlight@0.37.1(astro@5.16.6(@types/node@22.19.3)(rollup@4.55.1)(typescript@5.8.2))) @@ -69,6 +66,15 @@ importers: astro-vtbot: specifier: ^3.1.0 version: 3.1.0 + remark: + specifier: ^15.0.1 + version: 15.0.1 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-mdx: + specifier: ^3.1.1 + version: 3.1.1 tinyexec: specifier: ^1.0.2 version: 1.0.2 @@ -2470,6 +2476,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + restructure@3.0.2: resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} @@ -2547,11 +2556,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - starlight-markdown@0.1.5: - resolution: {integrity: sha512-23LXRaZp7pyE+r/HP6rxHfwic8HfvUBT4EImECA6encs/eTtrF0Z+7svANofdtfbiNt31D5q26i03B6FtcSmGg==} - peerDependencies: - astro: ^5.0.0 - starlight-sidebar-topics@0.6.2: resolution: {integrity: sha512-SNCTUZS/hcVor0ZcaXbaSVU37+V+qtvzNirkvnOg3Mqu/awuGpthkH5+uKpiZqWxLffp6TrOlsv5E5QsxrndNg==} engines: {node: '>=18'} @@ -5750,6 +5754,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + restructure@3.0.2: {} retext-latin@4.0.0: @@ -5914,10 +5927,6 @@ snapshots: space-separated-tokens@2.0.2: {} - starlight-markdown@0.1.5(astro@5.16.6(@types/node@22.19.3)(rollup@4.55.1)(typescript@5.8.2)): - dependencies: - astro: 5.16.6(@types/node@22.19.3)(rollup@4.55.1)(typescript@5.8.2) - starlight-sidebar-topics@0.6.2(@astrojs/starlight@0.37.1(astro@5.16.6(@types/node@22.19.3)(rollup@4.55.1)(typescript@5.8.2))): dependencies: '@astrojs/starlight': 0.37.1(astro@5.16.6(@types/node@22.19.3)(rollup@4.55.1)(typescript@5.8.2)) diff --git a/src/lib/mdx-to-markdown.test.ts b/src/lib/mdx-to-markdown.test.ts new file mode 100644 index 0000000..0f8e193 --- /dev/null +++ b/src/lib/mdx-to-markdown.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mdxToMarkdown } from "./mdx-to-markdown.ts"; + +test("strips import statements", () => { + const out = mdxToMarkdown( + "import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n# Hello\n\nSome text.\n", + ); + assert.ok(!out.includes("import"), `import leaked into output:\n${out}`); + assert.ok(out.includes("# Hello")); + assert.ok(out.includes("Some text.")); +}); + +test("unwraps Tabs/TabItem with bold labels, preserving code fences", () => { + const out = mdxToMarkdown( + [ + "", + '', + "```sh", + "npm install @clack/prompts", + "```", + "", + '', + "```sh", + "pnpm add @clack/prompts", + "```", + "", + "", + ].join("\n"), + ); + assert.ok(!out.includes(" { + const out = mdxToMarkdown( + '\n', + ); + assert.ok(!out.includes(" **Caution:**"), `missing label:\n${out}`); + assert.ok(out.includes("Do not do the thing.")); +}); + +test("Aside with custom title uses the title", () => { + const out = mdxToMarkdown( + '\n', + ); + assert.ok(out.includes("> **Pro tip:**"), `missing title:\n${out}`); +}); + +test("plain markdown passes through, including tables", () => { + const src = [ + "# Title", + "", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "Text with `code`.", + "", + ].join("\n"); + const out = mdxToMarkdown(src); + assert.ok(out.includes("| a | b |"), `table mangled:\n${out}`); + assert.ok(out.includes("Text with `code`.")); +}); + +test("drops JSX expressions and unwraps unknown components", () => { + const out = mdxToMarkdown( + 'export const x = 1;\n\n\n\nInner *content* here.\n\n\n\nValue: {x}\n', + ); + assert.ok(!out.includes("export const"), `export leaked:\n${out}`); + assert.ok(!out.includes("; + +const ASIDE_LABELS: Record = { + note: "Note", + tip: "Tip", + caution: "Caution", + danger: "Danger", +}; + +function attribute(node: Node, name: string): string | undefined { + for (const attr of node.attributes ?? []) { + if (attr.type === "mdxJsxAttribute" && attr.name === name) { + if (typeof attr.value === "string") return attr.value; + } + } + return undefined; +} + +function bold(text: string): Node { + return { + type: "paragraph", + children: [{ type: "strong", children: [{ type: "text", value: text }] }], + }; +} + +/** Replace a JSX element with plain markdown nodes (or [] to drop it). */ +function replaceElement(node: Node): Node[] { + const children = transformChildren(node.children ?? []); + switch (node.name) { + case "Aside": { + const label = + attribute(node, "title") ?? + ASIDE_LABELS[attribute(node, "type") ?? "note"] ?? + "Note"; + return [ + { type: "blockquote", children: [bold(`${label}:`), ...children] }, + ]; + } + case "TabItem": { + const label = attribute(node, "label"); + return label ? [bold(label), ...children] : children; + } + case "Card": { + const title = attribute(node, "title"); + return title ? [bold(title), ...children] : children; + } + case "LinkCard": + case "LinkButton": { + const href = attribute(node, "href"); + const title = + attribute(node, "title") ?? + (node.children?.[0]?.value as string | undefined); + if (href) { + return [ + { + type: "paragraph", + children: [ + { + type: "link", + url: href, + children: [{ type: "text", value: title ?? href }], + }, + ], + }, + ]; + } + return children; + } + // Tabs, Steps, CardGrid, FileTree, and anything unrecognized: unwrap. + default: + return children; + } +} + +function transformChildren(children: Node[]): Node[] { + const result: Node[] = []; + for (const child of children) { + switch (child.type) { + case "mdxjsEsm": + case "mdxFlowExpression": + case "mdxTextExpression": + break; + case "mdxJsxFlowElement": + case "mdxJsxTextElement": + result.push(...replaceElement(child)); + break; + default: + if (Array.isArray(child.children)) { + child.children = transformChildren(child.children); + } + result.push(child); + } + } + return result; +} + +const parser = remark().use(remarkMdx).use(remarkGfm); +// Stringify without the MDX extensions so output uses plain markdown escaping. +const printer = remark().use(remarkGfm); + +export function mdxToMarkdown(source: string): string { + const tree = parser.parse(source) as Node; + tree.children = transformChildren(tree.children); + return printer.stringify(tree as any); +} diff --git a/src/pages/[...path]/index.md.ts b/src/pages/[...path]/index.md.ts new file mode 100644 index 0000000..53cac1f --- /dev/null +++ b/src/pages/[...path]/index.md.ts @@ -0,0 +1,36 @@ +/** + * Markdown twin for every doc page at `/{slug}/index.md`, for agents. + * The root index and 404 are handled separately (`/index.md` is a + * sitemap-style index; 404 has no markdown twin). + */ +import type { APIRoute } from "astro"; +import { getCollection, type CollectionEntry } from "astro:content"; +import { mdxToMarkdown } from "../../lib/mdx-to-markdown"; + +const BASE_URL = "https://bomb.sh/docs"; + +export async function getStaticPaths() { + const docs = await getCollection("docs"); + return docs + .filter((doc) => doc.id !== "index" && doc.id !== "404") + .map((doc) => ({ params: { path: doc.id }, props: { doc } })); +} + +export const GET: APIRoute<{ doc: CollectionEntry<"docs"> }> = ({ props }) => { + const { doc } = props; + const frontmatter = [ + "---", + `title: ${JSON.stringify(doc.data.title)}`, + doc.data.description && + `description: ${JSON.stringify(doc.data.description)}`, + `canonical: ${BASE_URL}/${doc.id}/`, + "---", + ] + .filter(Boolean) + .join("\n"); + + const body = mdxToMarkdown(doc.body ?? ""); + return new Response(`${frontmatter}\n\n# ${doc.data.title}\n\n${body}`, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +}; From b03c1041e9e6ded7dd01b643d7eae2bce924c292 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 14 Aug 2026 23:54:27 -0400 Subject: [PATCH 2/4] feat: add markdown sitemap index and alternate link discovery --- src/pages/index.md.ts | 62 +++++++++++++++++++++++++++++++ src/starlightOverrides/Head.astro | 11 ++++++ 2 files changed, 73 insertions(+) create mode 100644 src/pages/index.md.ts diff --git a/src/pages/index.md.ts b/src/pages/index.md.ts new file mode 100644 index 0000000..8e32e71 --- /dev/null +++ b/src/pages/index.md.ts @@ -0,0 +1,62 @@ +/** + * Sitemap-style markdown index at `/docs/index.md` — the entry point agents + * land on via content negotiation. Purely descriptive: titles, descriptions, + * and markdown URLs, grouped by topic. + */ +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; + +const BASE_URL = "https://bomb.sh/docs"; + +const SECTION_LABELS: Record = { + clack: "Clack — prompts", + args: "Args — argument parsing", + tab: "Tab — autocomplete", + tty: "TTY — layout & rendering", +}; + +export const GET: APIRoute = async () => { + const docs = await getCollection("docs"); + + const sections = new Map(); + for (const doc of docs) { + if (doc.id === "index" || doc.id === "404") continue; + const section = doc.id.split("/")[0]; + if (!sections.has(section)) sections.set(section, []); + sections.get(section)!.push({ + title: doc.data.title, + description: doc.data.description ?? "", + url: `${BASE_URL}/${doc.id}/index.md`, + }); + } + + const lines = [ + "---", + 'title: "Bombshell Documentation"', + `canonical: ${BASE_URL}/`, + "---", + "", + "# Bombshell Documentation", + "", + "> Effortlessly build beautiful command-line apps. Documentation for", + "> Clack, Args, Tab, and TTY — an ecosystem of terminal primitives for", + "> Node.js CLIs and TUIs.", + "", + `Every page is available as markdown at \`{page}/index.md\`, or by`, + `requesting any page URL with \`Accept: text/markdown\`.`, + "", + ]; + + for (const [section, pages] of [...sections.entries()].sort()) { + lines.push(`## ${SECTION_LABELS[section] ?? section}`, ""); + for (const page of pages.sort((a, b) => a.url.localeCompare(b.url))) { + const suffix = page.description ? `: ${page.description}` : ""; + lines.push(`- [${page.title}](${page.url})${suffix}`); + } + lines.push(""); + } + + return new Response(`${lines.join("\n").trimEnd()}\n`, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +}; diff --git a/src/starlightOverrides/Head.astro b/src/starlightOverrides/Head.astro index 127bb5b..44631a3 100644 --- a/src/starlightOverrides/Head.astro +++ b/src/starlightOverrides/Head.astro @@ -11,8 +11,19 @@ const ACCENTS = { tty: { dark: "300 100% 55%", light: "300 100% 38%" }, }; const accent = ACCENTS[Astro.locals.starlightRoute.id.split("/")[0]]; + +// Advertise the markdown twin of this page to agents (404 has none). +const routeId = Astro.locals.starlightRoute.id; +const markdownHref = + routeId === "404" + ? null + : `/docs/${routeId && routeId !== "index" ? `${routeId}/` : ""}index.md`; --- +{markdownHref && ( + +)} + {/* replaceSidebarContent: sidebar content differs per topic (starlight-sidebar-topics) */} From 4da795ecf436f15cd59eb94beab54e3b7fc9ab21 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 14 Aug 2026 23:54:27 -0400 Subject: [PATCH 3/4] feat(router): negotiate markdown via Accept header and .md paths --- router/package.json | 3 +- router/src/index.ts | 55 ++++++++++++++++++++++++++- router/src/negotiate.test.ts | 74 ++++++++++++++++++++++++++++++++++++ router/src/negotiate.ts | 38 ++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 router/src/negotiate.test.ts create mode 100644 router/src/negotiate.ts diff --git a/router/package.json b/router/package.json index f1ce5d3..a8e3b6d 100644 --- a/router/package.json +++ b/router/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "test": "node --test \"src/**/*.test.ts\"" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250327.0", diff --git a/router/src/index.ts b/router/src/index.ts index d7c6e6a..937bb76 100644 --- a/router/src/index.ts +++ b/router/src/index.ts @@ -1,5 +1,17 @@ +import { prefersMarkdown, toMarkdownPath } from "./negotiate"; + export interface Env { } +// Public origin used in Link headers (canonical/alternate) so agents always +// discover the proxied bomb.sh URLs, never the internal workers.dev ones. +const SITE = "https://bomb.sh"; + +const MARKDOWN_404 = `# 404: Not Found + +This page does not exist. An index of all Bombshell documentation is +available at ${SITE}/docs/index.md +`; + // Where to proxy docs requests. In production this is the live site. On // Cloudflare branch previews both Workers share the same branch slug // (e.g. `fix-404-bombsh-docs-router` ↔ `fix-404-bombshell-docs`), so we point @@ -40,6 +52,39 @@ export default { if (url.pathname.startsWith("/docs")) { const origin = docsOrigin(url.host); + + // Agent-facing markdown: explicit `.md` paths always serve markdown; + // extensionless page routes negotiate on `Accept: text/markdown`. + // Negotiation rewrites to a distinct origin URL, so HTML and markdown + // variants get distinct cache keys — Cloudflare's cache ignores `Vary`. + const markdownPath = toMarkdownPath(url.pathname); + const wantsMarkdown = + markdownPath !== null && + (url.pathname.endsWith(".md") || + prefersMarkdown(request.headers.get("Accept"))); + + if (wantsMarkdown && markdownPath) { + const response = await fetch(new URL(markdownPath, origin)); + if (response.status === 404) { + return new Response(MARKDOWN_404, { + status: 404, + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Vary": "Accept", + }, + }); + } + const headers = new Headers(response.headers); + headers.set("Content-Type", "text/markdown; charset=utf-8"); + headers.set("Vary", "Accept"); + const htmlPath = markdownPath.slice(0, -"index.md".length); + headers.set("Link", `<${SITE}${htmlPath}>; rel="canonical"`); + return new Response(response.body, { + status: response.status, + headers, + }); + } + let response = await fetch(new URL(url.pathname, docsOrigin(url.host))); console.log({ from: url, to: new URL(url.pathname, docsOrigin(url.host)) }); @@ -56,7 +101,15 @@ export default { headers.set("Cross-Origin-Resource-Policy", "cross-origin"); headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - // If we got 404, return the HTML, but set status to 404 manually, + // Advertise the markdown twin to agents crawling the HTML variant. + if (markdownPath && status === 200) { + headers.set( + "Link", + `<${SITE}${markdownPath}>; rel="alternate"; type="text/markdown"`, + ); + } + + // If we got 404, return the HTML, but set status to 404 manually, // because the response status would be 200 return new Response(response.body, { status: status, diff --git a/router/src/negotiate.test.ts b/router/src/negotiate.test.ts new file mode 100644 index 0000000..6c5923d --- /dev/null +++ b/router/src/negotiate.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { prefersMarkdown, toMarkdownPath } from "./negotiate.ts"; + +test("prefersMarkdown: no Accept header", () => { + assert.equal(prefersMarkdown(null), false); +}); + +test("prefersMarkdown: typical browser Accept header", () => { + assert.equal( + prefersMarkdown( + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + ), + false, + ); +}); + +test("prefersMarkdown: bare text/markdown", () => { + assert.equal(prefersMarkdown("text/markdown"), true); +}); + +test("prefersMarkdown: markdown preferred over html", () => { + assert.equal(prefersMarkdown("text/markdown,text/html;q=0.8"), true); +}); + +test("prefersMarkdown: markdown listed but html preferred", () => { + assert.equal(prefersMarkdown("text/html,text/markdown;q=0.5"), false); +}); + +test("prefersMarkdown: markdown explicitly refused", () => { + assert.equal(prefersMarkdown("text/markdown;q=0"), false); +}); + +test("prefersMarkdown: wildcard only is not markdown", () => { + assert.equal(prefersMarkdown("*/*"), false); +}); + +test("toMarkdownPath: docs root", () => { + assert.equal(toMarkdownPath("/docs"), "/docs/index.md"); + assert.equal(toMarkdownPath("/docs/"), "/docs/index.md"); +}); + +test("toMarkdownPath: page route with trailing slash", () => { + assert.equal( + toMarkdownPath("/docs/clack/basics/getting-started/"), + "/docs/clack/basics/getting-started/index.md", + ); +}); + +test("toMarkdownPath: page route without trailing slash", () => { + assert.equal( + toMarkdownPath("/docs/clack/basics/getting-started"), + "/docs/clack/basics/getting-started/index.md", + ); +}); + +test("toMarkdownPath: explicit .md request maps to index.md twin", () => { + assert.equal(toMarkdownPath("/docs/args/api.md"), "/docs/args/api/index.md"); +}); + +test("toMarkdownPath: already an index.md path is unchanged", () => { + assert.equal( + toMarkdownPath("/docs/tty/api/index.md"), + "/docs/tty/api/index.md", + ); +}); + +test("toMarkdownPath: assets and files with extensions are ignored", () => { + assert.equal(toMarkdownPath("/docs/_astro/hoisted.BQ1yu2o0.js"), null); + assert.equal(toMarkdownPath("/docs/favicon.svg"), null); + assert.equal(toMarkdownPath("/docs/docs-index.json"), null); + assert.equal(toMarkdownPath("/docs/og-docs.png"), null); + assert.equal(toMarkdownPath("/docs/404.html"), null); +}); diff --git a/router/src/negotiate.ts b/router/src/negotiate.ts new file mode 100644 index 0000000..ec2ca3c --- /dev/null +++ b/router/src/negotiate.ts @@ -0,0 +1,38 @@ +// Content negotiation for agent-facing markdown, per +// https://cra.mr/optimizing-content-for-agents: agents signal themselves with +// `Accept: text/markdown`; humans never do. + +/** Parse the quality value for a media type out of an Accept header. */ +function quality(accept: string, type: string): number { + for (const part of accept.split(",")) { + const [media, ...params] = part.trim().split(";"); + if (media.trim().toLowerCase() !== type) continue; + for (const param of params) { + const [key, value] = param.trim().split("="); + if (key === "q") return Number.parseFloat(value) || 0; + } + return 1; + } + return 0; +} + +export function prefersMarkdown(accept: string | null): boolean { + if (!accept) return false; + const markdown = quality(accept, "text/markdown"); + if (markdown === 0) return false; + return markdown >= quality(accept, "text/html"); +} + +/** + * Map a request path to its static markdown twin, or null if the path is not + * a documentation page (assets, feeds, and other files keep their extension). + */ +export function toMarkdownPath(pathname: string): string | null { + if (pathname.endsWith("/index.md")) return pathname; + if (pathname.endsWith(".md")) { + return `${pathname.slice(0, -".md".length)}/index.md`; + } + const lastSegment = pathname.slice(pathname.lastIndexOf("/") + 1); + if (lastSegment.includes(".")) return null; + return pathname.endsWith("/") ? `${pathname}index.md` : `${pathname}/index.md`; +} From 0a186f33f9587e0fac08b8dcbf6c21c958a250e6 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 14 Aug 2026 23:54:39 -0400 Subject: [PATCH 4/4] chore: remove llms.txt in favor of markdown negotiation --- scripts/generate-docs-index.ts | 42 ++-------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/scripts/generate-docs-index.ts b/scripts/generate-docs-index.ts index a1608ab..9b62b07 100644 --- a/scripts/generate-docs-index.ts +++ b/scripts/generate-docs-index.ts @@ -1,6 +1,6 @@ /** - * Walks `src/content/docs` and emits `public/llms.txt` plus - * `public/docs-index.json` for agent discoverability and offline search. + * Walks `src/content/docs` and emits `public/docs-index.json`, a + * machine-readable page index for offline search and local agent tooling. */ import fs from 'node:fs/promises'; import path from 'node:path'; @@ -98,42 +98,6 @@ function isIndexed(page: DocPage): boolean { return true; } -function generateLlmsTxt(pages: DocPage[]): string { - const indexed = pages.filter(isIndexed); - const lines = [ - '# Bombshell Documentation', - '', - '> Effortlessly build beautiful command-line apps. Docs for Clack, Args, Tab, and TTY.', - '', - `Canonical docs: ${BASE_URL}/`, - '', - ]; - - const homepage = indexed.find((page) => page.slug === ''); - if (homepage) { - lines.push(`- [${homepage.title}](${homepage.url}): ${homepage.description}`, ''); - } - - const sections = new Map(); - for (const page of indexed) { - if (page.slug === '') continue; - const section = page.slug.split('/')[0]; - if (!sections.has(section)) sections.set(section, []); - sections.get(section)!.push(page); - } - - for (const [section, sectionPages] of [...sections.entries()].sort()) { - const label = section.charAt(0).toUpperCase() + section.slice(1); - lines.push(`## ${label}`, ''); - for (const page of sectionPages.sort((a, b) => a.slug.localeCompare(b.slug))) { - lines.push(`- [${page.title}](${page.url}): ${page.description}`); - } - lines.push(''); - } - - return `${lines.join('\n').trimEnd()}\n`; -} - async function main() { const pages = await walkDocs(docsDir); const indexed = pages.filter(isIndexed); @@ -150,8 +114,6 @@ async function main() { 2, )}\n`, ); - await fs.writeFile(path.join(rootDir, 'public/llms.txt'), generateLlmsTxt(pages)); - console.log(`Generated docs index with ${indexed.length} pages`); }