Skip to content

Commit 9437950

Browse files
authored
Generate llms.txt file (#1304)
* Generate llms.txt file * Share frontmatter parsing
1 parent 7668881 commit 9437950

4 files changed

Lines changed: 194 additions & 41 deletions

File tree

plugins/generate-llms-txt.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import fs from 'fs/promises';
2+
import fsSync from 'fs';
3+
import path from 'path';
4+
import { fileURLToPath } from 'url';
5+
import { parseFrontmatter, cleanReplComments } from '../src/lib/frontmatter.js';
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
10+
/**
11+
* Read all markdown files from the guide directory
12+
* @param {string} guideDir
13+
* @returns {Promise<Array<{filename: string, content: string}>>}
14+
*/
15+
async function readMarkdownFiles(guideDir) {
16+
const files = await fs.readdir(guideDir);
17+
const markdownFiles = files.filter(file => file.endsWith('.md'));
18+
19+
return Promise.all(
20+
markdownFiles.map(async filename => {
21+
const filePath = path.join(guideDir, filename);
22+
const content = await fs.readFile(filePath, 'utf-8');
23+
return { filename, content };
24+
})
25+
);
26+
}
27+
28+
/**
29+
* Generate the llms.txt content
30+
* @param {Array<{filename: string, content: string}>} files
31+
* @returns {string}
32+
*/
33+
function generateLlmsTxt(files) {
34+
const header = `# Preact Documentation
35+
36+
This file contains comprehensive documentation for Preact v10, a fast 3kB alternative to React with the same modern API.
37+
Preact is a fast, lightweight alternative to React that provides the same modern API in a much smaller package. This documentation covers all aspects of Preact v10, including components, hooks, server-side rendering, TypeScript support, and more.
38+
39+
---
40+
41+
`;
42+
43+
let content = header;
44+
45+
files.sort((a, b) => a.filename.localeCompare(b.filename));
46+
47+
files.forEach(({ filename, content: fileContent }) => {
48+
const { description, body } = parseFrontmatter(fileContent, filename);
49+
let cleanedBody = cleanReplComments(body);
50+
51+
// Remove <toc></toc> tags
52+
cleanedBody = cleanedBody.replace(/<toc><\/toc>/g, '');
53+
54+
// Clean up multiple consecutive newlines and empty lines around separators
55+
cleanedBody = cleanedBody.replace(/\n{3,}/g, '\n\n');
56+
cleanedBody = cleanedBody.replace(/---\s*\n\s*\n\s*---/g, '');
57+
58+
// Fix heading hierarchy: convert # to ## for consistency
59+
cleanedBody = cleanedBody.replace(/^# /gm, '## ');
60+
61+
if (description) {
62+
content += `**Description:** ${description}\n\n`;
63+
}
64+
65+
content += `${cleanedBody}\n\n`;
66+
content += `---\n\n`;
67+
});
68+
69+
return content;
70+
}
71+
72+
/**
73+
* Vite plugin to generate llms.txt from Preact documentation
74+
* @param {Object} options
75+
* @param {string} [options.guideDir] - Path to the guide directory
76+
* @param {string} [options.outputFile] - Path to the output file
77+
* @returns {import('vite').Plugin}
78+
*/
79+
export default function generateLlmsTxtPlugin(options = {}) {
80+
const guideDir =
81+
options.guideDir ||
82+
path.join(__dirname, '..', 'content', 'en', 'guide', 'v10');
83+
84+
return {
85+
name: 'generate-llms-txt',
86+
async buildStart() {
87+
try {
88+
if (!fsSync.existsSync(guideDir)) {
89+
this.warn(`Guide directory not found: ${guideDir}`);
90+
return;
91+
}
92+
93+
const files = await readMarkdownFiles(guideDir);
94+
const llmsTxtContent = generateLlmsTxt(files);
95+
96+
this.emitFile({
97+
type: 'asset',
98+
fileName: 'llms.txt',
99+
source: llmsTxtContent
100+
});
101+
} catch (error) {
102+
this.error(`Error generating llms.txt: ${error.message}`);
103+
}
104+
}
105+
};
106+
}

plugins/precompile-markdown/index.js

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import yaml from 'yaml';
21
import { marked } from 'marked';
32
import Prism from 'prismjs';
43
import loadLanguages from 'prismjs/components/';
54
import { parse } from 'node-html-parser';
65
import { replace } from './gh-emoji/index.js';
76
import { textToBase64 } from '../../src/components/controllers/repl/query-encode.js';
7+
import { parseFrontmatter } from '../../src/lib/frontmatter.js';
88

99
// Prism will always load `markup`, `css`, `clike` and `javascript` by default.
1010
// Any additional languages we need should be loaded here
@@ -38,41 +38,23 @@ export async function precompileMarkdown(content, path) {
3838
* @param {string} path
3939
*/
4040
function parseContent(content, path) {
41-
/** @type {import('../../src/types.d.ts').ContentMetaData} */
42-
let meta = {};
43-
44-
// Find YAML FrontMatter preceeding a markdown document
45-
const FRONT_MATTER_REG = /^\s*---\n\s*([\s\S]*?)\s*\n---\n/i;
46-
47-
const matches = content.match(FRONT_MATTER_REG);
48-
if (!matches) throw new Error(`Missing YAML FrontMatter in ${path}`);
49-
try {
50-
meta = yaml.parse('---\n' + matches[1].replace(/^/gm, ' ') + '\n');
51-
if (!meta.title) {
52-
throw new Error(`Missing title in YAML FrontMatter for ${path}`);
53-
}
54-
if (!meta.description) {
55-
//console.warn(`Missing description in FrontMatter for ${path}`);
56-
}
57-
} catch (e) {
58-
throw new Error(`Error parsing YAML FrontMatter in ${path}`);
59-
}
41+
const { body, meta } = parseFrontmatter(content, path);
6042

61-
content = content.replace(FRONT_MATTER_REG, '');
43+
let processedContent = body;
6244

6345
if (path.includes('/guide/')) {
64-
meta.toc = generateToc(content);
46+
meta.toc = generateToc(processedContent);
6547
}
6648

6749
// extract tutorial setup, initial and final code blocks
6850
if (/tutorial\/\d/.test(path)) {
69-
const { markdown, tutorial } = extractTutorialCodeBlocks(content);
70-
content = markdown;
51+
const { markdown, tutorial } = extractTutorialCodeBlocks(processedContent);
52+
processedContent = markdown;
7153
meta.tutorial = tutorial;
7254
}
7355

7456
return {
75-
content,
57+
content: processedContent,
7658
meta
7759
};
7860
}
@@ -121,10 +103,12 @@ marked.use({
121103

122104
Prism.languages[lang] == null
123105
? console.warn(`No Prism highlighter for language: ${lang}`)
124-
: text = Prism.highlight(code, Prism.languages[lang], lang);
106+
: (text = Prism.highlight(code, Prism.languages[lang], lang));
125107

126108
const runInReplLink = runInRepl
127-
? `<a class="repl-link" href="/repl?code=${encodeURIComponent(textToBase64(source))}">Run in REPL</a>`
109+
? `<a class="repl-link" href="/repl?code=${encodeURIComponent(
110+
textToBase64(source)
111+
)}">Run in REPL</a>`
128112
: '';
129113

130114
return `
@@ -227,7 +211,9 @@ function highlightCodeBlocks(data) {
227211
const doc = parse(data.html, { blockTextElements: { code: true } });
228212

229213
// Only get the pre blocks that haven't already been highlighted
230-
const codeBlocks = doc.querySelectorAll('pre:not([class="highlight"]):has(> code[class])');
214+
const codeBlocks = doc.querySelectorAll(
215+
'pre:not([class="highlight"]):has(> code[class])'
216+
);
231217
for (const block of codeBlocks) {
232218
const child = block.childNodes[0];
233219

@@ -243,24 +229,31 @@ function highlightCodeBlocks(data) {
243229
* and switch it back to `\n` for the code content after marked is through with it.
244230
* We only do this on the home/index page at the moment.
245231
*/
246-
const rawCodeBlockText = unescapeHTML(child.innerText.trim().replace('<br>', '\n'));
232+
const rawCodeBlockText = unescapeHTML(
233+
child.innerText.trim().replace('<br>', '\n')
234+
);
247235
const [code, source, runInRepl] = processRepl(rawCodeBlockText);
248236

249237
const lang = child.getAttribute('class').replace('language-', '');
250238

251239
Prism.languages[lang] == null
252240
? console.warn(`No Prism highlighter for language: ${lang}`)
253-
: child.innerHTML = Prism.highlight(code, Prism.languages[lang], lang);
241+
: (child.innerHTML = Prism.highlight(code, Prism.languages[lang], lang));
254242

255-
block.insertAdjacentHTML('beforebegin', '<div class="highlight-container">');
243+
block.insertAdjacentHTML(
244+
'beforebegin',
245+
'<div class="highlight-container">'
246+
);
256247
const container = block.previousSibling;
257248
container.appendChild(block);
258249
block.setAttribute('class', 'highlight');
259250

260251
if (runInRepl) {
261252
block.insertAdjacentHTML(
262253
'afterend',
263-
`<a class="repl-link" href="/repl?code=${encodeURIComponent(textToBase64(source))}">
254+
`<a class="repl-link" href="/repl?code=${encodeURIComponent(
255+
textToBase64(source)
256+
)}">
264257
Run in REPL
265258
</a>`
266259
);
@@ -271,7 +264,6 @@ function highlightCodeBlocks(data) {
271264
return data;
272265
}
273266

274-
275267
/**
276268
* Marked escapes HTML entities, which is normally great,
277269
* but we want to feed the raw code into Prism for highlighting.
@@ -280,12 +272,12 @@ function highlightCodeBlocks(data) {
280272
* @returns {string}
281273
*/
282274
function unescapeHTML(str) {
283-
return str
284-
.replace(/&amp;/g, '&')
285-
.replace(/&lt;/g, '<')
286-
.replace(/&gt;/g, '>')
287-
.replace(/&quot;/g, '"')
288-
.replace(/&#39;/g, "'");
275+
return str
276+
.replace(/&amp;/g, '&')
277+
.replace(/&lt;/g, '<')
278+
.replace(/&gt;/g, '>')
279+
.replace(/&quot;/g, '"')
280+
.replace(/&#39;/g, "'");
289281
}
290282

291283
/**

src/lib/frontmatter.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import yaml from 'yaml';
2+
3+
/**
4+
* Parse Markdown with YAML FrontMatter
5+
* @param {string} content - Raw markdown content with frontmatter
6+
* @param {string} path - File path for error reporting
7+
* @returns {{title: string, description: string, body: string, meta: object}}
8+
*/
9+
export function parseFrontmatter(content, path = '') {
10+
const FRONT_MATTER_REG = /^\s*---\n\s*([\s\S]*?)\s*\n---\n/i;
11+
12+
const matches = content.match(FRONT_MATTER_REG);
13+
if (!matches) {
14+
throw new Error(`Missing YAML FrontMatter in ${path}`);
15+
}
16+
17+
let meta = {};
18+
try {
19+
meta = yaml.parse('---\n' + matches[1].replace(/^/gm, ' ') + '\n');
20+
21+
if (!meta.title) {
22+
throw new Error(`Missing title in YAML FrontMatter for ${path}`);
23+
}
24+
} catch (e) {
25+
throw new Error(`Error parsing YAML FrontMatter in ${path}: ${e.message}`);
26+
}
27+
28+
const body = content.replace(FRONT_MATTER_REG, '').trim();
29+
30+
return {
31+
title: meta.title || '',
32+
description: meta.description || '',
33+
body,
34+
meta
35+
};
36+
}
37+
38+
/**
39+
* Process and clean code blocks for LLM consumption
40+
* Removes --repl comments and markers that are used for REPL functionality
41+
* @param {string} content - Markdown content
42+
* @returns {string} - Cleaned content
43+
*/
44+
export function cleanReplComments(content) {
45+
// Remove --repl comment lines and markers
46+
return content
47+
.replace(/^\/\/ --repl.*$/gm, '')
48+
.replace(/^\/\/ --repl-before.*$/gm, '')
49+
.replace(/^\/\/ --repl-after.*$/gm, '')
50+
.replace(/\n\n\n+/g, '\n\n') // Clean up extra newlines
51+
.trim();
52+
}

vite.config.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { netlifyPlugin } from './plugins/netlify.js';
88
import { spaFallbackMiddlewarePlugin } from './plugins/spa-fallback-middleware.js';
99
import { htmlRoutingMiddlewarePlugin } from './plugins/html-routing-middleware.js';
1010
import { rssFeedPlugin } from './plugins/rss-feed.js';
11+
import generateLlmsTxtPlugin from './plugins/generate-llms-txt.js';
1112

1213
// TODO: Should we do this for all routes, rely on discovery a bit less?
1314
import { tutorialRoutes } from './src/lib/route-utils.js';
@@ -44,7 +45,8 @@ export default defineConfig({
4445
{
4546
src: './content/**/*.md',
4647
dest: './',
47-
rename: (_name, _fileExtension, fullPath) => path.basename(fullPath).replace(/\.md$/, '.json'),
48+
rename: (_name, _fileExtension, fullPath) =>
49+
path.basename(fullPath).replace(/\.md$/, '.json'),
4850
transform: precompileMarkdown
4951
}
5052
],
@@ -74,6 +76,7 @@ export default defineConfig({
7476
netlifyPlugin(),
7577
spaFallbackMiddlewarePlugin(),
7678
htmlRoutingMiddlewarePlugin(),
77-
rssFeedPlugin()
79+
rssFeedPlugin(),
80+
generateLlmsTxtPlugin()
7881
]
7982
});

0 commit comments

Comments
 (0)