@@ -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 ( / \. m d x ? $ / , '' ) ;
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 ( / ^ ( i n d e x | r e a d m e ) $ / 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 ( / \. m d x ? $ / , '' )
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.` ) ;
0 commit comments