Skip to content

Commit 505a055

Browse files
authored
docs: Add JSON-LD and llms.txt for AI search (#782)
Two SEO changes for the docs site. Nothing changes the rendered page. - **JSON-LD**: `Organization` with the same `@id` as swmansion.com, plus `SoftwareSourceCode` for the library, derived from `siteConfig`. The identical `@id` is what makes engines read the docs and the company site as one entity instead of two unrelated ones. - **`llms.txt`**, generated at build — no new dependency. Lists every docs page. The docs build now fails if the file is missing or lists nothing. - **`docs/static/robots.txt` deleted.** It never did anything: `robots.txt` is only read from the origin root, and this one sat at `/react-native-enriched-html/robots.txt`. Its rules now live at the origin (software-mansion/software-mansion.github.io#1, merged), where they actually apply. Both come from one local plugin, `plugins/swm-geo.js`, registered with a single line. The same change is going out across the docs repos; reference: software-mansion/react-native-reanimated#10332. **Check:** CI prints `llms.txt lists N pages`.
1 parent 2251fc6 commit 505a055

4 files changed

Lines changed: 181 additions & 4 deletions

File tree

.github/workflows/docs-build.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,17 @@ jobs:
3939

4040
- name: Build docs
4141
run: yarn build
42+
43+
- name: Check docs llms.txt
44+
run: |
45+
file=build/llms.txt
46+
if [ ! -s "$file" ]; then
47+
echo "::error::$file is missing or empty"
48+
exit 1
49+
fi
50+
count=$(grep -cE '^- \[[^]]+\]\(https://docs\.swmansion\.com/react-native-enriched-html/[^)]+\)' "$file" || true)
51+
if [ "$count" -eq 0 ]; then
52+
echo "::error::$file lists no pages"
53+
exit 1
54+
fi
55+
echo "llms.txt lists $count pages"

docs/docusaurus.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const config = {
134134
],
135135

136136
plugins: [
137+
require('./plugins/swm-geo'),
137138
reactNativeWebPlugin,
138139
enrichedHtmlLocalSourcePlugin,
139140
function transpileTRexUiTheme() {

docs/plugins/swm-geo.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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(/&amp;/g, '&')
24+
.replace(/&lt;/g, '<')
25+
.replace(/&gt;/g, '>')
26+
.replace(/&quot;/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;

docs/static/robots.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)