|
| 1 | +const fs = require('node:fs'); |
| 2 | +const path = require('node:path'); |
| 3 | + |
| 4 | +const ORGANIZATION_ID = 'https://swmansion.com/#organization'; |
| 5 | +const SECTION_NAMES = { docs: 'Documentation' }; |
| 6 | +const ACRONYMS = { api: 'API', ui: 'UI' }; |
| 7 | + |
| 8 | +const titleCase = (segment) => |
| 9 | + segment |
| 10 | + .split(/[-_]/) |
| 11 | + .map((word) => ACRONYMS[word] ?? word.replace(/^./, (c) => c.toUpperCase())) |
| 12 | + .join(' '); |
| 13 | + |
| 14 | +const sectionOf = (relative) => { |
| 15 | + const parts = relative.split('/').filter(Boolean); |
| 16 | + if (SECTION_NAMES[parts[0]]) return SECTION_NAMES[parts[0]]; |
| 17 | + if (parts.length < 2) return 'Pages'; |
| 18 | + return titleCase(parts[0]); |
| 19 | +}; |
| 20 | + |
| 21 | +const decode = (value) => |
| 22 | + value |
| 23 | + .replace(/&/g, '&') |
| 24 | + .replace(/</g, '<') |
| 25 | + .replace(/>/g, '>') |
| 26 | + .replace(/"/g, '"') |
| 27 | + .replace(/&#(?:39|x27);/g, "'") |
| 28 | + .trim(); |
| 29 | + |
| 30 | +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 31 | + |
| 32 | +const metaOf = (html, name) => |
| 33 | + new RegExp( |
| 34 | + `<meta[^>]+name="${escapeRegExp(name)}"[^>]+content="([^"]*)"`, |
| 35 | + 'i', |
| 36 | + ).exec(html)?.[1] ?? ''; |
| 37 | + |
| 38 | +function describe(html, siteTitle) { |
| 39 | + const raw = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? ''; |
| 40 | + const title = decode(raw).replace( |
| 41 | + new RegExp(`\\s*\\|\\s*${escapeRegExp(siteTitle)}$`), |
| 42 | + '', |
| 43 | + ); |
| 44 | + return { title, description: decode(metaOf(html, 'description')) }; |
| 45 | +} |
| 46 | + |
| 47 | +// Same @id as swmansion.com, so engines read one company across both domains. |
| 48 | +function buildStructuredData(siteConfig) { |
| 49 | + const { organizationName, projectName, tagline, title } = siteConfig; |
| 50 | + const repository = |
| 51 | + organizationName && projectName |
| 52 | + ? `https://github.com/${organizationName}/${projectName}` |
| 53 | + : undefined; |
| 54 | + |
| 55 | + return { |
| 56 | + '@context': 'https://schema.org', |
| 57 | + '@graph': [ |
| 58 | + { |
| 59 | + '@type': 'Organization', |
| 60 | + '@id': ORGANIZATION_ID, |
| 61 | + name: 'Software Mansion', |
| 62 | + url: 'https://swmansion.com', |
| 63 | + sameAs: [ |
| 64 | + 'https://github.com/software-mansion', |
| 65 | + 'https://www.linkedin.com/company/software-mansion/', |
| 66 | + 'https://twitter.com/swmansion', |
| 67 | + 'https://www.youtube.com/c/SoftwareMansion', |
| 68 | + ], |
| 69 | + }, |
| 70 | + { |
| 71 | + '@type': 'SoftwareSourceCode', |
| 72 | + name: title, |
| 73 | + ...(tagline ? { description: tagline } : {}), |
| 74 | + ...(repository ? { codeRepository: repository } : {}), |
| 75 | + author: { '@id': ORGANIZATION_ID }, |
| 76 | + maintainer: { '@id': ORGANIZATION_ID }, |
| 77 | + }, |
| 78 | + ], |
| 79 | + }; |
| 80 | +} |
| 81 | + |
| 82 | +function buildLlmsTxt({ siteConfig, routesPaths, readPage }) { |
| 83 | + const { baseUrl, title, tagline, url } = siteConfig; |
| 84 | + const grouped = new Map(); |
| 85 | + |
| 86 | + for (const route of routesPaths) { |
| 87 | + if (!route.startsWith(baseUrl) || route.endsWith('404.html')) continue; |
| 88 | + |
| 89 | + const relative = route.slice(baseUrl.length); |
| 90 | + const html = readPage(relative); |
| 91 | + if (!html) continue; |
| 92 | + |
| 93 | + const page = describe(html, title); |
| 94 | + if (!page.title) continue; |
| 95 | + |
| 96 | + const section = sectionOf(relative); |
| 97 | + const line = `- [${page.title}](${url.replace(/\/$/, '')}${route})${page.description ? `: ${page.description}` : ''}`; |
| 98 | + |
| 99 | + if (!grouped.has(section)) grouped.set(section, []); |
| 100 | + grouped.get(section).push(line); |
| 101 | + } |
| 102 | + |
| 103 | + const lines = [`# ${title}`]; |
| 104 | + if (tagline) lines.push('', `> ${tagline}`); |
| 105 | + |
| 106 | + // Documentation first, the rest alphabetically, loose pages last. |
| 107 | + const order = [ |
| 108 | + ...(grouped.has('Documentation') ? ['Documentation'] : []), |
| 109 | + ...[...grouped.keys()] |
| 110 | + .filter((section) => section !== 'Documentation' && section !== 'Pages') |
| 111 | + .sort(), |
| 112 | + ...(grouped.has('Pages') ? ['Pages'] : []), |
| 113 | + ]; |
| 114 | + |
| 115 | + for (const section of order) { |
| 116 | + const entries = grouped.get(section); |
| 117 | + if (!entries?.length) continue; |
| 118 | + lines.push('', `## ${section}`, '', ...entries.sort()); |
| 119 | + } |
| 120 | + |
| 121 | + lines.push( |
| 122 | + '', |
| 123 | + '## About', |
| 124 | + '', |
| 125 | + `- [Software Mansion](https://swmansion.com): maintainer of ${title}`, |
| 126 | + '', |
| 127 | + ); |
| 128 | + |
| 129 | + return lines.join('\n'); |
| 130 | +} |
| 131 | + |
| 132 | +module.exports = function swmGeoPlugin(context) { |
| 133 | + return { |
| 134 | + name: 'swm-geo', |
| 135 | + |
| 136 | + injectHtmlTags() { |
| 137 | + return { |
| 138 | + headTags: [ |
| 139 | + { |
| 140 | + tagName: 'script', |
| 141 | + attributes: { type: 'application/ld+json' }, |
| 142 | + innerHTML: JSON.stringify( |
| 143 | + buildStructuredData(context.siteConfig), |
| 144 | + ).replace(/</g, '\\u003c'), |
| 145 | + }, |
| 146 | + ], |
| 147 | + }; |
| 148 | + }, |
| 149 | + |
| 150 | + async postBuild({ siteConfig, routesPaths, outDir }) { |
| 151 | + const readPage = (relative) => { |
| 152 | + const file = path.join(outDir, relative, 'index.html'); |
| 153 | + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; |
| 154 | + }; |
| 155 | + |
| 156 | + await fs.promises.writeFile( |
| 157 | + path.join(outDir, 'llms.txt'), |
| 158 | + buildLlmsTxt({ siteConfig, routesPaths, readPage }), |
| 159 | + 'utf8', |
| 160 | + ); |
| 161 | + }, |
| 162 | + }; |
| 163 | +}; |
| 164 | + |
| 165 | +module.exports.buildLlmsTxt = buildLlmsTxt; |
| 166 | +module.exports.buildStructuredData = buildStructuredData; |
0 commit comments