mirror of https://github.com/vuejs/vitepress
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
407 lines
12 KiB
407 lines
12 KiB
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<string, string> = {
|
|
'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<string, string> = 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> = {}): 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',
|
|
'',
|
|
'<llm-only>',
|
|
'',
|
|
'Secret for LLMs.',
|
|
'',
|
|
'</llm-only>',
|
|
'',
|
|
'<llm-exclude>',
|
|
'Humans only.',
|
|
'</llm-exclude>',
|
|
'',
|
|
'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')
|
|
})
|
|
})
|