import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import type { Logger } from 'vite' import { collectLlmsSource, generateLlmsTxt } from 'node/build/generateLlmsTxt' import type { SiteConfig } from 'node/siteConfig' const logger = { info() {}, warn() {}, error() {} } as unknown as Logger // sources as collected from the markdown → Vue pipeline: includes are // already expanded there const fixtures: Record = { 'index.md': [ '---', 'layout: home', 'hero:', ' name: Test Site', ' text: A test site for LLMs', '---' ].join('\n'), 'guide/index.md': '# Getting Started\n\nWelcome to the guide.', 'guide/advanced.md': [ '---', 'title: Advanced Guide', 'description: Advanced usage patterns', '---', '', '# Advanced', '', 'Advanced content.' ].join('\n'), 'api/reference.md': '# API Reference\n\nShared API notes.\n', 'fr/guide.md': '# Guide en français', 'unlisted.md': '# Unlisted Page' } function seed(config: SiteConfig, files: Record = fixtures) { for (const [file, content] of Object.entries(files)) { collectLlmsSource(config, file, content) } } describe('node/build/generateLlmsTxt', () => { let srcDir: string let outDir: string beforeEach(() => { srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-llms-src-')) outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-llms-out-')) }) afterEach(() => { fs.rmSync(srcDir, { recursive: true, force: true }) fs.rmSync(outDir, { recursive: true, force: true }) }) function makeConfig(overrides: Partial = {}): SiteConfig { return { srcDir, outDir, pages: [ 'api/reference.md', 'data/1.md', 'fr/guide.md', 'guide/advanced.md', 'guide/index.md', 'index.md', 'unlisted.md' ], dynamicRoutes: [{ path: 'data/1.md' }], rewrites: { map: {}, inv: {} }, cleanUrls: false, markdown: {}, logger, site: { title: 'Fallback Title', description: 'Fallback description', base: '/', locales: { root: { label: 'English', lang: 'en-US' }, fr: { label: 'Français', lang: 'fr-FR' } }, themeConfig: { sidebar: [ { text: 'Guide', items: [ { text: 'Getting Started', link: '/guide/' }, { text: 'Advanced', link: '/guide/advanced' } ] }, { text: 'API', items: [{ text: 'Reference', link: '/api/reference' }] } ] } }, userConfig: { locales: { root: { label: 'English', lang: 'en-US' }, fr: { label: 'Français', lang: 'fr-FR' } } }, llms: { hostname: 'https://example.com' }, ...overrides } as unknown as SiteConfig } test('does nothing when llms is not enabled', async () => { const config = makeConfig({ llms: undefined }) seed(config) await generateLlmsTxt(config) expect(fs.existsSync(path.join(outDir, 'llms.txt'))).toBe(false) }) test('does nothing when llms.enabled is false', async () => { const config = makeConfig({ llms: { enabled: false, hostname: 'https://example.com' } }) seed(config) await generateLlmsTxt(config) expect(fs.existsSync(path.join(outDir, 'llms.txt'))).toBe(false) }) test('generates llms.txt with hero metadata and sidebar-ordered TOC', async () => { const config = makeConfig() seed(config) await generateLlmsTxt(config) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).toContain('# Test Site') expect(llmsTxt).toContain('> A test site for LLMs') expect(llmsTxt).toContain('### Guide') expect(llmsTxt).toContain( '- [Getting Started](https://example.com/guide.md)' ) expect(llmsTxt).toContain( '- [Advanced Guide](https://example.com/guide/advanced.md): Advanced usage patterns' ) expect(llmsTxt).toContain('### API') expect(llmsTxt).toContain( '- [API Reference](https://example.com/api/reference.md)' ) // pages not in the sidebar are appended at the end expect(llmsTxt).toContain( '- [Unlisted Page](https://example.com/unlisted.md)' ) // sidebar order is preserved expect(llmsTxt.indexOf('Getting Started')).toBeLessThan( llmsTxt.indexOf('Advanced Guide') ) expect(llmsTxt.indexOf('Advanced Guide')).toBeLessThan( llmsTxt.indexOf('API Reference') ) }) test('emits per-page markdown files with url frontmatter', async () => { const config = makeConfig() seed(config) await generateLlmsTxt(config) // dir/index.md collapses to dir.md const guide = fs.readFileSync(path.join(outDir, 'guide.md'), 'utf-8') expect(guide).toContain('url: "https://example.com/guide.md"') expect(guide).toContain('# Getting Started') const advanced = fs.readFileSync( path.join(outDir, 'guide/advanced.md'), 'utf-8' ) expect(advanced).toContain('url: "https://example.com/guide/advanced.md"') expect(advanced).toContain('description: "Advanced usage patterns"') // original frontmatter is replaced expect(advanced).not.toContain('title: Advanced Guide') // collected sources have includes already expanded const reference = fs.readFileSync( path.join(outDir, 'api/reference.md'), 'utf-8' ) expect(reference).toContain('Shared API notes.') }) test('falls back to reading the source file when a page was not collected', async () => { const config = makeConfig() const { 'unlisted.md': unlisted, ...collected } = fixtures seed(config, collected) fs.writeFileSync(path.join(srcDir, 'unlisted.md'), unlisted) await generateLlmsTxt(config) const out = fs.readFileSync(path.join(outDir, 'unlisted.md'), 'utf-8') expect(out).toContain('# Unlisted Page') }) test('generates llms-full.txt with all pages in TOC order', async () => { const config = makeConfig() seed(config) await generateLlmsTxt(config) const full = fs.readFileSync(path.join(outDir, 'llms-full.txt'), 'utf-8') expect(full).toContain('# Getting Started') expect(full).toContain('Advanced content.') expect(full).toContain('Shared API notes.') expect(full).toContain('# Unlisted Page') expect(full.indexOf('# Getting Started')).toBeLessThan( full.indexOf('Advanced content.') ) }) test('skips non-root locales and dynamic routes', async () => { const config = makeConfig() seed(config) await generateLlmsTxt(config) expect(fs.existsSync(path.join(outDir, 'fr/guide.md'))).toBe(false) expect(fs.existsSync(path.join(outDir, 'data/1.md'))).toBe(false) const full = fs.readFileSync(path.join(outDir, 'llms-full.txt'), 'utf-8') expect(full).not.toContain('français') }) test('applies rewrites to output paths and links', async () => { const config = makeConfig({ rewrites: { map: { 'guide/advanced.md': 'advanced.md' }, inv: { 'advanced.md': 'guide/advanced.md' } } }) seed(config) await generateLlmsTxt(config) expect(fs.existsSync(path.join(outDir, 'advanced.md'))).toBe(true) expect(fs.existsSync(path.join(outDir, 'guide/advanced.md'))).toBe(false) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).toContain('(https://example.com/advanced.md)') }) test('detects the landing page through rewrites (en/index.md -> index.md)', async () => { const config = makeConfig({ pages: ['en/guide.md', 'en/index.md', 'unlisted.md'], rewrites: { map: { 'en/index.md': 'index.md', 'en/guide.md': 'guide.md' }, inv: { 'index.md': 'en/index.md', 'guide.md': 'en/guide.md' } } }) seed(config, { 'en/index.md': [ '---', 'layout: home', 'hero:', ' name: Rewritten Site', ' text: Rewritten description', '---' ].join('\n'), 'en/guide.md': '# Rewritten Guide', 'unlisted.md': fixtures['unlisted.md'] }) await generateLlmsTxt(config) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).toContain('# Rewritten Site') expect(llmsTxt).toContain('> Rewritten description') expect(llmsTxt).toContain( '- [Rewritten Guide](https://example.com/guide.md)' ) // the landing page is not emitted nor listed expect(fs.existsSync(path.join(outDir, 'index.md'))).toBe(false) expect(llmsTxt).not.toContain('](https://example.com/index.md)') }) test('resolves the sidebar from additional config layers', async () => { const config = makeConfig() delete (config.site.themeConfig as any).sidebar ;(config.site as any).additionalConfig = { '/': { themeConfig: { sidebar: [ { text: 'Layered', items: [{ text: 'Advanced', link: '/guide/advanced' }] } ] } } } seed(config) await generateLlmsTxt(config) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).toContain('### Layered') expect(llmsTxt.indexOf('Advanced Guide')).toBeLessThan( llmsTxt.indexOf('### Other') ) }) test('falls back to site title/description and flat TOC without sidebar', async () => { const config = makeConfig() delete (config.site.themeConfig as any).sidebar config.pages = config.pages.filter((p) => p !== 'index.md') const { 'index.md': _, ...collected } = fixtures seed(config, collected) await generateLlmsTxt(config) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).toContain('# Fallback Title') expect(llmsTxt).toContain('> Fallback description') expect(llmsTxt).toContain('- [Advanced Guide](') }) test('respects base in generated links', async () => { const config = makeConfig() config.site.base = '/docs/' seed(config) await generateLlmsTxt(config) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).toContain('(https://example.com/docs/guide.md)') }) test('skips pages matching ignoreFiles patterns', async () => { const config = makeConfig({ llms: { hostname: 'https://example.com', ignoreFiles: ['api/**'] } }) seed(config) await generateLlmsTxt(config) expect(fs.existsSync(path.join(outDir, 'api/reference.md'))).toBe(false) expect(fs.existsSync(path.join(outDir, 'guide/advanced.md'))).toBe(true) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).not.toContain('API Reference') const full = fs.readFileSync(path.join(outDir, 'llms-full.txt'), 'utf-8') expect(full).not.toContain('Shared API notes.') }) test('matches ignoreFiles against rewritten output paths too', async () => { const config = makeConfig({ llms: { hostname: 'https://example.com', ignoreFiles: ['advanced.md'] }, rewrites: { map: { 'guide/advanced.md': 'advanced.md' }, inv: { 'advanced.md': 'guide/advanced.md' } } }) seed(config) await generateLlmsTxt(config) expect(fs.existsSync(path.join(outDir, 'advanced.md'))).toBe(false) const llmsTxt = fs.readFileSync(path.join(outDir, 'llms.txt'), 'utf-8') expect(llmsTxt).not.toContain('Advanced Guide') }) test('unwraps llm-only and drops llm-exclude in LLM output', async () => { const config = makeConfig() config.pages = [...config.pages, 'tags.md'] seed(config, { ...fixtures, 'tags.md': [ '# Tags', '', '', '', 'Secret for LLMs.', '', '', '', '', 'Humans only.', '', '', 'Shared content.' ].join('\n') }) await generateLlmsTxt(config) const tags = fs.readFileSync(path.join(outDir, 'tags.md'), 'utf-8') expect(tags).toContain('Secret for LLMs.') expect(tags).toContain('Shared content.') expect(tags).not.toContain('Humans only.') expect(tags).not.toContain('llm-only') expect(tags).not.toContain('llm-exclude') }) })