Skip to content

Commit f2fd86f

Browse files
committed
Add generation of llms.txt files
1 parent d286f54 commit f2fd86f

File tree

2 files changed

+261
-3
lines changed

2 files changed

+261
-3
lines changed

docusaurus.config.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import remarkRawMarkdown from './src/plugins/remark-raw-markdown.mjs';
99
import darkTheme from './src/themes/react-navigation-dark';
1010
import lightTheme from './src/themes/react-navigation-light';
1111

12+
const url = 'https://reactnavigation.org';
13+
const latestVersion = '7.x';
14+
1215
const config: Config = {
1316
title: 'React Navigation',
1417
tagline: 'Routing and navigation for your React Native apps',
15-
url: 'https://reactnavigation.org/',
18+
url,
1619
baseUrl: '/',
1720
favicon: 'img/favicon.ico',
1821
organizationName: 'react-navigation',
@@ -131,6 +134,7 @@ const config: Config = {
131134
plugins: [
132135
'./src/plugins/disable-fully-specified.mjs',
133136
'./src/plugins/react-navigation-versions.mjs',
137+
['./src/plugins/llms-txt.mjs', { latestVersion, baseUrl: url }],
134138
[
135139
'@docusaurus/plugin-client-redirects',
136140
{
@@ -159,9 +163,9 @@ const config: Config = {
159163
editUrl:
160164
'https://github.com/react-navigation/react-navigation.github.io/edit/main/',
161165
includeCurrentVersion: false,
162-
lastVersion: '7.x',
166+
lastVersion: latestVersion,
163167
versions: {
164-
'7.x': {
168+
[latestVersion]: {
165169
badge: false,
166170
},
167171
},

src/plugins/llms-txt.mjs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import util from 'node:util';
4+
import versions from '../../versions.json';
5+
6+
/**
7+
* Parses frontmatter from markdown content.
8+
* Extracts metadata like title and description, and returns the cleaned content.
9+
*
10+
* @param {string} fileContent - Raw markdown file content.
11+
* @returns {{data: Object, content: string}} - Parsed data and stripped content.
12+
*/
13+
function parseFrontMatter(fileContent) {
14+
const frontMatterRegex = /^---\n([\s\S]+?)\n---\n/;
15+
const match = fileContent.match(frontMatterRegex);
16+
17+
if (!match) {
18+
return { data: {}, content: fileContent };
19+
}
20+
21+
const frontMatterBlock = match[1];
22+
const content = fileContent.replace(frontMatterRegex, '');
23+
24+
const data = {};
25+
26+
frontMatterBlock.split('\n').forEach((line) => {
27+
const parts = line.split(':');
28+
29+
if (parts.length >= 2) {
30+
const key = parts[0].trim();
31+
32+
let value = parts.slice(1).join(':').trim();
33+
34+
// Remove surrounding quotes if present
35+
if (
36+
(value.startsWith("'") && value.endsWith("'")) ||
37+
(value.startsWith('"') && value.endsWith('"'))
38+
) {
39+
value = value.slice(1, -1);
40+
}
41+
42+
data[key] = value;
43+
}
44+
});
45+
46+
return { data, content };
47+
}
48+
49+
/**
50+
* Recursively processes sidebar items to generate the LLM index and collect full docs.
51+
* Handles different sidebar structures (flat arrays, categories).
52+
*
53+
* @param {Array} items - Sidebar items to process.
54+
* @param {string} docsPath - Base path to documentation files.
55+
* @param {string} version - Current version being processed (e.g., '7.x').
56+
* @param {boolean} isLatest - Whether this is the latest/stable version.
57+
* @param {string} baseUrl - The base URL of the website.
58+
* @param {number} level - Current nesting level for headings.
59+
* @returns {{content: string, docs: Array}} - Generated index content and list of doc objects.
60+
*/
61+
function processSidebar(
62+
items,
63+
docsPath,
64+
version,
65+
isLatest,
66+
baseUrl,
67+
level = 0
68+
) {
69+
let llmsContent = '';
70+
let fullDocsList = [];
71+
72+
items.forEach((item) => {
73+
// Case 1: Item is a direct link (string ID)
74+
if (typeof item === 'string') {
75+
const id = item;
76+
const filePath = path.join(docsPath, `${id}.md`);
77+
78+
if (fs.existsSync(filePath)) {
79+
const fileContent = fs.readFileSync(filePath, 'utf8');
80+
const { data, content } = parseFrontMatter(fileContent);
81+
82+
const title = data.title || id;
83+
const description = data.description || '';
84+
85+
// Construct the public URL for the document
86+
// Latest version uses /docs/id, others use /docs/version/id
87+
const urlPath = isLatest ? `/docs/${id}` : `/docs/${version}/${id}`;
88+
const fullUrl = `${baseUrl}${urlPath}`;
89+
90+
llmsContent += `- [${title}](${fullUrl})${
91+
description ? `: ${description}` : ''
92+
}\n`;
93+
94+
fullDocsList.push({
95+
title,
96+
url: fullUrl,
97+
content,
98+
});
99+
}
100+
}
101+
// Case 2: Item is a category object
102+
else if (item.type === 'category') {
103+
const label = item.label;
104+
const headingPrefix = '#'.repeat(level + 3); // Start at level 3 (###)
105+
106+
llmsContent += `\n${headingPrefix} ${label}\n\n`;
107+
108+
const { content: childContent, docs: childDocs } = processSidebar(
109+
item.items,
110+
docsPath,
111+
version,
112+
isLatest,
113+
baseUrl,
114+
level + 1
115+
);
116+
117+
llmsContent += childContent;
118+
fullDocsList = fullDocsList.concat(childDocs);
119+
}
120+
});
121+
122+
return { content: llmsContent, docs: fullDocsList };
123+
}
124+
125+
/**
126+
* Generates the llms.txt and llms-full.txt files for a specific version.
127+
*
128+
* @returns {Array<string>} - List of generated filenames.
129+
*/
130+
function generateForVersion(
131+
siteDir,
132+
outDir,
133+
version,
134+
outputPrefix,
135+
isLatest,
136+
baseUrl
137+
) {
138+
const docsPath = path.join(siteDir, 'versioned_docs', `version-${version}`);
139+
const sidebarPath = path.join(
140+
siteDir,
141+
'versioned_sidebars',
142+
`version-${version}-sidebars.json`
143+
);
144+
145+
if (!fs.existsSync(sidebarPath)) {
146+
// Silent return if sidebar is missing to avoid noise
147+
return [];
148+
}
149+
150+
const sidebarConfig = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
151+
152+
// Handle different Docusaurus sidebar structures (root 'docs' key or first key)
153+
let rootItems = sidebarConfig.docs || Object.values(sidebarConfig)[0] || [];
154+
155+
// Normalize object-style sidebars (older Docusaurus versions) to array format
156+
if (!Array.isArray(rootItems) && typeof rootItems === 'object') {
157+
const normalized = [];
158+
159+
for (const [label, items] of Object.entries(rootItems)) {
160+
normalized.push({
161+
type: 'category',
162+
label: label,
163+
items: items,
164+
});
165+
}
166+
167+
rootItems = normalized;
168+
}
169+
170+
const { content: sidebarContent, docs } = processSidebar(
171+
rootItems,
172+
docsPath,
173+
version,
174+
isLatest,
175+
baseUrl
176+
);
177+
178+
// 1. Generate Summary (llms.txt)
179+
let llmsTxt = `# React Navigation ${version}\n\n`;
180+
181+
llmsTxt += `> Routing and navigation for your React Native apps.\n\n`;
182+
llmsTxt += `## Documentation\n`;
183+
llmsTxt += sidebarContent;
184+
185+
const summaryFilename = `${outputPrefix}.txt`;
186+
187+
fs.writeFileSync(path.join(outDir, summaryFilename), llmsTxt);
188+
189+
// 2. Generate Full Content (llms-full.txt)
190+
let llmsFullTxt = `# React Navigation ${version} Documentation\n\n`;
191+
192+
docs.forEach((doc) => {
193+
llmsFullTxt += `## ${doc.title}\n\n`;
194+
llmsFullTxt += `Source: ${doc.url}\n\n`;
195+
llmsFullTxt += `${doc.content.trim()}\n\n---\n\n`;
196+
});
197+
198+
// Determine full filename (e.g., llms-v8.x -> llms-full-v8.x.txt)
199+
let fullFilename;
200+
201+
if (outputPrefix === 'llms') {
202+
fullFilename = 'llms-full.txt';
203+
} else {
204+
if (outputPrefix.includes('llms-')) {
205+
fullFilename = outputPrefix.replace('llms-', 'llms-full-') + '.txt';
206+
} else {
207+
fullFilename = outputPrefix + '-full.txt';
208+
}
209+
}
210+
211+
fs.writeFileSync(path.join(outDir, fullFilename), llmsFullTxt);
212+
213+
return [summaryFilename, fullFilename];
214+
}
215+
216+
export default function (context, options) {
217+
return {
218+
name: 'llms.txt',
219+
async postBuild({ siteDir, outDir }) {
220+
const { latestVersion, baseUrl } = options;
221+
222+
if (!latestVersion) {
223+
throw new Error('[llms.txt] "latestVersion" option is required.');
224+
}
225+
226+
if (!baseUrl) {
227+
throw new Error('[llms.txt] "baseUrl" option is required.');
228+
}
229+
230+
const generatedFiles = [];
231+
232+
versions.forEach((version) => {
233+
const isLatest = version === latestVersion;
234+
// Prefix: 'llms' for latest, 'llms-vX.x' for others
235+
const outputPrefix = isLatest ? 'llms' : `llms-v${version}`;
236+
237+
const files = generateForVersion(
238+
siteDir,
239+
outDir,
240+
version,
241+
outputPrefix,
242+
isLatest,
243+
baseUrl
244+
);
245+
246+
generatedFiles.push(...files);
247+
});
248+
249+
console.log(
250+
`${util.styleText(['magenta', 'bold'], '[llms.txt]')} Wrote ${generatedFiles.length} files in build output.`
251+
);
252+
},
253+
};
254+
}

0 commit comments

Comments
 (0)