Files
2026-07-13 13:18:05 +08:00

662 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Generates AI-friendly English markdown copies of the built documentation
* under website/dist/llm/. Reads cleanest sources where possible (manual
* markdown, example .ts/.json) and converts generated API HTML fragments
* to markdown.
*
* Output layout:
* website/dist/llm/
* index.md
* README.md
* manual/{slug}.md, index.md
* api/{category}/{folder}/{name}.md, index.md
* examples/{slug}.md, index.md
* website/dist/llms.txt
*/
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, extname, resolve } from 'node:path';
import { workspaceRoot } from './package-utils.mjs';
const LOCALE = 'en-US';
const manualSourceRoot = resolve(workspaceRoot, 'website/src/content/manual');
const examplesSourceRoot = resolve(workspaceRoot, 'website/src/content/examples');
const generatedApiRoot = resolve(workspaceRoot, 'website/.generated/api');
const websiteDistRoot = resolve(workspaceRoot, 'website/dist');
const llmRoot = resolve(workspaceRoot, 'website/dist/llm');
const llmsTxtPath = resolve(websiteDistRoot, 'llms.txt');
await rm(llmRoot, { recursive: true, force: true });
await mkdir(llmRoot, { recursive: true });
const apiManifest = await loadApiManifest();
const manualEntries = await emitManual();
const apiEntries = await emitApi();
const exampleEntries = await emitExamples();
await writeFile(resolve(llmRoot, 'index.md'), renderRootIndex(manualEntries, apiEntries, exampleEntries));
await writeFile(resolve(llmRoot, 'README.md'), renderReadme());
await writeFile(llmsTxtPath, renderLlmsTxt(manualEntries, apiEntries, exampleEntries));
console.log(
`[llm-docs] Wrote markdown corpus and /llms.txt: ` +
`${manualEntries.length} manual, ${apiEntries.length} api, ${exampleEntries.length} example files.`,
);
// ---------- Manual ----------
async function emitManual() {
const sourceDir = resolve(manualSourceRoot, LOCALE);
if (!existsSync(sourceDir)) {
return [];
}
const files = (await readdir(sourceDir, { withFileTypes: true }))
.filter(entry => entry.isFile() && extname(entry.name) === '.md')
.map(entry => entry.name);
const entries = [];
for (const fileName of files) {
const filePath = resolve(sourceDir, fileName);
const source = await readFile(filePath, 'utf8');
const { frontmatter, body } = parseFrontmatter(source);
const slug = fileName.replace(/\.md$/, '');
const title = stringField(frontmatter, 'title') ?? slug;
const description = stringField(frontmatter, 'description') ?? '';
const order = numberField(frontmatter, 'order') ?? 0;
const rewrittenBody = rewriteManualBody(body);
const markdown = renderManualMarkdown(title, description, rewrittenBody);
await writeMarkdown(resolve(llmRoot, 'manual', `${slug}.md`), markdown);
entries.push({ slug, title, description, order });
}
entries.sort((left, right) => left.order - right.order || left.slug.localeCompare(right.slug));
await writeMarkdown(resolve(llmRoot, 'manual', 'index.md'), renderManualIndex(entries));
return entries;
}
function renderManualMarkdown(title, description, body) {
const sections = [`# ${title}`];
if (description && description !== title) {
sections.push(`> ${description}`);
}
sections.push(body.trim());
return `${sections.join('\n\n')}\n`;
}
function renderManualIndex(entries) {
const lines = [
'# Manual',
'',
'> Full product manual. Read `getting-started.md` then `basic-concepts.md` before using the SDK.',
'',
];
for (const entry of entries) {
const description = entry.description && entry.description !== entry.title ? ` — ${entry.description}` : '';
lines.push(`- [${entry.title}](./${entry.slug}.md)${description}`);
}
return `${lines.join('\n')}\n`;
}
function rewriteManualBody(body) {
const withRewrittenLinks = body.replace(
/(^|[^!])(\[[^\]]*]\()([^)\s]+)([^)]*\))/g,
(match, prefix, opening, href, closing) => {
const rewritten = rewriteManualHref(href);
return rewritten ? `${prefix}${opening}${rewritten}${closing}` : match;
},
);
return withRewrittenLinks.replace(/(!\[[^\]]*]\()([^)\s]+)([^)]*\))/g, (match, opening, href, closing) => {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/')) {
return match;
}
const sourceLocation = `../../src/content/manual/${LOCALE}/${href.replace(/^\.?\/+/, '')}`;
return `${opening}${sourceLocation}${closing}`;
});
}
function rewriteManualHref(href) {
if (!href) {
return undefined;
}
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#')) {
return undefined;
}
if (href.startsWith('api:')) {
const symbol = href
.slice('api:'.length)
.replace(/[#?].*$/, '')
.trim();
const entry = findApiEntryBySymbol(symbol);
return entry ? `../api/${entry.slug}.md` : undefined;
}
if (href.startsWith('/')) {
return undefined;
}
// Rewrite cross-section relative links like `../../examples/foo/` → `../examples/foo.md`.
const crossSection = href.match(/^\.\.\/\.\.\/(examples|api|manual)\/([^?#]*?)\/?([?#].*)?$/);
if (crossSection) {
const [, section, slug, trailing = ''] = crossSection;
const cleanSlug = slug.replace(/\/$/, '');
return `../${section}/${cleanSlug === '' ? 'index' : cleanSlug}.md${trailing}`;
}
if (href.endsWith('.md') || /\.md[?#]/.test(href)) {
return href;
}
return undefined;
}
// ---------- API ----------
async function emitApi() {
if (!apiManifest) {
return [];
}
const apiLocaleDir = resolve(generatedApiRoot, LOCALE);
if (!existsSync(apiLocaleDir)) {
return [];
}
const entries = [];
for (const entry of apiManifest.entries ?? []) {
const sourcePath = resolve(apiLocaleDir, `${entry.slug}.html`);
if (!existsSync(sourcePath)) {
continue;
}
const html = await readFile(sourcePath, 'utf8');
const body = htmlToMarkdown(html, {
rewriteLink: href => rewriteApiHref(href, entry.slug),
});
const heading = entry.signature
? `# ${entry.title}\n\n\`\`\`ts\n${entry.signature}\n\`\`\``
: `# ${entry.title}`;
const meta = renderApiMeta(entry);
const description = entry.description ? `> ${entry.description}\n\n` : '';
const markdown = `${heading}\n\n${meta}${description}${body.trim()}\n`;
await writeMarkdown(resolve(llmRoot, 'api', `${entry.slug}.md`), markdown);
entries.push(entry);
}
await writeMarkdown(resolve(llmRoot, 'api', 'index.md'), renderApiIndex(entries));
return entries;
}
function renderApiMeta(entry) {
const segments = [];
if (entry.categoryLabel) {
segments.push(`Category: \`${entry.categoryLabel}\``);
}
if (entry.namespaceLabel && entry.namespaceLabel !== entry.categoryLabel) {
segments.push(`Namespace: \`${entry.namespaceLabel}\``);
}
if (entry.kindLabel) {
segments.push(`Kind: \`${entry.kindLabel}\``);
}
return segments.length ? `${segments.join(' · ')}\n\n` : '';
}
function renderApiIndex(entries) {
const grouped = new Map();
for (const entry of entries) {
const key = `${entry.categoryLabel ?? 'Misc'}::${entry.namespaceLabel ?? entry.categoryLabel ?? 'Misc'}`;
if (!grouped.has(key)) {
grouped.set(key, {
categoryLabel: entry.categoryLabel ?? 'Misc',
namespaceLabel: entry.namespaceLabel ?? entry.categoryLabel ?? 'Misc',
entries: [],
});
}
grouped.get(key).entries.push(entry);
}
const lines = [
'# API Reference',
'',
'> Generated from TypeDoc, grouped by namespace. Each `.md` file corresponds to one exported symbol.',
'',
];
let lastCategory;
for (const group of grouped.values()) {
if (group.categoryLabel !== lastCategory) {
lines.push('', `## ${group.categoryLabel}`, '');
lastCategory = group.categoryLabel;
}
if (group.namespaceLabel && group.namespaceLabel !== group.categoryLabel) {
lines.push(`### ${group.namespaceLabel}`, '');
}
for (const entry of group.entries) {
lines.push(`- [${entry.title}](./${entry.slug}.md) — ${entry.kindLabel}`);
}
}
return `${lines.join('\n')}\n`;
}
function rewriteApiHref(href, currentSlug) {
if (!href) {
return undefined;
}
const localePrefix = `/${LOCALE}/api/`;
if (href.startsWith(localePrefix)) {
const [pathname, hash = ''] = splitHash(href.slice(localePrefix.length));
const targetSlug = pathname.replace(/\/$/, '');
if (!targetSlug) {
return `./index.md${hash}`;
}
return relativeMdHref(currentSlug, targetSlug) + hash;
}
if (href.startsWith('http://') || href.startsWith('https://')) {
return href;
}
return undefined;
}
function splitHash(value) {
const index = value.indexOf('#');
if (index === -1) {
return [value, ''];
}
return [value.slice(0, index), value.slice(index)];
}
function relativeMdHref(fromSlug, toSlug) {
const fromParts = fromSlug.split('/');
const toParts = toSlug.split('/');
fromParts.pop();
let common = 0;
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
common += 1;
}
const ups = fromParts.length - common;
const downs = toParts.slice(common);
const prefix = ups === 0 ? './' : '../'.repeat(ups);
return `${prefix}${downs.join('/')}.md`;
}
function findApiEntryBySymbol(symbol) {
if (!apiManifest) {
return undefined;
}
const entries = apiManifest.entries ?? [];
const direct = entries.find(entry => entry.title === symbol);
if (direct) {
return direct;
}
const dotted = symbol.split('.');
if (dotted.length === 2) {
return entries.find(entry => entry.namespace === dotted[0] && entry.title === dotted[1]);
}
return undefined;
}
async function loadApiManifest() {
const manifestPath = resolve(generatedApiRoot, 'manifest.ts');
if (!existsSync(manifestPath)) {
return undefined;
}
const source = await readFile(manifestPath, 'utf8');
const start = source.indexOf('{');
const end = source.lastIndexOf('}');
if (start === -1 || end === -1 || end <= start) {
return undefined;
}
return JSON.parse(source.slice(start, end + 1));
}
// ---------- Examples ----------
async function emitExamples() {
if (!existsSync(examplesSourceRoot)) {
return [];
}
const entries = await readdir(examplesSourceRoot, { withFileTypes: true });
const slugs = entries
.filter(entry => entry.isFile() && extname(entry.name) === '.json')
.map(entry => entry.name.replace(/\.json$/, ''));
const items = [];
for (const slug of slugs) {
const jsonPath = resolve(examplesSourceRoot, `${slug}.json`);
const codePath = resolve(examplesSourceRoot, `${slug}.ts`);
const meta = JSON.parse(await readFile(jsonPath, 'utf8'));
const code = existsSync(codePath) ? await readFile(codePath, 'utf8') : '';
const title = meta?.title?.[LOCALE] ?? meta?.title?.['en-US'] ?? slug;
const tags = Array.isArray(meta?.tags) ? meta.tags : [];
const order = typeof meta?.order === 'number' ? meta.order : Number.POSITIVE_INFINITY;
const markdown = renderExampleMarkdown({ slug, title, tags, code });
await writeMarkdown(resolve(llmRoot, 'examples', `${slug}.md`), markdown);
items.push({ slug, title, tags, order });
}
items.sort((left, right) => left.order - right.order || left.slug.localeCompare(right.slug));
await writeMarkdown(resolve(llmRoot, 'examples', 'index.md'), renderExamplesIndex(items));
return items;
}
function renderExampleMarkdown({ slug, title, tags, code }) {
const tagLine = tags.length ? `Tags: ${tags.map(tag => `\`${tag}\``).join(', ')}\n\n` : '';
const sourceHint = `Source: \`website/src/content/examples/${slug}.ts\`\n\n`;
const liveUrl = `/${LOCALE}/examples/${slug}/`;
const codeBlock = code.trim() ? `\`\`\`ts\n${code.trim()}\n\`\`\`\n` : '_No code attached._\n';
return `# ${title}\n\n${tagLine}${sourceHint}Live page: \`${liveUrl}\`\n\n${codeBlock}`;
}
function renderExamplesIndex(items) {
const lines = [
'# Examples',
'',
'> Each example demonstrates one SDK capability. The markdown file contains the original TypeScript source.',
'',
];
for (const item of items) {
const tagSuffix = item.tags.length ? ` — tags: ${item.tags.join(', ')}` : '';
lines.push(`- [${item.title}](./${item.slug}.md)${tagSuffix}`);
}
return `${lines.join('\n')}\n`;
}
// ---------- Root index ----------
function renderRootIndex(manual, api, examples) {
const lines = [
'# Aholo Viewer — AI-Friendly Documentation',
'',
'This directory is the machine-readable corpus of the Aholo Viewer docs. ',
'It is regenerated on every `pnpm build` from the same sources that produce the public website.',
'',
'## Entry Points',
'',
`- [Manual](./manual/index.md) — ${manual.length} pages`,
`- [API Reference](./api/index.md) — ${api.length} symbols`,
`- [Examples](./examples/index.md) — ${examples.length} runnable demos`,
'',
'## Recommended Reading Order',
'',
'1. `manual/getting-started.md` — install and bootstrap a viewer in minutes.',
'2. `manual/basic-concepts.md` — mental model for scene, camera, renderer.',
'3. Browse `api/` and `examples/` on demand.',
'',
'## Directory Layout',
'',
'```',
'llm/',
' index.md # this file',
' manual/ # user manual (markdown)',
' api/ # API reference (converted from TypeDoc HTML)',
' examples/ # runnable examples with TypeScript source',
'```',
'',
];
return `${lines.join('\n')}\n`;
}
function renderReadme() {
return [
'# /llm — AI-friendly docs',
'',
'This folder is generated by `scripts/generate-llm-docs.mjs` as part of `pnpm build`.',
'',
'It mirrors the human-facing site under `/{locale}/manual`, `/{locale}/api`, `/{locale}/examples`',
'but as plain English markdown, which is easier for automated coding agents to parse.',
'',
'Do not edit files here directly — they will be regenerated on every build.',
'',
].join('\n');
}
function renderLlmsTxt(manual, api, examples) {
return [
'# Aholo Viewer',
'',
'> High-performance 3D Gaussian Splatting rendering for web applications.',
'',
'AI assistants should use the markdown corpus under `/llm/` before scraping the human-facing site.',
'',
'## Primary Docs',
'',
`- [AI documentation index](/llm/index.md): overview and recommended reading order.`,
`- [Manual](/llm/manual/index.md): product manual, ${manual.length} pages.`,
`- [API Reference](/llm/api/index.md): exported SDK symbols, ${api.length} entries.`,
`- [Examples](/llm/examples/index.md): runnable TypeScript examples, ${examples.length} demos.`,
'',
'## Recommended Use',
'',
'- Start with `/llm/manual/getting-started.md` for setup.',
'- Read `/llm/manual/basic-concepts.md` for the scene, camera, and renderer model.',
'- Use `/llm/api/index.md` to locate public exports.',
'- Use `/llm/examples/index.md` for runnable integration patterns.',
'',
].join('\n');
}
// ---------- Utilities ----------
async function writeMarkdown(filePath, content) {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, content);
}
function parseFrontmatter(source) {
if (!source.startsWith('---')) {
return { frontmatter: {}, body: source };
}
const closing = source.indexOf('\n---', 3);
if (closing === -1) {
return { frontmatter: {}, body: source };
}
const rawFrontmatter = source.slice(3, closing).trim();
const body = source.slice(closing + 4).replace(/^\r?\n/, '');
const frontmatter = {};
for (const line of rawFrontmatter.split(/\r?\n/)) {
const match = line.match(/^([\w-]+)\s*:\s*(.*)$/);
if (!match) {
continue;
}
const [, key, rawValue] = match;
const trimmed = rawValue.trim().replace(/^['"](.*)['"]$/, '$1');
frontmatter[key] = trimmed;
}
return { frontmatter, body };
}
function stringField(frontmatter, key) {
const value = frontmatter[key];
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}
function numberField(frontmatter, key) {
const value = Number(frontmatter[key]);
return Number.isFinite(value) ? value : undefined;
}
// ---------- HTML → Markdown ----------
function htmlToMarkdown(html, { rewriteLink } = {}) {
let working = html;
working = working.replace(/<a\b[^>]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\1[^>]*>[\s\S]*?<\/a>/g, '');
working = working.replace(/<svg\b[\s\S]*?<\/svg>/g, '');
working = working.replace(/<wbr\s*\/?>/g, '');
working = working.replace(/<pre\b[^>]*>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/g, (_match, attrs, inner) => {
const langMatch = attrs.match(/class=(["'])([^"']*)\1/);
const langClass = langMatch?.[2] ?? '';
const lang = langClass.match(/language-([\w-]+)/)?.[1] ?? '';
const text = decodeHtmlEntities(stripTags(inner));
return `\n\n\`\`\`${lang}\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`;
});
working = working.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/g, (_match, inner) => {
const text = decodeHtmlEntities(stripTags(inner));
return `\n\n\`\`\`\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`;
});
working = working.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/g, (_match, inner) => {
const text = decodeHtmlEntities(stripTags(inner)).replace(/`/g, '\\`');
return `\`${text}\``;
});
working = working.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/g, (_match, level, inner) => {
const text = decodeHtmlEntities(stripTags(inner)).trim();
if (!text) return '';
return `\n\n${'#'.repeat(Number(level))} ${text}\n\n`;
});
working = working.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/g, (_match, attrs, inner) => {
const hrefMatch = attrs.match(/href=(["'])([^"']*)\1/);
const text = decodeHtmlEntities(stripTags(inner)).trim();
if (!hrefMatch) {
return text;
}
const rawHref = decodeHtmlEntities(hrefMatch[2]);
const finalHref = rewriteLink ? (rewriteLink(rawHref) ?? rawHref) : rawHref;
if (!text) {
return finalHref;
}
return `[${text}](${finalHref})`;
});
working = working.replace(/<(?:strong|b)\b[^>]*>([\s\S]*?)<\/(?:strong|b)>/g, (_match, inner) => {
return `**${decodeHtmlEntities(stripTags(inner)).trim()}**`;
});
working = working.replace(/<(?:em|i)\b[^>]*>([\s\S]*?)<\/(?:em|i)>/g, (_match, inner) => {
return `*${decodeHtmlEntities(stripTags(inner)).trim()}*`;
});
working = working.replace(/<br\s*\/?>(\s*)/g, '\n$1');
working = working.replace(/<(ul|ol)\b[^>]*>([\s\S]*?)<\/\1>/g, (_match, tag, inner) => {
const isOrdered = tag === 'ol';
const items = [];
let counter = 1;
inner.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/g, (_full, itemInner) => {
const itemText = collapseWhitespace(decodeHtmlEntities(stripTags(itemInner)));
if (itemText) {
const bullet = isOrdered ? `${counter}.` : '-';
items.push(`${bullet} ${itemText}`);
counter += 1;
}
return '';
});
return `\n\n${items.join('\n')}\n\n`;
});
working = working.replace(/<(p|section|article|div|details|summary)\b[^>]*>/g, '\n\n');
working = working.replace(/<\/(p|section|article|div|details|summary)>/g, '\n\n');
working = stripTags(working);
working = decodeHtmlEntities(working);
working = working.replace(/[ \t]+\n/g, '\n');
working = working.replace(/\n{3,}/g, '\n\n');
return working.trim();
}
function stripTags(value) {
return value.replace(/<[^>]+>/g, '');
}
function collapseWhitespace(value) {
return value.replace(/\s+/g, ' ').trim();
}
function decodeHtmlEntities(value) {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-fA-F]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16)))
.replace(/&amp;/g, '&');
}