Skip to content

Commit 29e4157

Browse files
committed
Add llms.txt and llms-full.txt
1 parent 6823d79 commit 29e4157

19 files changed

Lines changed: 349 additions & 2 deletions

.vitepress/config.mts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineConfig, HeadConfig } from 'vitepress'
22
import { generateRss, rssPlugin } from './rss'
3+
import { generateLlms, llmsPlugin } from './llms'
34
import { isBlogPath } from './locales'
45

56
const baseUrl = 'https://php-testo.github.io'
@@ -14,7 +15,7 @@ export default defineConfig({
1415
ignoreDeadLinks: [/feed\.xml$/],
1516

1617
vite: {
17-
plugins: [rssPlugin()],
18+
plugins: [rssPlugin(), llmsPlugin()],
1819
},
1920

2021
head: [
@@ -26,7 +27,10 @@ gtag('js', new Date());
2627
gtag('config', 'G-VYGDN3X0PR');`],
2728
],
2829

29-
buildEnd: generateRss,
30+
buildEnd: async (config) => {
31+
await generateRss(config)
32+
await generateLlms(config)
33+
},
3034

3135
locales: {
3236
root: {

.vitepress/llms.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { writeFileSync, mkdirSync, readdirSync, readFileSync, existsSync } from 'fs'
2+
import path from 'path'
3+
import { SiteConfig } from 'vitepress'
4+
import type { Plugin } from 'vite'
5+
// @ts-ignore
6+
import matter from 'gray-matter'
7+
import { llmsConfig } from '../llms.config'
8+
9+
interface PageInfo {
10+
title: string
11+
llms_description: string
12+
url: string
13+
srcPath: string
14+
content: string
15+
section: 'docs' | 'optional'
16+
}
17+
18+
function extractH1(content: string): string | null {
19+
const match = content.match(/^#\s+(.+)$/m)
20+
return match ? match[1].trim() : null
21+
}
22+
23+
function readPages(srcDir: string): PageInfo[] {
24+
const pages: PageInfo[] = []
25+
26+
const docsDir = path.join(srcDir, 'docs')
27+
if (existsSync(docsDir)) {
28+
for (const file of readdirSync(docsDir).filter(f => f.endsWith('.md'))) {
29+
const filePath = path.join(docsDir, file)
30+
const raw = readFileSync(filePath, 'utf-8')
31+
const { data: fm, content } = matter(raw)
32+
33+
if (fm.llms === false || !fm.llms_description) continue
34+
35+
const slug = file.replace(/\.md$/, '')
36+
const llmsValue = fm.llms ?? true
37+
pages.push({
38+
title: fm.title || extractH1(content) || slug,
39+
llms_description: fm.llms_description,
40+
url: `/docs/${slug}`,
41+
srcPath: `llm/docs/${file}`,
42+
content: content.trim(),
43+
section: llmsValue === 'optional' ? 'optional' : 'docs',
44+
})
45+
}
46+
}
47+
48+
return pages
49+
}
50+
51+
function getSidebarPaths(config: SiteConfig): string[] {
52+
const paths: string[] = []
53+
54+
const sidebar =
55+
(config.site.locales?.root as any)?.themeConfig?.sidebar ??
56+
config.site.themeConfig?.sidebar ??
57+
{}
58+
59+
function extract(items: any[]) {
60+
if (!Array.isArray(items)) return
61+
for (const item of items) {
62+
if (item.link) paths.push(item.link)
63+
if (item.items) extract(item.items)
64+
}
65+
}
66+
67+
for (const section of Object.values(sidebar)) {
68+
if (Array.isArray(section)) extract(section)
69+
}
70+
71+
return paths
72+
}
73+
74+
function sortPages(pages: PageInfo[], sidebarPaths: string[]): PageInfo[] {
75+
const order = new Map(sidebarPaths.map((p, i) => [p, i]))
76+
77+
return [...pages].sort((a, b) => {
78+
const ia = order.get(a.url) ?? 9999
79+
const ib = order.get(b.url) ?? 9999
80+
return ia - ib || a.url.localeCompare(b.url)
81+
})
82+
}
83+
84+
function buildLlmsTxt(pages: PageInfo[]): string {
85+
const { title, summary, details, baseUrl, docsSection, optionalSection } = llmsConfig
86+
87+
const docs = pages.filter(p => p.section === 'docs')
88+
const optional = pages.filter(p => p.section === 'optional')
89+
90+
const lines: string[] = [
91+
`# ${title}`,
92+
'',
93+
`> ${summary}`,
94+
'',
95+
...details.map(d => `- ${d}`),
96+
'',
97+
]
98+
99+
if (docs.length > 0) {
100+
lines.push(`## ${docsSection}`, '')
101+
for (const p of docs) {
102+
lines.push(`- [${p.title}](${baseUrl}/${p.srcPath}): ${p.llms_description}`)
103+
}
104+
lines.push('')
105+
}
106+
107+
if (optional.length > 0) {
108+
lines.push(`## ${optionalSection}`, '')
109+
for (const p of optional) {
110+
lines.push(`- [${p.title}](${baseUrl}/${p.srcPath}): ${p.llms_description}`)
111+
}
112+
lines.push('')
113+
}
114+
115+
return lines.join('\n')
116+
}
117+
118+
function buildLlmsFullTxt(pages: PageInfo[]): string {
119+
const { title, summary, details } = llmsConfig
120+
121+
const lines: string[] = [
122+
`# ${title}`,
123+
'',
124+
`> ${summary}`,
125+
'',
126+
...details.map(d => `- ${d}`),
127+
]
128+
129+
for (const page of pages) {
130+
const startsWithH1 = /^#\s+/.test(page.content)
131+
lines.push('', '---', '')
132+
if (!startsWithH1) {
133+
lines.push(`# ${page.title}`, '')
134+
}
135+
lines.push(page.content)
136+
}
137+
138+
lines.push('')
139+
return lines.join('\n')
140+
}
141+
142+
// Build-time generation (called from buildEnd)
143+
export async function generateLlms(config: SiteConfig) {
144+
const pages = sortPages(readPages(config.srcDir), getSidebarPaths(config))
145+
146+
// Per-page .md files
147+
for (const page of pages) {
148+
const outPath = path.join(config.outDir, page.srcPath)
149+
mkdirSync(path.dirname(outPath), { recursive: true })
150+
writeFileSync(outPath, page.content + '\n')
151+
}
152+
153+
writeFileSync(path.join(config.outDir, 'llms.txt'), buildLlmsTxt(pages))
154+
writeFileSync(path.join(config.outDir, 'llms-full.txt'), buildLlmsFullTxt(pages))
155+
156+
console.log(`✓ llms.txt generated (${pages.length} docs)`)
157+
console.log(`✓ llms-full.txt generated`)
158+
console.log(`✓ ${pages.length} per-page .md files generated`)
159+
}
160+
161+
// Vite plugin for dev server
162+
export function llmsPlugin(): Plugin {
163+
let docsRoot: string
164+
165+
return {
166+
name: 'vitepress-llms-dev',
167+
configResolved(config) {
168+
docsRoot = config.root
169+
},
170+
configureServer(server) {
171+
server.middlewares.use((req, res, next) => {
172+
const url = req.url || ''
173+
174+
if (url === '/llms.txt') {
175+
const pages = sortPages(readPages(docsRoot), [])
176+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
177+
res.end(buildLlmsTxt(pages))
178+
return
179+
}
180+
181+
if (url === '/llms-full.txt') {
182+
const pages = sortPages(readPages(docsRoot), [])
183+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
184+
res.end(buildLlmsFullTxt(pages))
185+
return
186+
}
187+
188+
// Serve per-page .md files
189+
const mdMatch = url.match(/^\/llm\/docs\/(.+\.md)$/)
190+
if (mdMatch) {
191+
const filePath = path.join(docsRoot, 'docs', mdMatch[1])
192+
if (existsSync(filePath)) {
193+
const raw = readFileSync(filePath, 'utf-8')
194+
const { content } = matter(raw)
195+
res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
196+
res.end(content.trim() + '\n')
197+
return
198+
}
199+
}
200+
201+
next()
202+
})
203+
},
204+
}
205+
}

CLAUDE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,35 @@ author: Author Name
7979
- `outline: false` — hide outline completely
8080
- `outline: 2` — show only h2
8181

82+
## llms.txt
83+
84+
Testo provides `llms.txt` for AI agents. Only `docs/` pages are included — blog posts are excluded from llms generation entirely (no llms frontmatter in blog files).
85+
86+
**Manifest:** `llms.config.ts` — project-level metadata (title, summary, key facts, base URL). Edit when project description or key facts change.
87+
88+
**Generator:** `.vitepress/llms.ts` — builds `llms.txt`, `llms-full.txt`, and per-page `.md` files during `buildEnd`. Only scans `docs/` directory.
89+
90+
**Frontmatter fields (English `docs/` pages only, not `ru/` or `blog/`):**
91+
92+
```yaml
93+
---
94+
llms: true # default — included in "Docs" section
95+
llms: "optional" # included in "Optional" section (secondary content)
96+
llms: false # excluded from llms.txt
97+
llms_description: "Technical description of what LLM learns from this page"
98+
---
99+
```
100+
101+
- `llms` — controls inclusion. Default is `true` (can be omitted). `"optional"` for secondary content, `false` to exclude
102+
- `llms_description` — short, technical description for LLM context. Lists key entities and concepts, not marketing text. Required for all included pages
103+
104+
**Guidelines for `llms_description`:**
105+
- List specific classes, attributes, methods — not vague descriptions
106+
- Example: `"#[BeforeEach], #[AfterEach], #[BeforeAll], #[AfterAll] lifecycle hooks, execution order, priority"`
107+
- NOT: `"Learn about test lifecycle management"`
108+
109+
**When adding new doc pages:** add `llms_description` to the English version frontmatter. Do NOT add llms frontmatter to blog posts.
110+
82111
## VitePress Commands
83112

84113
```bash

docs/cli-reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "CLI commands and flags: testo run, --config, --teamcity, --suite, --path, --filter, filter combination logic, exit codes"
3+
---
4+
15
# Command Line Interface
26

37
This document describes the command line interface for Testo.

docs/data-providers.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "Parameterized tests: #[DataSet], #[DataProvider] with callables, DataZip (pairing), DataCross (cartesian product), DataUnion (merging), nested combinations"
3+
---
4+
15
# Data Providers
26

37
Data providers let you run one test with different sets of input data. Each set runs as a separate test.

docs/events.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "PSR-14 event system: TestSuite/TestCase/Test event hierarchy, lifecycle event ordering, polymorphic listeners, custom dispatchers"
3+
---
4+
15
# Events
26

37
Testo emits events throughout the test execution lifecycle. Events are your primary extension point for customizing test behavior, collecting metrics, or integrating with external tools.

docs/filtering.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "Filter class API, name/path/suite filters, OR/AND combination logic, 5-stage filtering pipeline, pattern matching behavior, DataProvider indices"
3+
---
4+
15
# Test Filtering
26

37
This document describes the business logic of test filtering in Testo.

docs/getting-started.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "Installation via Composer, testo.php configuration, writing first test class, running tests, IDE plugin setup"
3+
---
4+
15
# Getting Started
26

37

docs/inline-tests.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "#[TestInline] attribute for testing methods in-place, private method testing, custom assertions, named arguments"
3+
---
4+
15
# Inline Tests
26

37
Inline tests let you write test cases directly on the method being tested using the `#[TestInline]` attribute. No separate test class needed.

docs/lifecycle.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
llms_description: "#[BeforeEach], #[AfterEach], #[BeforeAll], #[AfterAll] lifecycle hooks, execution order, priority, class instantiation behavior"
3+
---
4+
15
# Lifecycle
26

37
Lifecycle attributes define setup and teardown methods that run automatically at specific points during test execution.

0 commit comments

Comments
 (0)