From 329cb114209641be33732c8f33ff4e431fd294bf Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Thu, 20 Aug 2026 13:19:18 +0200 Subject: [PATCH] fix(website): render llms routes for one framework instead of four (#3983) * fix(website): render llms routes for one framework instead of four velite expanded every `` and `` at build time, where there is no framework, so it emitted a `#### React` / `#### Vue` / `#### Svelte` / `#### Solid` block for each. The request-time code that does this per framework then found no tags left to expand, so llms-react.txt shipped 505 Vue, 562 Solid and 494 Svelte markers, and llms-full.txt grew to 20,037,108 bytes and failed the Vercel 20MB prerender cap by 37KB. velite now leaves those tags in place and the request-time path resolves them, so each file carries only its own framework: 4.78MB down to 1.4MB. llms-full.txt is removed and redirects to llms.txt, matching TanStack, who serve their index at both URLs for the same reason. The spec never defined llms-full.txt. Also expands the tags the routes were dropping on the floor: `` (9 tags, which left Locale and Environment with no code samples at all), `` carrying a `replace` map (Segment Group had no props documented), `` for components with no zag machine, and ``. Pages now resolve per framework, so each bundle carries its own changelog rather than @ark-ui/mcp's 829 bytes, and the changelog glob no longer treats mcp as a framework. Fixes the format-number page id, which did not match its sidebar entry and dropped the page from llms.txt and the sitemap. llms.txt gains the H1, summary and section structure every comparable project uses, links the four bundles, and prefixes repeated titles with their framework. Claude-Session: https://claude.ai/code/session_0133qcH6rpUR3edEeGJSxsF1 * fix(scripts): format the example registry before writing it The generator wrote output that `prettier --check` rejects, so every website build left the tree dirty and failing lint. Reverting that churn also discarded real content: the committed registry was missing six examples, including signature-pad/controlled, which components/signature-pad.mdx references and so could not load. Formats with the repo config resolved from the output path, and regenerates. Two runs now produce identical bytes. Claude-Session: https://claude.ai/code/session_0133qcH6rpUR3edEeGJSxsF1 * feat(scripts): check llms response sizes in CI Vercel rejects a prerendered response over 20MB and `next build` does not catch it, so llms-full.txt only failed at deploy. Building the site in CI to measure would cost minutes, so this bounds the responses from their inputs: a llms- response is the page corpus with one framework's examples and prop tables inlined, so corpus + examples + types is an upper bound. Currently 1.95MB against a 12MB budget. Claude-Session: https://claude.ai/code/session_0133qcH6rpUR3edEeGJSxsF1 * docs(svelte): add the missing popover factory example React, Solid and Vue all have this example; Svelte did not, so the composition guide rendered "Example not found" for Svelte readers on both the docs page and in llms-svelte.txt. Uses the snippet form of `asChild` that the sibling as-child example uses, since the Svelte factory takes `as` and renders a snippet rather than exposing an `ark.span` proxy. Claude-Session: https://claude.ai/code/session_0133qcH6rpUR3edEeGJSxsF1 --- .github/workflows/quality.yml | 5 + .../popover/examples/factory.svelte | 9 + scripts/package.json | 1 + scripts/src/check-llms-size.ts | 69 +++++++ scripts/src/generate-example-registry.ts | 6 +- website/next.config.mjs | 8 + website/src/app/(llms)/llms-full.txt/route.ts | 30 --- .../src/app/(llms)/llms-react.txt/route.ts | 4 +- .../src/app/(llms)/llms-solid.txt/route.ts | 4 +- .../src/app/(llms)/llms-svelte.txt/route.ts | 4 +- website/src/app/(llms)/llms-vue.txt/route.ts | 4 +- website/src/app/(llms)/llms.txt/route.ts | 42 ++-- website/src/app/api/docs/[...slug]/route.ts | 2 +- website/src/app/docs/[...slug]/page.tsx | 7 +- website/src/app/llms.txt/[...slug]/route.ts | 11 +- website/src/components/copy-page-widget.tsx | 11 +- website/src/content/pages/ai/llms.txt.mdx | 2 - .../content/pages/utilities/format-number.mdx | 2 +- website/src/lib/docs.ts | 16 +- website/src/lib/example-registry.ts | 12 ++ .../(llms)/shared.ts => lib/llm-content.ts} | 29 ++- website/src/lib/mdx-transform.ts | 182 +----------------- website/src/lib/sidebar.ts | 13 +- website/velite.config.ts | 21 +- 24 files changed, 225 insertions(+), 269 deletions(-) create mode 100644 packages/svelte/src/lib/components/popover/examples/factory.svelte create mode 100644 scripts/src/check-llms-size.ts delete mode 100644 website/src/app/(llms)/llms-full.txt/route.ts rename website/src/{app/(llms)/shared.ts => lib/llm-content.ts} (81%) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index ea8743fb44..14e283acce 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -74,6 +74,11 @@ jobs: - name: Check component exports run: bun scripts check:exports + - name: Check llms.txt response sizes + run: | + bun --cwd website velite build + bun scripts check:llms + - name: Check for same HTML tags run: bun scripts check:nodes diff --git a/packages/svelte/src/lib/components/popover/examples/factory.svelte b/packages/svelte/src/lib/components/popover/examples/factory.svelte new file mode 100644 index 0000000000..9a8da77190 --- /dev/null +++ b/packages/svelte/src/lib/components/popover/examples/factory.svelte @@ -0,0 +1,9 @@ + + + + {#snippet asChild(props)} + Ark UI + {/snippet} + diff --git a/scripts/package.json b/scripts/package.json index a1e12f8828..173dbb1080 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,6 +6,7 @@ "scripts": { "check:anatomy": "bun run src/check-anatomy.ts", "check:exports": "bun run src/check-exports.ts", + "check:llms": "bun run src/check-llms-size.ts", "check:nodes": "bun run src/check-nodes.ts", "check:zag": "bun run src/check-zag-versions.ts", "exports:files": "bun run src/exports-files.ts", diff --git a/scripts/src/check-llms-size.ts b/scripts/src/check-llms-size.ts new file mode 100644 index 0000000000..48ec5d065b --- /dev/null +++ b/scripts/src/check-llms-size.ts @@ -0,0 +1,69 @@ +/** + * Vercel rejects a prerendered response over 20MB (FALLBACK_BODY_TOO_LARGE). + * llms-full.txt crossed it by 37KB once, and `next build` does not catch it. + * + * Rendering the routes here would mean building the site, so this bounds them + * from their inputs instead. An llms- response is the page corpus + * with that framework's examples and prop tables inlined, so + * corpus + examples + types is an upper bound on the response. + */ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const VERCEL_LIMIT_BYTES = 20_000_000 +const BUDGET_BYTES = 12_000_000 + +const FRAMEWORKS = [ + { name: 'react', src: 'src' }, + { name: 'solid', src: 'src' }, + { name: 'vue', src: 'src' }, + { name: 'svelte', src: 'src/lib' }, +] as const + +const root = resolve('..') +const pagesPath = join(root, 'website/.velite/pages.json') + +const dirBytes = (dir: string): number => { + if (!existsSync(dir)) return 0 + return readdirSync(dir, { withFileTypes: true }).reduce((sum, entry) => { + const path = join(dir, entry.name) + return sum + (entry.isDirectory() ? dirBytes(path) : statSync(path).size) + }, 0) +} + +const exampleBytes = (framework: (typeof FRAMEWORKS)[number]) => { + const componentsDir = join(root, 'packages', framework.name, framework.src, 'components') + if (!existsSync(componentsDir)) return 0 + return readdirSync(componentsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .reduce((sum, entry) => sum + dirBytes(join(componentsDir, entry.name, 'examples')), 0) +} + +const main = () => { + if (!existsSync(pagesPath)) { + console.error(`No velite output at ${pagesPath}. Run \`bunx velite build\` in website/ first.`) + process.exit(1) + } + + const pages: { llm?: string }[] = JSON.parse(readFileSync(pagesPath, 'utf-8')) + const corpus = pages.reduce((sum, page) => sum + Buffer.byteLength(page.llm ?? '', 'utf8'), 0) + const mb = (bytes: number) => `${(bytes / 1e6).toFixed(2)}MB` + + const bounds = FRAMEWORKS.map((framework) => ({ + name: framework.name, + bytes: corpus + exampleBytes(framework) + dirBytes(join(root, 'website/src/content/types', framework.name)), + })).sort((a, b) => b.bytes - a.bytes) + + const worst = bounds[0] + if (worst.bytes <= BUDGET_BYTES) { + console.log(`llms responses bound at ${mb(worst.bytes)} (${worst.name}), budget ${mb(BUDGET_BYTES)}.`) + return + } + + console.error(`llms response bound is ${mb(worst.bytes)}, over the ${mb(BUDGET_BYTES)} budget.`) + console.error(`Vercel rejects any prerendered response over ${mb(VERCEL_LIMIT_BYTES)}.`) + for (const bound of bounds) console.error(` llms-${bound.name}.txt <= ${mb(bound.bytes)}`) + process.exit(1) +} + +main() diff --git a/scripts/src/generate-example-registry.ts b/scripts/src/generate-example-registry.ts index 60ccc37cde..0f06a2d9ed 100644 --- a/scripts/src/generate-example-registry.ts +++ b/scripts/src/generate-example-registry.ts @@ -1,6 +1,7 @@ import { writeFileSync } from 'node:fs' import { basename, join } from 'node:path' import { globby } from 'globby' +import prettier from 'prettier' const rootDir = join(import.meta.dirname, '../..') @@ -165,7 +166,10 @@ export function hasExample(component: string, example: string): boolean { // Write to website/src/lib/example-registry.ts const outputPath = join(rootDir, 'website/src/lib/example-registry.ts') - writeFileSync(outputPath, output) + + // format before writing, so a build never leaves the tree failing `prettier --check` + const config = await prettier.resolveConfig(outputPath) + writeFileSync(outputPath, await prettier.format(output, { ...config, parser: 'typescript' })) console.log(`Generated example registry with ${allFiles.length} examples`) console.log(`Output: ${outputPath}`) diff --git a/website/next.config.mjs b/website/next.config.mjs index cead1b44e1..7de505664f 100644 --- a/website/next.config.mjs +++ b/website/next.config.mjs @@ -60,6 +60,14 @@ const nextConfig = { destination: '/docs/overview/getting-started', permanent: false, }, + { + // llms-full.txt duplicated every page across all four frameworks and + // outgrew Vercel's 20MB prerender cap. The per-framework files carry + // the same content, scoped. + source: '/llms-full.txt', + destination: '/llms.txt', + permanent: false, + }, { // Exclude `api` so /api/docs* is not treated as a framework docs path. source: '/:framework((?!api)[^/]+)/docs/:slug*', diff --git a/website/src/app/(llms)/llms-full.txt/route.ts b/website/src/app/(llms)/llms-full.txt/route.ts deleted file mode 100644 index 5ad04a2f2b..0000000000 --- a/website/src/app/(llms)/llms-full.txt/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { cleanupPageContent } from '~/app/(llms)/shared' -import { frameworks } from '~/lib/frameworks' -import { getSidebarGroupsWithPages } from '~/lib/sidebar' -import type { Pages } from '.velite' - -export const dynamic = 'force-static' - -const generatePageContent = async (page: Pages) => - ( - await Promise.all( - frameworks.map(async (framework) => { - return `# ${page.title} (${framework.toUpperCase()})\n\n${await cleanupPageContent(page, framework)}\n\n` - }), - ) - ).join('\n') - -const generateCategorySection = async (group: { title: string; items: Pages[] }) => { - const header = `# ${group.title.toUpperCase()}\n\n---\n` - const pagesContent = await Promise.all(group.items.map(generatePageContent)) - return `${header}\n${pagesContent.join('\n')}` -} - -export const GET = async () => { - const sidebarGroups = getSidebarGroupsWithPages() - const content = await Promise.all(sidebarGroups.map(generateCategorySection)).then((sections) => - sections.join('\n\n'), - ) - - return new Response(content) -} diff --git a/website/src/app/(llms)/llms-react.txt/route.ts b/website/src/app/(llms)/llms-react.txt/route.ts index ebf3aee1c1..dfe9cf4af5 100644 --- a/website/src/app/(llms)/llms-react.txt/route.ts +++ b/website/src/app/(llms)/llms-react.txt/route.ts @@ -1,4 +1,4 @@ -import { cleanupPageContent } from '~/app/(llms)/shared' +import { cleanupPageContent } from '~/lib/llm-content' import { getSidebarGroupsWithPages } from '~/lib/sidebar' import type { Pages } from '.velite' @@ -13,7 +13,7 @@ const generateCategorySection = async (group: { title: string; items: Pages[] }) } export const GET = async () => { - const sidebarGroups = getSidebarGroupsWithPages() + const sidebarGroups = getSidebarGroupsWithPages('react') const content = await Promise.all(sidebarGroups.map(generateCategorySection)).then((sections) => sections.join('\n\n'), ) diff --git a/website/src/app/(llms)/llms-solid.txt/route.ts b/website/src/app/(llms)/llms-solid.txt/route.ts index ff35310bea..cb514d6b70 100644 --- a/website/src/app/(llms)/llms-solid.txt/route.ts +++ b/website/src/app/(llms)/llms-solid.txt/route.ts @@ -1,4 +1,4 @@ -import { cleanupPageContent } from '~/app/(llms)/shared' +import { cleanupPageContent } from '~/lib/llm-content' import { getSidebarGroupsWithPages } from '~/lib/sidebar' import type { Pages } from '.velite' @@ -13,7 +13,7 @@ const generateCategorySection = async (group: { title: string; items: Pages[] }) } export const GET = async () => { - const sidebarGroups = getSidebarGroupsWithPages() + const sidebarGroups = getSidebarGroupsWithPages('solid') const content = await Promise.all(sidebarGroups.map(generateCategorySection)).then((sections) => sections.join('\n\n'), ) diff --git a/website/src/app/(llms)/llms-svelte.txt/route.ts b/website/src/app/(llms)/llms-svelte.txt/route.ts index 3c57a6dbe7..b28adfc78d 100644 --- a/website/src/app/(llms)/llms-svelte.txt/route.ts +++ b/website/src/app/(llms)/llms-svelte.txt/route.ts @@ -1,4 +1,4 @@ -import { cleanupPageContent } from '~/app/(llms)/shared' +import { cleanupPageContent } from '~/lib/llm-content' import { getSidebarGroupsWithPages } from '~/lib/sidebar' import type { Pages } from '.velite' @@ -13,7 +13,7 @@ const generateCategorySection = async (group: { title: string; items: Pages[] }) } export const GET = async () => { - const sidebarGroups = getSidebarGroupsWithPages() + const sidebarGroups = getSidebarGroupsWithPages('svelte') const content = await Promise.all(sidebarGroups.map(generateCategorySection)).then((sections) => sections.join('\n\n'), ) diff --git a/website/src/app/(llms)/llms-vue.txt/route.ts b/website/src/app/(llms)/llms-vue.txt/route.ts index fa420daa20..2b21f886d4 100644 --- a/website/src/app/(llms)/llms-vue.txt/route.ts +++ b/website/src/app/(llms)/llms-vue.txt/route.ts @@ -1,4 +1,4 @@ -import { cleanupPageContent } from '~/app/(llms)/shared' +import { cleanupPageContent } from '~/lib/llm-content' import { getSidebarGroupsWithPages } from '~/lib/sidebar' import type { Pages } from '.velite' @@ -13,7 +13,7 @@ const generateCategorySection = async (group: { title: string; items: Pages[] }) } export const GET = async () => { - const sidebarGroups = getSidebarGroupsWithPages() + const sidebarGroups = getSidebarGroupsWithPages('vue') const content = await Promise.all(sidebarGroups.map(generateCategorySection)).then((sections) => sections.join('\n\n'), ) diff --git a/website/src/app/(llms)/llms.txt/route.ts b/website/src/app/(llms)/llms.txt/route.ts index b6938964f7..7208b9c983 100644 --- a/website/src/app/(llms)/llms.txt/route.ts +++ b/website/src/app/(llms)/llms.txt/route.ts @@ -3,21 +3,41 @@ import { getSidebarGroups } from '~/lib/sidebar' export const dynamic = 'force-static' -export const GET = async () => { - const sidebarGroups = getSidebarGroups() +const LABELS: Record = { react: 'React', solid: 'Solid', vue: 'Vue', svelte: 'Svelte' } + +const SUMMARY = + 'Ark UI is a headless component library for building design systems. It ships the same accessible, unstyled components for React, Solid, Svelte, and Vue, built on Zag.js state machines.' - const generateUrl = (framework: string, slug: string) => `https://ark-ui.com/${framework}/docs/${slug}` +export const GET = async () => { + const pageUrl = (framework: string, slug: string) => `https://ark-ui.com/${framework}/docs/${slug}` - const generatePageLinks = (page: { title: string; slug: string }) => - frameworks.map((framework) => `- [${page.title}](${generateUrl(framework, page.slug)})`).join('\n') + // one H1, first line, then H2 sections (see https://llmstxt.org) + const intro = [ + '# Ark UI', + '', + `> ${SUMMARY}`, + '', + 'Every page exists once per framework. Links are prefixed with the framework they document.', + '', + '## Full documentation', + '', + ...frameworks.map( + (f) => `- [llms-${f}.txt](https://ark-ui.com/llms-${f}.txt): All ${LABELS[f]} documentation in one file`, + ), + '', + '- A single page as markdown: `https://ark-ui.com/llms.txt/{slug}`, for example [components/accordion](https://ark-ui.com/llms.txt/components/accordion). Add `?framework=vue` to switch framework.', + ].join('\n') - const generateCategorySection = (group: (typeof sidebarGroups)[number]) => { - const header = `# ${group.title.toUpperCase()}\n` - const pageLinks = group.items.map(generatePageLinks).join('\n') - return `${header}\n${pageLinks}` + const section = (group: ReturnType[number]) => { + const links = group.items.flatMap((page) => + frameworks.map((f) => `- [${LABELS[f]}: ${page.title}](${pageUrl(f, page.slug)})`), + ) + return `## ${group.title}\n\n${links.join('\n')}` } - const content = sidebarGroups.map(generateCategorySection).join('\n\n') + const content = [intro, ...getSidebarGroups().map(section)].join('\n\n') - return new Response(content) + return new Response(content, { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }) } diff --git a/website/src/app/api/docs/[...slug]/route.ts b/website/src/app/api/docs/[...slug]/route.ts index d9e2c57942..4a2ca04253 100644 --- a/website/src/app/api/docs/[...slug]/route.ts +++ b/website/src/app/api/docs/[...slug]/route.ts @@ -4,7 +4,7 @@ type Params = Promise<{ slug: string[] }> export const GET = async (_request: Request, segmentData: { params: Params }) => { const { slug } = await segmentData.params - const doc = getDoc(slug.join('/')) + const doc = await getDoc(slug.join('/')) if (!doc) { return Response.json({ error: `Documentation page not found: ${slug.join('/')}` }, { status: 404 }) diff --git a/website/src/app/docs/[...slug]/page.tsx b/website/src/app/docs/[...slug]/page.tsx index 2bfa56bff4..489339e34e 100644 --- a/website/src/app/docs/[...slug]/page.tsx +++ b/website/src/app/docs/[...slug]/page.tsx @@ -8,6 +8,7 @@ import { TableOfContent } from '~/components/table-of-content' import { Heading } from '~/components/ui/heading' import { Text } from '~/components/ui/text' import { getFramework } from '~/lib/frameworks' +import { cleanupPageContent } from '~/lib/llm-content' import { getAllPageSlugs, getPageBySlug, getPageNavigation } from '~/lib/pages' import { getServerContext } from '~/lib/server-context' import { MDXContent } from '~/mdx-content' @@ -46,7 +47,11 @@ export default async function Page(props: Props) { {currentPage.description} - + diff --git a/website/src/app/llms.txt/[...slug]/route.ts b/website/src/app/llms.txt/[...slug]/route.ts index b1f303bf8d..9d169f62d0 100644 --- a/website/src/app/llms.txt/[...slug]/route.ts +++ b/website/src/app/llms.txt/[...slug]/route.ts @@ -1,13 +1,18 @@ import { notFound } from 'next/navigation' import { getDoc } from '~/lib/docs' +import { type Framework, frameworks } from '~/lib/frameworks' interface RouteContext { params: Promise<{ slug: string[] }> } -export async function GET(_request: Request, context: RouteContext) { +const isFramework = (value: string | null): value is Framework => + !!value && (frameworks as readonly string[]).includes(value) + +export async function GET(request: Request, context: RouteContext) { const params = await context.params - const doc = getDoc(params.slug.join('/')) + const requested = new URL(request.url).searchParams.get('framework') + const doc = await getDoc(params.slug.join('/'), isFramework(requested) ? requested : 'react') if (!doc) { notFound() @@ -15,7 +20,7 @@ export async function GET(_request: Request, context: RouteContext) { return new Response(doc.content, { headers: { - 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'public, max-age=3600', }, }) diff --git a/website/src/components/copy-page-widget.tsx b/website/src/components/copy-page-widget.tsx index 60f3c0e852..905e6c58ad 100644 --- a/website/src/components/copy-page-widget.tsx +++ b/website/src/components/copy-page-widget.tsx @@ -13,14 +13,15 @@ import { getPublicUrl } from '~/lib/get-public-url' interface CopyPageWidgetProps { slug: string content: string + framework: string } export const CopyPageWidget = (props: CopyPageWidgetProps) => { - const { slug, content } = props + const { slug, content, framework } = props return ( - + ) } @@ -40,8 +41,8 @@ const CopyPageButton = (props: { content: string }) => { ) } -const ActionMenu = (props: { slug: string }) => { - const { slug } = props +const ActionMenu = (props: { slug: string; framework: string }) => { + const { slug, framework } = props const pageUrl = getPublicUrl(`/docs/${slug}`) const readUrl = encodeURIComponent( @@ -51,7 +52,7 @@ const ActionMenu = (props: { slug: string }) => { const items = [ { label: 'View as markdown', - href: `${pageUrl}.mdx`, + href: `${pageUrl}.mdx?framework=${framework}`, icon: () => , }, { diff --git a/website/src/content/pages/ai/llms.txt.mdx b/website/src/content/pages/ai/llms.txt.mdx index b6b071ea9c..f10cb23017 100644 --- a/website/src/content/pages/ai/llms.txt.mdx +++ b/website/src/content/pages/ai/llms.txt.mdx @@ -15,8 +15,6 @@ We provide several LLMs.txt routes to help AI tools access our documentation: - [llms.txt](https://ark-ui.com/llms.txt) - Contains a structured overview of all components and their documentation links -- [llms-full.txt](https://ark-ui.com/llms-full.txt) - Provides comprehensive documentation including implementation - details and examples - [llms-react.txt](https://ark-ui.com/llms-react.txt) - React-specific documentation and implementation details - [llms-solid.txt](https://ark-ui.com/llms-solid.txt) - SolidJS-specific documentation and implementation details - [llms-vue.txt](https://ark-ui.com/llms-vue.txt) - Vue-specific documentation and implementation details diff --git a/website/src/content/pages/utilities/format-number.mdx b/website/src/content/pages/utilities/format-number.mdx index e12dc5c502..f17470068c 100644 --- a/website/src/content/pages/utilities/format-number.mdx +++ b/website/src/content/pages/utilities/format-number.mdx @@ -1,5 +1,5 @@ --- -id: format +id: format-number title: Format Number description: Used to format numbers to a specific locale and options --- diff --git a/website/src/lib/docs.ts b/website/src/lib/docs.ts index 7b527c04a5..c63b26c3ab 100644 --- a/website/src/lib/docs.ts +++ b/website/src/lib/docs.ts @@ -1,4 +1,6 @@ import { matchSorter } from 'match-sorter' +import type { Framework } from '~/lib/frameworks' +import { cleanupPageContent } from '~/lib/llm-content' import type { Pages } from '.velite' import { getSidebarGroupsWithPages } from './sidebar' @@ -19,8 +21,8 @@ interface DocsPageSource { category: string } -const getDocsSources = (): DocsPageSource[] => - getSidebarGroupsWithPages().flatMap((group) => +const getDocsSources = (framework?: Framework): DocsPageSource[] => + getSidebarGroupsWithPages(framework).flatMap((group) => group.items .filter((page) => !!page.llm) .map((page) => ({ @@ -37,7 +39,7 @@ const toEntry = ({ page, category }: DocsPageSource): DocsEntry => ({ url: `https://ark-ui.com/docs/${page.slug}`, }) -export const formatDocContent = (page: Pages) => `# ${page.title} +export const formatDocContent = async (page: Pages, framework: Framework = 'react') => `# ${page.title} URL: https://ark-ui.com/docs/${page.slug} LLM: https://ark-ui.com/llms.txt/${page.slug} @@ -46,18 +48,18 @@ ${page.description || ''} --- -${page.llm}` +${await cleanupPageContent(page, framework)}` export const listDocs = (): DocsEntry[] => getDocsSources().map(toEntry) -export const getDoc = (slug: string): DocsPage | null => { +export const getDoc = async (slug: string, framework: Framework = 'react'): Promise => { const normalized = slug.replace(/\.mdx$/, '') - const source = getDocsSources().find(({ page }) => page.slug === normalized) + const source = getDocsSources(framework).find(({ page }) => page.slug === normalized) if (!source) return null return { ...toEntry(source), - content: formatDocContent(source.page), + content: await formatDocContent(source.page, framework), } } diff --git a/website/src/lib/example-registry.ts b/website/src/lib/example-registry.ts index 7026d62c2d..8a1b7fd29a 100644 --- a/website/src/lib/example-registry.ts +++ b/website/src/lib/example-registry.ts @@ -66,6 +66,7 @@ import * as Clipboard_Timeout from '@examples/clipboard/examples/timeout' import * as Clipboard_ValueText from '@examples/clipboard/examples/value-text' import * as Collapsible_Basic from '@examples/collapsible/examples/basic' import * as Collapsible_Disabled from '@examples/collapsible/examples/disabled' +import * as Collapsible_HideMode from '@examples/collapsible/examples/hide-mode' import * as Collapsible_InitialOpen from '@examples/collapsible/examples/initial-open' import * as Collapsible_LazyMount from '@examples/collapsible/examples/lazy-mount' import * as Collapsible_Nested from '@examples/collapsible/examples/nested' @@ -162,8 +163,10 @@ import * as Dialog_Confirmation from '@examples/dialog/examples/confirmation' import * as Dialog_Context from '@examples/dialog/examples/context' import * as Dialog_Controlled from '@examples/dialog/examples/controlled' import * as Dialog_FinalFocus from '@examples/dialog/examples/final-focus' +import * as Dialog_HideMode from '@examples/dialog/examples/hide-mode' import * as Dialog_InitialFocus from '@examples/dialog/examples/initial-focus' import * as Dialog_InsideScroll from '@examples/dialog/examples/inside-scroll' +import * as Dialog_LazyMountHideMode from '@examples/dialog/examples/lazy-mount-hide-mode' import * as Dialog_LazyMount from '@examples/dialog/examples/lazy-mount' import * as Dialog_MultipleTriggers from '@examples/dialog/examples/multiple-triggers' import * as Dialog_Nested from '@examples/dialog/examples/nested' @@ -177,6 +180,7 @@ import * as DownloadTrigger_Svg from '@examples/download-trigger/examples/svg' import * as DownloadTrigger_WithPromise from '@examples/download-trigger/examples/with-promise' import * as Drawer_Basic from '@examples/drawer/examples/basic' import * as Drawer_Controlled from '@examples/drawer/examples/controlled' +import * as Drawer_HideMode from '@examples/drawer/examples/hide-mode' import * as Drawer_IndentBackground from '@examples/drawer/examples/indent-background' import * as Drawer_Modal from '@examples/drawer/examples/modal' import * as Drawer_MultipleTriggers from '@examples/drawer/examples/multiple-triggers' @@ -393,6 +397,7 @@ import * as Popover_RootProvider from '@examples/popover/examples/root-provider' import * as Popover_SameWidth from '@examples/popover/examples/same-width' import * as Popover_WithDialog from '@examples/popover/examples/with-dialog' import * as Presence_Basic from '@examples/presence/examples/basic' +import * as Presence_HideMode from '@examples/presence/examples/hide-mode' import * as Presence_LazyMountAndUnmountOnExit from '@examples/presence/examples/lazy-mount-and-unmount-on-exit' import * as Presence_LazyMount from '@examples/presence/examples/lazy-mount' import * as Presence_SkipAnimationOnMount from '@examples/presence/examples/skip-animation-on-mount' @@ -460,6 +465,7 @@ import * as Select_SelectAll from '@examples/select/examples/select-all' import * as Select_SelectOnHighlight from '@examples/select/examples/select-on-highlight' import * as Select_WithField from '@examples/select/examples/with-field' import * as SignaturePad_Basic from '@examples/signature-pad/examples/basic' +import * as SignaturePad_Controlled from '@examples/signature-pad/examples/controlled' import * as SignaturePad_ImagePreview from '@examples/signature-pad/examples/image-preview' import * as SignaturePad_RootProvider from '@examples/signature-pad/examples/root-provider' import * as SignaturePad_WithField from '@examples/signature-pad/examples/with-field' @@ -666,6 +672,7 @@ const exampleModules: Record = { 'clipboard/value-text': Clipboard_ValueText, 'collapsible/basic': Collapsible_Basic, 'collapsible/disabled': Collapsible_Disabled, + 'collapsible/hide-mode': Collapsible_HideMode, 'collapsible/initial-open': Collapsible_InitialOpen, 'collapsible/lazy-mount': Collapsible_LazyMount, 'collapsible/nested': Collapsible_Nested, @@ -762,8 +769,10 @@ const exampleModules: Record = { 'dialog/context': Dialog_Context, 'dialog/controlled': Dialog_Controlled, 'dialog/final-focus': Dialog_FinalFocus, + 'dialog/hide-mode': Dialog_HideMode, 'dialog/initial-focus': Dialog_InitialFocus, 'dialog/inside-scroll': Dialog_InsideScroll, + 'dialog/lazy-mount-hide-mode': Dialog_LazyMountHideMode, 'dialog/lazy-mount': Dialog_LazyMount, 'dialog/multiple-triggers': Dialog_MultipleTriggers, 'dialog/nested': Dialog_Nested, @@ -777,6 +786,7 @@ const exampleModules: Record = { 'download-trigger/with-promise': DownloadTrigger_WithPromise, 'drawer/basic': Drawer_Basic, 'drawer/controlled': Drawer_Controlled, + 'drawer/hide-mode': Drawer_HideMode, 'drawer/indent-background': Drawer_IndentBackground, 'drawer/modal': Drawer_Modal, 'drawer/multiple-triggers': Drawer_MultipleTriggers, @@ -993,6 +1003,7 @@ const exampleModules: Record = { 'popover/same-width': Popover_SameWidth, 'popover/with-dialog': Popover_WithDialog, 'presence/basic': Presence_Basic, + 'presence/hide-mode': Presence_HideMode, 'presence/lazy-mount-and-unmount-on-exit': Presence_LazyMountAndUnmountOnExit, 'presence/lazy-mount': Presence_LazyMount, 'presence/skip-animation-on-mount': Presence_SkipAnimationOnMount, @@ -1060,6 +1071,7 @@ const exampleModules: Record = { 'select/select-on-highlight': Select_SelectOnHighlight, 'select/with-field': Select_WithField, 'signature-pad/basic': SignaturePad_Basic, + 'signature-pad/controlled': SignaturePad_Controlled, 'signature-pad/image-preview': SignaturePad_ImagePreview, 'signature-pad/root-provider': SignaturePad_RootProvider, 'signature-pad/with-field': SignaturePad_WithField, diff --git a/website/src/app/(llms)/shared.ts b/website/src/lib/llm-content.ts similarity index 81% rename from website/src/app/(llms)/shared.ts rename to website/src/lib/llm-content.ts index bc7e8176a1..fc58cd6006 100644 --- a/website/src/app/(llms)/shared.ts +++ b/website/src/lib/llm-content.ts @@ -6,7 +6,8 @@ import { types } from '.velite' // Constants for regex patterns const PATTERNS = { - EXAMPLE: //g, + // `` is the code-only variant; both inline the same source + EXAMPLE: //g, EXAMPLE_ID: /id="([^"]*)"/, EXAMPLE_COMPONENT: /component="([^"]*)"/, ANATOMY: //g, @@ -109,6 +110,23 @@ const replaceComponentTypes = (id: string, framework: string) => { .join('\n\n') } +// renders a light/dark image pair; markdown keeps the alt text and one src +const THEME_IMAGE_RE = //g + +// `` +const COMPONENT_TYPES_RE = /]*?)\/>/g + +const applyReplacements = (content: string, rest: string) => { + const map = rest.match(/replace=\{\{([\s\S]*?)\}\}/) + if (!map) return content + + let res = content + for (const [, from, to] of map[1].matchAll(/'([^']*)'\s*:\s*'([^']*)'/g)) { + res = res.replaceAll(from, to) + } + return res +} + const replaceExamples = async (content: string, page: Pages, framework: string) => { const examples = content.match(PATTERNS.EXAMPLE) || [] let res = content @@ -139,10 +157,17 @@ export const cleanupPageContent = async (page: Pages, framework: 'react' | 'soli res = res.replace(PATTERNS.IMAGES, 'https://ark-ui.com/images') // Replace components with their content + res = res.replace(THEME_IMAGE_RE, (_, attrs: string) => { + const alt = attrs.match(/alt="([^"]*)"/)?.[1] ?? '' + const src = attrs.match(/srcLight="([^"]*)"/)?.[1] ?? '' + return src ? `![${alt}](${src})` : '' + }) res = res.replace(//g, replaceQuickstart()) res = res.replace(//g, replaceInstallCmd(framework)) res = res.replace(//g, (_, id) => replaceKeyboardTable(id)) - res = res.replace(//g, (_, id) => replaceComponentTypes(id, framework)) + res = res.replace(COMPONENT_TYPES_RE, (_, id: string, rest: string) => + applyReplacements(replaceComponentTypes(id, framework), rest), + ) res = await replaceExamples(res, page, framework) diff --git a/website/src/lib/mdx-transform.ts b/website/src/lib/mdx-transform.ts index 0e75aaa90b..df07f9c84f 100644 --- a/website/src/lib/mdx-transform.ts +++ b/website/src/lib/mdx-transform.ts @@ -1,180 +1,4 @@ -import { - type ApiDocKey, - type CssVarDocKey, - type DataAttrDocKey, - getApiDoc, - getCssVarDoc, - getDataAttrDoc, -} from '@zag-js/docs' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' - -const frameworks = ['react', 'solid', 'vue', 'svelte'] as const - -function getFrameworkExtension(framework: string): string { - if (framework === 'vue') return 'vue' - if (framework === 'svelte') return 'svelte' - return 'tsx' -} - -function getExamplePath(component: string): string { - if (['progress-circular', 'progress-linear'].includes(component)) { - return `components/progress/examples/${component.split('-')[1]}` - } - if (['environment', 'locale'].includes(component)) { - return `providers/${component}/examples` - } - return `components/${component}/examples` -} - -function readExampleFile(framework: string, component: string, id: string): string | null { - const extension = getFrameworkExtension(framework) - const examplePath = getExamplePath(component) - const srcPath = framework === 'svelte' ? 'src/lib' : 'src' - const basePath = join(process.cwd(), '..', 'packages', framework, srcPath) - const filePath = join(basePath, examplePath, `${id}.${extension}`) - - try { - const content = readFileSync(filePath, 'utf-8') - return content.replaceAll(/from '\.\/icons'/g, `from 'lucide-react'`).replace(/.*@ts-expect-error.*\n/g, '') - } catch { - return null - } -} - -export function replaceExample(text: string, componentFromPath: string): string { - const matches = text.matchAll(//g) - - if (!matches) return text - - for (const match of matches) { - const id = match[1] - const component = match[2] || componentFromPath - - if (!component) { - console.log('[velite] no component for example:', id) - continue - } - - const codeBlocks: string[] = [] - - for (const framework of frameworks) { - const code = readExampleFile(framework, component, id) - if (code) { - const extension = getFrameworkExtension(framework) - const frameworkLabel = framework.charAt(0).toUpperCase() + framework.slice(1) - codeBlocks.push(`#### ${frameworkLabel}\n\n\`\`\`${extension}\n${code}\n\`\`\``) - } - } - - if (codeBlocks.length > 0) { - const replacement = `**Example: ${id}**\n\n${codeBlocks.join('\n\n')}` - text = text.replace(match[0], replacement) - } else { - console.log('[velite] no examples found for:', component, id) - } - } - - return text -} - -export function replaceComponentTypes(text: string): string { - const matches = text.matchAll(//g) - - if (!matches) return text - - for (const match of matches) { - const id = match[1] - const sections: string[] = [] - - for (const framework of frameworks) { - const typesPath = join(process.cwd(), 'src', 'content', 'types', framework, `${id}.types.json`) - - try { - const typesContent = readFileSync(typesPath, 'utf-8') - const typesData = JSON.parse(typesContent) - - const frameworkLabel = framework.charAt(0).toUpperCase() + framework.slice(1) - const tables: string[] = [] - - // Sort to put Root first - const sortedParts = Object.entries(typesData).sort(([keyA], [keyB]) => { - if (keyA === 'Root') return -1 - if (keyB === 'Root') return 1 - return 0 - }) - - for (const [partName, partData] of sortedParts) { - const propsEntries = Object.entries((partData as any).props || {}) - - if (propsEntries.length > 0) { - let table = `**${partName} Props:**\n\n` - table += '| Prop | Type | Required | Description |\n' - table += '|------|------|----------|-------------|\n' - - for (const [propName, propData] of propsEntries) { - const data = propData as any - const required = data.isRequired ? 'Yes' : 'No' - const description = data.description || '' - table += `| \`${propName}\` | \`${data.type}\` | ${required} | ${description} |\n` - } - - tables.push(table) - } - - // Add data attributes - try { - const dataAttrs = getDataAttrDoc(id as DataAttrDocKey)[partName] - if (dataAttrs && Object.keys(dataAttrs).length > 0) { - let dataTable = `**${partName} Data Attributes:**\n\n` - dataTable += '| Attribute | Value |\n' - dataTable += '|-----------|-------|\n' - - for (const [attr, value] of Object.entries(dataAttrs)) { - dataTable += `| \`[${attr}]\` | ${value} |\n` - } - - tables.push(dataTable) - } - } catch { - // No data attributes for this part, skip - } - - // Add CSS variables - try { - const cssVars = getCssVarDoc(id as CssVarDocKey)[partName] - if (cssVars && Object.keys(cssVars).length > 0) { - let cssTable = `**${partName} CSS Variables:**\n\n` - cssTable += '| Variable | Description |\n' - cssTable += '|----------|-------------|\n' - - for (const [varName, description] of Object.entries(cssVars)) { - cssTable += `| \`${varName}\` | ${description} |\n` - } - - tables.push(cssTable) - } - } catch { - // No CSS variables for this part, skip - } - } - - if (tables.length > 0) { - sections.push(`#### ${frameworkLabel}\n\n${tables.join('\n\n')}`) - } - } catch { - // Type file doesn't exist for this framework, skip silently - } - } - - if (sections.length > 0) { - const replacement = `**Component API Reference**\n\n${sections.join('\n\n')}` - text = text.replace(match[0], replacement) - } - } - - return text -} +import { type ApiDocKey, getApiDoc } from '@zag-js/docs' export function replaceContextType(text: string): string { const matches = text.matchAll(//g) @@ -201,7 +25,9 @@ export function replaceContextType(text: string): string { text = text.replace(match[0], apiTable) } } catch { - console.log('[velite] no context/api found for:', id) + // components without a zag machine (Swap, Segment Group) have no context API. + // The ContextType component renders nothing for these, so drop the tag. + text = text.replace(match[0], '') } } diff --git a/website/src/lib/sidebar.ts b/website/src/lib/sidebar.ts index 2ffe66d8d9..8e611a19eb 100644 --- a/website/src/lib/sidebar.ts +++ b/website/src/lib/sidebar.ts @@ -1,3 +1,4 @@ +import type { Framework } from '~/lib/frameworks' import { type Pages, pages } from '.velite' import { sidebarConfig } from './sidebar-config' @@ -18,9 +19,11 @@ export interface SidebarGroupWithPages { items: Pages[] } -const findPageById = (id: string): Pages | undefined => { - return pages.find((p) => p.id === id && p.framework === '*') ?? pages.find((p) => p.id === id) -} +// most pages are framework-agnostic ('*'); the changelogs exist once per framework +const findPageById = (id: string, framework?: Framework): Pages | undefined => + pages.find((p) => p.id === id && p.framework === '*') ?? + (framework ? pages.find((p) => p.id === id && p.framework === framework) : undefined) ?? + pages.find((p) => p.id === id) export const getSidebarGroups = (): SidebarGroup[] => { return sidebarConfig @@ -43,12 +46,12 @@ export const getSidebarGroups = (): SidebarGroup[] => { } // Returns full page objects for LLMs routes that need content -export const getSidebarGroupsWithPages = (): SidebarGroupWithPages[] => { +export const getSidebarGroupsWithPages = (framework?: Framework): SidebarGroupWithPages[] => { return sidebarConfig .map((group) => { const items: Pages[] = [] for (const item of group.items) { - const page = findPageById(item.id) + const page = findPageById(item.id, framework) if (page) { items.push(page) } diff --git a/website/velite.config.ts b/website/velite.config.ts index 4c6f742326..1cd417c730 100644 --- a/website/velite.config.ts +++ b/website/velite.config.ts @@ -12,13 +12,15 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeSlug from 'rehype-slug' import remarkRemoveFirstHeading from './src/lib/remark-remove-first-heading' import { defineCollection, defineConfig, s } from 'velite' -import { replaceComponentTypes, replaceContextType, replaceExample } from './src/lib/mdx-transform' +import { replaceContextType } from './src/lib/mdx-transform' const normalizePath = (path: string) => path.replace(/\\/g, '/') const pages = defineCollection({ name: 'Pages', - pattern: ['pages/**/*.mdx', '../../../packages/*/CHANGELOG.md'], + // only the framework packages: `framework` is set from this directory name, + // and packages like mcp are not frameworks + pattern: ['pages/**/*.mdx', '../../../packages/{react,solid,svelte,vue}/CHANGELOG.md'], schema: s .object({ // TODO create a changelog collection instead @@ -34,18 +36,9 @@ const pages = defineCollection({ code: s.mdx(), llm: s.custom().transform((_data, { meta }) => { const content = meta.content as string - const path = normalizePath(meta.path as string) - const isChangelog = path.includes('CHANGELOG.md') - const component = isChangelog - ? '' - : path - .split('/') - .pop() - ?.replace(/\.mdx$/, '') || '' - let processed = replaceExample(content, component) - processed = replaceComponentTypes(processed) - processed = replaceContextType(processed) - return processed + // `` and `` stay as tags: they resolve per + // framework, and there is no framework at build time. See ~/lib/llm-content. + return replaceContextType(content) }), }) .transform((data, { meta }) => {