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 @@
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
+})