diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c7e18270..f2079274e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,3 +25,17 @@ jobs: cd packages/audiodocs yarn install --frozen-lockfile yarn build + + - name: Check docs llms.txt + run: | + file=packages/audiodocs/build/llms.txt + if [ ! -s "$file" ]; then + echo "::error::$file is missing or empty" + exit 1 + fi + count=$(grep -cE '^- \[[^]]+\]\(https://docs\.swmansion\.com/react-native-audio-api/[^)]+\)' "$file" || true) + if [ "$count" -eq 0 ]; then + echo "::error::$file lists no pages" + exit 1 + fi + echo "llms.txt lists $count pages" \ No newline at end of file diff --git a/packages/audiodocs/docusaurus.config.js b/packages/audiodocs/docusaurus.config.js index 70d8ec740..1573ea534 100644 --- a/packages/audiodocs/docusaurus.config.js +++ b/packages/audiodocs/docusaurus.config.js @@ -160,6 +160,7 @@ const config = { ], plugins: [ + require('./plugins/swm-geo'), [ '@docusaurus/plugin-google-tag-manager', { diff --git a/packages/audiodocs/plugins/swm-geo.js b/packages/audiodocs/plugins/swm-geo.js new file mode 100644 index 000000000..495adbd76 --- /dev/null +++ b/packages/audiodocs/plugins/swm-geo.js @@ -0,0 +1,141 @@ +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 escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const metaOf = (html, name) => + 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*${escapeRegExp(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;