Skip to content

Commit 6c2d809

Browse files
authored
Add a custom plugin for llms.txt generation (#52)
1 parent d3b2e65 commit 6c2d809

4 files changed

Lines changed: 199 additions & 11169 deletions

File tree

docusaurus/docusaurus.config.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ const config = {
3434
]
3535
}],
3636
[
37-
"docusaurus-plugin-generate-llms-txt",
37+
require.resolve('./plugins/llms-txt/index.js'),
3838
{
39-
outputFile: "llms.txt", // defaults to llms.txt if not specified
39+
docsDir: 'docs',
40+
outputFile: 'llms.txt',
4041
},
4142
],
4243
],

docusaurus/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
"@mdx-js/react": "^1.6.22",
2121
"clsx": "^1.2.1",
2222
"docusaurus-lunr-search": "^3.3.2",
23-
"docusaurus-plugin-generate-llms-txt": "^0.0.1",
2423
"prism-react-renderer": "^1.3.5",
2524
"react": "^17.0.2",
2625
"react-dom": "^17.0.2"
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// plugins/llms-txt/index.js
2+
const path = require('path');
3+
const fs = require('fs');
4+
5+
// Simple recursive file finder — no glob dependency needed
6+
function findFiles(dir, extensions, results = []) {
7+
if (!fs.existsSync(dir)) return results;
8+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9+
if (entry.name.startsWith('_')) continue; // skip partials
10+
const fullPath = path.join(dir, entry.name);
11+
if (entry.isDirectory()) {
12+
findFiles(fullPath, extensions, results);
13+
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
14+
results.push(fullPath);
15+
}
16+
}
17+
return results;
18+
}
19+
20+
// Strip leading numeric prefix: "12-native-token.md" → "native-token"
21+
function stripPrefix(segment) {
22+
return segment.replace(/^\d+-/, '');
23+
}
24+
25+
// Read _category_.json from a directory, returns { label, position, description } or null
26+
function readCategory(dir) {
27+
const catPath = path.join(dir, '_category_.json');
28+
if (!fs.existsSync(catPath)) return null;
29+
try {
30+
const data = JSON.parse(fs.readFileSync(catPath, 'utf8'));
31+
return {
32+
label: data.label || path.basename(dir),
33+
position: data.position ?? 999,
34+
description: data.link?.description || '',
35+
};
36+
} catch {
37+
return null;
38+
}
39+
}
40+
41+
// Build a link line for a single markdown file
42+
function buildLink(file, docsDir, siteUrl) {
43+
const relative = path.relative(docsDir, file);
44+
const urlPath = relative
45+
.replace(/\\/g, '/')
46+
.replace(/\.mdx?$/, '')
47+
.replace(/\/index$/, '')
48+
.split('/')
49+
.map(stripPrefix)
50+
.join('/');
51+
52+
const fullUrl = `${siteUrl}/docs/${urlPath}`;
53+
54+
const content = fs.readFileSync(file, 'utf8');
55+
const frontmatterTitle = content.match(/^title:\s*['"]?(.+?)['"]?\s*$/m);
56+
const headingTitle = content.match(/^#\s+(.+)$/m);
57+
const pageTitle = frontmatterTitle?.[1] ?? headingTitle?.[1] ?? urlPath;
58+
59+
return `- [${pageTitle}](${fullUrl})`;
60+
}
61+
62+
module.exports = function llmsTxtPlugin(context, options) {
63+
return {
64+
name: 'llms-txt-plugin',
65+
async postBuild({ siteConfig, outDir }) {
66+
const { title, url, tagline } = siteConfig;
67+
const docsDir = path.join(context.siteDir, options.docsDir || 'docs');
68+
69+
console.log(`Generating llms.txt for ${title} at ${url}`);
70+
71+
// Collect top-level files (not inside a subdirectory)
72+
const topLevelFiles = findFiles(docsDir, ['.md', '.mdx']).filter(
73+
(f) => path.dirname(f) === docsDir
74+
);
75+
76+
// Collect sections from subdirectories
77+
const sections = [];
78+
for (const entry of fs.readdirSync(docsDir, { withFileTypes: true })) {
79+
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
80+
const subDir = path.join(docsDir, entry.name);
81+
const category = readCategory(subDir);
82+
const files = findFiles(subDir, ['.md', '.mdx']);
83+
if (files.length === 0) continue;
84+
85+
sections.push({
86+
label: category?.label || entry.name,
87+
position: category?.position ?? 999,
88+
description: category?.description || '',
89+
files,
90+
});
91+
}
92+
93+
// Sort sections by position
94+
sections.sort((a, b) => a.position - b.position);
95+
96+
// Build output
97+
let output = `# ${title}\n\n> ${tagline || ''}\n`;
98+
99+
// Top-level files (if any)
100+
if (topLevelFiles.length > 0) {
101+
output += '\n';
102+
for (const file of topLevelFiles) {
103+
output += buildLink(file, docsDir, url) + '\n';
104+
}
105+
}
106+
107+
// Sections
108+
for (const section of sections) {
109+
output += `\n## ${section.label}\n`;
110+
if (section.description) {
111+
output += `\n> ${section.description}\n`;
112+
}
113+
output += '\n';
114+
for (const file of section.files) {
115+
output += buildLink(file, docsDir, url) + '\n';
116+
}
117+
}
118+
119+
const outputFileName = options.outputFile || 'llms.txt';
120+
const staticDir = path.join(context.siteDir, 'static');
121+
122+
// Write to static/ (persists in repo) and outDir (current build output)
123+
fs.writeFileSync(path.join(staticDir, outputFileName), output);
124+
fs.writeFileSync(path.join(outDir, outputFileName), output);
125+
126+
const totalFiles = topLevelFiles.length + sections.reduce((sum, s) => sum + s.files.length, 0);
127+
console.log(`✅ ${outputFileName} generated in static/ with ${totalFiles} pages in ${sections.length} sections`);
128+
},
129+
};
130+
};

0 commit comments

Comments
 (0)