Files
supabase/apps/ui-library/lib/registry-resolution.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

130 lines
4.5 KiB
TypeScript

import type { RegistryItem } from 'shadcn/schema'
export type RegistryFile = NonNullable<RegistryItem['files']>[number]
/** Canonical folders before the installing project's aliases or src directory are applied. */
export function getInstalledPath(file: { path: string; target?: string }): string {
// A `~/` target opts a file out of the project's src directory, so it is already project-relative.
const source = (file.target || file.path)
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/^~\//, '')
const installedPath = file.target
? source
: source
.replace(/^node_modules\/@supabase\/vue-blocks\//, '')
.replace(/^registry\/[^/]+\/(?:blocks|clients|platform)\/[^/]+\//, '')
if (
!installedPath ||
/^(?:\/|[a-z]:)/i.test(installedPath) ||
installedPath.split('/').some((part) => !part || part === '.' || part === '..')
) {
throw new Error(`Invalid installed path "${installedPath}" for registry file "${file.path}"`)
}
return installedPath
}
/** Keep Vue's alias-based installer from treating package source folders as installed folders. */
export function normalizeVueRegistryFiles(files: RegistryFile[]): RegistryFile[] {
return files.map((file) =>
!file.target && file.path.startsWith('node_modules/@supabase/vue-blocks/')
? { ...file, path: getInstalledPath(file) }
: file
)
}
/** Bare names belong to the CLI's UI registry; only explicit Supabase references are local. */
export function getFirstPartyDependencyName(dependency: string): string | undefined {
const prefix = dependency.startsWith('@supabase/')
? '@supabase/'
: dependency.startsWith('https://supabase.com/library/r/')
? 'https://supabase.com/library/r/'
: undefined
if (!prefix) return undefined
const name = dependency.slice(prefix.length).replace(/\.json$/, '')
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
throw new Error(`Invalid Supabase registry dependency "${dependency}"`)
}
return name
}
export function uniqueInstalledFiles<
T extends { path: string; target?: string; content?: string; type?: string },
>(files: readonly T[], context: string): T[] {
const destinations = new Map<string, T>()
for (const file of files) {
const destination = getInstalledPath(file)
const previous = destinations.get(destination)
if (
previous &&
(previous.path !== file.path ||
previous.type !== file.type ||
previous.content !== file.content)
) {
throw new Error(
`${context}: conflicting destination "${destination}" from "${previous.path}" and "${file.path}"`
)
}
if (!previous) destinations.set(destination, file)
}
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
): 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
visited.add(item.name)
files.push(...(item.files ?? []))
const path = [...ancestors, item.name]
for (const dependency of item.registryDependencies ?? []) {
const localName = getFirstPartyDependencyName(dependency)
if (!localName) {
external.add(dependency)
continue
}
if (path.includes(localName)) {
throw new Error(`Registry dependency cycle: ${[...path, localName].join(' -> ')}`)
}
let dependencyItem: RegistryItem | undefined
try {
dependencyItem = getItem(localName)
} catch (error) {
throw new Error(
`Registry item "${item.name}" references missing dependency "${localName}"`,
{ cause: error }
)
}
if (!dependencyItem) {
throw new Error(`Registry item "${item.name}" references missing dependency "${localName}"`)
}
visit(dependencyItem, path)
}
}
visit(root, [])
return {
...root,
files: uniqueInstalledFiles(files, `Registry item "${name}"`),
firstPartyDependencies: [...visited].filter((itemName) => itemName !== name),
externalRegistryDependencies: [...external],
}
}