Skip to content

feat: add llms.txt and LLM-friendly markdown docs generation#497

Open
takaebato wants to merge 4 commits intoapache:masterfrom
takaebato:add-llms-txt
Open

feat: add llms.txt and LLM-friendly markdown docs generation#497
takaebato wants to merge 4 commits intoapache:masterfrom
takaebato:add-llms-txt

Conversation

@takaebato
Copy link
Copy Markdown

@takaebato takaebato commented Mar 1, 2026

Summary

Add llms.txt and LLM-friendly markdown documentation generation to the ECharts doc build pipeline. Since the current SPA-based docs are difficult for AI agents to access via web fetch, this provides static Markdown alternatives.

  • Add build/build-llms.js that mechanically converts documents/*-parts/*.json to llms-documents/*-parts/*.md by converting HTML descriptions to Markdown via turndown, with type/default info extracted from full schema JSONs (option.json, api.json, etc.)
  • Integrate into the existing build pipeline via build/build-doc.js (runs after main doc build)
  • Generate llms.txt index file per language (en/zh) listing all available documentation
  • Add turndown and turndown-plugin-gfm as dependencies

Output structure

  en/
  ├── llms.txt
  └── llms-documents/
      ├── option-parts/     (55 files)
      ├── option-gl-parts/  (18 files)
      ├── api-parts/        (5 files)
      └── tutorial-parts/   (1 file)
  zh/ (same structure)

Output examples

en/llms.txt
# 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.

## API

- [api.action](llms-documents/api-parts/api.action.md)
- [api.echarts](llms-documents/api-parts/api.echarts.md)
- [api.echartsInstance](llms-documents/api-parts/api.echartsInstance.md)
- [api.events](llms-documents/api-parts/api.events.md)
- [api](llms-documents/api-parts/api.md)

## Option GL

- [option-gl.geo3D](llms-documents/option-gl-parts/option-gl.geo3D.md)
- [option-gl.globe](llms-documents/option-gl-parts/option-gl.globe.md)
- [option-gl.grid3D](llms-documents/option-gl-parts/option-gl.grid3D.md)
- [option-gl.mapbox3D](llms-documents/option-gl-parts/option-gl.mapbox3D.md)
- [option-gl](llms-documents/option-gl-parts/option-gl.md)
- [option-gl.series-bar3D](llms-documents/option-gl-parts/option-gl.series-bar3D.md)
- [option-gl.series-flowGL](llms-documents/option-gl-parts/option-gl.series-flowGL.md)
- [option-gl.series-graphGL](llms-documents/option-gl-parts/option-gl.series-graphGL.md)
- [option-gl.series-line3D](llms-documents/option-gl-parts/option-gl.series-line3D.md)
- [option-gl.series-lines3D](llms-documents/option-gl-parts/option-gl.series-lines3D.md)
- [option-gl.series-map3D](llms-documents/option-gl-parts/option-gl.series-map3D.md)
- [option-gl.series-polygons3D](llms-documents/option-gl-parts/option-gl.series-polygons3D.md)
- [option-gl.series-scatter3D](llms-documents/option-gl-parts/option-gl.series-scatter3D.md)
- [option-gl.series-scatterGL](llms-documents/option-gl-parts/option-gl.series-scatterGL.md)
- [option-gl.series-surface](llms-documents/option-gl-parts/option-gl.series-surface.md)
- [option-gl.xAxis3D](llms-documents/option-gl-parts/option-gl.xAxis3D.md)
- [option-gl.yAxis3D](llms-documents/option-gl-parts/option-gl.yAxis3D.md)
- [option-gl.zAxis3D](llms-documents/option-gl-parts/option-gl.zAxis3D.md)

## Option

- [option.angleAxis](llms-documents/option-parts/option.angleAxis.md)
- [option.aria](llms-documents/option-parts/option.aria.md)
- [option.axisPointer](llms-documents/option-parts/option.axisPointer.md)
- [option.brush](llms-documents/option-parts/option.brush.md)
- [option.calendar](llms-documents/option-parts/option.calendar.md)
- [option.dataset](llms-documents/option-parts/option.dataset.md)
- [option.dataZoom-inside](llms-documents/option-parts/option.dataZoom-inside.md)
- [option.dataZoom-slider](llms-documents/option-parts/option.dataZoom-slider.md)
- [option.geo](llms-documents/option-parts/option.geo.md)
- [option.graphic](llms-documents/option-parts/option.graphic.md)
- [option.grid](llms-documents/option-parts/option.grid.md)
- [option.legend](llms-documents/option-parts/option.legend.md)
- [option.matrix](llms-documents/option-parts/option.matrix.md)
- [option](llms-documents/option-parts/option.md)
- [option.media](llms-documents/option-parts/option.media.md)
- [option.parallel](llms-documents/option-parts/option.parallel.md)
- [option.parallelAxis](llms-documents/option-parts/option.parallelAxis.md)
- [option.polar](llms-documents/option-parts/option.polar.md)
- [option.radar](llms-documents/option-parts/option.radar.md)
- [option.radiusAxis](llms-documents/option-parts/option.radiusAxis.md)
- [option.series-bar](llms-documents/option-parts/option.series-bar.md)
- [option.series-boxplot](llms-documents/option-parts/option.series-boxplot.md)
- [option.series-candlestick](llms-documents/option-parts/option.series-candlestick.md)
- [option.series-chord](llms-documents/option-parts/option.series-chord.md)
- [option.series-custom](llms-documents/option-parts/option.series-custom.md)
- [option.series-effectScatter](llms-documents/option-parts/option.series-effectScatter.md)
- [option.series-funnel](llms-documents/option-parts/option.series-funnel.md)
- [option.series-gauge](llms-documents/option-parts/option.series-gauge.md)
- [option.series-graph](llms-documents/option-parts/option.series-graph.md)
- [option.series-heatmap](llms-documents/option-parts/option.series-heatmap.md)
- [option.series-line](llms-documents/option-parts/option.series-line.md)
- [option.series-lines](llms-documents/option-parts/option.series-lines.md)
- [option.series-map](llms-documents/option-parts/option.series-map.md)
- [option.series-parallel](llms-documents/option-parts/option.series-parallel.md)
- [option.series-pictorialBar](llms-documents/option-parts/option.series-pictorialBar.md)
- [option.series-pie](llms-documents/option-parts/option.series-pie.md)
- [option.series-radar](llms-documents/option-parts/option.series-radar.md)
- [option.series-sankey](llms-documents/option-parts/option.series-sankey.md)
- [option.series-scatter](llms-documents/option-parts/option.series-scatter.md)
- [option.series-sunburst](llms-documents/option-parts/option.series-sunburst.md)
- [option.series-themeRiver](llms-documents/option-parts/option.series-themeRiver.md)
- [option.series-tree](llms-documents/option-parts/option.series-tree.md)
- [option.series-treemap](llms-documents/option-parts/option.series-treemap.md)
- [option.singleAxis](llms-documents/option-parts/option.singleAxis.md)
- [option.stateAnimation](llms-documents/option-parts/option.stateAnimation.md)
- [option.textStyle](llms-documents/option-parts/option.textStyle.md)
- [option.thumbnail](llms-documents/option-parts/option.thumbnail.md)
- [option.timeline](llms-documents/option-parts/option.timeline.md)
- [option.title](llms-documents/option-parts/option.title.md)
- [option.toolbox](llms-documents/option-parts/option.toolbox.md)
- [option.tooltip](llms-documents/option-parts/option.tooltip.md)
- [option.visualMap-continuous](llms-documents/option-parts/option.visualMap-continuous.md)
- [option.visualMap-piecewise](llms-documents/option-parts/option.visualMap-piecewise.md)
- [option.xAxis](llms-documents/option-parts/option.xAxis.md)
- [option.yAxis](llms-documents/option-parts/option.yAxis.md)

## Tutorial

- [tutorial](llms-documents/tutorial-parts/tutorial.md)

llms-documents/option-parts/option.title.md (excerpt)
# option.title

## id
- **Type**: `string`

Component ID, not specified by default. If specified, it can be used to refer the component in option or API.

## show
- **Type**: `boolean`
- **Default**: `true`

Set this to `false` to prevent the title from showing

## text
- **Type**: `string`
- **Default**: `''`

The main title text, supporting for `\n` for newlines.

\[WARNING\]: When enabling [toolbox.feature.saveAsImage](option.toolbox.md#feature.saveAsImage), and [toolbox.feature.saveAsImage.name](option.toolbox.md#feature.saveAsImage.name) is not provided, it has historically been using `title[0].text` instead. This usage is not recommended -- [toolbox.feature.saveAsImage.name](option.toolbox.md#feature.saveAsImage.name) should always be specified explicitly; otherwise, **correctness** and **security risks** for a filename have to be considered in this `title.text` option. See document ["Security Guidelines"](http://localhost/echarts-website/handbook/en/best-practices/security) for recommendations on safe usage.

## link
- **Type**: `string`
- **Default**: `''`

The hyper link of main title text.

\[WARNING\]: This URL string is accepted directly without any internal sanitization. **Security risks** must be considered if it comes from untrusted sources. See document ["Security Guidelines"](http://localhost/echarts-website/handbook/en/best-practices/security) for recommendations on safe usage.

## target
- **Type**: `string`
- **Default**: `'blank'`

Open the hyper link of main title in specified tab.

**options:**

*   `'self'` opening it in current tab
    
*   `'blank'` opening it in a new tab

### textStyle.color
- **Type**: `Color`
- **Default**: `'#333'`

main title text color.

### textStyle.fontStyle
- **Type**: `string`
- **Default**: `'normal'`

main title font style.

Options are:

*   `'normal'`
*   `'italic'`
*   `'oblique'`

### textStyle.fontWeight
- **Type**: `string|number`
- **Default**: `'bolder'`

main title font thick weight.

Options are:

*   `'normal'`
*   `'bold'`
*   `'bolder'`
*   `'lighter'`
*   100 | 200 | 300 | 400...

### textStyle.fontFamily
- **Type**: `string`
- **Default**: `'sans-serif'`

main title font family.

Can also be 'serif' , 'monospace', ...

### textStyle.fontSize
- **Type**: `number`
- **Default**: `18`

main title font size.

Link resolution

Internal links in HTML (href="#property.path" and href="(option|api|tutorial).html#...") are resolved to relative .md file paths before turndown conversion on a best-effort basis.
Out of ~22,600 total links in the source, ~12,000 internal links are resolved to .md file paths (~99%), ~10,600 external links are preserved as-is, and ~50 links with non-standard formats (e.g. missing # prefix) are left unresolved.

@takaebato takaebato marked this pull request as ready for review March 1, 2026 11:09
@takaebato takaebato marked this pull request as draft March 1, 2026 13:18
@takaebato takaebato marked this pull request as ready for review March 1, 2026 14:26
@takaebato
Copy link
Copy Markdown
Author

Just a gentle follow-up on this PR.
I'm happy to adjust the approach, scope, or implementation as needed, so I'd appreciate any feedback.
Thanks!

@takaebato
Copy link
Copy Markdown
Author

Hi @plainheart , sorry for the ping.
Could you review this or point me to the right person?
llms.txt is becoming a standard for AI-ready docs, already adopted by Cloudflare, Vite, and Next.js, etc. I think it’d be a great addition to ECharts. Thanks!

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an LLM-friendly, static Markdown documentation output to the ECharts doc build, including per-language llms.txt indexes and Markdown conversions of the existing documents/*-parts/*.json content.

Changes:

  • Add build/build-llms.js to convert built part JSON (HTML descriptions) into Markdown and generate per-language llms.txt.
  • Integrate the new Markdown generation step into build/build-doc.js after the main doc build.
  • Add turndown + turndown-plugin-gfm dev dependencies and ignore generated public/*/llms* outputs.

