From 3e79df3ece250babe2947278e2f0d505de58cf5a Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 22 Sep 2026 22:14:04 +1000 Subject: [PATCH] feat(library): serve the block catalog as Markdown and harden the exporter (#50370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature, bug fix. Part 3 of 6 in a stack that splits the library redesign into reviewable pieces. ## What is the current behavior? An agent can already fetch any guide as Markdown, but has no way to find out what guides exist: the entry point is a rendered React page. The exporter also fails quietly in ways that ship wrong output rather than failing the build: - An unknown component silently unwraps to its children, so a component rename drops its rendered content. - A registry item that cannot be read produces a page with no file listing. - A link to a missing page produces a 404 URL. - An unrecognized install framework produces a plausible command for the wrong CLI. - Only absolute `/library/docs` links are rewritten, so in-page anchors and sibling links break in the export. ## What is the new behavior? `/library` negotiates Markdown the same way the guides do β€” `Accept: text/markdown`, or an explicit `/library/index.md` β€” and returns a categorized catalog with every block, its description, its framework variants, and a link to each guide's Markdown. `config/library.ts` is the single catalog description the generator reads, and a test ties it to the content directory in both directions: a guide cannot be added without a catalog entry, or listed without a guide. Each quiet failure above now throws, and links resolve against the page they appear on and are checked against the set of published documents. ```bash curl -H 'Accept: text/markdown' https://supabase.com/library ``` ## Additional context `config/library.ts` also carries the category and preview metadata the redesigned homepage consumes in the last PR of the stack; here it is exercised by the Markdown index and its test. ## Summary by CodeRabbit - **New Features** - Added a browsable library catalog covering categories, blocks, starter apps, and supported frameworks. - Added Markdown versions of the library homepage and documentation for compatible tools and workflows. - Added framework-aware links and expanded registry information, including dependencies and source details. - Markdown requests now work for the homepage and documentation, while browser requests continue receiving HTML. - **Bug Fixes** - Improved document link handling, metadata validation, slug consistency, and detection of duplicate or missing documentation entries. - **Tests** - Added coverage for catalog routes, Markdown generation, homepage negotiation, document parsing, and framework-specific links. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Ivan Vasilov --- apps/ui-library/app/api/index-md/route.ts | 26 +++ apps/ui-library/config/library.ts | 211 ++++++++++++++++++ apps/ui-library/lib/library-catalog.test.ts | 92 ++++++++ apps/ui-library/lib/library-documents.test.ts | 88 ++++++++ apps/ui-library/lib/library-documents.ts | 63 ++++++ .../lib/library-mdx-to-markdown.test.ts | 15 ++ .../ui-library/lib/library-mdx-to-markdown.ts | 48 ++-- apps/ui-library/lib/markdown-schema.ts | 101 ++++++--- apps/ui-library/lib/registry-resolution.ts | 21 +- apps/ui-library/middleware.test.ts | 29 +++ apps/ui-library/middleware.ts | 37 ++- apps/ui-library/next.config.mjs | 1 + apps/ui-library/package.json | 8 +- apps/ui-library/scripts/build-llms-txt.ts | 106 --------- .../scripts/build-markdown-index.test.ts | 36 +++ .../scripts/build-markdown-index.ts | 109 +++++++++ apps/ui-library/scripts/build-markdown.ts | 28 +-- apps/ui-library/vitest.config.ts | 7 +- 18 files changed, 824 insertions(+), 202 deletions(-) create mode 100644 apps/ui-library/app/api/index-md/route.ts create mode 100644 apps/ui-library/config/library.ts create mode 100644 apps/ui-library/lib/library-catalog.test.ts create mode 100644 apps/ui-library/lib/library-documents.test.ts create mode 100644 apps/ui-library/lib/library-documents.ts delete mode 100644 apps/ui-library/scripts/build-llms-txt.ts create mode 100644 apps/ui-library/scripts/build-markdown-index.test.ts create mode 100644 apps/ui-library/scripts/build-markdown-index.ts diff --git a/apps/ui-library/app/api/index-md/route.ts b/apps/ui-library/app/api/index-md/route.ts new file mode 100644 index 00000000000..3b4f20e1193 --- /dev/null +++ b/apps/ui-library/app/api/index-md/route.ts @@ -0,0 +1,26 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { NextResponse } from 'next/server' + +export async function GET() { + const filePath = path.join(process.cwd(), 'public/markdown/index.md') + + try { + const content = await fs.readFile(filePath, 'utf-8') + return new NextResponse(content, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600', + Vary: 'Accept', + }, + }) + } catch { + return new NextResponse( + `# Supabase Library\n\nThe block catalog is unavailable.\n\nSee also: [Supabase Library](https://supabase.com/library/llms.txt)\n`, + { + status: 404, + headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'no-store' }, + } + ) + } +} diff --git a/apps/ui-library/config/library.ts b/apps/ui-library/config/library.ts new file mode 100644 index 00000000000..913068e101a --- /dev/null +++ b/apps/ui-library/config/library.ts @@ -0,0 +1,211 @@ +import { componentPages, mcpBlocks, oauthBlocks, platformBlocks } from './docs' + +export const libraryCategories = [ + { + name: 'Starter apps', + slug: 'starter-apps', + description: 'Complete starting points for your next product.', + }, + { + name: 'Authentication', + slug: 'authentication', + description: 'Sign-in, sessions, and account management.', + }, + { name: 'Database', slug: 'database', description: 'Connect your interface to Postgres data.' }, + { name: 'Storage', slug: 'storage', description: 'Upload files with Supabase Storage.' }, + { name: 'Realtime', slug: 'realtime', description: 'Build experiences that stay in sync.' }, + { + name: 'Messaging', + slug: 'messaging', + description: 'Bring conversations into your application.', + }, + { name: 'AI & APIs', slug: 'ai-apis', description: 'Connect agents to your application.' }, + { + name: 'Application foundations', + slug: 'application-foundations', + description: 'Connect and extend your Supabase project.', + }, +] as const + +export type LibraryCategory = (typeof libraryCategories)[number]['name'] +export type CatalogPreviewKind = + | 'auth' + | 'social' + | 'consent' + | 'avatar' + | 'table' + | 'storage' + | 'cursors' + | 'editor' + | 'flow' + | 'avatars' + | 'chat' + | 'mcp' + | 'agents' + | 'dashboard' + | 'client' + +export type LibraryBlock = { + slug: string + title: string + description: string + category: LibraryCategory + preview: CatalogPreviewKind + tags: string[] + href: string + supportedFrameworks?: string[] + frameworkLabel?: string + external?: boolean +} + +const blockMetadata: Record> = { + 'password-based-auth': { + description: 'Sign in and sign up with email and password.', + category: 'Authentication', + preview: 'auth', + }, + 'social-auth': { + description: 'OAuth sign-in flows for popular providers.', + category: 'Authentication', + preview: 'social', + }, + 'oauth-consent': { + description: 'Let users review and approve application access.', + category: 'Authentication', + preview: 'consent', + }, + 'current-user-avatar': { + description: 'Display the signed-in user’s avatar and profile.', + category: 'Authentication', + preview: 'avatar', + }, + 'infinite-query': { + description: 'Fetch and paginate Supabase data as users scroll.', + category: 'Database', + preview: 'table', + }, + dropzone: { + description: 'Drag-and-drop file uploads with progress tracking.', + category: 'Storage', + preview: 'storage', + }, + 'realtime-cursor': { + description: 'Share live cursor positions across your application.', + category: 'Realtime', + preview: 'cursors', + }, + 'realtime-monaco': { + description: 'Edit code together with a collaborative Monaco editor.', + category: 'Realtime', + preview: 'editor', + }, + 'realtime-flow': { + description: 'Build collaborative diagrams with React Flow.', + category: 'Realtime', + preview: 'flow', + }, + 'realtime-avatar-stack': { + description: 'Show who is online with a live avatar stack.', + category: 'Realtime', + preview: 'avatars', + }, + 'realtime-chat': { + description: 'Send and receive messages in realtime.', + category: 'Messaging', + preview: 'chat', + }, + 'mcp-server': { + description: 'Add a user-scoped MCP server to your product.', + category: 'AI & APIs', + preview: 'mcp', + }, + 'headless-app': { + description: 'Combine auth, consent, and MCP tools into an agent-driven app.', + category: 'AI & APIs', + preview: 'agents', + }, + client: { + description: 'Set up a Supabase client for your framework.', + category: 'Application foundations', + preview: 'client', + }, + 'platform-kit': { + description: 'Embed Supabase project management in your platform.', + category: 'Application foundations', + preview: 'dashboard', + }, +} + +// Starter guides share the same documentation routes and layout as individual blocks. +const starterApps: LibraryBlock[] = [ + { + title: 'Next.js starter', + slug: 'nextjs-starter', + description: 'A Next.js app with cookie-based authentication, TypeScript, and Tailwind CSS.', + category: 'Starter apps', + preview: 'auth', + tags: ['Next.js', 'Authentication', 'TypeScript'], + href: '/docs/starters/nextjs-starter', + frameworkLabel: 'Next.js', + }, + { + title: 'SaaS starter', + slug: 'saas-starter', + description: 'Subscription payments with Stripe, Supabase, and Next.js.', + category: 'Starter apps', + preview: 'dashboard', + tags: ['Next.js', 'Stripe', 'Subscriptions'], + href: '/docs/starters/saas-starter', + frameworkLabel: 'Next.js', + }, + { + title: 'AI chat app', + slug: 'ai-chat-app', + description: 'A conversational app with Next.js, the Vercel AI SDK, and Supabase.', + category: 'Starter apps', + preview: 'chat', + tags: ['Next.js', 'AI', 'Messaging'], + href: '/docs/starters/ai-chat-app', + frameworkLabel: 'Next.js', + }, + { + title: 'Flutter starter', + slug: 'flutter-starter', + description: 'A user management app with authentication, profiles, and file storage.', + category: 'Starter apps', + preview: 'avatar', + tags: ['Flutter', 'Authentication', 'Storage'], + href: '/docs/starters/flutter-starter', + frameworkLabel: 'Flutter', + }, +] + +export const libraryBlocks: LibraryBlock[] = [ + ...starterApps, + ...[ + ...componentPages.items, + ...oauthBlocks.items, + ...mcpBlocks.items, + ...platformBlocks.items, + ].map((item) => { + const slug = item.href!.split('/').pop()! + return { + slug, + title: item.title, + href: item.href!, + supportedFrameworks: item.supportedFrameworks, + tags: item.supportedFrameworks ?? [], + ...blockMetadata[slug], + } + }), +] + +export function getLibraryBlockHref(block: LibraryBlock, framework?: string) { + return framework && block.supportedFrameworks?.includes(framework) + ? `/docs/${framework}/${block.slug}` + : block.href +} + +export function getLibraryCategoryHref(category: LibraryCategory) { + return `/?category=${encodeURIComponent(category)}` +} diff --git a/apps/ui-library/lib/library-catalog.test.ts b/apps/ui-library/lib/library-catalog.test.ts new file mode 100644 index 00000000000..cd69e2c2ce0 --- /dev/null +++ b/apps/ui-library/lib/library-catalog.test.ts @@ -0,0 +1,92 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { componentPages, mcpBlocks, oauthBlocks, platformBlocks } from '../config/docs' +import { getLibraryBlockHref, libraryBlocks, libraryCategories } from '../config/library' +import { collectMdxFiles, getDocSlug } from './library-documents' + +describe('library catalog', () => { + it('accounts for every block guide and framework variant in the content directory', () => { + const contentDirectory = fileURLToPath(new URL('../content/docs', import.meta.url)) + const catalogRoutes = new Set( + libraryBlocks.flatMap((block) => [ + block.href, + ...(block.supportedFrameworks ?? []).map((framework) => + getLibraryBlockHref(block, framework) + ), + ]) + ) + // These guides are reachable directly but intentionally absent from the block catalog. + const unlistedRoutes = new Set([ + '/docs/getting-started/introduction', + '/docs/getting-started/quickstart', + '/docs/getting-started/faq', + '/docs/nextjs/tanstack-db', + ]) + const documentRoutes = new Set( + collectMdxFiles(contentDirectory).map( + (file) => `/docs/${getDocSlug(path.relative(contentDirectory, file))}` + ) + ) + + for (const route of documentRoutes) { + expect( + catalogRoutes.has(route) || unlistedRoutes.has(route), + `${route} needs a catalog entry` + ).toBe(true) + } + for (const route of [...catalogRoutes, ...unlistedRoutes]) { + expect(documentRoutes.has(route), `${route} must resolve to a guide`).toBe(true) + } + for (const block of libraryBlocks) { + expect(block.title.trim(), `${block.slug} needs a title`).toBeTruthy() + expect(block.description?.trim(), `${block.slug} needs a description`).toBeTruthy() + expect(block.preview, `${block.slug} needs a preview`).toBeTruthy() + expect(libraryCategories.some((category) => category.name === block.category)).toBe(true) + } + }) + + it('keeps every existing block discoverable with a valid category and documentation route', () => { + const existingItems = [ + ...componentPages.items, + ...oauthBlocks.items, + ...mcpBlocks.items, + ...platformBlocks.items, + ] + + for (const item of existingItems) { + const block = libraryBlocks.find((block) => block.href === item.href) + if (!block) throw new Error(`${item.title} must remain in the catalog`) + expect(libraryCategories.some((category) => category.name === block.category)).toBe(true) + expect(existsSync(new URL(`../content${block.href}.mdx`, import.meta.url))).toBe(true) + for (const framework of block.supportedFrameworks ?? []) { + const href = getLibraryBlockHref(block, framework) + expect(existsSync(new URL(`../content${href}.mdx`, import.meta.url)), href).toBe(true) + } + } + }) + + it('uses a supported framework and falls back to an existing route otherwise', () => { + const oauth = libraryBlocks.find((block) => block.slug === 'oauth-consent')! + const monaco = libraryBlocks.find((block) => block.slug === 'realtime-monaco')! + const infiniteQuery = libraryBlocks.find((block) => block.slug === 'infinite-query')! + + expect(getLibraryBlockHref(oauth, 'react-router')).toBe('/docs/react-router/oauth-consent') + expect(getLibraryBlockHref(monaco, 'vue')).toBe('/docs/nextjs/realtime-monaco') + expect(getLibraryBlockHref(infiniteQuery, 'nextjs')).toBe('/docs/react/infinite-query') + }) + + it('includes starter apps as blocks with unique slugs and internal documentation routes', () => { + expect(new Set(libraryBlocks.map((block) => block.slug)).size).toBe(libraryBlocks.length) + const starters = libraryBlocks.filter((block) => block.category === 'Starter apps') + expect(starters.length > 0).toBe(true) + for (const starter of starters) { + expect(starter.href).toBe(`/docs/starters/${starter.slug}`) + expect(existsSync(new URL(`../content${starter.href}.mdx`, import.meta.url))).toBe(true) + expect(starter.external).not.toBe(true) + expect(getLibraryBlockHref(starter, 'vue')).toBe(starter.href) + } + }) +}) diff --git a/apps/ui-library/lib/library-documents.test.ts b/apps/ui-library/lib/library-documents.test.ts new file mode 100644 index 00000000000..5a76f9743ed --- /dev/null +++ b/apps/ui-library/lib/library-documents.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { buildLlmsTxt, getDocFiles } from '../scripts/build-markdown-index' +import { collectMdxFiles, getDocSlug, parseLibraryDocument } from './library-documents' + +describe('library document exports', () => { + it('decodes YAML folded and quoted metadata for the LLM index', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'library-documents-')) + try { + mkdirSync(path.join(directory, 'folded')) + writeFileSync( + path.join(directory, 'folded', 'index.mdx'), + `--- +title: "A title: with punctuation" +description: >- + A folded description + across two lines +--- + +Body +` + ) + writeFileSync( + path.join(directory, 'quoted.mdx'), + `--- +title: 'Quoted title' +description: 'A quoted description' +--- +` + ) + const docs = getDocFiles(directory) + expect(docs).toEqual([ + { + title: 'A title: with punctuation', + description: 'A folded description across two lines', + path: 'folded', + }, + { title: 'Quoted title', description: 'A quoted description', path: 'quoted' }, + ]) + const output = buildLlmsTxt(docs, new Date('2026-09-11T00:00:00Z')) + expect(output).toMatch(/folded.md\)/) + expect(output).toMatch(/ - A folded description across two lines/) + expect(output).toMatch(/ - A quoted description/) + expect(output).not.toMatch(/>-|description:|'A quoted description'/) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('uses the same slug coverage for the LLM index and Markdown pages', () => { + const directory = fileURLToPath(new URL('../content/docs/', import.meta.url)) + const sources = collectMdxFiles(directory) + const docs = getDocFiles(directory) + expect(docs.map((doc) => doc.path)).toEqual( + sources.map((source) => getDocSlug(path.relative(directory, source))) + ) + expect(new Set(docs.map((doc) => doc.path)).size).toBe(docs.length) + expect(getDocSlug('framework\\index.mdx')).toBe('framework') + const aiChat = docs.find((doc) => doc.path === 'starters/ai-chat-app')! + expect(aiChat.description).toBe( + 'A Next.js chat app with streaming responses, authentication, and saved conversations' + ) + const output = buildLlmsTxt(docs) + expect(output.includes(' - Local-first, reactive collections backed by Supabase')).toBe(true) + expect(output).not.toMatch(/ - >-/) + }) + + it('rejects invalid metadata types rather than stringifying them into generated content', () => { + expect(() => parseLibraryDocument('---\ntitle: [one, two]\n---')).toThrow( + /title must be a string/ + ) + expect(() => parseLibraryDocument('---\ndescription: 42\n---')).toThrow( + /description must be a string/ + ) + expect(() => parseLibraryDocument('---\npreview: true\n---')).toThrow( + /preview must be a string/ + ) + const source = readFileSync( + new URL('../content/docs/starters/ai-chat-app.mdx', import.meta.url), + 'utf8' + ) + expect(parseLibraryDocument(source).content).toMatch(/npx create-next-app/) + }) +}) diff --git a/apps/ui-library/lib/library-documents.ts b/apps/ui-library/lib/library-documents.ts new file mode 100644 index 00000000000..197186e7553 --- /dev/null +++ b/apps/ui-library/lib/library-documents.ts @@ -0,0 +1,63 @@ +import { readdirSync } from 'node:fs' +import path from 'node:path' +import matter from 'gray-matter' + +export function parseLibraryDocument(raw: string) { + const { content, data } = matter(raw) + for (const field of ['title', 'description', 'preview'] as const) { + if (data[field] !== undefined && typeof data[field] !== 'string') { + throw new Error(`Document ${field} must be a string`) + } + } + return { + content, + data: data as { title?: string; description?: string; preview?: string }, + } +} + +export function collectMdxFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(directory, entry.name) + if (entry.isDirectory()) return collectMdxFiles(entryPath) + return entry.name.endsWith('.mdx') ? [entryPath] : [] + }) + .sort((a, b) => a.localeCompare(b)) +} + +// Match Velite's flattened path, relative to content/docs. +export function getDocSlug(relativePath: string): string { + return relativePath + .replace(/\\/g, '/') + .replace(/\.mdx$/, '') + .replace(/\/index$/, '') +} + +export function toAgentHref( + href: string, + documentSlugs?: ReadonlySet, + documentBasePath?: string +): string { + if (!href || href.startsWith('#') || href.startsWith('//')) return href + const isRelative = !/^[a-z][a-z\d+.-]*:/i.test(href) + if (!isRelative && !href.startsWith('https://supabase.com/library/docs/')) return href + if (isRelative && !href.startsWith('/') && !documentBasePath) return href + + // documentBasePath is the source-relative path (e.g. "foo/index"), not the + // flattened doc slug ("foo") β€” that keeps relative links from foo/index.mdx + // resolving inside foo/, instead of jumping to foo's parent directory. + const url = new URL(href, `https://supabase.com/library/docs/${documentBasePath ?? ''}`) + if (url.pathname.startsWith('/library/docs/')) { + const slug = url.pathname.slice('/library/docs/'.length).replace(/\.md$/, '') + if (documentSlugs && !documentSlugs.has(slug)) { + throw new Error(`Missing library document: ${slug}`) + } + url.pathname = `/library/docs/${slug}.md` + } + return url.href +} + +export function markdownLink(title: string, url: string): string { + const escaped = title.replace(/\s+/g, ' ').replace(/([\\\[\]])/g, '\\$1') + return `[${escaped}](${url})` +} diff --git a/apps/ui-library/lib/library-mdx-to-markdown.test.ts b/apps/ui-library/lib/library-mdx-to-markdown.test.ts index 26d9510cfad..d772df542a7 100644 --- a/apps/ui-library/lib/library-mdx-to-markdown.test.ts +++ b/apps/ui-library/lib/library-mdx-to-markdown.test.ts @@ -78,6 +78,21 @@ See the [React client](/library/docs/react/client). expect(output).toMatch(/https:\/\/supabase\.com\/library\/docs\/react\/client\.md/) }) + it('resolves a relative link from an index document within its own directory', () => { + const output = transformLibraryMdx( + `--- +title: Foo +description: Foo +--- + +See the [child page](./child). +`, + { documentSlugs: new Set(['foo', 'foo/child']), documentBasePath: 'foo/index' } + ) + + expect(output).toMatch(/https:\/\/supabase\.com\/library\/docs\/foo\/child\.md/) + }) + it('rewrites documentation links nested inside components', () => { const output = transformLibraryMdx(`--- title: Auth diff --git a/apps/ui-library/lib/library-mdx-to-markdown.ts b/apps/ui-library/lib/library-mdx-to-markdown.ts index 352435f4909..1b2bbe93692 100644 --- a/apps/ui-library/lib/library-mdx-to-markdown.ts +++ b/apps/ui-library/lib/library-mdx-to-markdown.ts @@ -1,4 +1,3 @@ -import matter from 'gray-matter' import type { Content, Parent, Root } from 'mdast' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm' @@ -9,13 +8,15 @@ import { gfm } from 'micromark-extension-gfm' import { mdxjs } from 'micromark-extension-mdxjs' import { visit } from 'unist-util-visit' -import { markdownSchema } from './markdown-schema' +import { parseLibraryDocument, toAgentHref } from './library-documents' +import { markdownSchema, type MarkdownOptions } from './markdown-schema' type JsxNode = MdxJsxFlowElement | MdxJsxTextElement type ComponentHandler = (ctx: { props: Record children: string node: JsxNode + options: MarkdownOptions }) => string const PARSE_OPTIONS = { @@ -31,8 +32,6 @@ const SERIALIZE_OPTIONS = { const parseMdx = (source: string): Root => fromMarkdown(source, PARSE_OPTIONS) const serializeMdx = (tree: Parent): string => toMarkdown(tree as Root, SERIALIZE_OPTIONS) -const defaultHandler: ComponentHandler = ({ children }) => children - const isJsx = (n: Content): n is JsxNode => n.type === 'mdxJsxFlowElement' || n.type === 'mdxJsxTextElement' @@ -47,9 +46,13 @@ function propsFrom(node: JsxNode): Record { return props } -function applySchema(parent: Parent, schema: Record): void { +function applySchema( + parent: Parent, + schema: Record, + options: MarkdownOptions +): void { for (const child of parent.children as Content[]) { - if ('children' in child) applySchema(child as Parent, schema) + if ('children' in child) applySchema(child as Parent, schema, options) } const next: Content[] = [] for (const child of parent.children as Content[]) { @@ -61,12 +64,17 @@ function applySchema(parent: Parent, schema: Record): continue } if (isJsx(child)) { - const handler = schema[child.name ?? ''] ?? defaultHandler + const handler = schema[child.name ?? ''] + if (!handler && child.name && /^[A-Z]/.test(child.name)) { + throw new Error(`No Markdown handler for component: ${child.name}`) + } const children = serializeMdx({ type: 'root', children: child.children as Root['children'], }).trim() - const value = handler({ props: propsFrom(child), children, node: child }) + const value = handler + ? handler({ props: propsFrom(child), children, node: child, options }) + : children if (value) { next.push({ type: 'html', value } as Content) } @@ -77,27 +85,17 @@ function applySchema(parent: Parent, schema: Record): parent.children = next as Parent['children'] } -function rewriteLibraryLinks(tree: Root): void { +function rewriteLibraryLinks(tree: Root, options: MarkdownOptions): void { visit(tree, 'link', (node) => { - if (!node.url.startsWith('/')) return - if (node.url.startsWith('//')) return - - if (node.url.startsWith('/library/docs/')) { - const [pathname, hash] = node.url.split('#') - const withMd = pathname.endsWith('.md') ? pathname : `${pathname}.md` - node.url = `https://supabase.com${withMd}${hash ? `#${hash}` : ''}` - return - } - - node.url = `https://supabase.com${node.url}` + node.url = toAgentHref(node.url, options.documentSlugs, options.documentBasePath) }) } -export function transformLibraryMdx(raw: string): string { - const { content, data } = matter(raw) - const tree = parseMdx(content) - rewriteLibraryLinks(tree) - applySchema(tree, markdownSchema) +export function transformLibraryMdx(raw: string, options: MarkdownOptions = {}): string { + const { content, data } = parseLibraryDocument(raw) + const tree = parseMdx([data.preview, content].filter(Boolean).join('\n\n')) + rewriteLibraryLinks(tree, options) + applySchema(tree, markdownSchema, options) const body = serializeMdx(tree).trim() const headerParts: string[] = [] diff --git a/apps/ui-library/lib/markdown-schema.ts b/apps/ui-library/lib/markdown-schema.ts index 982c3df8922..a2a35934a35 100644 --- a/apps/ui-library/lib/markdown-schema.ts +++ b/apps/ui-library/lib/markdown-schema.ts @@ -2,10 +2,20 @@ import path from 'node:path' import { getInstallCommands } from '../lib/install-command' import { generateRegistryTree, type RegistryNode } from '../lib/process-registry' +import { resolveRegistryItem } from '../lib/registry-resolution' +import { registry } from '../registry' +import { toAgentHref } from './library-documents' + +export type MarkdownOptions = { + registryDirectory?: string + documentSlugs?: ReadonlySet + documentBasePath?: string +} type HandlerContext = { props: Record children: string + options: MarkdownOptions } type ComponentHandler = (ctx: HandlerContext) => string @@ -13,22 +23,21 @@ type ComponentHandler = (ctx: HandlerContext) => string const omit: ComponentHandler = () => '' const unwrap: ComponentHandler = ({ children }) => children -function toAgentHref(href: string): string { - if (!href) return href - if (href.startsWith('/library/docs/')) { - const [pathname, hash] = href.split('#') - const withMd = pathname.endsWith('.md') ? pathname : `${pathname}.md` - return `https://supabase.com${withMd}${hash ? `#${hash}` : ''}` +function getRegistryItem(name: string) { + return registry.items.find((item) => item.name === name) +} + +function requiredName(props: Record, field: string): string { + const name = props[field] + if (typeof name !== 'string' || !name) { + throw new Error(`Registry component requires a ${field}`) } - if (href.startsWith('/') && !href.startsWith('//')) { - return `https://supabase.com${href}` - } - return href + return name } function BlockItem({ props }: HandlerContext): string { - const name = String(props.name ?? '') - if (!name) return '' + const name = requiredName(props, 'name') + resolveRegistryItem(getRegistryItem, name) const framework = props.framework ?? 'react' if (framework !== 'react' && framework !== 'vue') { throw new Error(`Unsupported install framework for ${name}: ${String(framework)}`) @@ -37,25 +46,44 @@ function BlockItem({ props }: HandlerContext): string { return ['Install this block:', '', '```bash', command, '```'].join('\n') } -function RegistryBlock({ props }: HandlerContext): string { - const itemName = String(props.itemName ?? '') - if (!itemName) return '' - - const registryPath = path.join(process.cwd(), 'public', 'r', `${itemName}.json`) - let listing = '' +function RegistryBlock({ props, options }: HandlerContext): string { + const itemName = requiredName(props, 'itemName') + const definition = resolveRegistryItem(getRegistryItem, itemName) + const registryPath = path.join( + options.registryDirectory ?? path.join(process.cwd(), 'public', 'r'), + `${itemName}.json` + ) + let tree: RegistryNode[] try { - const tree = generateRegistryTree(registryPath) - listing = formatTree(tree) - } catch { - listing = '' + tree = generateRegistryTree(registryPath) + } catch (error) { + throw new Error(`Cannot export registry files for ${itemName}: ${String(error)}`, { + cause: error, + }) } - const registryUrl = `https://supabase.com/library/r/${itemName}.json` - const parts = [listing, listing ? '' : null, `Full source: ${registryUrl}`].filter( - (part) => part !== null + const sources = [itemName, ...definition.firstPartyDependencies].map( + (name) => `Full source: https://supabase.com/library/r/${name}.json` ) + const scope = definition.firstPartyDependencies.length + ? `Includes first-party dependencies: ${definition.firstPartyDependencies.join(', ')}.` + : '' + const external = definition.externalRegistryDependencies.length + ? `External registry dependencies: ${definition.externalRegistryDependencies.join(', ')}.` + : '' - return parts.join('\n').trim() + return [scope, formatTree(tree), external, sources.join('\n')].filter(Boolean).join('\n\n') +} + +function BlockOverview({ props, children, options }: HandlerContext): string { + const name = requiredName(props, 'name') + const files = + props.showFiles === true || props.showFiles === 'true' + ? ['## Files', RegistryBlock({ props: { itemName: name }, children: '', options })].join( + '\n\n' + ) + : '' + return [children, files].filter(Boolean).join('\n\n') } function formatTree(nodes: RegistryNode[], indent = 0): string { @@ -79,8 +107,12 @@ function AccordionTrigger({ children }: HandlerContext): string { return title ? `**${title}**` : '' } -function LinkedCard({ props, children }: HandlerContext): string { - const href = toAgentHref(String(props.href ?? '')) +function LinkedCard({ props, children, options }: HandlerContext): string { + const href = toAgentHref( + String(props.href ?? ''), + options.documentSlugs, + options.documentBasePath + ) const label = children.replace(/\s+/g, ' ').trim() return href ? `- [${label || href}](${href})` : label } @@ -102,13 +134,18 @@ function TanstackDBGenerator(): string { ].join('\n') } -function Anchor({ props, children }: HandlerContext): string { - const href = toAgentHref(String(props.href ?? '')) +function Anchor({ props, children, options }: HandlerContext): string { + const href = toAgentHref( + String(props.href ?? ''), + options.documentSlugs, + options.documentBasePath + ) return href ? `[${children}](${href})` : children } export const markdownSchema: Record = { BlockItem, + BlockOverview, RegistryBlock, Callout, Accordion: unwrap, @@ -117,7 +154,11 @@ export const markdownSchema: Record = { AccordionContent: unwrap, Card: unwrap, LinkedCard, + FrameworkQuickstart: unwrap, + FrameworkQuickstartTab: unwrap, + QuickstartStep: unwrap, ComponentPreview, + CatalogPreview: omit, BlockPreview: omit, DualRealtimeChat: omit, DualRealtimeFlow: omit, diff --git a/apps/ui-library/lib/registry-resolution.ts b/apps/ui-library/lib/registry-resolution.ts index 299039d10cf..387fa9758ed 100644 --- a/apps/ui-library/lib/registry-resolution.ts +++ b/apps/ui-library/lib/registry-resolution.ts @@ -72,15 +72,22 @@ export function uniqueInstalledFiles< return [...destinations.values()] } +export type ResolvedRegistryItem = RegistryItem & { + files: RegistryFile[] + firstPartyDependencies: string[] + externalRegistryDependencies: string[] +} + /** Follow Supabase dependencies only; external UI kits are the installer's responsibility. */ export function resolveRegistryItem( getItem: (name: string) => RegistryItem | undefined, name: string -): RegistryItem & { files: RegistryFile[] } { +): ResolvedRegistryItem { const root = getItem(name) if (!root) throw new Error(`Missing registry item "${name}"`) const visited = new Set() + const external = new Set() const files: RegistryFile[] = [] const visit = (item: RegistryItem, ancestors: string[]) => { if (visited.has(item.name)) return @@ -89,7 +96,10 @@ export function resolveRegistryItem( const path = [...ancestors, item.name] for (const dependency of item.registryDependencies ?? []) { const localName = getFirstPartyDependencyName(dependency) - if (!localName) continue + if (!localName) { + external.add(dependency) + continue + } if (path.includes(localName)) { throw new Error(`Registry dependency cycle: ${[...path, localName].join(' -> ')}`) } @@ -110,5 +120,10 @@ export function resolveRegistryItem( } visit(root, []) - return { ...root, files: uniqueInstalledFiles(files, `Registry item "${name}"`) } + return { + ...root, + files: uniqueInstalledFiles(files, `Registry item "${name}"`), + firstPartyDependencies: [...visited].filter((itemName) => itemName !== name), + externalRegistryDependencies: [...external], + } } diff --git a/apps/ui-library/middleware.test.ts b/apps/ui-library/middleware.test.ts index e02a847a5e6..676fbf60453 100644 --- a/apps/ui-library/middleware.test.ts +++ b/apps/ui-library/middleware.test.ts @@ -46,3 +46,32 @@ describe('middleware markdown negotiation', () => { expect(response.headers.get('x-middleware-rewrite')).toBeNull() }) }) + +describe('middleware homepage negotiation', () => { + const HOME_URL = 'https://supabase.com/library' + + it('rewrites the homepage to the index markdown route when Accept prefers markdown', () => { + const response = middleware(request(HOME_URL, { accept: 'text/markdown' })) + + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://supabase.com/library/api/index-md' + ) + }) + + it('serves the index markdown at /index.md regardless of Accept', () => { + const response = middleware(request(`${HOME_URL}/index.md`, { accept: 'text/html' })) + + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://supabase.com/library/api/index-md' + ) + }) + + it('serves the homepage as html to browsers', () => { + const response = middleware( + request(`${HOME_URL}/`, { accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) +}) diff --git a/apps/ui-library/middleware.ts b/apps/ui-library/middleware.ts index 6e89fd85905..75ed24407ad 100644 --- a/apps/ui-library/middleware.ts +++ b/apps/ui-library/middleware.ts @@ -1,4 +1,4 @@ -import { negotiateMarkdown } from 'common/markdown-negotiation' +import { negotiateMarkdown, type MarkdownDecision } from 'common/markdown-negotiation' import { NextResponse, type NextRequest } from 'next/server' import MARKDOWN_SLUGS from '@/public/markdown/manifest.json' @@ -6,29 +6,48 @@ import MARKDOWN_SLUGS from '@/public/markdown/manifest.json' const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/library' const DOCS_PATH = `${BASE_PATH}/docs` const MARKDOWN_SLUG_SET = new Set(MARKDOWN_SLUGS) +// The homepage catalog, generated by scripts/build-markdown-index.ts. +const HOME_PATHS = new Set([BASE_PATH, `${BASE_PATH}/`, `${BASE_PATH}/index.md`]) export function middleware(request: NextRequest) { const url = new URL(request.url) const { pathname } = url - if (!pathname.startsWith(`${DOCS_PATH}/`)) { + // Server Actions POST to the page URL with `Accept: text/x-component`, which + // matches none of the types these routes negotiate and would 406. Let Next.js + // handle them. + if (request.headers.get('next-action')) { return NextResponse.next() } - // Server Actions POST to the page URL with `Accept: text/x-component`, which - // matches none of the types this route negotiates and would 406. Let Next.js - // handle them. - if (request.headers.get('next-action')) { + const acceptHeader = request.headers.get('accept') ?? '' + + if (HOME_PATHS.has(pathname)) { + return respond( + negotiateMarkdown( + { acceptHeader }, + { hasMarkdownVariant: true, isMarkdownSuffix: pathname.endsWith('.md') } + ), + url, + `${BASE_PATH}/api/index-md` + ) + } + + if (!pathname.startsWith(`${DOCS_PATH}/`)) { return NextResponse.next() } const isMdSuffix = pathname.endsWith('.md') const slug = pathname.replace(`${DOCS_PATH}/`, '').replace(/\.md$/, '') const decision = negotiateMarkdown( - { acceptHeader: request.headers.get('accept') ?? '' }, + { acceptHeader }, { hasMarkdownVariant: MARKDOWN_SLUG_SET.has(slug), isMarkdownSuffix: isMdSuffix } ) + return respond(decision, url, `${BASE_PATH}/api/docs-md/${slug}`) +} + +function respond(decision: MarkdownDecision, url: URL, markdownPathname: string) { if (decision === 'not-acceptable') { return new NextResponse('Not Acceptable', { status: 406, @@ -38,7 +57,7 @@ export function middleware(request: NextRequest) { if (decision === 'markdown') { const rewriteUrl = new URL(url) - rewriteUrl.pathname = `${BASE_PATH}/api/docs-md/${slug}` + rewriteUrl.pathname = markdownPathname return NextResponse.rewrite(rewriteUrl) } @@ -46,5 +65,5 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: ['/docs/:path*'], + matcher: ['/', '/index.md', '/docs/:path*'], } diff --git a/apps/ui-library/next.config.mjs b/apps/ui-library/next.config.mjs index aa85884ccc7..f16894b1087 100644 --- a/apps/ui-library/next.config.mjs +++ b/apps/ui-library/next.config.mjs @@ -12,6 +12,7 @@ const nextConfig = { }, outputFileTracingIncludes: { '/api/docs-md/**/*': ['./public/markdown/docs/**/*'], + '/api/index-md/**/*': ['./public/markdown/index.md'], }, async redirects() { return [ diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index c2e41e23c8e..cc4410d29a3 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -7,20 +7,20 @@ "preinstall": "npx only-allow pnpm", "dev:content": "velite dev", "dev:next": "next dev --port 3004", - "dev": "run-p build:content build:registry build:markdown && run-p --race dev:*", + "dev": "pnpm build:registry && run-p build:content build:markdown build:markdown-index && run-p --race dev:*", "build:content": "velite build --strict", "build:registry": "rimraf -G public/r/* && tsx ./scripts/build-registry.mts && shadcn build public/r/registry.json && tsx scripts/clean-registry.ts", "build:markdown": "tsx ./scripts/build-markdown.ts", - "build:llms": "tsx ./scripts/build-llms-txt.ts", + "build:markdown-index": "tsx ./scripts/build-markdown-index.ts", "build:next": "next build --turbopack", - "build": "run-p build:content build:registry build:llms build:markdown && pnpm build:next", + "build": "pnpm build:registry && run-p build:content build:markdown build:markdown-index && pnpm build:next", "test": "pnpm build:markdown && vitest", "test:headless-tools": "tsx scripts/test-headless-tools.mts", "start": "next start", "lint": "eslint .", "lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml", "clean": "rimraf .next .turbo tsconfig.tsbuildinfo .contentlayer .velite", - "typecheck": "run-p build:content build:markdown && tsc --noEmit -p tsconfig.json" + "typecheck": "run-p build:content build:markdown && next typegen && tsc --noEmit -p tsconfig.json" }, "dependencies": { "@hookform/resolvers": "^3.1.1", diff --git a/apps/ui-library/scripts/build-llms-txt.ts b/apps/ui-library/scripts/build-llms-txt.ts deleted file mode 100644 index 9ef7647916d..00000000000 --- a/apps/ui-library/scripts/build-llms-txt.ts +++ /dev/null @@ -1,106 +0,0 @@ -import fs from 'fs' -import path from 'path' - -const BASE_URL = 'https://supabase.com/library/docs' - -interface DocMeta { - title: string - description?: string - path: string -} - -console.log('πŸ€– Building llms.txt') - -// Function to extract frontmatter from MDX files -function extractFrontmatter(content: string): { title?: string; description?: string } { - const frontmatterRegex = /---\n([\s\S]*?)\n---/ - const match = content.match(frontmatterRegex) - if (!match) return {} - - const frontmatter = match[1] - const titleMatch = frontmatter.match(/title:\s*(.*)/) - const descriptionMatch = frontmatter.match(/description:\s*(.*)/) - - return { - title: titleMatch?.[1], - description: descriptionMatch?.[1], - } -} - -// Function to recursively get all MDX files -function getMdxFiles(dir: string): string[] { - const files: string[] = [] - const entries = fs.readdirSync(dir, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - files.push(...getMdxFiles(fullPath)) - } else if (entry.name.endsWith('.mdx')) { - files.push(fullPath) - } - } - - return files -} - -// Function to get all MDX files and their metadata -function getDocFiles(): DocMeta[] { - const docsDir = path.join(process.cwd(), 'content/docs') - const mdxFiles = getMdxFiles(docsDir).sort((a, b) => a.localeCompare(b)) - - const docs: DocMeta[] = [] - - for (const fullPath of mdxFiles) { - console.log(fullPath) - const content = fs.readFileSync(fullPath, 'utf-8') - const { title, description } = extractFrontmatter(content) - - if (title) { - // Get relative path and convert to URL path - const relativePath = path.relative(docsDir, fullPath) - const urlPath = relativePath - .replace(/\.mdx$/, '') - .replace(/\/index$/, '') - .replace(/\\/g, '/') - - docs.push({ - title, - description, - path: urlPath, - }) - } - } - - return docs -} - -// Generate the llms.txt content -const docs = getDocFiles() -let content = `# Supabase Library -Last updated: ${new Date().toISOString()} - -## Overview -Library of components for your project. The components integrate with Supabase and are shadcn compatible. Each docs page is also available as markdown for agents (append .md to the URL). - -## Docs -` - -// Add documentation links -for (const doc of docs) { - const url = `${BASE_URL}/${doc.path}.md` - content += `- [${doc.title}](${url})` - if (doc.description) { - content += `\n - ${doc.description}` - } - content += '\n' -} - -// Write the file -const publicDir = path.join(process.cwd(), 'public') -if (!fs.existsSync(publicDir)) { - fs.mkdirSync(publicDir, { recursive: true }) -} - -fs.writeFileSync(path.join(publicDir, 'llms.txt'), content) -console.log('βœ… Generated llms.txt in public directory') diff --git a/apps/ui-library/scripts/build-markdown-index.test.ts b/apps/ui-library/scripts/build-markdown-index.test.ts new file mode 100644 index 00000000000..3ba41ce1eda --- /dev/null +++ b/apps/ui-library/scripts/build-markdown-index.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'vitest' + +import { libraryBlocks, libraryCategories } from '../config/library' +import { buildIndexMarkdown } from './build-markdown-index' + +describe('index markdown', () => { + const markdown = buildIndexMarkdown(new Date('2025-01-01T00:00:00.000Z')) + + it('lists every block under its category', () => { + for (const category of libraryCategories) { + assert.ok(markdown.includes(`## ${category.name}`), `${category.name} needs a section`) + } + + for (const block of libraryBlocks) { + assert.ok( + markdown.includes(`[${block.title}](https://supabase.com/library${block.href}.md)`), + `${block.slug} needs a markdown link` + ) + assert.ok(markdown.includes(block.description), `${block.slug} needs its description`) + } + }) + + it('names the frameworks a block supports', () => { + const infiniteQuery = libraryBlocks.find((block) => block.slug === 'infinite-query')! + assert.ok( + markdown.includes(`Frameworks: ${infiniteQuery.supportedFrameworks!.join(', ')}.`), + 'framework variants need to be discoverable' + ) + }) + + it('links the getting started guides and the full page index', () => { + assert.ok(markdown.includes('https://supabase.com/library/docs/getting-started/quickstart.md')) + assert.ok(markdown.includes('https://supabase.com/library/llms.txt')) + }) +}) diff --git a/apps/ui-library/scripts/build-markdown-index.ts b/apps/ui-library/scripts/build-markdown-index.ts new file mode 100644 index 00000000000..ce6195fc902 --- /dev/null +++ b/apps/ui-library/scripts/build-markdown-index.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { libraryBlocks, libraryCategories } from '../config/library' +import { + collectMdxFiles, + getDocSlug, + markdownLink, + parseLibraryDocument, +} from '../lib/library-documents' + +const DOCS_BASE_URL = 'https://supabase.com/library/docs' +const LIBRARY_BASE_URL = 'https://supabase.com/library' + +interface DocMeta { + title: string + description?: string + path: string +} + +export function getDocFiles(docsDirectory: string): DocMeta[] { + return collectMdxFiles(docsDirectory).map((fullPath) => { + const { data } = parseLibraryDocument(fs.readFileSync(fullPath, 'utf8')) + if (!data.title) throw new Error(`Missing document title: ${fullPath}`) + return { + title: data.title, + description: data.description, + path: getDocSlug(path.relative(docsDirectory, fullPath)), + } + }) +} + +export function buildLlmsTxt(docs: DocMeta[], generatedAt = new Date()): string { + const entries = docs.map((doc) => { + const description = doc.description?.replace(/\s+/g, ' ').trim() + return [ + `- ${markdownLink(doc.title, `${DOCS_BASE_URL}/${doc.path}.md`)}`, + description ? ` - ${description}` : '', + ] + .filter(Boolean) + .join('\n') + }) + + return `# Supabase Library +Last updated: ${generatedAt.toISOString()} + +## Overview +Library of components for your project. The components integrate with Supabase and are shadcn compatible. Each docs page is also available as markdown for agents (append .md to the URL). + +Block catalog: https://supabase.com/library/index.md + +## Docs +${entries.join('\n')} +` +} + +/** + * The homepage catalog as markdown, so an agent can list every block without + * rendering the page. Mirrors the categories and blocks in `config/library.ts`. + */ +export function buildIndexMarkdown(generatedAt = new Date()): string { + const sections = libraryCategories + .map((category) => { + const blocks = libraryBlocks.filter((block) => block.category === category.name) + const entries = blocks.map((block) => { + const description = block.description.replace(/\s+/g, ' ').trim() + // Framework variants follow the URL pattern spelled out in the overview, + // so listing the slugs beats repeating a near-identical link per framework. + const frameworks = block.supportedFrameworks?.length + ? ` Frameworks: ${block.supportedFrameworks.join(', ')}.` + : block.frameworkLabel + ? ` Framework: ${block.frameworkLabel}.` + : '' + return `- ${markdownLink(block.title, `${LIBRARY_BASE_URL}${block.href}.md`)} β€” ${description}${frameworks}` + }) + return [`## ${category.name}`, category.description, '', entries.join('\n')].join('\n') + }) + .filter(Boolean) + + return `# Supabase Library +Last updated: ${generatedAt.toISOString()} + +## Overview +Building blocks for your next backend. Every block is shadcn compatible and integrates with Supabase, and each one ships with a guide you can install from. + +Every docs page is also available as markdown for agents (append .md to the URL). Blocks that support several frameworks share one guide per framework at ${LIBRARY_BASE_URL}/docs//.md. + +Start here: ${markdownLink('Quick Start', `${LIBRARY_BASE_URL}/docs/getting-started/quickstart.md`)}, ${markdownLink('Introduction', `${LIBRARY_BASE_URL}/docs/getting-started/introduction.md`)}, ${markdownLink('FAQ', `${LIBRARY_BASE_URL}/docs/getting-started/faq.md`)}. +Full page index: ${LIBRARY_BASE_URL}/llms.txt + +${sections.join('\n\n')} +` +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const publicDirectory = path.join(process.cwd(), 'public') + fs.mkdirSync(publicDirectory, { recursive: true }) + fs.writeFileSync( + path.join(publicDirectory, 'llms.txt'), + buildLlmsTxt(getDocFiles(path.join(process.cwd(), 'content', 'docs'))) + ) + console.log('Generated llms.txt in public/') + + const indexOutputPath = path.join(publicDirectory, 'markdown', 'index.md') + fs.mkdirSync(path.dirname(indexOutputPath), { recursive: true }) + fs.writeFileSync(indexOutputPath, buildIndexMarkdown()) + console.log('Generated index.md in public/markdown/') +} diff --git a/apps/ui-library/scripts/build-markdown.ts b/apps/ui-library/scripts/build-markdown.ts index 43990d78ba4..9fd8079fd5d 100644 --- a/apps/ui-library/scripts/build-markdown.ts +++ b/apps/ui-library/scripts/build-markdown.ts @@ -1,30 +1,17 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { collectMdxFiles, getDocSlug } from '../lib/library-documents' import { transformLibraryMdx } from '../lib/library-mdx-to-markdown' const CONTENT_DIR = path.join(process.cwd(), 'content', 'docs') const OUTPUT_DIR = path.join(process.cwd(), 'public', 'markdown', 'docs') const MANIFEST_PATH = path.join(process.cwd(), 'public', 'markdown', 'manifest.json') -async function collectMdxFiles(dir: string): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - files.push(...(await collectMdxFiles(fullPath))) - } else if (entry.name.endsWith('.mdx')) { - files.push(fullPath) - } - } - - return files.sort((a, b) => a.localeCompare(b)) -} - async function generate() { - const sources = await collectMdxFiles(CONTENT_DIR) + const sources = collectMdxFiles(CONTENT_DIR) + const documentSlugs = new Set(sources.map((file) => getDocSlug(path.relative(CONTENT_DIR, file)))) + if (documentSlugs.size !== sources.length) throw new Error('Duplicate library document slugs') const slugs: string[] = [] // Wipe first so pages that were renamed or deleted don't leave stale markdown @@ -34,13 +21,16 @@ async function generate() { for (const sourceFile of sources) { const relativePath = path.relative(CONTENT_DIR, sourceFile) - const slug = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') + const slug = getDocSlug(relativePath) + // Keep the "index" segment here (unlike slug) so relative links inside + // foo/index.mdx resolve against foo/, not foo's parent directory. + const documentBasePath = relativePath.replace(/\\/g, '/').replace(/\.mdx$/, '') const outPath = path.join(OUTPUT_DIR, `${slug}.md`) const raw = await fs.readFile(sourceFile, 'utf8') let output: string try { - output = transformLibraryMdx(raw) + output = transformLibraryMdx(raw, { documentSlugs, documentBasePath }) } catch (err) { throw new Error( `Failed to process ${sourceFile}: ${err instanceof Error ? err.message : err}`, diff --git a/apps/ui-library/vitest.config.ts b/apps/ui-library/vitest.config.ts index cb514b82d6d..3395f10734e 100644 --- a/apps/ui-library/vitest.config.ts +++ b/apps/ui-library/vitest.config.ts @@ -19,12 +19,7 @@ export default defineConfig({ test: { name: 'node', environment: 'node', - include: [ - 'middleware.test.ts', - './lib/registry-resolution.test.ts', - './lib/library-mdx-to-markdown.test.ts', - './lib/install-command.test.ts', - ], + include: ['middleware.test.ts', 'scripts/*.test.ts', './lib/*.test.ts'], }, }, ],