mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add a "Copy to LLM" dropdown with ChatGPT and Claude shortcuts (#2952)
Co-authored-by: codecalm <codecalm@gmail.com>
This commit is contained in:
1 parent
b4b10f924a
commit
8fc079d26c
15 files changed
+313
-77
No files matched your search
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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} />
|
||||||
@@ -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)} />}
|
||||||
@@ -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} />
|
||||||
@@ -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} />
|
||||||
Vendored
+7
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 |')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('-->', '-->')}\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('-->', '-->'))
|
||||||
|
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')
|
||||||
|
}
|
||||||
Reference in new issue
Block a user