From e23201d607c2f9388f8024a46c2bcb68ded511c9 Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 10 Jul 2026 06:54:53 +0100 Subject: [PATCH] feat(website): generate llms.txt and llms-full.txt (#3179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(website): generate llms.txt and llms-full.txt Add a post-build generator that produces llms.txt (a curated index) and llms-full.txt (the full text of every page) for the marketing site, following the llmstxt.org convention and served from the site root. The generator extracts clean Markdown from the rendered public/ HTML rather than the Markdown/data sources, so it captures the marketing copy that lives in Hugo layouts (home, features, get-started) and never drifts from what visitors actually see. Wired into `bun run build` and the deploy-website workflow. Also fixes four pre-existing `sort $map` calls in the /api templates: in Hugo, sorting a map collapses it to a slice, so the keys were replaced by numeric indices — API paths, HTTP status codes, request body content-types, and schema field names all rendered as 0, 1, 2… on the live page. Hugo already ranges maps in sorted-key order, so dropping `sort` fixes it. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(website): escape backslashes before pipes in llms table cells CodeQL js/incomplete-sanitization: the Markdown table-cell escaping replaced "|" but not "\", so a literal backslash in a cell could combine with the pipe escape. Escape backslashes first. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(website): collapse mixed-content whitespace and harden llms entrypoint Address Copilot review: - serializeMixed() now collapses text-node whitespace the same way inline() does, so HTML source indentation/newlines can't leak into list items and other mixed-content blocks. - main() gets a .catch that sets a non-zero exit code, so build/CI failures (missing public/, read/write/parse errors) are deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-website.yaml | 8 + website/bun.lock | 23 ++ website/layouts/_default/api.html | 11 +- website/layouts/partials/api-schema.html | 6 +- website/package.json | 6 +- website/scripts/generate-llms.ts | 414 +++++++++++++++++++++++ 6 files changed, 461 insertions(+), 7 deletions(-) create mode 100644 website/scripts/generate-llms.ts diff --git a/.github/workflows/deploy-website.yaml b/.github/workflows/deploy-website.yaml index 2e711614..5c02c27d 100644 --- a/.github/workflows/deploy-website.yaml +++ b/.github/workflows/deploy-website.yaml @@ -123,6 +123,14 @@ jobs: - name: Build website run: hugo --source website --minify + # Generate llms.txt / llms-full.txt from the rendered public/ + # tree (llmstxt.org convention). Reading the built HTML keeps + # the files in sync with what visitors see, including marketing + # copy that lives in Hugo layouts rather than content/*.md. + - name: Generate llms.txt + run: bun run llms:generate + working-directory: website + # Fetch rpi-imager.json from the latest GitHub release. The # file is built by build-balena-disk-image.yaml at release time # and Hugo serves it at /rpi-imager.json. diff --git a/website/bun.lock b/website/bun.lock index 953ba51c..df6cc08f 100644 --- a/website/bun.lock +++ b/website/bun.lock @@ -9,6 +9,7 @@ "@fortawesome/fontawesome-free": "^7.2.0", "@tailwindcss/cli": "^4.2.4", "@types/bun": "^1.3.14", + "node-html-parser": "^9.0.0", }, }, }, @@ -89,12 +90,28 @@ "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -133,6 +150,10 @@ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-html-parser": ["node-html-parser@9.0.0", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-MhdaHPyxnyYu/sf0TpiRvDnTrkum0UKHC7FdbDGIUQNlx3I7xzwXoyV0eMUMv/XU+lkJT1glOUzpDPq7b2p1Ew=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -156,5 +177,7 @@ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], } } diff --git a/website/layouts/_default/api.html b/website/layouts/_default/api.html index 9ad2c2a5..04ffb4cf 100644 --- a/website/layouts/_default/api.html +++ b/website/layouts/_default/api.html @@ -4,7 +4,7 @@ {{- /* Build sorted list of unique tags */ -}} {{- $allTags := slice -}} -{{- range $path, $ops := sort $spec.paths -}} +{{- range $path, $ops := $spec.paths -}} {{- range $method := $methods -}} {{- with index $ops $method -}} {{- $op := . -}} @@ -36,7 +36,7 @@

{{ replace $tag "_" " " }}

- {{- range $path, $ops := sort $spec.paths -}} + {{- range $path, $ops := $spec.paths -}} {{- range $method := $methods -}} {{- with index $ops $method -}} {{- $op := . -}} @@ -95,7 +95,7 @@ {{- $contentType := "application/json" -}} {{- if not $body -}} {{- $content := .content -}} - {{- range $ct, $entry := sort $content -}} + {{- range $ct, $entry := $content -}} {{- if not $body -}}{{- $body = $entry -}}{{- $contentType = $ct -}}{{- end -}} {{- end -}} {{- end -}} @@ -112,7 +112,10 @@

Responses

- {{- range $status, $resp := sort $responses -}} + {{- /* Map range is already key-sorted; `sort` + would drop the keys and make $status a + numeric index instead of the HTTP code. */ -}} + {{- range $status, $resp := $responses -}}
{{- $statusColor := "bg-zinc-100 text-zinc-700" -}} diff --git a/website/layouts/partials/api-schema.html b/website/layouts/partials/api-schema.html index 4a4f7d2a..935bb309 100644 --- a/website/layouts/partials/api-schema.html +++ b/website/layouts/partials/api-schema.html @@ -34,7 +34,11 @@ {{- $required := default (slice) $schema.required -}} - {{- range $name, $prop := sort $schema.properties -}} + {{- /* Range directly over the map: Hugo iterates map keys in + sorted order, and `sort` here would collapse the map to a + slice — making $name the numeric index instead of the + property name. */ -}} + {{- range $name, $prop := $schema.properties -}} {{- $propRef := index $prop "$ref" -}} diff --git a/website/package.json b/website/package.json index 3d0c5ca5..ebcee384 100644 --- a/website/package.json +++ b/website/package.json @@ -9,12 +9,14 @@ "css:build": "bun run assets:install && tailwindcss -i src/main.css -o assets/styles/style.css", "css:watch": "bun run assets:install && tailwindcss -i src/main.css -o assets/styles/style.css --watch", "dev": "bash -c 'bun run css:watch & css=$!; hugo server & hugo=$!; trap \"kill $css $hugo 2>/dev/null\" EXIT INT TERM; wait -n; exit $?'", - "build": "bun run css:build && hugo" + "llms:generate": "bun run scripts/generate-llms.ts", + "build": "bun run css:build && hugo && bun run llms:generate" }, "devDependencies": { "@fontsource/plus-jakarta-sans": "^5.2.8", "@fortawesome/fontawesome-free": "^7.2.0", "@tailwindcss/cli": "^4.2.4", - "@types/bun": "^1.3.14" + "@types/bun": "^1.3.14", + "node-html-parser": "^9.0.0" } } diff --git a/website/scripts/generate-llms.ts b/website/scripts/generate-llms.ts new file mode 100644 index 00000000..5d91a8a4 --- /dev/null +++ b/website/scripts/generate-llms.ts @@ -0,0 +1,414 @@ +#!/usr/bin/env bun +/** + * Generates `llms.txt` and `llms-full.txt` for the marketing site. + * + * Both files follow the llmstxt.org convention and are served from the + * site root so LLM tooling can find them at the well-known paths: + * + * - /llms.txt — a curated index: H1, a `>` summary, then H2 + * sections linking to each page. + * - /llms-full.txt — the full text of every page concatenated into + * one Markdown document, so a model can ingest the + * whole site without following links. + * + * The generator runs AFTER `hugo` against the built `public/` tree and + * extracts text from the rendered HTML. Reading the published output + * (rather than the Markdown/data sources) means the file never drifts + * from what visitors actually see — including the marketing copy that + * lives in Hugo layouts (home, features, get-started) rather than in + * `content/*.md`. + */ + +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' + +import { + parse, + NodeType, + type HTMLElement, + type Node, + type TextNode, +} from 'node-html-parser' + +const PUBLIC_DIR = join(import.meta.dir, '..', 'public') +const HUGO_CONFIG = join(import.meta.dir, '..', 'hugo.toml') + +// Inline elements that, when they appear on their own at block level, +// should be serialized as inline Markdown rather than recursed into +// (keeps e.g. permalink "#" anchors from leaking onto their own line). +const INLINE_TAGS = new Set([ + 'a', + 'span', + 'strong', + 'b', + 'em', + 'i', + 'code', + 'small', + 'sup', + 'sub', + 'abbr', + 'time', + 'kbd', + 'mark', +]) + +// Site chrome and non-prose elements to drop before serializing. +const STRIP_TAGS = new Set([ + 'nav', + 'footer', + 'script', + 'style', + 'noscript', + 'svg', + 'head', + 'template', +]) + +// Preferred ordering for the top of the file; anything not listed +// falls through in alphabetical route order after these. +const ROUTE_ORDER = [ + '/', + '/features/', + '/get-started/', + '/api/', + '/faq/', + '/docs/', +] + +interface Page { + route: string + url: string + title: string + description: string + body: string +} + +/** Read `baseURL` and `title` out of hugo.toml without a TOML parser. */ +async function readSiteMeta(): Promise<{ baseURL: string; title: string }> { + const toml = await readFile(HUGO_CONFIG, 'utf8') + const baseURL = toml.match(/^\s*baseURL\s*=\s*(["'])(.*?)\1/m)?.[2] ?? '/' + const title = toml.match(/^\s*title\s*=\s*(["'])(.*?)\1/m)?.[2] ?? 'Site' + return { baseURL: baseURL.replace(/\/$/, ''), title } +} + +/** Recursively collect every `index.html` under `public/`. */ +async function findHtmlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const files: string[] = [] + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...(await findHtmlFiles(full))) + } else if (entry.name === 'index.html') { + files.push(full) + } + } + return files +} + +/** Map a built file path to its site route, e.g. docs/qa/index.html -> /docs/qa/ */ +function routeForFile(file: string): string { + const rel = relative(PUBLIC_DIR, file).replace(/index\.html$/, '') + return '/' + rel.replace(/\\/g, '/') +} + +const collapse = (s: string) => s.replace(/[ \t\r\n]+/g, ' ') + +/** Serialize an element's inline children (text, links, emphasis) to Markdown. */ +function inline(node: Node): string { + if (node.nodeType === NodeType.TEXT_NODE) + return collapse((node as TextNode).text) + const el = node as HTMLElement + if (!el.tagName) return '' + const tag = el.tagName.toLowerCase() + if (STRIP_TAGS.has(tag)) return '' + const inner = el.childNodes.map(inline).join('') + switch (tag) { + case 'br': + return '\n' + case 'a': { + const href = el.getAttribute('href') ?? '' + const text = inner.trim() + if (!text) return '' + // Drop bare permalink glyphs (e.g. the "#" heading anchors). + if (/^[#¶§]+$/.test(text)) return '' + // Keep the visible text but drop the target for in-page anchors. + if (!href || href.startsWith('#')) return text + return `[${text}](${href})` + } + case 'strong': + case 'b': + return inner.trim() ? `**${inner.trim()}**` : '' + case 'em': + case 'i': + return inner.trim() ? `*${inner.trim()}*` : '' + case 'code': + return inner.trim() ? `\`${inner.trim()}\`` : '' + default: + return inner + } +} + +/** + * Serialize an element's children that mix inline text with block + * children (e.g. a list item holding prose plus a code block): + * consecutive inline nodes are joined into one line, block nodes are + * emitted as their own entries. + */ +function serializeMixed(el: HTMLElement, out: string[]): void { + let buf = '' + const flush = () => { + const t = buf.trim() + if (t) out.push(t) + buf = '' + } + for (const child of el.childNodes) { + if (child.nodeType === NodeType.TEXT_NODE) { + // Collapse the same way inline() does, so HTML source + // indentation/newlines don't leak into the output. + buf += collapse((child as TextNode).text) + continue + } + const c = child as HTMLElement + const tag = c.tagName?.toLowerCase() + if (!tag || STRIP_TAGS.has(tag)) continue + if (INLINE_TAGS.has(tag)) { + buf += inline(c) + continue + } + flush() + block(c, out) + } + flush() +} + +/** Serialize a block-level element tree to a Markdown string. */ +function block(node: Node, out: string[]): void { + if (node.nodeType === NodeType.TEXT_NODE) { + const text = collapse((node as TextNode).text).trim() + if (text) out.push(text) + return + } + const el = node as HTMLElement + if (!el.tagName) return + const tag = el.tagName.toLowerCase() + if (STRIP_TAGS.has(tag)) return + + // A standalone inline element at block level: serialize inline so + // link/emphasis handling (and permalink-glyph stripping) applies. + if (INLINE_TAGS.has(tag)) { + const text = inline(el).trim() + if (text) out.push(text) + return + } + + switch (tag) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + const level = Number(tag[1]) + const text = inline(el).trim() + // Down-shift by one so page

