Files
supabase/apps/ui-library/lib/markdown-schema.ts
3e79df3ece 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>
2026-09-22 14:14:04 +02:00

175 lines
5.3 KiB
TypeScript

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
const omit: ComponentHandler = () => ''
const unwrap: ComponentHandler = ({ children }) => children
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}`)
}
return name
}
function BlockItem({ props }: HandlerContext): string {
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)}`)
}
const command = getInstallCommands(name, { framework, production: true }).npm
return ['Install this block:', '', '```bash', command, '```'].join('\n')
}
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 {
tree = generateRegistryTree(registryPath)
} catch (error) {
throw new Error(`Cannot export registry files for ${itemName}: ${String(error)}`, {
cause: error,
})
}
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 [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 {
return nodes
.map((node) => {
const prefix = `${' '.repeat(indent)}- \`${node.name}${node.type === 'directory' ? '/' : ''}\``
const children = node.children?.length ? `\n${formatTree(node.children, indent + 1)}` : ''
return `${prefix}${children}`
})
.join('\n')
}
function Callout({ props, children }: HandlerContext): string {
const type = String(props.type ?? 'note')
const label = type.charAt(0).toUpperCase() + type.slice(1)
return `${label}: ${children}`.trim()
}
function AccordionTrigger({ children }: HandlerContext): string {
const title = children.trim()
return title ? `**${title}**` : ''
}
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
}
function ComponentPreview({ props }: HandlerContext): string {
const description = String(props.description ?? '').trim()
return description
}
function TanStackBeta(): string {
return 'Note: TanStack Start support is in beta. APIs may change.'
}
function TanstackDBGenerator(): string {
return [
'This block is generated from your project schema.',
'Open the HTML page to log in and generate an install command:',
'https://supabase.com/library/docs/nextjs/tanstack-db',
].join('\n')
}
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,
AccordionItem: unwrap,
AccordionTrigger,
AccordionContent: unwrap,
Card: unwrap,
LinkedCard,
FrameworkQuickstart: unwrap,
FrameworkQuickstartTab: unwrap,
QuickstartStep: unwrap,
ComponentPreview,
CatalogPreview: omit,
BlockPreview: omit,
DualRealtimeChat: omit,
DualRealtimeFlow: omit,
DualRealtimeMonaco: omit,
RealtimeMonaco: omit,
TanStackBeta,
TanstackDBGenerator,
CopyButton: omit,
svg: omit,
path: omit,
title: omit,
a: Anchor,
}