mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
18 files changed
+824
-202
No files matched your search
@@ -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' },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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*'],
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const nextConfig = {
|
||||
},
|
||||
outputFileTracingIncludes: {
|
||||
'/api/docs-md/**/*': ['./public/markdown/docs/**/*'],
|
||||
'/api/index-md/**/*': ['./public/markdown/index.md'],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/')
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user