From 65a23e9909f959c061b96435cb46b07c950e246b Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Thu, 20 Aug 2026 10:58:11 +0200 Subject: [PATCH 1/5] Add JSON-LD and a build-time llms.txt for the docs One local plugin injects Organization and SoftwareSourceCode structured data using the same @id as swmansion.com, and writes llms.txt from the pages Docusaurus just built. CI fails if the file is missing or lists nothing. --- .github/workflows/deploy_docs.yml | 13 +++ website/docusaurus.config.js | 2 + website/plugins/swm-geo.js | 135 ++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 website/plugins/swm-geo.js diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 826e8ad12c..a9363112ba 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -31,6 +31,19 @@ jobs: run: yarn build + - name: Check docs llms.txt + run: | + file=website/build/llms.txt + if [ ! -s "$file" ]; then + echo "::error::$file is missing or empty" + exit 1 + fi + count=$(grep -cE '^- \[[^]]+\]\(https://docs\.swmansion\.com/protostar/[^)]+\)' "$file" || true) + if [ "$count" -eq 0 ]; then + echo "::error::$file lists no pages" + exit 1 + fi + echo "llms.txt lists $count pages" - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v3 with: diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 00bfe6abeb..92dd50a1f6 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -208,6 +208,8 @@ const config = { }), plugins: [ + + require("./plugins/swm-geo"), ['@docusaurus/plugin-client-redirects', { redirects: [ // NOTE: Old links before 2022-07-21 diff --git a/website/plugins/swm-geo.js b/website/plugins/swm-geo.js new file mode 100644 index 0000000000..8a96647ffc --- /dev/null +++ b/website/plugins/swm-geo.js @@ -0,0 +1,135 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const ORGANIZATION_ID = 'https://swmansion.com/#organization'; +const SECTIONS = { docs: 'Documentation', blog: 'Blog', examples: 'Examples' }; + +const decode = (value) => + value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&#(?:39|x27);/g, "'") + .trim(); + +const metaOf = (html, name) => + new RegExp(`]+name="${name}"[^>]+content="([^"]*)"`, 'i').exec( + html, + )?.[1] ?? ''; + +function describe(html, siteTitle) { + const raw = /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? ''; + const title = decode(raw).replace(new RegExp(`\\s*\\|\\s*${siteTitle}$`), ''); + return { title, description: decode(metaOf(html, 'description')) }; +} + +// Same @id as swmansion.com, so engines read one company across both domains. +function buildStructuredData(siteConfig) { + const { organizationName, projectName, tagline, title } = siteConfig; + const repository = + organizationName && projectName + ? `https://github.com/${organizationName}/${projectName}` + : undefined; + + return { + '@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, + ...(tagline ? { description: tagline } : {}), + ...(repository ? { codeRepository: repository } : {}), + author: { '@id': ORGANIZATION_ID }, + maintainer: { '@id': ORGANIZATION_ID }, + }, + ], + }; +} + +function buildLlmsTxt({ siteConfig, routesPaths, readPage }) { + const { baseUrl, title, tagline, url } = siteConfig; + const grouped = new Map(); + + for (const route of routesPaths) { + if (!route.startsWith(baseUrl) || route.endsWith('404.html')) continue; + + const relative = route.slice(baseUrl.length); + const html = readPage(relative); + if (!html) continue; + + const page = describe(html, title); + if (!page.title) continue; + + const section = SECTIONS[relative.split('/')[0]] ?? 'Pages'; + const line = `- [${page.title}](${url.replace(/\/$/, '')}${route})${page.description ? `: ${page.description}` : ''}`; + + if (!grouped.has(section)) grouped.set(section, []); + grouped.get(section).push(line); + } + + const lines = [`# ${title}`]; + if (tagline) lines.push('', `> ${tagline}`); + + for (const section of ['Documentation', 'Examples', 'Blog', 'Pages']) { + const entries = grouped.get(section); + if (!entries?.length) continue; + lines.push('', `## ${section}`, '', ...entries.sort()); + } + + lines.push( + '', + '## About', + '', + `- [Software Mansion](https://swmansion.com): maintainer of ${title}`, + '', + ); + + return lines.join('\n'); +} + +module.exports = function swmGeoPlugin(context) { + return { + name: 'swm-geo', + + injectHtmlTags() { + return { + headTags: [ + { + tagName: 'script', + attributes: { type: 'application/ld+json' }, + innerHTML: JSON.stringify(buildStructuredData(context.siteConfig)), + }, + ], + }; + }, + + async postBuild({ siteConfig, routesPaths, outDir }) { + const readPage = (relative) => { + const file = path.join(outDir, relative, 'index.html'); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + }; + + await fs.promises.writeFile( + path.join(outDir, 'llms.txt'), + buildLlmsTxt({ siteConfig, routesPaths, readPage }), + 'utf8', + ); + }, + }; +}; + +module.exports.buildLlmsTxt = buildLlmsTxt; +module.exports.buildStructuredData = buildStructuredData; From f4cc29055189bef5d070eb50f941d690bc01ef6e Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Thu, 20 Aug 2026 11:01:38 +0200 Subject: [PATCH 2/5] Drop a stray blank line in the plugins list --- website/docusaurus.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 92dd50a1f6..ac815c6221 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -208,7 +208,6 @@ const config = { }), plugins: [ - require("./plugins/swm-geo"), ['@docusaurus/plugin-client-redirects', { redirects: [ From b6f367870e2bd53027091c527ec79f716525662b Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Thu, 20 Aug 2026 11:06:57 +0200 Subject: [PATCH 3/5] Fix the llms.txt path: the job already runs inside the docs directory --- .github/workflows/deploy_docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index a9363112ba..8d2d2ae8c7 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -33,7 +33,7 @@ jobs: - name: Check docs llms.txt run: | - file=website/build/llms.txt + file=build/llms.txt if [ ! -s "$file" ]; then echo "::error::$file is missing or empty" exit 1 From dbbe06d79d79a544d205d9b3cbfbdc72c9838a25 Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Thu, 20 Aug 2026 11:08:09 +0200 Subject: [PATCH 4/5] Escape the site title before using it in a regex A title containing regex metacharacters would change the pattern's meaning or throw at build time. --- website/plugins/swm-geo.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/website/plugins/swm-geo.js b/website/plugins/swm-geo.js index 8a96647ffc..495adbd76b 100644 --- a/website/plugins/swm-geo.js +++ b/website/plugins/swm-geo.js @@ -13,14 +13,20 @@ const decode = (value) => .replace(/&#(?:39|x27);/g, "'") .trim(); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const metaOf = (html, name) => - new RegExp(`]+name="${name}"[^>]+content="([^"]*)"`, 'i').exec( - html, - )?.[1] ?? ''; + new RegExp( + `]+name="${escapeRegExp(name)}"[^>]+content="([^"]*)"`, + 'i', + ).exec(html)?.[1] ?? ''; function describe(html, siteTitle) { const raw = /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? ''; - const title = decode(raw).replace(new RegExp(`\\s*\\|\\s*${siteTitle}$`), ''); + const title = decode(raw).replace( + new RegExp(`\\s*\\|\\s*${escapeRegExp(siteTitle)}$`), + '', + ); return { title, description: decode(metaOf(html, 'description')) }; } From 59052db1c271e4e4751938a0fba23b3c8ddc0163 Mon Sep 17 00:00:00 2001 From: Szymon Halski Date: Fri, 21 Aug 2026 10:55:50 +0200 Subject: [PATCH 5/5] Escape the JSON-LD against early script termination JSON.stringify leaves `<` unescaped, so a `` inside any config value would close the tag early. Escaped to <. --- website/plugins/swm-geo.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/plugins/swm-geo.js b/website/plugins/swm-geo.js index 495adbd76b..8e8ab712c6 100644 --- a/website/plugins/swm-geo.js +++ b/website/plugins/swm-geo.js @@ -116,7 +116,9 @@ module.exports = function swmGeoPlugin(context) { { tagName: 'script', attributes: { type: 'application/ld+json' }, - innerHTML: JSON.stringify(buildStructuredData(context.siteConfig)), + innerHTML: JSON.stringify( + buildStructuredData(context.siteConfig), + ).replace(/