Document llms.txt and the markdown page mirrors (#2904)

This commit is contained in:
Paweł Kuna
2026-08-20 00:36:32 +02:00
committed by GitHub
parent 74fa6f303f
commit 1adeb6833a
12 changed files with 174 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/docs": patch
---
Fixed html comments in docs code examples running into the closing tag of the element before them.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/docs": minor
---
Added a Docs for LLMs page explaining `llms.txt` and the `.md` page mirrors, with a sidebar link.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/docs": patch
---
Fixed the `.md` docs mirrors showing component tags like `<Icon />` instead of the rendered html.
+2 -2
View File
@@ -57,8 +57,8 @@ const innerClasses = ['p-6 w-full', column && 'd-flex gap-3 flex-column', !colum
const highlighted = hideCode ? '' : await highlightCode(beautifyHtml(code ?? html), 'html')
---
<!--EXAMPLE-->
<div class="docs-example my-5">
<!--EXAMPLE-->{/* data-example-markup carries the markup for examples with no code panel, so the .md mirror (docs/lib/llms.ts) still has real html to show */}
<div class="docs-example my-5" data-example-markup={hideCode ? (code ?? html) : undefined}>
{
hasDemo && (
<div class:list={exampleClasses} style={height ? `height: ${height}` : undefined}>
+1 -7
View File
@@ -35,13 +35,7 @@ If you need to add icons to your website, you can use the [Tabler Icons library]
## Base icon
To add an icon to your code copy the SVG code from the Tabler Icons website and paste it into your HTML file.
```html
<Icon name="heart" />
```
Results can be seen in the example below.
To add an icon to your code copy the SVG code from the Tabler Icons website and paste it into your HTML file. The example below shows a few icons; open its code panel for the markup.
<Example centered>
<Icon name="heart" />
+69
View File
@@ -0,0 +1,69 @@
---
title: Docs for LLMs
summary: The Tabler documentation is also published as plain markdown, so AI tools and agents can read it without parsing HTML.
description: Use llms.txt and the markdown version of every Tabler docs page to give AI tools accurate, up-to-date context about Tabler.
---
Large language models work better when they read the real documentation instead of guessing class names. Tabler publishes its docs in two machine-readable forms: one index file that lists every page, and a markdown copy of each page.
## Overview
[llms.txt](https://llmstxt.org) is a simple convention. A site puts one markdown file at its root that describes what the site is about and links to the pages worth reading. Tabler follows it:
- `https://docs.tabler.io/llms.txt` — the index of the whole documentation.
- `https://docs.tabler.io/<page>.md` — the markdown version of a single page.
Both are plain text. You can open them in a browser, download them with `curl`, or paste the url into a chat with an AI assistant.
## The index file
Fetch the index to see the full map of the docs:
```shell
curl https://docs.tabler.io/llms.txt
```
It contains:
- A short description of what Tabler is.
- Every documentation page, grouped the same way as the sidebar, with a one-line description and a link to its markdown file.
- A class reference for every component that has one, so a model can look up which classes exist without opening each page.
In the class reference, a name in braces stands for a family of classes. `alert-{color}` means any base color, and `table-mobile-{breakpoint}` means any breakpoint.
## Markdown pages
Add `.md` to any docs url to get the same page as markdown:
```shell
curl https://docs.tabler.io/ui/components/button.md
```
The markdown file keeps the page title, the summary, the prose, and the markup of every example as fenced code blocks. It drops the navigation, the styling, and everything else that only matters in a browser.
The home page is the one exception. Use `/index.md`, because `/.md` is not a valid url.
Every docs page also links to these files in its `<head>`, so a crawler that already has the html can find them:
```html
<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt" />
<link rel="alternate" type="text/markdown" href="/ui/components/button.md" title="Button (markdown)" />
```
## Usage
Point your tool at the file that fits the task:
- **Whole library.** Give the assistant `https://docs.tabler.io/llms.txt` when it needs to know what Tabler offers and which page to read next.
- **One component.** Give it the page's `.md` url when you are working on a single component and want the exact markup.
- **Local context.** Download the files into your project and commit them, so the same context is used by everyone on the team and by tools that cannot fetch urls.
A short prompt is usually enough:
```text
Read https://docs.tabler.io/ui/components/card.md and build a card with a header, a footer and a table inside.
```
## Where the markup comes from
The examples are taken from the rendered page, not from the documentation source. A `.md` file gives you the same markup as the copy button on the html page, with every icon and helper already expanded to plain html.
+6
View File
@@ -332,6 +332,12 @@ const relatedPages = await Promise.all(
<div class="flex-fill">
<DocsMenu url={docsUrl} />
</div>
{/* Machine-readable index of the docs — see /ui/getting-started/llms */}
<nav class="nav nav-vertical" aria-label="Docs for AI tools">
<a class="nav-link" href="/llms.txt">
<span class="nav-link-title">llms.txt</span>
</a>
</nav>
</div>
</div>
</div>
+64 -7
View File
@@ -1,12 +1,21 @@
// Source-level MDX → plain markdown, for the /llms.txt endpoints.
// MDX → plain markdown, for the /llms.txt endpoints.
//
// The docs are MDX: prose is already markdown, but the parts that carry the most
// value for a reader (the actual Tabler markup) sit inside <Example> slots and
// component props. Stripping components wholesale — the usual llms.txt recipe —
// would delete exactly that. So the components that hold content are unwrapped
// into fenced code blocks instead, and only the decorative ones are dropped.
//
// Prose comes from the MDX source, but example markup comes from the *rendered*
// page (see renderedExamples below): a third of the examples are written with
// docs components — <Icon>, <AvatarList>, <Badge> — and the source form of those
// is of no use to a reader who wants the html.
import type { CollectionEntry } from 'astro:content'
import { extractMarkedSnippet } from '@shared/lib/code-example'
import { render } from 'astro:content'
import { loadRenderers } from 'astro:container'
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import { getContainerRenderer } from '@astrojs/mdx/container-renderer'
import { beautifyHtml, extractMarkedSnippet } from '@shared/lib/code-example'
import { site } from '@shared/lib/site'
import packageManagers from '@data/package-managers.json'
import { cdnCssTag, cdnJsTag, cdnPackageSnippet, cdnPluginSnippet } from './cdn-snippets.ts'
@@ -110,8 +119,53 @@ function restoreCode(text: string, store: string[]) {
return result
}
/** Turn one page's MDX body into plain markdown. */
export async function mdxToMarkdown(body: string): Promise<string> {
// Marker Example.astro puts in front of every example it renders.
const EXAMPLE_MARKER = '<!--EXAMPLE-->'
const decodeEntities = (value: string) =>
value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
// One container for the whole build: creating it loads the MDX renderer.
let container: Promise<AstroContainer> | undefined
const getContainer = () => (container ??= loadRenderers([getContainerRenderer()]).then((renderers) => AstroContainer.create({ renderers })))
/**
* The final markup of every <Example> on a page, in document order — the same
* html the page's copy button hands out, so components are already expanded.
* An entry is null only when the example renders nothing, and the whole list is
* empty when the page cannot be rendered.
*/
async function renderedExamples(entry: CollectionEntry<'docs'>): Promise<(string | null)[]> {
try {
const { Content } = await render(entry)
const html = await (await getContainer()).renderToString(Content)
return html
.split(EXAMPLE_MARKER)
.slice(1)
.map((block) => {
// the copy button, or the wrapper attribute when the example hides its code panel
const markup = block.match(/data-clipboard-text="([^"]*)"/) ?? block.match(/data-example-markup="([^"]*)"/)
return markup ? beautifyHtml(decodeEntities(markup[1]!)) : null
})
} catch {
// A page that fails to render still gets its prose and its source-level examples.
return []
}
}
/**
* Turn one page's MDX body into plain markdown. `examples` comes from
* renderedExamples() and replaces the source of each <Example> slot; it is
* ignored unless it lines up one-to-one with the examples in the source.
*/
export async function mdxToMarkdown(body: string, examples: (string | null)[] = []): Promise<string> {
// `<Code lang code={`…`} />` first: its template literal contains backticks, which
// would otherwise be mistaken for markdown code spans by protectCode() below.
let text = body.replace(/<Code\b[^>]*?code=\{`([\s\S]*?)`\}[\s\S]*?\/>/g, (match, snippet: string) => {
@@ -127,8 +181,11 @@ export async function mdxToMarkdown(body: string): Promise<string> {
text = text.replace(/^import\s+.+?from\s+['"][^'"]+['"];?[ \t]*$/gm, '')
// <Example> slots hold the markup the page is actually documenting
text = text.replace(/<Example\b[^>]*>([\s\S]*?)<\/Example>/g, (_match, inner: string) => {
const snippet = dedent(inner)
const examplePattern = /<Example\b[^>]*>([\s\S]*?)<\/Example>/g
const useRendered = examples.length === (text.match(examplePattern)?.length ?? 0)
let exampleIndex = 0
text = text.replace(examplePattern, (_match, inner: string) => {
const snippet = (useRendered ? examples[exampleIndex++] : null) ?? dedent(inner)
return snippet ? `\n${fence(snippet)}\n` : ''
})
@@ -170,7 +227,7 @@ export async function pageMarkdown(entry: CollectionEntry<'docs'>, url: string):
const { title, summary, description } = entry.data
const header = [`# ${title}`, '', `> ${summary}`, '', description, '', `Source: ${url}`, '', '---', ''].join('\n')
return `${header}\n${await mdxToMarkdown(entry.body ?? '')}\n`
return `${header}\n${await mdxToMarkdown(entry.body ?? '', await renderedExamples(entry))}\n`
}
/** Absolute in production, root-relative in dev — same rule as sitemap.xml.ts. */
+1 -1
View File
@@ -87,7 +87,7 @@ Tabler is a free and open source dashboard UI kit built on Bootstrap. This file
Every page is available as markdown by appending \`.md\` to its url, for example \`${site.docsUrl}/ui/components/button.md\`. Those files contain the same prose as the html page plus the markup of every example, as fenced code blocks.
The markup is taken from the documentation source. A small number of examples are written with Tabler's own documentation components, and those appear as component tags (for example \`<Alert type="success" />\`) instead of the final html; open the page itself for those.
The markup is taken from the rendered pages, so every example is plain html — the same markup the copy button on the page gives you.
${sections.join('\n\n')}
+4
View File
@@ -475,6 +475,10 @@
"title": "FAQ",
"url": "/ui/getting-started/faq"
},
{
"title": "Docs for LLMs",
"url": "/ui/getting-started/llms"
},
{
"title": "3rd-party libraries & resources",
"url": "/ui/getting-started/references"
+6
View File
@@ -11,6 +11,12 @@ describe('beautifyHtml', () => {
// between sibling elements — so line breaks have to be added back.
expect(beautifyHtml('<div><span>x</span></div>')).toBe('<div>\n <span>x</span>\n</div>')
})
it('starts a comment that follows an element on its own line', () => {
// Icon examples are a comment plus an svg, repeated — without the break the
// next comment ends up on the closing tag of the icon before it.
expect(beautifyHtml('<svg></svg><!-- next --><svg></svg>')).toBe('<svg></svg>\n<!-- next -->\n<svg></svg>')
})
})
describe('highlightCode', () => {
+6 -1
View File
@@ -13,7 +13,12 @@ function getHighlighter(): Promise<Highlighter> {
}
export function beautifyHtml(code: string): string {
return beautify.html(code, {
// A comment that follows an element stays glued to it — js-beautify never adds
// the break itself. Icon examples arrive as one line, so every "Download SVG
// icon" comment would sit on the closing tag of the icon before it.
const withCommentBreaks = code.replace(/>\s*<!--/g, '>\n<!--')
return beautify.html(withCommentBreaks, {
indent_size: 2,
indent_char: ' ',
max_preserve_newlines: 5,