Files
2026-07-13 12:30:54 +08:00

272 lines
8.5 KiB
TypeScript

#!/usr/bin/env ts-node
/**
* Generate a single llms.txt file by collecting Docusaurus content.
* - Scans markdown sources: website/docs, website/blog, website/src/pages
* - Strips frontmatter and MDX import/export statements
* - Flattens most MDX JSX elements while preserving inner text
* - Keeps code fences, headings, links and images as Markdown
* - Writes merged content to website/static/llms.txt
*
* Inspired by Mantine's compiler:
* https://raw.githubusercontent.com/mantinedev/mantine/ddae40ed58986b1260e8422771ac04c1ed95e58b/scripts/llm/compile-llm-doc.ts
*/
import * as fs from 'fs';
import * as path from 'path';
// Resolve website root relative to this script location to avoid relying on cwd
const WEBSITE_ROOT = path.resolve(__dirname, '..');
const TARGETS = [
path.join(WEBSITE_ROOT, 'docs'),
path.join(WEBSITE_ROOT, 'src', 'pages'),
];
const OUTPUT_TITLES_FILE = path.join(WEBSITE_ROOT, 'static', 'llms.txt');
const OUTPUT_FULL_FILE = path.join(WEBSITE_ROOT, 'static', 'llms-full.txt');
const ALLOWED_EXTENSIONS = new Set(['.md', '.mdx']);
async function fileExists(filePath: string): Promise<boolean> {
return new Promise((resolve) => {
fs.stat(filePath, (err) => resolve(!err));
});
}
function isDirectory(p: string): boolean {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
}
function collectFiles(dir: string, out: string[] = []): string[] {
if (!isDirectory(dir)) return out;
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
try {
const stat = fs.statSync(full);
if (stat.isDirectory()) {
collectFiles(full, out);
} else if (stat.isFile()) {
const ext = path.extname(full).toLowerCase();
if (ALLOWED_EXTENSIONS.has(ext)) out.push(full);
}
} catch {
// ignore
}
}
return out;
}
function stripFrontmatter(src: string): string {
// Remove YAML frontmatter at the very beginning of the file
return src.replace(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)/, '');
}
function stripMdxEsm(src: string): string {
// Remove MDX ESM import/export at start of line
return src.replace(/^\s*(import|export)\s[\s\S]*?;\s*$/gm, '');
}
function flattenMdxJsx(src: string): string {
// Replace MDX JSX blocks with their inner text when possible
// <Component ...>inner</Component> -> inner
let out = src.replace(/<([A-Z][\w.-]*)([^>]*)>([\s\S]*?)<\/\1>/g, '$3');
// Self-closing MDX JSX: <Component .../> -> ''
out = out.replace(/<([A-Z][\w.-]*)([^>]*)\/>/g, '');
return out;
}
function sanitizeContent(src: string): string {
let out = src;
out = stripFrontmatter(out);
out = stripMdxEsm(out);
out = flattenMdxJsx(out);
// Normalize Windows newlines just in case
out = out.replace(/\r\n/g, '\n');
// Collapse 3+ empty lines to 2
out = out.replace(/\n{3,}/g, '\n\n');
return out.trim() + '\n';
}
/**
* Try to read docusaurus config to get site url and baseUrl
*/
function readSiteConfig(): { url: string; baseUrl: string } {
const fallback = { url: 'https://tianji.dev', baseUrl: '/' };
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const cfg = require(path.join(WEBSITE_ROOT, 'docusaurus.config'));
const url = cfg?.url || fallback.url;
const baseUrl = cfg?.baseUrl || fallback.baseUrl;
return { url, baseUrl };
} catch {
return fallback;
}
}
/**
* Extract title from frontmatter if present.
*/
function extractTitleFromFrontmatter(src: string): string | '' {
const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return '';
const fm = fmMatch[1];
const titleMatch = fm.match(/^title:\s*(.*)$/m);
if (!titleMatch) return '';
return titleMatch[1].replace(/^['"`]?(.*)['"`]?$/, '$1').trim();
}
/**
* Extract first markdown H1 as title.
*/
function extractH1Title(src: string): string | '' {
const m = src.match(/^\s*#\s+(.+?)\s*$/m);
return m ? m[1].trim() : '';
}
/**
* Fallback: create readable title from file path.
*/
function deriveTitleFromPath(absPath: string, baseDir: string): string {
const rel = path.relative(baseDir, absPath);
const noExt = rel.replace(/\.(md|mdx)$/i, '');
const parts = noExt.split(path.sep).filter(Boolean);
let base = parts[parts.length - 1] || 'Untitled';
if (base.toLowerCase() === 'index' && parts.length > 1) {
base = parts[parts.length - 2];
}
return base.replace(/[-_]/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
}
/**
* Compute public route from file path for docs and pages.
*/
function computeRouteForFile(
absPath: string,
roots: { docs: string; pages: string },
baseUrl: string
): string {
const toPosix = (p: string) => p.split(path.sep).join('/');
const ensureSlash = (p: string) => (p.endsWith('/') ? p : p + '/');
if (absPath.startsWith(roots.docs + path.sep) || absPath === roots.docs) {
const rel = path.relative(roots.docs, absPath);
const noExt = rel.replace(/\.(md|mdx)$/i, '');
const segments = noExt.split(path.sep).filter(Boolean);
if (
segments.length &&
segments[segments.length - 1].toLowerCase() === 'index'
) {
segments.pop();
}
const route = ensureSlash(baseUrl) + 'docs/' + toPosix(segments.join('/'));
return route.replace(/\/$/, '') || ensureSlash(baseUrl) + 'docs';
}
if (absPath.startsWith(roots.pages + path.sep) || absPath === roots.pages) {
const rel = path.relative(roots.pages, absPath);
const noExt = rel.replace(/\.(md|mdx)$/i, '');
const segments = noExt.split(path.sep).filter(Boolean);
if (
segments.length &&
segments[segments.length - 1].toLowerCase() === 'index'
) {
segments.pop();
}
const route = '/' + toPosix(segments.join('/'));
return route === '/'
? ensureSlash(baseUrl)
: ensureSlash(baseUrl) + route.replace(/^\//, '');
}
return ensureSlash(baseUrl);
}
async function main() {
const existingTargets = (
await Promise.all(
TARGETS.map(async (p) => ((await fileExists(p)) ? p : ''))
)
).filter(Boolean) as string[];
const files = existingTargets.flatMap((dir) => collectFiles(dir)).sort();
const headerFull = [
'# Tianji Documentation Export (Full)',
'',
'This file aggregates documentation for LLM consumption.',
'It preserves Markdown headings, links, and code fences; frontmatter and MDX ESM are removed.',
'',
'='.repeat(80),
'',
].join('\n');
const chunksFull: string[] = [headerFull];
const { url: siteUrl, baseUrl } = readSiteConfig();
const docsRoot = path.join(WEBSITE_ROOT, 'docs');
const pagesRoot = path.join(WEBSITE_ROOT, 'src', 'pages');
const routes: Array<{ title: string; url: string }> = [];
for (const absPath of files) {
let content = '';
try {
content = fs.readFileSync(absPath, 'utf-8');
} catch {
continue;
}
const relPath = path.relative(WEBSITE_ROOT, absPath);
const sanitized = sanitizeContent(content);
const sectionHeader = [`===== FILE: ${relPath} =====`, ''].join('\n');
chunksFull.push(sectionHeader, sanitized, '');
// Build titles list
const titleFromFm = extractTitleFromFrontmatter(content);
const titleFromH1 = titleFromFm || extractH1Title(content);
const title =
titleFromH1 ||
deriveTitleFromPath(
absPath,
absPath.includes('/src/pages') ? pagesRoot : docsRoot
);
const routePath = computeRouteForFile(
absPath,
{ docs: docsRoot, pages: pagesRoot },
baseUrl
);
const absoluteUrl =
siteUrl.replace(/\/+$/, '') + '/' + routePath.replace(/^\//, '');
routes.push({ title, url: absoluteUrl });
}
const outputFull = chunksFull.join('\n');
const headerTitles = [
'# Tianji Documentation Index',
'',
'This file lists documentation titles with absolute URLs.',
'',
'='.repeat(80),
'',
].join('\n');
const outputTitles =
[headerTitles, ...routes.map((r) => `${r.title} - ${r.url}`)].join('\n') +
'\n';
// Ensure output directory exists
const outDir = path.join(WEBSITE_ROOT, 'static');
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
fs.writeFileSync(OUTPUT_TITLES_FILE, outputTitles, 'utf-8');
fs.writeFileSync(OUTPUT_FULL_FILE, outputFull, 'utf-8');
// eslint-disable-next-line no-console
console.log(
`Generated ${path.relative(WEBSITE_ROOT, OUTPUT_TITLES_FILE)} (titles) and ${path.relative(WEBSITE_ROOT, OUTPUT_FULL_FILE)} (full) from ${files.length} files`
);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('Failed to generate llms.txt', err);
process.exit(1);
});