From bc9f5b9258dea25364d21897cbd3ea3492fc887b Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Thu, 20 Aug 2026 11:40:16 +0200 Subject: [PATCH 1/2] Add JSON-LD and a build-time llms.txt for the website One module injects Organization and SoftwareSourceCode structured data using the same @id as swmansion.com, and writes llms.txt from the pages VitePress just built. The workflow fails if the file is missing or lists nothing. --- .github/workflows/website_deploy.yml | 13 +++ website/.vitepress/config.mjs | 2 + website/.vitepress/swm-geo.mjs | 122 +++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 website/.vitepress/swm-geo.mjs diff --git a/.github/workflows/website_deploy.yml b/.github/workflows/website_deploy.yml index 9cd109b..4fcd435 100644 --- a/.github/workflows/website_deploy.yml +++ b/.github/workflows/website_deploy.yml @@ -32,6 +32,19 @@ jobs: - run: npm ci - run: npm run build + - name: Check docs llms.txt + run: | + file=.vitepress/dist/llms.txt + if [ ! -s "$file" ]; then + echo "::error::$file is missing or empty" + exit 1 + fi + count=$(grep -cE '^- \[[^]]+\]\(https://docs\.swmansion\.com/cairo-debugger/[^)]+\)' "$file" || true) + if [ "$count" -eq 0 ]; then + echo "::error::$file lists no pages" + exit 1 + fi + echo "llms.txt lists $count pages" - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/website/.vitepress/config.mjs b/website/.vitepress/config.mjs index d7cdd88..a863a17 100644 --- a/website/.vitepress/config.mjs +++ b/website/.vitepress/config.mjs @@ -40,6 +40,8 @@ export default defineConfig({ lang, base, + transformHead: (context) => [swmStructuredData(context)], + buildEnd: writeLlmsTxt, head: [ ["meta", {httpEquiv: "Content-Language", content: lang}], ["link", {rel: "icon", href: `${base}favicon.svg`, type: "image/x-icon"}], diff --git a/website/.vitepress/swm-geo.mjs b/website/.vitepress/swm-geo.mjs new file mode 100644 index 0000000..a6ef86d --- /dev/null +++ b/website/.vitepress/swm-geo.mjs @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; + +const ORGANIZATION_ID = "https://swmansion.com/#organization"; +const SITE = "https://docs.swmansion.com"; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const decode = (value) => + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/&#(?:39|x27);/g, "'") + .trim(); + +const projectFromBase = (base) => base.replace(/\//g, ""); + +// Same @id as swmansion.com, so engines read one company across both domains. +export function swmStructuredData({ siteConfig }) { + const { base, description, title } = siteConfig.site; + const project = projectFromBase(base); + + return [ + "script", + { type: "application/ld+json" }, + JSON.stringify({ + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": ORGANIZATION_ID, + name: "Software Mansion", + url: "https://swmansion.com", + sameAs: [ + "https://github.com/software-mansion", + "https://www.linkedin.com/company/software-mansion/", + "https://twitter.com/swmansion", + "https://www.youtube.com/c/SoftwareMansion", + ], + }, + { + "@type": "SoftwareSourceCode", + name: title, + ...(description ? { description } : {}), + ...(project + ? { + codeRepository: `https://github.com/software-mansion/${project}`, + } + : {}), + author: { "@id": ORGANIZATION_ID }, + maintainer: { "@id": ORGANIZATION_ID }, + }, + ], + }), + ]; +} + +function collectHtml(dir, root = dir, found = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) collectHtml(full, root, found); + else if (entry.name.endsWith(".html") && entry.name !== "404.html") + found.push(path.relative(root, full)); + } + return found; +} + +export function buildLlmsTxt({ base, description, files, readFile, title }) { + const entries = []; + + for (const file of files) { + const html = readFile(file); + const raw = /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? ""; + const name = decode(raw).replace( + new RegExp(`\\s*\\|\\s*${escapeRegExp(title)}$`), + "", + ); + if (!name) continue; + + const detail = decode( + /]+name="description"[^>]+content="([^"]*)"/i.exec( + html, + )?.[1] ?? "", + ); + const route = file.replace(/index\.html$/, "").replace(/\.html$/, ""); + entries.push( + `- [${name}](${SITE}${base}${route})${detail ? `: ${detail}` : ""}`, + ); + } + + const lines = [`# ${title}`]; + if (description) lines.push("", `> ${description}`); + if (entries.length) lines.push("", "## Documentation", "", ...entries.sort()); + lines.push( + "", + "## About", + "", + `- [Software Mansion](https://swmansion.com): maintainer of ${title}`, + "", + ); + + return lines.join("\n"); +} + +export async function writeLlmsTxt(siteConfig) { + const { outDir, site } = siteConfig; + const files = collectHtml(outDir); + + await fs.promises.writeFile( + path.join(outDir, "llms.txt"), + buildLlmsTxt({ + base: site.base, + description: site.description, + files, + readFile: (file) => fs.readFileSync(path.join(outDir, file), "utf8"), + title: site.title, + }), + "utf8", + ); +} From a39e9833fa79bd474dd6cd4a7f4ec1f915518da0 Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Thu, 20 Aug 2026 11:47:30 +0200 Subject: [PATCH 2/2] Match the repository's formatting and drop an unused import --- website/.vitepress/swm-geo.mjs | 181 ++++++++++++++++----------------- 1 file changed, 90 insertions(+), 91 deletions(-) diff --git a/website/.vitepress/swm-geo.mjs b/website/.vitepress/swm-geo.mjs index a6ef86d..bc11182 100644 --- a/website/.vitepress/swm-geo.mjs +++ b/website/.vitepress/swm-geo.mjs @@ -7,116 +7,115 @@ const SITE = "https://docs.swmansion.com"; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const decode = (value) => - value - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/&#(?:39|x27);/g, "'") - .trim(); + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/&#(?:39|x27);/g, "'") + .trim(); const projectFromBase = (base) => base.replace(/\//g, ""); // Same @id as swmansion.com, so engines read one company across both domains. export function swmStructuredData({ siteConfig }) { - const { base, description, title } = siteConfig.site; - const project = projectFromBase(base); + const { base, description, title } = siteConfig.site; + const project = projectFromBase(base); - return [ - "script", - { type: "application/ld+json" }, - JSON.stringify({ - "@context": "https://schema.org", - "@graph": [ - { - "@type": "Organization", - "@id": ORGANIZATION_ID, - name: "Software Mansion", - url: "https://swmansion.com", - sameAs: [ - "https://github.com/software-mansion", - "https://www.linkedin.com/company/software-mansion/", - "https://twitter.com/swmansion", - "https://www.youtube.com/c/SoftwareMansion", - ], - }, - { - "@type": "SoftwareSourceCode", - name: title, - ...(description ? { description } : {}), - ...(project - ? { - codeRepository: `https://github.com/software-mansion/${project}`, - } - : {}), - author: { "@id": ORGANIZATION_ID }, - maintainer: { "@id": ORGANIZATION_ID }, - }, - ], - }), - ]; + return [ + "script", + { type: "application/ld+json" }, + JSON.stringify({ + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": ORGANIZATION_ID, + name: "Software Mansion", + url: "https://swmansion.com", + sameAs: [ + "https://github.com/software-mansion", + "https://www.linkedin.com/company/software-mansion/", + "https://twitter.com/swmansion", + "https://www.youtube.com/c/SoftwareMansion", + ], + }, + { + "@type": "SoftwareSourceCode", + name: title, + ...(description ? { description } : {}), + ...(project + ? { + codeRepository: `https://github.com/software-mansion/${project}`, + } + : {}), + author: { "@id": ORGANIZATION_ID }, + maintainer: { "@id": ORGANIZATION_ID }, + }, + ], + }), + ]; } function collectHtml(dir, root = dir, found = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) collectHtml(full, root, found); - else if (entry.name.endsWith(".html") && entry.name !== "404.html") - found.push(path.relative(root, full)); - } - return found; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) collectHtml(full, root, found); + else if (entry.name.endsWith(".html") && entry.name !== "404.html") + found.push(path.relative(root, full)); + } + return found; } export function buildLlmsTxt({ base, description, files, readFile, title }) { - const entries = []; + const entries = []; - for (const file of files) { - const html = readFile(file); - const raw = /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? ""; - const name = decode(raw).replace( - new RegExp(`\\s*\\|\\s*${escapeRegExp(title)}$`), - "", - ); - if (!name) continue; - - const detail = decode( - /]+name="description"[^>]+content="([^"]*)"/i.exec( - html, - )?.[1] ?? "", - ); - const route = file.replace(/index\.html$/, "").replace(/\.html$/, ""); - entries.push( - `- [${name}](${SITE}${base}${route})${detail ? `: ${detail}` : ""}`, - ); - } + for (const file of files) { + const html = readFile(file); + const raw = /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? ""; + const name = decode(raw).replace( + new RegExp(`\\s*\\|\\s*${escapeRegExp(title)}$`), + "", + ); + if (!name) continue; - const lines = [`# ${title}`]; - if (description) lines.push("", `> ${description}`); - if (entries.length) lines.push("", "## Documentation", "", ...entries.sort()); - lines.push( - "", - "## About", - "", - `- [Software Mansion](https://swmansion.com): maintainer of ${title}`, + const detail = decode( + /]+name="description"[^>]+content="([^"]*)"/i.exec(html)?.[1] ?? "", ); + const route = file.replace(/index\.html$/, "").replace(/\.html$/, ""); + entries.push( + `- [${name}](${SITE}${base}${route})${detail ? `: ${detail}` : ""}`, + ); + } + + const lines = [`# ${title}`]; + if (description) lines.push("", `> ${description}`); + if (entries.length) lines.push("", "## Documentation", "", ...entries.sort()); + lines.push( + "", + "## About", + "", + `- [Software Mansion](https://swmansion.com): maintainer of ${title}`, + "", + ); - return lines.join("\n"); + return lines.join("\n"); } export async function writeLlmsTxt(siteConfig) { - const { outDir, site } = siteConfig; - const files = collectHtml(outDir); + const { outDir, site } = siteConfig; + const files = collectHtml(outDir); - await fs.promises.writeFile( - path.join(outDir, "llms.txt"), - buildLlmsTxt({ - base: site.base, - description: site.description, - files, - readFile: (file) => fs.readFileSync(path.join(outDir, file), "utf8"), - title: site.title, - }), - "utf8", - ); + await fs.promises.writeFile( + path.join(outDir, "llms.txt"), + buildLlmsTxt({ + base: site.base, + description: site.description, + files, + readFile: (file) => fs.readFileSync(path.join(outDir, file), "utf8"), + title: site.title, + }), + "utf8", + ); }