Reviewed changes

Copilot reviewed 3 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
package.json Adds Turndown dependencies for HTML→Markdown conversion.
package-lock.json Locks new Turndown-related transitive dependencies.
build/build-llms.js New converter/generator for llms-documents Markdown + llms.txt indexes.
build/build-doc.js Runs LLM-doc generation as part of the standard doc build (non-watch mode).
.gitignore Ignores generated public/{en,zh}/llms.txt and llms-documents/.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 5 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +176 to +189
function formatPropertyEntry(key, entry, 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 = entry.desc ? htmlToMd(linkResolver(entry.desc)) : '';
return [heading, ...meta, ...(body ? ['', body] : []), ''];
}

function jsonToMd(data, typeMap, baseName, linkResolver) {
const lines = Object.entries(data).flatMap(([key, entry]) => {
const fullKey = baseName ? `${baseName}.${key}` : key;
return formatPropertyEntry(key, entry, typeMap[fullKey], linkResolver);
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated headings can include dots (e.g. ### textStyle.color), but rewritten link fragments also preserve dots (e.g. ...md#textStyle.color). In common Markdown renderers (including GFM), heading IDs are slugified and punctuation like . is removed/normalized, so these fragments often won’t navigate to the intended section. Consider either slugifying fragments to match the heading-id algorithm you target, or emitting explicit anchors/IDs for each property so #axisLabel.interval/#textStyle.color works reliably.

Suggested change
function formatPropertyEntry(key, entry, 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 = entry.desc ? htmlToMd(linkResolver(entry.desc)) : '';
return [heading, ...meta, ...(body ? ['', body] : []), ''];
}
function jsonToMd(data, typeMap, baseName, linkResolver) {
const lines = Object.entries(data).flatMap(([key, entry]) => {
const fullKey = baseName ? `${baseName}.${key}` : key;
return formatPropertyEntry(key, entry, typeMap[fullKey], linkResolver);
function escapeHtmlAttribute(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function formatPropertyEntry(key, fullKey, entry, typeInfo, linkResolver) {
const heading = '#'.repeat(Math.min(key.split('.').length + 1, MAX_HEADING_DEPTH)) + ' ' + key;
const anchor = escapeHtmlAttribute(fullKey);
const meta = [
typeInfo && typeInfo.type && `- **Type**: \`${typeInfo.type}\``,
typeInfo && typeInfo.default != null && `- **Default**: \`${typeInfo.default}\``
].filter(Boolean);
const body = entry.desc ? htmlToMd(linkResolver(entry.desc)) : '';
return [`<a id="${anchor}" name="${anchor}"></a>`, heading, ...meta, ...(body ? ['', body] : []), ''];
}
function jsonToMd(data, typeMap, baseName, linkResolver) {
const lines = Object.entries(data).flatMap(([key, entry]) => {
const fullKey = baseName ? `${baseName}.${key}` : key;
return formatPropertyEntry(key, fullKey, entry, typeMap[fullKey], linkResolver);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a trade-off.
Since this is primarily LLM-facing documentation rather than browser-rendered content, not adding explicit HTML anchors to keep the Markdown clean seems better.
The heading text preserves the original property path (e.g. ### textStyle.color), which should be sufficient for LLM consumption.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 5 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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}\``
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When rendering metadata, typeInfo.default != null will suppress a captured default value of null. If you change default-capture to include null, also update this check to test for presence (e.g. 'default' in typeInfo) so the output includes - **Default**: null`` when appropriate.

Suggested change
typeInfo && typeInfo.default != null && `- **Default**: \`${typeInfo.default}\``
typeInfo && Object.prototype.hasOwnProperty.call(typeInfo, 'default') && `- **Default**: \`${typeInfo.default}\``

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the source schema, both cases — default field absent and default: null (e.g. = null in source md file) — are displayed as ... (no default shown) on the website.
The != null check intentionally matches this behavior, filtering out both undefined and null to stay consistent with the website.

@takaebato
Copy link
Copy Markdown
Author

@plainheart

Addressed all feedback from the Copilot review. See individual replies on each comment for details.
Also includes additional improvements: consistent naming, JSDoc, image path resolution, and root file placement at top level.
I think it's ready for re-review. Could you please take another look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants