Files
supabase/apps/ui-library/velite.config.js
3253595fe4 feat(library): move Open in v0 into the page header (#50371)
## 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 4 of 6 in a stack that splits the library redesign into reviewable
pieces.

## What is the current behavior?

`BlockItem` renders the Open in v0 button, and `BlockItem` sits inside
the Installation section — so the button appears partway down the page.
On guides that install a client first it appears twice, once for the
client and once for the block, and the MDX opts the extra one out with
`showOpenInV0={false}`.

So the page's markup already knows which registry item v0 should open.
Only the component using it is in the wrong place.

## What is the new behavior?

Velite reads that same signal at build time and records the item on the
document, so the page header renders one icon button beside the
framework selector, above the fold. `BlockItem` is left rendering only
the install command.

Guides with no installable item — the getting-started pages, the
TanStack DB generator, and the starters — get no button. Guides that
install a client first resolve to the block, not the client.

## Additional context

63 of 73 documents resolve to a registry item; the 10 that don't are
exactly the ones that should have no button.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Documentation pages now display an “Open in v0” button when supported.
* The button uses a compact, icon-only design alongside the framework
selector.

* **Improvements**
* The applicable v0 destination is determined automatically from
documentation content.
* The page-level action replaces individual block-level v0 buttons,
providing a more consistent experience.
* The button includes an accessible label and appears only for
documentation with a supported v0 destination.
<!-- 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:43:51 +02:00

166 lines
5.2 KiB
JavaScript

import path from 'path'
import { getHighlighter, loadTheme } from '@shikijs/compat'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import { codeImport } from 'remark-code-import'
import remarkGfm from 'remark-gfm'
import { visit } from 'unist-util-visit'
import { defineConfig, s } from 'velite'
const LinksProperties = s.object({
doc: s.string().optional(),
api: s.string().optional(),
})
const NestedProperties = s.object({
radix: s.boolean().optional(),
shadcn: s.boolean().optional(),
vaul: s.boolean().optional(),
inputOtp: s.boolean().optional(),
reactAccessibleTreeview: s.boolean().optional(),
})
// The MDX is the only reliable source for which registry item v0 can open: doc
// slugs don't map onto registry names, and pages that install a client first
// list it as an extra BlockItem that opts out. Take the first item still opted in.
const getV0RegistryName = (raw) => {
for (const [, attributes] of raw.matchAll(/<BlockItem\b([^>]*?)\/?>/g)) {
if (/showOpenInV0=\{false\}/.test(attributes)) continue
const name = attributes.match(/name="([^"]+)"/)
if (name) return name[1]
}
return undefined
}
const docs = s
.object({
title: s.string(),
description: s.string(),
published: s.boolean().default(true),
links: LinksProperties.optional(),
featured: s.boolean().default(false),
component: s.boolean().default(false),
fragment: s.boolean().default(false),
toc: s.boolean().default(true),
source: NestedProperties.optional(),
// mirrors contentlayer2's `_raw.flattenedPath`: file path relative to the
// content dir, extension stripped, trailing `/index` dropped.
path: s.path(),
raw: s.raw(),
// internal doc cross-links (e.g. `[Button](components/button)`) aren't
// real files on disk — disable Velite's default asset-copying behavior,
// which otherwise treats every relative link as a local file to copy.
code: s.mdx({ copyLinkedFiles: false }),
})
.transform(({ path: flattenedPath, ...data }) => ({
...data,
slug: `/${flattenedPath}`,
slugAsParams: flattenedPath.split('/').slice(1).join('/'),
v0Name: getV0RegistryName(data.raw),
}))
export default defineConfig({
root: './content',
output: {
clean: true,
},
collections: {
allDocs: {
name: 'Doc',
pattern: 'docs/**/*.mdx',
schema: docs,
},
},
mdx: {
remarkPlugins: [remarkGfm, codeImport],
rehypePlugins: [
rehypeSlug,
() => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
const [codeEl] = node.children
if (codeEl.tagName !== 'code') {
return
}
if (codeEl.data?.meta) {
// Extract event from meta and pass it down the tree.
const regex = /event="([^"]*)"/
const match = codeEl.data?.meta.match(regex)
if (match) {
node.__event__ = match ? match[1] : null
codeEl.data.meta = codeEl.data.meta.replace(regex, '')
}
}
node.__rawString__ = codeEl.children?.[0].value
node.__src__ = node.properties?.__src__
node.__style__ = node.properties?.__style__
}
})
},
[
rehypePrettyCode,
{
getHighlighter: async () => {
const theme = await loadTheme(path.join(process.cwd(), '/lib/themes/supabase-2.json'))
return await getHighlighter({ theme })
},
onVisitLine(node) {
// Prevent lines from collapsing in `display: grid` mode, and allow empty
// lines to be copy/pasted
if (node.children.length === 0) {
node.children = [{ type: 'text', value: ' ' }]
}
},
onVisitHighlightedLine(node) {
node.properties.className.push('line--highlighted')
},
onVisitHighlightedWord(node) {
node.properties.className = ['word--highlighted']
},
},
],
() => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'div') {
if (!('data-rehype-pretty-code-fragment' in node.properties)) {
return
}
const preElement = node.children.at(-1)
if (preElement.tagName !== 'pre') {
return
}
preElement.properties['__withMeta__'] = node.children.at(0).tagName === 'div'
preElement.properties['__rawString__'] = node.__rawString__
if (node.__src__) {
preElement.properties['__src__'] = node.__src__
}
if (node.__event__) {
preElement.properties['__event__'] = node.__event__
}
if (node.__style__) {
preElement.properties['__style__'] = node.__style__
}
}
})
},
[
rehypeAutolinkHeadings,
{
properties: {
className: ['subheading-anchor'],
ariaLabel: 'Link to section',
},
},
],
],
},
})