Add a "Copy to LLM" dropdown with ChatGPT and Claude shortcuts (#2952)

Co-authored-by: codecalm <codecalm@gmail.com>
This commit is contained in:
Bartosz-Doandcodecalm authored and GitHub committed 2026-09-07 23:32:25 +02:00
1 parent b4b10f924a
commit 8fc079d26c
15 files changed
+313 -77

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@tabler/docs': minor
---
Added a "Copy to LLM" split button to docs pages with "View as markdown", "Ask ChatGPT" and "Ask Claude" shortcuts, replacing the sidebar copy button.
@@ -0,0 +1,5 @@
---
'@tabler/docs': patch
---
Added the color palette, flags, payments and social icons tables to the docs `.md` mirrors via a new `MarkdownSource` component.
+15 -2
View File
@@ -1,14 +1,26 @@
--- ---
import MarkdownSource from './MarkdownSource.astro'
import { markdownTable } from '@shared/lib/markdown-source'
interface Props { interface Props {
colors: Record<string, { prop: string; title: string }> colors: Record<string, { prop: string; title: string; hex?: string }>
} }
const { colors }: Props = Astro.props const { colors }: Props = Astro.props
// The .md mirror gets the palette as a table; hex values are listed only when
// the data has real ones (the gray scale points at a CSS variable instead).
const list = Object.values(colors)
const withHex = list.some((color) => color.hex?.startsWith('#'))
const markdown = markdownTable(
['Color', 'CSS variable', ...(withHex ? ['Hex'] : [])],
list.map((color) => [color.title, `\`${color.prop}\``, ...(withHex ? [color.hex?.startsWith('#') ? `\`${color.hex}\`` : ''] : [])]),
)
--- ---
<div class="row row-cols-4 row-cols-md-6 g-3 g-md-4"> <div class="row row-cols-4 row-cols-md-6 g-3 g-md-4">
{ {
Object.values(colors).map((color) => { list.map((color) => {
return ( return (
<div class="text-center"> <div class="text-center">
<div class="p-6 rounded border" style={{ backgroundColor: `var(${color.prop})` }} /> <div class="p-6 rounded border" style={{ backgroundColor: `var(${color.prop})` }} />
@@ -18,3 +30,4 @@ const { colors }: Props = Astro.props
}) })
} }
</div> </div>
<MarkdownSource name="Colors" markdown={markdown} />
@@ -0,0 +1,91 @@
---
// Fetches this page's Markdown mirror (its url with ".md" appended) and copies
// its content to the clipboard, so the page can be pasted into an LLM directly.
import Icon from '@ui/Icon.astro'
interface Props {
/** Url of the page's `.md` mirror, fetched on click. */
url: string
/** Absolute url of the same `.md` mirror, for the "Ask ChatGPT" / "Ask Claude" prompts. */
absoluteUrl?: string | undefined
class?: string
}
const { url, absoluteUrl, class: className } = Astro.props
// Both ChatGPT and Claude open with this text already in the composer; the
// reader still has to press send.
const prompt = absoluteUrl && `Read ${absoluteUrl} so I can ask questions about it.`
const askChatGptUrl = prompt && `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`
const askClaudeUrl = prompt && `https://claude.ai/new?q=${encodeURIComponent(prompt)}`
---
<div class="btn-group">
<button type="button" class:list={['btn btn-sm', className]} data-docs-copy-markdown-link={url}>
<Icon name="clipboard" />
<Icon name="check" class="d-none" />
<span data-docs-copy-markdown-link-label>Copy to LLM</span>
</button>
<button type="button" class="btn btn-sm dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href={url} target="_blank">
<Icon name="external-link" />
<span>View as markdown</span>
</a>
{
askChatGptUrl && (
<a class="dropdown-item" href={askChatGptUrl} target="_blank" rel="noopener noreferrer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
<span>Ask ChatGPT</span>
</a>
)
}
{
askClaudeUrl && (
<a class="dropdown-item" href={askClaudeUrl} target="_blank" rel="noopener noreferrer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" />
</svg>
<span>Ask Claude</span>
</a>
)
}
</div>
</div>
<script>
// Wire every instance on the page and read each one's url from its own attribute.
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-docs-copy-markdown-link]')
buttons.forEach((button) => {
const label = button.querySelector<HTMLElement>('[data-docs-copy-markdown-link-label]')
const [clipboardIcon, checkIcon] = button.querySelectorAll<HTMLElement>('svg')
button.addEventListener('click', async () => {
const url = button.dataset.docsCopyMarkdownLink
if (!url || !label) return
try {
const response = await fetch(url)
await navigator.clipboard.writeText(await response.text())
const original = label.textContent
label.textContent = 'Copied'
clipboardIcon?.classList.add('d-none')
checkIcon?.classList.remove('d-none')
setTimeout(() => {
label.textContent = original
clipboardIcon?.classList.remove('d-none')
checkIcon?.classList.add('d-none')
}, 2000)
} catch {
// clipboard permission refused — leave the label alone
}
})
})
</script>
+2 -38
View File
@@ -19,11 +19,9 @@ interface Props {
editUrl?: string | undefined editUrl?: string | undefined
/** URL of the file implementing the component, from the page's `source` front matter */ /** URL of the file implementing the component, from the page's `source` front matter */
sourceUrl?: string | undefined sourceUrl?: string | undefined
/** url of this page's markdown mirror, offered as a copy button */
markdownUrl?: string | undefined
} }
const { toc = [], editUrl, sourceUrl, markdownUrl } = Astro.props const { toc = [], editUrl, sourceUrl } = Astro.props
const illustrationsCount = illustrations.length const illustrationsCount = illustrations.length
--- ---
@@ -54,15 +52,8 @@ const illustrationsCount = illustrations.length
</a> </a>
{ {
(markdownUrl || sourceUrl || editUrl) && ( (sourceUrl || editUrl) && (
<nav class="docs-page-links mt-6" aria-label="Page tools"> <nav class="docs-page-links mt-6" aria-label="Page tools">
{markdownUrl && (
<button type="button" class="docs-page-link" data-docs-copy-markdown={markdownUrl}>
<Icon name="clipboard" />
<span data-docs-copy-label>Copy page as Markdown</span>
</button>
)}
{sourceUrl && ( {sourceUrl && (
<a href={sourceUrl} class="docs-page-link" target="_blank" rel="noopener noreferrer"> <a href={sourceUrl} class="docs-page-link" target="_blank" rel="noopener noreferrer">
<Icon name="code" /> <Icon name="code" />
@@ -80,30 +71,3 @@ const illustrationsCount = illustrations.length
) )
} }
<!-- END DOCS TOC --> <!-- END DOCS TOC -->
<script>
// Fetches the .md mirror of the page and puts it on the clipboard, so the
// whole page can be pasted into an assistant without hunting for the url.
const button = document.querySelector<HTMLButtonElement>('[data-docs-copy-markdown]')
if (button) {
const label = button.querySelector<HTMLElement>('[data-docs-copy-label]')
button.addEventListener('click', async () => {
if (!label) return
const url = button.dataset.docsCopyMarkdown
if (!url) return
try {
const response = await fetch(url)
await navigator.clipboard.writeText(await response.text())
const original = label.textContent
label.textContent = 'Copied'
setTimeout(() => {
label.textContent = original
}, 2000)
} catch {
// clipboard or network refused — leave the label alone
}
})
}
</script>
+9
View File
@@ -1,5 +1,13 @@
--- ---
import MarkdownSource from './MarkdownSource.astro'
import { markdownTable } from '@shared/lib/markdown-source'
import flags from '@data/flags.json' import flags from '@data/flags.json'
// Same list as a table for the page's .md mirror
const markdown = markdownTable(
['Country', 'Code', 'CSS class'],
flags.map((flag) => [flag.name, `\`${flag.flag}\``, `\`.flag-country-${flag.flag}\``]),
)
--- ---
<table class="table table-vcenter"> <table class="table table-vcenter">
@@ -30,3 +38,4 @@ import flags from '@data/flags.json'
} }
</tbody> </tbody>
</table> </table>
<MarkdownSource name="Flags" markdown={markdown} />
+17
View File
@@ -0,0 +1,17 @@
---
// Ships a markdown version of a component's output for the page's .md mirror.
// Render it next to the html; `name` is the tag the MDX uses for the component.
// Only the mirror render (docs/lib/llms.ts, which sets the `markdownMirror`
// local) gets the comment — a browser page never carries it.
// See shared/lib/markdown-source.ts.
import { markdownSource } from '@shared/lib/markdown-source'
interface Props {
name: string
markdown: string
}
const { name, markdown } = Astro.props
---
{Astro.locals.markdownMirror && <Fragment set:html={markdownSource(name, markdown)} />}
+9
View File
@@ -1,5 +1,13 @@
--- ---
import MarkdownSource from './MarkdownSource.astro'
import { markdownTable } from '@shared/lib/markdown-source'
import payments from '@data/payments.json' import payments from '@data/payments.json'
// Same list as a table for the page's .md mirror
const markdown = markdownTable(
['Provider', 'CSS class', 'Dark variant'],
payments.map((payment) => [payment.name, `\`.payment-provider-${payment.logo}\``, `\`.payment-provider-${payment.logo}-dark\``]),
)
--- ---
<table class="table table-vcenter"> <table class="table table-vcenter">
@@ -36,3 +44,4 @@ import payments from '@data/payments.json'
} }
</tbody> </tbody>
</table> </table>
<MarkdownSource name="Payments" markdown={markdown} />
+9
View File
@@ -1,5 +1,13 @@
--- ---
import MarkdownSource from './MarkdownSource.astro'
import { markdownTable } from '@shared/lib/markdown-source'
import socials from '@data/socials.json' import socials from '@data/socials.json'
// Same list as a table for the page's .md mirror
const markdown = markdownTable(
['App', 'CSS class'],
socials.map((social) => [social.name, `\`.social-app-${social.file}\``]),
)
--- ---
<table class="table table-vcenter"> <table class="table table-vcenter">
@@ -29,3 +37,4 @@ import socials from '@data/socials.json'
} }
</tbody> </tbody>
</table> </table>
<MarkdownSource name="Socials" markdown={markdown} />
+7
View File
@@ -3,3 +3,10 @@
// clipboard.js, loaded via a page lib (libs.json) as a classic <script> global, not an // clipboard.js, loaded via a page lib (libs.json) as a classic <script> global, not an
// npm package — declared ambiently so `astro check` can resolve it in DocsLayout's copy script. // npm package — declared ambiently so `astro check` can resolve it in DocsLayout's copy script.
declare var ClipboardJS: new (selector: Element, options: Record<string, unknown>) => { on(event: string, callback: (e: { clearSelection(): void; trigger: HTMLElement }) => void): void } declare var ClipboardJS: new (selector: Element, options: Record<string, unknown>) => { on(event: string, callback: (e: { clearSelection(): void; trigger: HTMLElement }) => void): void }
declare namespace App {
interface Locals {
/** set by docs/lib/llms.ts while rendering a page for its .md mirror; <MarkdownSource /> emits only then */
markdownMirror?: boolean
}
}
+31 -20
View File
@@ -13,6 +13,7 @@ import DocsSearch from '@components/DocsSearch.astro';
import DocsClassnames from '@components/DocsClassnames.astro'; import DocsClassnames from '@components/DocsClassnames.astro';
import DocsToc from '@components/DocsToc.astro'; import DocsToc from '@components/DocsToc.astro';
import type { TocItem } from '@components/DocsToc.astro'; import type { TocItem } from '@components/DocsToc.astro';
import DocsCopyMarkdownLink from '@components/DocsCopyMarkdownLink.astro';
import DocsPagination from '@components/DocsPagination.astro'; import DocsPagination from '@components/DocsPagination.astro';
import DocsCard from '@components/DocsCard.astro'; import DocsCard from '@components/DocsCard.astro';
import { getDocsPage } from '@lib/docs-pages'; import { getDocsPage } from '@lib/docs-pages';
@@ -114,6 +115,9 @@ const metaDescription = seoDescription ?? description;
const siteName = pageSection ? `Tabler ${pageSection} Documentation` : 'Tabler Documentation'; const siteName = pageSection ? `Tabler ${pageSection} Documentation` : 'Tabler Documentation';
const canonicalUrl = new URL(docsUrl, Astro.site ?? site.docsUrl).href; const canonicalUrl = new URL(docsUrl, Astro.site ?? site.docsUrl).href;
const ogImageUrl = new URL('/static/og.png', Astro.site ?? site.docsUrl).href; const ogImageUrl = new URL('/static/og.png', Astro.site ?? site.docsUrl).href;
// Absolute .md url: fetch() uses the relative markdownUrl (same-origin), but the
// "Ask ChatGPT" link needs a url ChatGPT can actually resolve on its own.
const canonicalMarkdownUrl = markdownUrl ? new URL(markdownUrl, Astro.site ?? site.docsUrl).href : undefined;
// Relative asset base: for /ui/components/alert/ -> "../../.." // Relative asset base: for /ui/components/alert/ -> "../../.."
const depth = docsUrl.split('/').filter(Boolean).length; const depth = docsUrl.split('/').filter(Boolean).length;
@@ -346,23 +350,30 @@ const relatedPages = await Promise.all(
<div class="col d-flex flex-column"> <div class="col d-flex flex-column">
<div class="docs-article flex-fill"> <div class="docs-article flex-fill">
<div class="p-4 py-6 p-lg-6"> <div class="p-4 py-6 p-lg-6">
{breadcrumbs && ( {
<nav aria-label="Breadcrumb"> (breadcrumbs || markdownUrl) && (
<ol class="breadcrumb breadcrumb-arrows breadcrumb-muted mb-3"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
{breadcrumbs.map((item, index) => {breadcrumbs && (
index < breadcrumbs.length - 1 ? ( <nav aria-label="Breadcrumb">
<li class="breadcrumb-item"> <ol class="breadcrumb breadcrumb-arrows breadcrumb-muted mb-0">
<a href={item.path}>{item.name}</a> {breadcrumbs.map((item, index) =>
</li> index < breadcrumbs.length - 1 ? (
) : ( <li class="breadcrumb-item">
<li class="breadcrumb-item active" aria-current="page"> <a href={item.path}>{item.name}</a>
{item.name} </li>
</li> ) : (
), <li class="breadcrumb-item active" aria-current="page">
{item.name}
</li>
),
)}
</ol>
</nav>
)} )}
</ol> {markdownUrl && <DocsCopyMarkdownLink url={markdownUrl} absoluteUrl={canonicalMarkdownUrl} />}
</nav> </div>
)} )
}
<Prose <Prose
data-bs-spy={toc.length > 0 ? 'scroll' : undefined} data-bs-spy={toc.length > 0 ? 'scroll' : undefined}
@@ -371,14 +382,14 @@ const relatedPages = await Promise.all(
data-bs-smooth-scroll={toc.length > 0 ? 'true' : undefined} data-bs-smooth-scroll={toc.length > 0 ? 'true' : undefined}
tabindex="0" tabindex="0"
> >
<div class="d-flex"> <div class="d-flex flex-column flex-sm-row align-items-sm-center gap-2">
<h1 class="docs-page-title"> <h1 class="docs-page-title mb-0">
{title} {title}
</h1> </h1>
{ {
addedIn && ( addedIn && (
<div class="ms-auto"> <div class="ms-sm-auto">
<span class="badge bg-primary-lt">Added in {addedIn}</span> <span class="badge bg-primary-lt">Added in {addedIn}</span>
</div> </div>
) )
@@ -470,7 +481,7 @@ const relatedPages = await Promise.all(
<!-- BEGIN DOCS TOC --> <!-- BEGIN DOCS TOC -->
<div class="col-2 d-none d-xxl-block"> <div class="col-2 d-none d-xxl-block">
<div class="py-5 sticky-top docs-toc-sticky"> <div class="py-5 sticky-top docs-toc-sticky">
<DocsToc toc={toc} editUrl={`${site.githubUrl}/edit/dev/${editFilePath}`} sourceUrl={sourcePath ? `${site.githubUrl}/blob/dev/${sourcePath}` : undefined} markdownUrl={markdownUrl} /> <DocsToc toc={toc} editUrl={`${site.githubUrl}/edit/dev/${editFilePath}`} sourceUrl={sourcePath ? `${site.githubUrl}/blob/dev/${sourcePath}` : undefined} />
</div> </div>
</div> </div>
<!-- END DOCS TOC --> <!-- END DOCS TOC -->
+39 -14
View File
@@ -5,9 +5,11 @@
// component props. Stripping components wholesale — the usual llms.txt recipe — // component props. Stripping components wholesale — the usual llms.txt recipe —
// would delete exactly that. So the components that hold content are unwrapped // would delete exactly that. So the components that hold content are unwrapped
// into fenced code blocks instead, and only the decorative ones are dropped. // into fenced code blocks instead, and only the decorative ones are dropped.
// A component whose content is data rather than markup (<Colors />, <Flags />)
// renders its own markdown in a comment, see shared/lib/markdown-source.ts.
// //
// Prose comes from the MDX source, but example markup comes from the *rendered* // 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 // page (see renderedPage below): a third of the examples are written with
// docs components — <Icon>, <AvatarList>, <Badge> — and the source form of those // docs components — <Icon>, <AvatarList>, <Badge> — and the source form of those
// is of no use to a reader who wants the html. // is of no use to a reader who wants the html.
import type { CollectionEntry } from 'astro:content' import type { CollectionEntry } from 'astro:content'
@@ -16,6 +18,7 @@ import { loadRenderers } from 'astro:container'
import { experimental_AstroContainer as AstroContainer } from 'astro/container' import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import { getContainerRenderer } from '@astrojs/mdx/container-renderer' import { getContainerRenderer } from '@astrojs/mdx/container-renderer'
import { beautifyHtml, extractMarkedSnippet } from '@shared/lib/code-example' import { beautifyHtml, extractMarkedSnippet } from '@shared/lib/code-example'
import { extractMarkdownSources } from '@shared/lib/markdown-source'
import { site } from '@shared/lib/site' import { site } from '@shared/lib/site'
import { callouts } from '@components/callouts/index.ts' import { callouts } from '@components/callouts/index.ts'
import docs from '@data/docs.json' import docs from '@data/docs.json'
@@ -137,19 +140,29 @@ let container: Promise<AstroContainer> | undefined
const getContainer = () => (container ??= loadRenderers([getContainerRenderer()]).then((renderers) => AstroContainer.create({ renderers }))) const getContainer = () => (container ??= loadRenderers([getContainerRenderer()]).then((renderers) => AstroContainer.create({ renderers })))
type RenderedPage = {
/** final markup of every <Example>, in document order; null when the example renders nothing */
examples: (string | null)[]
/** markdown shipped by components via <MarkdownSource />, per component name */
sources: Map<string, string[]>
}
const EMPTY_PAGE: RenderedPage = { examples: [], sources: new Map() }
/** /**
* The final markup of every <Example> on a page, in document order — the same * What the rendered page carries for the markdown mirror: the final markup of
* html the page's copy button hands out, so components are already expanded. * every <Example> — the same html the page's copy button hands out, so
* An entry is null only when the example renders nothing, and the whole list is * components are already expanded — and the markdown sources of data
* empty when the page cannot be rendered. * components. Both lists are empty when the page cannot be rendered.
*/ */
async function renderedExamples(entry: CollectionEntry<'docs'>): Promise<(string | null)[]> { async function renderedPage(entry: CollectionEntry<'docs'>): Promise<RenderedPage> {
try { try {
const { Content } = await render(entry) const { Content } = await render(entry)
// Same components the page route passes, or every page using a callout throws. // Same components the page route passes, or every page using a callout throws.
const html = await (await getContainer()).renderToString(Content, { props: { components: callouts } }) // The local tells <MarkdownSource /> this render is for the mirror.
const html = await (await getContainer()).renderToString(Content, { props: { components: callouts }, locals: { markdownMirror: true } })
return html const examples = html
.split(EXAMPLE_MARKER) .split(EXAMPLE_MARKER)
.slice(1) .slice(1)
.map((block) => { .map((block) => {
@@ -157,18 +170,21 @@ async function renderedExamples(entry: CollectionEntry<'docs'>): Promise<(string
const markup = block.match(/data-clipboard-text="([^"]*)"/) ?? block.match(/data-example-markup="([^"]*)"/) const markup = block.match(/data-clipboard-text="([^"]*)"/) ?? block.match(/data-example-markup="([^"]*)"/)
return markup ? beautifyHtml(decodeEntities(markup[1]!)) : null return markup ? beautifyHtml(decodeEntities(markup[1]!)) : null
}) })
return { examples, sources: extractMarkdownSources(html) }
} catch (error) { } catch (error) {
console.warn(`[llms] Rendering ${entry.id} failed, examples fall back to their MDX source:`, error) console.warn(`[llms] Rendering ${entry.id} failed, examples fall back to their MDX source:`, error)
return [] return EMPTY_PAGE
} }
} }
/** /**
* Turn one page's MDX body into plain markdown. `examples` comes from * Turn one page's MDX body into plain markdown. `examples` and `sources` come
* renderedExamples() and replaces the source of each <Example> slot; it is * from renderedPage(): the examples replace the source of each <Example> slot,
* ignored unless it lines up one-to-one with the examples in the source. * the sources replace the tags of the components that shipped them. Either is
* ignored unless it lines up one-to-one with the tags in the source.
*/ */
export async function mdxToMarkdown(body: string, examples: (string | null)[] = []): Promise<string> { export async function mdxToMarkdown(body: string, examples: (string | null)[] = [], sources: Map<string, string[]> = new Map()): Promise<string> {
// `<Code lang code={`…`} />` first: its template literal contains backticks, which // `<Code lang code={`…`} />` first: its template literal contains backticks, which
// would otherwise be mistaken for markdown code spans by protectCode() below. // 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) => { let text = body.replace(/<Code\b[^>]*?code=\{`([\s\S]*?)`\}[\s\S]*?\/>/g, (match, snippet: string) => {
@@ -210,6 +226,14 @@ export async function mdxToMarkdown(body: string, examples: (string | null)[] =
return `\n${fence(cdnPluginSnippet(plugins))}\n` return `\n${fence(cdnPluginSnippet(plugins))}\n`
}) })
// data components that shipped their own markdown (<Colors />, <Flags />, …)
for (const [name, markdowns] of sources) {
const tagPattern = new RegExp(`<${name}\\b[^>]*/>`, 'g')
if ((text.match(tagPattern)?.length ?? 0) !== markdowns.length) continue
let sourceIndex = 0
text = text.replace(tagPattern, () => `\n${markdowns[sourceIndex++]}\n`)
}
// the blocks just produced must be protected too, for the same reason // the blocks just produced must be protected too, for the same reason
text = protectCode(text, code) text = protectCode(text, code)
@@ -229,7 +253,8 @@ async function buildPageMarkdown(entry: CollectionEntry<'docs'>, url: string): P
const { title, summary, description } = entry.data const { title, summary, description } = entry.data
const header = [`# ${title}`, '', `> ${summary}`, '', description, '', `Source: ${url}`, '', '---', ''].join('\n') const header = [`# ${title}`, '', `> ${summary}`, '', description, '', `Source: ${url}`, '', '---', ''].join('\n')
return `${header}\n${await mdxToMarkdown(entry.body ?? '', await renderedExamples(entry))}\n` const { examples, sources } = await renderedPage(entry)
return `${header}\n${await mdxToMarkdown(entry.body ?? '', examples, sources)}\n`
} }
// [...slug].md.ts and llms-full.txt.ts render the same pages in one build. // [...slug].md.ts and llms-full.txt.ts render the same pages in one build.
+3 -3
View File
@@ -369,9 +369,9 @@ header > .navbar {
} }
} }
// Page tools under the table of contents: copy, source, edit. One shape for all // Page tools under the table of contents: source, edit. One shape for both
// three, so the button does not read as a different kind of control than the // links (copying the page as markdown moved to the "Copy to LLM" button above
// links next to it. // the title, see DocsCopyMarkdownLink).
.docs-page-links { .docs-page-links {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { extractMarkdownSources, markdownSource, markdownTable } from './markdown-source'
describe('markdownSource', () => {
it('wraps the markdown in a named comment', () => {
expect(markdownSource('Colors', '| a |\n')).toBe('<!--MARKDOWN:Colors-->\n| a |\n<!--/MARKDOWN-->')
})
it('keeps a "-->" in the markdown from closing the comment', () => {
const html = markdownSource('Arrows', 'a --> b')
expect(html.indexOf('-->')).toBe(html.indexOf('<!--MARKDOWN:Arrows-->') + '<!--MARKDOWN:Arrows'.length)
expect(extractMarkdownSources(html).get('Arrows')).toEqual(['a --> b'])
})
})
describe('extractMarkdownSources', () => {
it('collects sources per component name in document order', () => {
const html = `<p>x</p>${markdownSource('Colors', 'first')}<div>${markdownSource('Flags', 'flags')}</div>${markdownSource('Colors', 'second')}`
const sources = extractMarkdownSources(html)
expect(sources.get('Colors')).toEqual(['first', 'second'])
expect(sources.get('Flags')).toEqual(['flags'])
})
it('returns an empty map for a page without sources', () => {
expect(extractMarkdownSources('<p>nothing</p>').size).toBe(0)
})
})
describe('markdownTable', () => {
it('renders a header row, a separator and the rows', () => {
expect(markdownTable(['Name', 'Class'], [['Blue', '`.bg-blue`']])).toBe('| Name | Class |\n| --- | --- |\n| Blue | `.bg-blue` |')
})
it('escapes pipes inside cells', () => {
expect(markdownTable(['a'], [['x | y']])).toContain('| x \\| y |')
})
})
+34
View File
@@ -0,0 +1,34 @@
// A docs component whose output no MDX-to-markdown conversion could rebuild
// (a colour palette, a table built from a data file) can ship its own markdown
// next to its html, inside an html comment. docs/lib/llms.ts reads those
// comments out of the rendered page and puts each one where the component
// sits in the MDX source, so the .md mirror keeps the content instead of a gap.
// The component renders the comment only for that mirror render (it checks
// Astro.locals.markdownMirror), so the html a browser gets never carries it.
const OPEN = '<!--MARKDOWN:'
const CLOSE = '<!--/MARKDOWN-->'
const SOURCE_PATTERN = /<!--MARKDOWN:([A-Za-z]+)-->\n?([\s\S]*?)\n?<!--\/MARKDOWN-->/g
/** The comment a component renders; `name` must match the tag used in the MDX (`<Colors />` → "Colors"). */
export function markdownSource(name: string, markdown: string): string {
// "-->" inside the markdown would end the comment early
return `${OPEN}${name}-->\n${markdown.trim().replaceAll('-->', '--&gt;')}\n${CLOSE}`
}
/** Every markdown source in a rendered page, per component name, in document order. */
export function extractMarkdownSources(html: string): Map<string, string[]> {
const sources = new Map<string, string[]>()
for (const [, name, markdown] of html.matchAll(SOURCE_PATTERN)) {
const list = sources.get(name!) ?? []
list.push(markdown!.replaceAll('--&gt;', '-->'))
sources.set(name!, list)
}
return sources
}
/** A GitHub-flavoured markdown table; pipes inside cells are escaped. */
export function markdownTable(headers: string[], rows: string[][]): string {
const line = (cells: string[]) => `| ${cells.map((cell) => cell.replaceAll('|', '\\|')).join(' | ')} |`
return [line(headers), line(headers.map(() => '---')), ...rows.map(line)].join('\n')
}