-
Notifications
You must be signed in to change notification settings - Fork 202
feat: add llms.txt and LLM-friendly markdown docs generation #497
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,249 @@ | ||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Converts built part JSONs (HTML desc) to Markdown using turndown, | ||||||||||||||||||||||||||
| * and generates llms.txt + individual .md files. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Mechanically converts documents/*-parts/*.json to llms-documents/*-parts/*.md. | ||||||||||||||||||||||||||
| * Type information is extracted from documents/*.json (full schema) via traverse. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Prerequisites: JSON must be built first (node build.js --env dev) | ||||||||||||||||||||||||||
| * Usage: node build/build-llms.js --env dev | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| const fs = require('fs'); | ||||||||||||||||||||||||||
| const fse = require('fs-extra'); | ||||||||||||||||||||||||||
| const path = require('path'); | ||||||||||||||||||||||||||
| const globby = require('globby'); | ||||||||||||||||||||||||||
| const TurndownService = require('turndown'); | ||||||||||||||||||||||||||
| const {gfm} = require('turndown-plugin-gfm'); | ||||||||||||||||||||||||||
| const {traverse} = require('../tool/schemaHelper'); | ||||||||||||||||||||||||||
| const {readConfigEnvFile} = require('./helper'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Constants --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const LANGUAGES = ['en', 'zh']; | ||||||||||||||||||||||||||
| const OUTPUT_DIR_NAME = 'llms-documents'; | ||||||||||||||||||||||||||
| const MAX_HEADING_DEPTH = 6; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const CATEGORY_LABELS = { | ||||||||||||||||||||||||||
| en: {'option-parts': 'Option', 'option-gl-parts': 'Option GL', 'api-parts': 'API', 'tutorial-parts': 'Tutorial'}, | ||||||||||||||||||||||||||
| zh: {'option-parts': '配置项 (Option)', 'option-gl-parts': 'Option GL', 'api-parts': 'API', 'tutorial-parts': '教程 (Tutorial)'} | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const LLMS_TXT_HEADER = [ | ||||||||||||||||||||||||||
| '# Apache ECharts Documentation', | ||||||||||||||||||||||||||
| '', | ||||||||||||||||||||||||||
| '> Apache ECharts is a free, powerful charting and visualization library offering easy ways to add intuitive, interactive, and highly customizable charts to your commercial products.', | ||||||||||||||||||||||||||
| '' | ||||||||||||||||||||||||||
| ].join('\n'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Config --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const argv = require('yargs').argv; | ||||||||||||||||||||||||||
| const envType = (argv.dev != null || argv.debug != null || argv.env === 'dev') ? 'dev' : argv.env; | ||||||||||||||||||||||||||
| if (!envType) throw new Error('--env MUST be specified'); | ||||||||||||||||||||||||||
| const config = readConfigEnvFile(envType); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Turndown --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const td = new TurndownService({headingStyle: 'atx', codeBlockStyle: 'fenced'}); | ||||||||||||||||||||||||||
| td.use(gfm); | ||||||||||||||||||||||||||
| td.addRule('iframe', {filter: 'iframe', replacement: () => ''}); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function htmlToMd(html) { | ||||||||||||||||||||||||||
| return html ? td.turndown(html).replace(/\n{3,}/g, '\n\n').trim() : ''; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Extract type info from full schema JSON --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function buildTypeMap(schemaJsonPath, docName) { | ||||||||||||||||||||||||||
| if (!fs.existsSync(schemaJsonPath)) return {}; | ||||||||||||||||||||||||||
| const schema = JSON.parse(fs.readFileSync(schemaJsonPath, 'utf-8')); | ||||||||||||||||||||||||||
| const typeMap = {}; | ||||||||||||||||||||||||||
| traverse(schema, docName, (schemaPath, node) => { | ||||||||||||||||||||||||||
| if (node.type || node.default != null) { | ||||||||||||||||||||||||||
| typeMap[schemaPath] = { | ||||||||||||||||||||||||||
| type: node.type ? (Array.isArray(node.type) ? node.type.join('|') : node.type) : null, | ||||||||||||||||||||||||||
| default: node.default != null ? String(node.default) : null | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| return typeMap; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Resolve links in HTML --- | ||||||||||||||||||||||||||
| // Best-effort rewriting of <a href="#path"> and <a href="api.html#path"> in HTML | ||||||||||||||||||||||||||
| // so that turndown produces markdown links pointing to the correct .md files. | ||||||||||||||||||||||||||
| // Some source links have non-standard formats (e.g. missing "#", no dot separator) | ||||||||||||||||||||||||||
| // that cannot be resolved; these are left as-is or linked to the orphan file. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function tryResolveFileKey(linkPath, fileKeys) { | ||||||||||||||||||||||||||
| const [seg, ...rest] = linkPath.split('.'); | ||||||||||||||||||||||||||
| const frag = rest.length > 0 ? rest.join('.') : null; | ||||||||||||||||||||||||||
| const segL = seg.toLowerCase(); | ||||||||||||||||||||||||||
| const keysArr = [...fileKeys]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const key = fileKeys.has(seg) | ||||||||||||||||||||||||||
| ? seg | ||||||||||||||||||||||||||
| : keysArr.find(k => k.toLowerCase() === segL) | ||||||||||||||||||||||||||
| ?? keysArr.find(k => { | ||||||||||||||||||||||||||
| const kl = k.toLowerCase(); | ||||||||||||||||||||||||||
| return kl === segL + 's' || kl + 's' === segL; | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| ?? null; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return key ? {key, frag} : null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function tryResolveHtmlLinks(html, strippedKeys, docPrefix, hasOrphanFile) { | ||||||||||||||||||||||||||
| // Same-category links: href="#property.path" -> href="docPrefix.fileKey.md#fragment" | ||||||||||||||||||||||||||
| const resolved = html.replace(/href="#([^"]+)"/g, (match, lp) => { | ||||||||||||||||||||||||||
| const r = tryResolveFileKey(lp, strippedKeys); | ||||||||||||||||||||||||||
| if (!r) { | ||||||||||||||||||||||||||
| if (hasOrphanFile) return `href="${docPrefix}.md#${lp}"`; | ||||||||||||||||||||||||||
| return match; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return `href="${docPrefix}.${r.key}.md${r.frag ? '#' + r.frag : ''}"`; | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Cross-category links: href="api.html#echarts.init" -> href="../api-parts/api.echarts.md#init" | ||||||||||||||||||||||||||
| // Tutorial is a single file, so href="tutorial.html#X" -> href="../tutorial-parts/tutorial.md#X" | ||||||||||||||||||||||||||
| return resolved.replace( | ||||||||||||||||||||||||||
| /href="(option|api|tutorial)\.html#([^"]+)"/g, | ||||||||||||||||||||||||||
| (_, docType, fragment) => { | ||||||||||||||||||||||||||
| if (docType === 'tutorial') { | ||||||||||||||||||||||||||
| return `href="../tutorial-parts/tutorial.md#${fragment}"`; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| const {key, frag} = tryResolveFileKey(fragment, new Set([fragment.split('.')[0]])); | ||||||||||||||||||||||||||
| return `href="../${docType}-parts/${docType}.${key}.md${frag ? '#' + frag : ''}"`; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Convert part JSON to Markdown --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function formatPropertyEntry(key, val, typeInfo, linkResolver) { | ||||||||||||||||||||||||||
| const heading = '#'.repeat(Math.min(key.split('.').length + 1, MAX_HEADING_DEPTH)) + ' ' + key; | ||||||||||||||||||||||||||
| const meta = [ | ||||||||||||||||||||||||||
| typeInfo && typeInfo.type && `- **Type**: \`${typeInfo.type}\``, | ||||||||||||||||||||||||||
| typeInfo && typeInfo.default != null && `- **Default**: \`${typeInfo.default}\`` | ||||||||||||||||||||||||||
| ].filter(Boolean); | ||||||||||||||||||||||||||
| const body = val.desc ? htmlToMd(linkResolver(val.desc)) : ''; | ||||||||||||||||||||||||||
| return [heading, ...meta, ...(body ? ['', body] : []), '']; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function jsonToMd(data, typeMap, partKey, linkResolver) { | ||||||||||||||||||||||||||
| const lines = Object.entries(data).flatMap(([key, val]) => { | ||||||||||||||||||||||||||
| const fullKey = partKey ? `${partKey}.${key}` : key; | ||||||||||||||||||||||||||
| return formatPropertyEntry(key, val, typeMap[fullKey], linkResolver); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- File output --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function writeFile(dir, name, content, category) { | ||||||||||||||||||||||||||
| const fullPath = path.resolve(dir, name); | ||||||||||||||||||||||||||
| fse.ensureDirSync(path.dirname(fullPath)); | ||||||||||||||||||||||||||
| fs.writeFileSync(fullPath, content, 'utf-8'); | ||||||||||||||||||||||||||
| return {name, path: fullPath, category}; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Process a single *-parts/ directory --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function processPartsDir(partsDir, outDir, typeMap) { | ||||||||||||||||||||||||||
| const dirName = path.basename(partsDir); | ||||||||||||||||||||||||||
| const docPrefix = dirName.replace(/-parts$/, ''); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const jsonFiles = globby.sync(path.join(partsDir, '*.json')) | ||||||||||||||||||||||||||
| .filter(f => !path.basename(f).includes('-outline')); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Collect file keys for link resolution (e.g. "option.title", "option.series-bar") | ||||||||||||||||||||||||||
| const fileKeys = new Set(jsonFiles.map(f => path.basename(f, '.json'))); | ||||||||||||||||||||||||||
| const strippedKeys = new Set([...fileKeys].map(k => | ||||||||||||||||||||||||||
| k.startsWith(docPrefix + '.') ? k.slice(docPrefix.length + 1) : k | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
| const hasOrphanFile = fileKeys.has(docPrefix); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Create a link resolver that rewrites HTML hrefs before turndown | ||||||||||||||||||||||||||
| const linkResolver = (html) => tryResolveHtmlLinks(html, strippedKeys, docPrefix, hasOrphanFile); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return jsonFiles.map(f => { | ||||||||||||||||||||||||||
| const baseName = path.basename(f, '.json'); | ||||||||||||||||||||||||||
| const data = JSON.parse(fs.readFileSync(f, 'utf-8')); | ||||||||||||||||||||||||||
| const content = `# ${baseName}\n\n` + jsonToMd(data, typeMap, baseName, linkResolver); | ||||||||||||||||||||||||||
| return writeFile(outDir, `${dirName}/${baseName}.md`, content, dirName); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Generate docs for a single language --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function generateDocsForLang(lang) { | ||||||||||||||||||||||||||
| const docsDir = path.resolve(config.releaseDestDir, lang, 'documents'); | ||||||||||||||||||||||||||
| const outDir = path.resolve(config.releaseDestDir, lang, OUTPUT_DIR_NAME); | ||||||||||||||||||||||||||
| fse.ensureDirSync(outDir); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Step 1: Build a type map from full schema JSONs (option.json, api.json, etc.) | ||||||||||||||||||||||||||
| // by traversing the nested schema tree to collect type/default for each | ||||||||||||||||||||||||||
| // property path (e.g. "option.title.show" -> {type: "boolean", default: "true"}). | ||||||||||||||||||||||||||
| const schemaFiles = globby.sync(path.join(docsDir, '*.json')); | ||||||||||||||||||||||||||
| const typeMap = schemaFiles.reduce((map, f) => { | ||||||||||||||||||||||||||
| const docName = path.basename(f, '.json'); | ||||||||||||||||||||||||||
| return {...map, ...buildTypeMap(f, docName)}; | ||||||||||||||||||||||||||
| }, {}); | ||||||||||||||||||||||||||
|
Comment on lines
+187
to
+191
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Step 2: For each *-parts/ directory, read part JSONs (e.g. option.title.json), | ||||||||||||||||||||||||||
| // resolve internal links in HTML, convert desc to Markdown via turndown, | ||||||||||||||||||||||||||
| // attach type/default from the type map, and write as .md files. | ||||||||||||||||||||||||||
| const partsDirs = globby.sync(path.join(docsDir, '*-parts'), {onlyDirectories: true}); | ||||||||||||||||||||||||||
| const files = partsDirs | ||||||||||||||||||||||||||
| .flatMap(dir => processPartsDir(dir, outDir, typeMap)) | ||||||||||||||||||||||||||
| .sort((a, b) => a.name.localeCompare(b.name)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| console.log(`Generated ${files.length} docs for ${lang}`); | ||||||||||||||||||||||||||
| return files; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- llms.txt --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function groupByCategory(files) { | ||||||||||||||||||||||||||
| return files.reduce((groups, f) => ({ | ||||||||||||||||||||||||||
| ...groups, | ||||||||||||||||||||||||||
| [f.category]: [...(groups[f.category] || []), f] | ||||||||||||||||||||||||||
| }), {}); | ||||||||||||||||||||||||||
|
Comment on lines
+208
to
+211
|
||||||||||||||||||||||||||
| return files.reduce((groups, f) => ({ | |
| ...groups, | |
| [f.category]: [...(groups[f.category] || []), f] | |
| }), {}); | |
| const groups = {}; | |
| for (const f of files) { | |
| if (!groups[f.category]) { | |
| groups[f.category] = []; | |
| } | |
| groups[f.category].push(f); | |
| } | |
| return groups; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cross-category link rewriting always assumes there is a per-component markdown file for the first path segment (e.g.
option.visualMap.md). But the build output uses an orphan/root file (e.g.option.md) for top-level components likevisualMap/series, so links likeoption.html#visualMapwill be rewritten to a non-existent file. Consider resolving against the actual file keys in the target*-partsdirectory and falling back to../option-parts/option.md#...(or../api-parts/api.md#...) when no matching part file exists.