Skip to content

Commit 9a8c016

Browse files
docs: update llms-txt plugin to improve markdown handling and add API specs loading
1 parent a5a887c commit 9a8c016

2 files changed

Lines changed: 109 additions & 51 deletions

File tree

src/plugins/llms-txt/index.js

Lines changed: 98 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ async function generatePageContent(filePath) {
6565
let result = '';
6666

6767
const title = frontMatter.title || frontMatter.sidebar_label;
68-
if (title) {
68+
// Skip the frontmatter title when the body already opens with its own H1.
69+
if (title && !/^#\s/.test(cleanedContent)) {
6970
result += `# ${title}\n\n`;
7071
}
7172

@@ -78,34 +79,58 @@ async function generatePageContent(filePath) {
7879
return result;
7980
}
8081

82+
// Docusaurus strips number prefixes like "1-entities" from path segments.
83+
const stripNumberPrefix = (segment) => segment.replace(/^\d+[-_.]+/, '');
84+
8185
/**
82-
* Recursively collects all doc routes with source file paths.
86+
* Resolves the actual Docusaurus route for a doc source file, mirroring the
87+
* docs plugin's slug rules: frontmatter `slug` wins (absolute slugs are
88+
* relative to the docs base), `index`/`README` files map to their directory,
89+
* and number prefixes are stripped from every path segment.
8390
*/
84-
function collectDocRoutes(routes) {
85-
const result = [];
86-
87-
function walk(routeList) {
88-
for (const route of routeList) {
89-
if (route.metadata && route.metadata.sourceFilePath) {
90-
result.push({
91-
path: route.path,
92-
sourceFilePath: route.metadata.sourceFilePath,
93-
});
94-
}
95-
if (route.routes) {
96-
walk(route.routes);
97-
}
91+
function resolveDocRoute(relativePath, frontMatter) {
92+
const posixPath = relativePath.replace(/\\/g, '/').replace(/\.mdx?$/, '');
93+
const segments = posixPath.split('/').map(stripNumberPrefix);
94+
const baseName = segments[segments.length - 1];
95+
const dirSegments = segments.slice(0, -1);
96+
97+
const slug = frontMatter.slug;
98+
if (typeof slug === 'string' && slug.length > 0) {
99+
if (slug.startsWith('/')) {
100+
return `/docs${slug === '/' ? '' : slug}`.replace(/\/$/, '') || '/docs';
98101
}
102+
return ['/docs', ...dirSegments, slug].join('/');
99103
}
100104

101-
walk(routes);
102-
return result;
105+
if (/^(index|readme)$/i.test(baseName)) {
106+
return ['/docs', ...dirSegments].join('/').replace(/\/$/, '') || '/docs';
107+
}
108+
109+
return ['/docs', ...segments].join('/');
103110
}
104111

105112
/**
106-
* Generates the root llms.txt content with a page index.
113+
* Loads the OpenAPI spec list from redoc.config.js for the llms.txt APIs section.
107114
*/
108-
function generateRootLlmsTxt(siteConfig, items, siteDescription) {
115+
function loadApiSpecs(siteDir) {
116+
try {
117+
// eslint-disable-next-line import/no-dynamic-require
118+
const { specs } = require(path.join(siteDir, 'redoc.config.js'));
119+
return specs.map((spec) => ({
120+
title: spec.layout.title,
121+
routePath: spec.routePath,
122+
specUrl: spec.specUrl,
123+
}));
124+
} catch (err) {
125+
console.warn(`[${PLUGIN_NAME}] Could not load redoc.config.js:`, err.message);
126+
return [];
127+
}
128+
}
129+
130+
/**
131+
* Generates the root llms.txt content with agent instructions and a page index.
132+
*/
133+
function generateRootLlmsTxt(siteConfig, items, siteDescription, apiSpecs) {
109134
const siteUrl = siteConfig.url;
110135
const lines = [];
111136

@@ -120,6 +145,33 @@ function generateRootLlmsTxt(siteConfig, items, siteDescription) {
120145
lines.push('');
121146
}
122147

148+
lines.push('## Instructions for LLM agents');
149+
lines.push('');
150+
lines.push(
151+
'- Every documentation page is available as raw markdown by appending `.md` to its URL (e.g. `' +
152+
siteUrl +
153+
'/docs/intro.md`). Prefer the markdown version over the HTML page.',
154+
);
155+
lines.push(`- The complete documentation in a single file: ${siteUrl}/llms-full.txt`);
156+
lines.push(
157+
'- REST API contracts are published as raw OpenAPI 3.0 YAML specs (see the APIs section below). Use the spec, not the HTML API reference pages, which render client-side.',
158+
);
159+
lines.push(
160+
'- Official TypeScript SDK: `@epilot/sdk` on npm, plus per-API clients (e.g. `@epilot/entity-client`, `@epilot/pricing-client`). Check the npm registry for current versions instead of relying on memorized ones.',
161+
);
162+
lines.push('');
163+
164+
if (apiSpecs.length > 0) {
165+
lines.push('## APIs');
166+
lines.push('');
167+
lines.push('Raw OpenAPI 3.0 specifications for every epilot API:');
168+
lines.push('');
169+
for (const spec of apiSpecs) {
170+
lines.push(`- [${spec.title}](${spec.specUrl}): reference at ${siteUrl}${spec.routePath}`);
171+
}
172+
lines.push('');
173+
}
174+
123175
lines.push('## Documentation Pages');
124176
lines.push('');
125177

@@ -142,10 +194,8 @@ function generateRootLlmsTxt(siteConfig, items, siteDescription) {
142194

143195
for (const item of groupItems) {
144196
const fullUrl = `${siteUrl}${item.path}`;
145-
lines.push(`- [${item.title}](${fullUrl}): ${fullUrl}/llms.txt`);
146-
if (item.description) {
147-
lines.push(` ${item.description}`);
148-
}
197+
const description = item.description ? `: ${item.description}` : '';
198+
lines.push(`- [${item.title}](${fullUrl}.md)${description}`);
149199
}
150200
lines.push('');
151201
}
@@ -167,14 +217,8 @@ module.exports = function pluginLlmsTxt(context, options = {}) {
167217
return {
168218
name: PLUGIN_NAME,
169219

170-
async postBuild({ siteConfig, routes, outDir, siteDir }) {
171-
const docRoutes = collectDocRoutes(routes);
172-
173-
if (docRoutes.length === 0) {
174-
console.warn(`[${PLUGIN_NAME}] No doc routes with source files found. Falling back to docs/ directory scan.`);
175-
}
176-
177-
// Collect all doc files from the docs/ directory as a reliable source
220+
async postBuild({ siteConfig, outDir, siteDir }) {
221+
// Collect all doc files from the docs/ directory
178222
const docsDir = path.join(siteDir, 'docs');
179223
const allDocFiles = [];
180224

@@ -200,8 +244,9 @@ module.exports = function pluginLlmsTxt(context, options = {}) {
200244

201245
const items = [];
202246
let successCount = 0;
247+
let unresolvedCount = 0;
203248

204-
// Generate per-page llms.txt files
249+
// Generate per-page markdown (.md) and legacy llms.txt files
205250
await Promise.all(
206251
allDocFiles.map(async ({ fullPath, relativePath }) => {
207252
try {
@@ -211,29 +256,26 @@ module.exports = function pluginLlmsTxt(context, options = {}) {
211256
const fileContent = await fs.readFile(fullPath, 'utf-8');
212257
const { data: frontMatter } = matter(fileContent);
213258

214-
// Determine the URL path for this doc
215-
// e.g. docs/journeys/journey-builder.md -> /docs/journeys/journey-builder
216-
let urlPath = relativePath
217-
.replace(/\.mdx?$/, '')
218-
.replace(/\\/g, '/');
259+
const docPath = resolveDocRoute(relativePath, frontMatter);
219260

220-
// Handle index files (intro.md or index.md at directory level)
221-
if (urlPath.endsWith('/intro')) {
222-
// Keep as-is, Docusaurus maps these to the directory path or /intro
261+
// Only emit for routes that exist in the build output, so llms.txt
262+
// never links to pages that 404.
263+
const routeDir = path.join(outDir, docPath);
264+
if (!(await fs.pathExists(path.join(routeDir, 'index.html')))) {
265+
unresolvedCount++;
266+
console.warn(`[${PLUGIN_NAME}] No built page for ${relativePath} at ${docPath}, skipping.`);
267+
return;
223268
}
224269

225-
const docPath = `/docs/${urlPath}`;
270+
// Raw markdown at the parallel .md URL (industry convention)
271+
await fs.writeFile(`${routeDir}.md`, content, 'utf-8');
226272

227-
// Write llms.txt for this page
228-
const outputDir = path.join(outDir, docPath);
229-
const outputPath = path.join(outputDir, 'llms.txt');
230-
231-
await fs.ensureDir(outputDir);
232-
await fs.writeFile(outputPath, content, 'utf-8');
273+
// Legacy per-page llms.txt location, kept for existing consumers
274+
await fs.writeFile(path.join(routeDir, 'llms.txt'), content, 'utf-8');
233275
successCount++;
234276

235277
// Collect metadata for root index
236-
const title = frontMatter.title || frontMatter.sidebar_label || urlPath.split('/').pop();
278+
const title = frontMatter.title || frontMatter.sidebar_label || docPath.split('/').pop();
237279
items.push({
238280
path: docPath,
239281
title,
@@ -245,14 +287,19 @@ module.exports = function pluginLlmsTxt(context, options = {}) {
245287
}),
246288
);
247289

248-
console.log(`[${PLUGIN_NAME}] Generated ${successCount} per-page llms.txt files.`);
290+
console.log(
291+
`[${PLUGIN_NAME}] Generated ${successCount} per-page .md/llms.txt files` +
292+
(unresolvedCount ? ` (${unresolvedCount} files had no matching route).` : '.'),
293+
);
249294

250295
// Sort items by path
251296
items.sort((a, b) => a.path.localeCompare(b.path));
252297

298+
const apiSpecs = loadApiSpecs(siteDir);
299+
253300
// Generate root llms.txt
254301
try {
255-
const rootContent = generateRootLlmsTxt(siteConfig, items, siteDescription);
302+
const rootContent = generateRootLlmsTxt(siteConfig, items, siteDescription, apiSpecs);
256303
const rootPath = path.join(outDir, 'llms.txt');
257304
await fs.writeFile(rootPath, rootContent, 'utf-8');
258305
console.log(`[${PLUGIN_NAME}] Generated root llms.txt with ${items.length} entries.`);

static/robots.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# docs.epilot.io — public developer documentation.
2+
# All content here is intended to be read, indexed, and used by both humans
3+
# and AI agents. LLM-friendly resources:
4+
# - /llms.txt (index for LLM agents, https://llmstxt.org/)
5+
# - /llms-full.txt (complete documentation in one file)
6+
# - append .md to any docs URL for the raw markdown version
7+
8+
User-agent: *
9+
Allow: /
10+
11+
Sitemap: https://docs.epilot.io/sitemap.xml

0 commit comments

Comments
 (0)