becomes "##" under the page's + // own "#" heading in llms-full.txt. + if (text) out.push(`${'#'.repeat(Math.min(level + 1, 6))} ${text}`) + return + } + case 'p': { + const text = inline(el).trim() + if (text) out.push(text) + return + } + case 'ul': + case 'ol': { + const ordered = tag === 'ol' + const lines: string[] = [] + let i = 1 + for (const li of el.childNodes) { + if ((li as HTMLElement).tagName?.toLowerCase() !== 'li') continue + const sub: string[] = [] + serializeMixed(li as HTMLElement, sub) + const body = sub.join('\n\n').trim() + if (!body) continue + const marker = ordered ? `${i++}.` : '-' + const [first, ...rest] = body.split('\n') + lines.push(`${marker} ${first}`) + // Indent any continuation lines (code fences, nested lists) + // so they stay attached to the bullet. + for (const r of rest) lines.push(r ? ` ${r}` : '') + } + if (lines.length) out.push(lines.join('\n')) + return + } + case 'pre': { + // node-html-parser treats
 as a raw-text element, so its
+      // single child holds the inner HTML (Chroma's /
+      // wrappers) as an entity-encoded string. Re-parse that raw
+      // string and take its text: real code angle brackets are
+      // encoded as </> there, so the wrapper tags strip cleanly
+      // while literal "<...>" in the code survives.
+      const rawInner = el.childNodes
+        .map((c) => (c.nodeType === NodeType.TEXT_NODE ? (c as TextNode).rawText : ''))
+        .join('')
+      const code = parse(rawInner).textContent.replace(/\n+$/, '')
+      if (code.trim()) out.push('```\n' + code + '\n```')
+      return
+    }
+    case 'blockquote': {
+      const inner: string[] = []
+      for (const child of el.childNodes) block(child, inner)
+      const text = inner.join('\n\n').trim()
+      if (text)
+        out.push(
+          text
+            .split('\n')
+            .map((l) => `> ${l}`)
+            .join('\n'),
+        )
+      return
+    }
+    case 'table': {
+      const rows: string[][] = []
+      for (const tr of el.querySelectorAll('tr')) {
+        const cells = tr
+          .querySelectorAll('th,td')
+          // Escape backslashes before pipes so a literal "\" in a cell
+          // can't combine with the pipe escape.
+          .map((c) =>
+            inline(c).trim().replace(/\\/g, '\\\\').replace(/\|/g, '\\|'),
+          )
+        if (cells.length) rows.push(cells)
+      }
+      if (rows.length) {
+        const md = [`| ${rows[0].join(' | ')} |`]
+        md.push(`| ${rows[0].map(() => '---').join(' | ')} |`)
+        for (const r of rows.slice(1)) md.push(`| ${r.join(' | ')} |`)
+        out.push(md.join('\n'))
+      }
+      return
+    }
+    default:
+      // Structural container (div, section, header, article, main, …):
+      // recurse into children.
+      for (const child of el.childNodes) block(child, out)
+  }
+}
+
+/** Extract a clean Markdown body + metadata from one rendered page. */
+function extractPage(html: string, route: string, baseURL: string): Page | null {
+  const root = parse(html)
+
+  // Skip Hugo alias/redirect stubs.
+  if (root.querySelector('meta[http-equiv=refresh]')) return null
+
+  const title = (root.querySelector('title')?.textContent ?? route).trim()
+  const description = (
+    root.querySelector('meta[name=description]')?.getAttribute('content') ?? ''
+  ).trim()
+  const canonical = root
+    .querySelector('link[rel=canonical]')
+    ?.getAttribute('href')
+  const url = canonical ?? baseURL + route
+
+  const body = root.querySelector('body')
+  if (!body) return null
+
+  const out: string[] = []
+  for (const child of body.childNodes) block(child, out)
+
+  // Collapse runs of blank lines the recursion may have produced.
+  const text = out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim()
+  return { route, url, title, description, body: text }
+}
+
+function orderPages(pages: Page[]): Page[] {
+  const rank = (route: string) => {
+    const i = ROUTE_ORDER.indexOf(route)
+    if (i !== -1) return i
+    // Keep docs children grouped right after /docs/ but before unknowns.
+    return route.startsWith('/docs/') ? ROUTE_ORDER.length : ROUTE_ORDER.length + 1
+  }
+  return [...pages].sort(
+    (a, b) => rank(a.route) - rank(b.route) || a.route.localeCompare(b.route),
+  )
+}
+
+function stripHtmlTitleSuffix(title: string): string {
+  // Layouts append " | Anthias"; drop it for cleaner section headings.
+  return title.replace(/\s*\|\s*Anthias\s*$/, '').trim()
+}
+
+async function main() {
+  const { baseURL, title } = await readSiteMeta()
+  const files = await findHtmlFiles(PUBLIC_DIR)
+
+  const pages: Page[] = []
+  for (const file of files) {
+    const route = routeForFile(file)
+    const html = await readFile(file, 'utf8')
+    const page = extractPage(html, route, baseURL)
+    if (page && page.body) pages.push(page)
+  }
+
+  const ordered = orderPages(pages)
+  const summary =
+    'Anthias is the world’s most popular open source digital signage ' +
+    'software. It turns a Raspberry Pi, x86 PC, or generic 64-bit ARM board ' +
+    'into a digital sign that displays images, videos, and web pages, ' +
+    'scheduled from a self-hosted web dashboard. No subscriptions, no cloud ' +
+    'lock-in.'
+
+  // --- llms.txt (curated index) ---
+  const indexLines: string[] = [`# ${title}`, '', `> ${summary}`, '']
+  const docs = ordered.filter((p) => p.route.startsWith('/docs/') && p.route !== '/docs/')
+  const primary = ordered.filter((p) => !p.route.startsWith('/docs/') || p.route === '/docs/')
+  indexLines.push('## Pages', '')
+  for (const p of primary) {
+    const name = stripHtmlTitleSuffix(p.title)
+    indexLines.push(`- [${name}](${p.url})${p.description ? `: ${p.description}` : ''}`)
+  }
+  if (docs.length) {
+    indexLines.push('', '## Documentation', '')
+    for (const p of docs) {
+      const name = stripHtmlTitleSuffix(p.title)
+      indexLines.push(`- [${name}](${p.url})${p.description ? `: ${p.description}` : ''}`)
+    }
+  }
+  indexLines.push('', '## Full text', '')
+  indexLines.push(`- [llms-full.txt](${baseURL}/llms-full.txt): the full text of every page on this site, concatenated into one document.`)
+  indexLines.push('')
+
+  // --- llms-full.txt (full content) ---
+  const fullLines: string[] = [
+    `# ${title}`,
+    '',
+    `> ${summary}`,
+    '',
+    `This file concatenates the full text of every page on ${baseURL}. Each section is one page, with its source URL.`,
+    '',
+  ]
+  for (const p of ordered) {
+    fullLines.push('---', '')
+    fullLines.push(`# ${stripHtmlTitleSuffix(p.title)}`, '')
+    fullLines.push(`Source: ${p.url}`, '')
+    if (p.description) fullLines.push(`> ${p.description}`, '')
+    fullLines.push(p.body, '')
+  }
+
+  await writeFile(join(PUBLIC_DIR, 'llms.txt'), indexLines.join('\n'))
+  await writeFile(join(PUBLIC_DIR, 'llms-full.txt'), fullLines.join('\n'))
+
+  const fullBytes = Buffer.byteLength(fullLines.join('\n'))
+  console.log(
+    `Wrote llms.txt (${primary.length + docs.length} pages indexed) and ` +
+      `llms-full.txt (${ordered.length} pages, ${(fullBytes / 1024).toFixed(1)} KiB).`,
+  )
+}
+
+main().catch((err) => {
+  console.error(err)
+  process.exitCode = 1
+})