feat(library): serve the block catalog as Markdown and harden the exporter (#50370)

## 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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-09-22 14:14:04 +02:00
1 parent aef1599d3e
commit 3e79df3ece
18 files changed
+824 -202

No files matched your search

+26
View File
@@ -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' },
}
)
}
}
+211
View File
@@ -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<string, Pick<LibraryBlock, 'description' | 'category' | 'preview'>> = {
'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)}`
}
@@ -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)
}
})
})
@@ -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/)
})
})
+63
View File
@@ -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<string>,
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})`
}
@@ -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
+23 -25
View File
@@ -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<string, unknown>
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<string, unknown> {
return props
}
function applySchema(parent: Parent, schema: Record<string, ComponentHandler>): void {
function applySchema(
parent: Parent,
schema: Record<string, ComponentHandler>,
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<string, ComponentHandler>):
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<string, ComponentHandler>):
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[] = []
+71 -30
View File
@@ -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<string>
documentBasePath?: string
}
type HandlerContext = {
props: Record<string, unknown>
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<string, unknown>, 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<string, ComponentHandler> = {
BlockItem,
BlockOverview,
RegistryBlock,
Callout,
Accordion: unwrap,
@@ -117,7 +154,11 @@ export const markdownSchema: Record<string, ComponentHandler> = {
AccordionContent: unwrap,
Card: unwrap,
LinkedCard,
FrameworkQuickstart: unwrap,
FrameworkQuickstartTab: unwrap,
QuickstartStep: unwrap,
ComponentPreview,
CatalogPreview: omit,
BlockPreview: omit,
DualRealtimeChat: omit,
DualRealtimeFlow: omit,
+18 -3
View File
@@ -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<string>()
const external = new Set<string>()
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],
}
}
+29
View File
@@ -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()
})
})
+28 -9
View File
@@ -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*'],
}
+1
View File
@@ -12,6 +12,7 @@ const nextConfig = {
},
outputFileTracingIncludes: {
'/api/docs-md/**/*': ['./public/markdown/docs/**/*'],
'/api/index-md/**/*': ['./public/markdown/index.md'],
},
async redirects() {
return [
+4 -4
View File
@@ -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",
-106
View File
@@ -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')
@@ -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'))
})
})
@@ -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/<framework>/<block>.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/')
}
+9 -19
View File
@@ -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<string[]> {
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}`,
+1 -6
View File
@@ -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'],
},
},
],