diff --git a/.github/workflows/library-tests.yml b/.github/workflows/library-tests.yml
new file mode 100644
index 00000000000..9eca55ab6e4
--- /dev/null
+++ b/.github/workflows/library-tests.yml
@@ -0,0 +1,60 @@
+name: Library checks
+
+on:
+ # No branch filter: a stacked pull request targets the branch below it, and
+ # skipping its checks until the stack reaches master defeats the point.
+ pull_request:
+ paths:
+ - 'apps/ui-library/**'
+ - 'blocks/vue/**'
+ - 'packages/ui/**'
+ - 'packages/ui-patterns/**'
+ - 'packages/common/**'
+ - 'packages/icons/**'
+ - 'packages/shared-data/**'
+ - 'packages/api-types/**'
+ - 'packages/config/**'
+ - 'packages/tsconfig/**'
+ - 'packages/eslint-config-supabase/**'
+ - 'patches/**'
+ - 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
+ - 'package.json'
+ - '.github/workflows/library-tests.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ with:
+ run_install: false
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'pnpm'
+
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter library test
+ - run: pnpm --filter library build:registry
+ - name: Check generated registry
+ run: |
+ registry_changes="$(git status --porcelain --untracked-files=all -- apps/ui-library/public/r)"
+ if [ -n "$registry_changes" ]; then
+ printf '%s\n' "$registry_changes"
+ echo 'Run pnpm --filter library build:registry and commit the generated registry files.'
+ exit 1
+ fi
+ - run: pnpm --filter library build
diff --git a/apps/ui-library/components/block-item.tsx b/apps/ui-library/components/block-item.tsx
index 26c6d9504ec..058438feef6 100644
--- a/apps/ui-library/components/block-item.tsx
+++ b/apps/ui-library/components/block-item.tsx
@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic'
import { OpenInV0Button } from '@/components/open-in-v0-button'
+import type { ShadcnFramework } from '@/lib/install-command'
//The dynamic import is to prevent the command component from being rendered on the server and cause hydration errors
const Command = dynamic(() => import('./command').then((mod) => mod.Command), { ssr: false })
@@ -10,11 +11,10 @@ const Command = dynamic(() => import('./command').then((mod) => mod.Command), {
interface BlockItemProps {
name: string
showOpenInV0?: boolean
+ framework?: ShadcnFramework
}
-export const BlockItem = ({ name, showOpenInV0 = true }: BlockItemProps) => {
- const framework = name.includes('vue') || name.includes('nuxtjs') ? 'vue' : 'react'
-
+export const BlockItem = ({ name, showOpenInV0 = true, framework = 'react' }: BlockItemProps) => {
return (
diff --git a/apps/ui-library/content/docs/nuxtjs/client.mdx b/apps/ui-library/content/docs/nuxtjs/client.mdx
index 9eb258de305..2c55661e987 100644
--- a/apps/ui-library/content/docs/nuxtjs/client.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/client.mdx
@@ -5,7 +5,11 @@ description: Supabase client for Nuxt.js
## Installation
-
+
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/current-user-avatar.mdx b/apps/ui-library/content/docs/nuxtjs/current-user-avatar.mdx
index 29fe776f34e..6c6c87ac620 100644
--- a/apps/ui-library/content/docs/nuxtjs/current-user-avatar.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/current-user-avatar.mdx
@@ -10,6 +10,7 @@ description: Supabase Auth-aware avatar
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/dropzone.mdx b/apps/ui-library/content/docs/nuxtjs/dropzone.mdx
index e36e378e199..4c0c43f3ef0 100644
--- a/apps/ui-library/content/docs/nuxtjs/dropzone.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/dropzone.mdx
@@ -14,6 +14,7 @@ description: Displays a control for easier uploading of files directly to Supaba
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/password-based-auth.mdx b/apps/ui-library/content/docs/nuxtjs/password-based-auth.mdx
index 858a09f4cbc..231528737ae 100644
--- a/apps/ui-library/content/docs/nuxtjs/password-based-auth.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/password-based-auth.mdx
@@ -10,6 +10,7 @@ description: Password-based authentication block for Nuxt.js
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/realtime-avatar-stack.mdx b/apps/ui-library/content/docs/nuxtjs/realtime-avatar-stack.mdx
index d66a06b482e..66ed791bf58 100644
--- a/apps/ui-library/content/docs/nuxtjs/realtime-avatar-stack.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/realtime-avatar-stack.mdx
@@ -10,6 +10,7 @@ description: Avatar stack in realtime
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/realtime-chat.mdx b/apps/ui-library/content/docs/nuxtjs/realtime-chat.mdx
index e1435d6941f..8553c12694e 100644
--- a/apps/ui-library/content/docs/nuxtjs/realtime-chat.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/realtime-chat.mdx
@@ -10,6 +10,7 @@ description: Real-time chat component for collaborative applications
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx b/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx
index 7374feaceca..4828e72dd9a 100644
--- a/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx
@@ -13,6 +13,7 @@ description: Real-time cursor sharing for collaborative applications
## Folder structure
diff --git a/apps/ui-library/content/docs/nuxtjs/social-auth.mdx b/apps/ui-library/content/docs/nuxtjs/social-auth.mdx
index 47b0f62b2ab..d3e2afbd190 100644
--- a/apps/ui-library/content/docs/nuxtjs/social-auth.mdx
+++ b/apps/ui-library/content/docs/nuxtjs/social-auth.mdx
@@ -12,7 +12,11 @@ description: Social authentication block for Nuxt.js
## Installation
-
+
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/client.mdx b/apps/ui-library/content/docs/vue/client.mdx
index 8b4804eae22..699863f284b 100644
--- a/apps/ui-library/content/docs/vue/client.mdx
+++ b/apps/ui-library/content/docs/vue/client.mdx
@@ -5,7 +5,7 @@ description: Supabase client for Vue Single Page Applications
## Installation
-
+
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/current-user-avatar.mdx b/apps/ui-library/content/docs/vue/current-user-avatar.mdx
index d910c54510e..5ca6dbd99d9 100644
--- a/apps/ui-library/content/docs/vue/current-user-avatar.mdx
+++ b/apps/ui-library/content/docs/vue/current-user-avatar.mdx
@@ -7,7 +7,11 @@ description: Supabase Auth-aware avatar
## Installation
-
+
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/dropzone.mdx b/apps/ui-library/content/docs/vue/dropzone.mdx
index b32bae065bf..cb1a590ed1f 100644
--- a/apps/ui-library/content/docs/vue/dropzone.mdx
+++ b/apps/ui-library/content/docs/vue/dropzone.mdx
@@ -14,6 +14,7 @@ description: Displays a control for easier uploading of files directly to Supaba
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/infinite-query.mdx b/apps/ui-library/content/docs/vue/infinite-query.mdx
index fd713011c63..3075a1626dc 100644
--- a/apps/ui-library/content/docs/vue/infinite-query.mdx
+++ b/apps/ui-library/content/docs/vue/infinite-query.mdx
@@ -10,6 +10,7 @@ description: Vue Composable for infinite lists, fetching data from Supabase.
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/password-based-auth.mdx b/apps/ui-library/content/docs/vue/password-based-auth.mdx
index b16d398e242..d5f5d27516c 100644
--- a/apps/ui-library/content/docs/vue/password-based-auth.mdx
+++ b/apps/ui-library/content/docs/vue/password-based-auth.mdx
@@ -10,6 +10,7 @@ description: Password-based authentication block for Vue Single Page Application
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/realtime-avatar-stack.mdx b/apps/ui-library/content/docs/vue/realtime-avatar-stack.mdx
index a7eb4bc40c6..c7eb18473b4 100644
--- a/apps/ui-library/content/docs/vue/realtime-avatar-stack.mdx
+++ b/apps/ui-library/content/docs/vue/realtime-avatar-stack.mdx
@@ -10,6 +10,7 @@ description: Avatar stack in realtime
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/realtime-chat.mdx b/apps/ui-library/content/docs/vue/realtime-chat.mdx
index 2fc60e7f0c9..8ec969e039f 100644
--- a/apps/ui-library/content/docs/vue/realtime-chat.mdx
+++ b/apps/ui-library/content/docs/vue/realtime-chat.mdx
@@ -10,6 +10,7 @@ description: Real-time chat component for collaborative applications
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/realtime-cursor.mdx b/apps/ui-library/content/docs/vue/realtime-cursor.mdx
index cc7780ce58d..6af7dc6e44c 100644
--- a/apps/ui-library/content/docs/vue/realtime-cursor.mdx
+++ b/apps/ui-library/content/docs/vue/realtime-cursor.mdx
@@ -13,6 +13,7 @@ description: Real-time cursor sharing for collaborative applications
## Folder structure
diff --git a/apps/ui-library/content/docs/vue/social-auth.mdx b/apps/ui-library/content/docs/vue/social-auth.mdx
index e49cd600969..875a5ac4ef0 100644
--- a/apps/ui-library/content/docs/vue/social-auth.mdx
+++ b/apps/ui-library/content/docs/vue/social-auth.mdx
@@ -12,7 +12,11 @@ description: Social authentication block for Vue Single Page Applications
## Installation
-
+
## Folder structure
diff --git a/apps/ui-library/lib/install-command.test.ts b/apps/ui-library/lib/install-command.test.ts
new file mode 100644
index 00000000000..827532cf38c
--- /dev/null
+++ b/apps/ui-library/lib/install-command.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { getInstallCommands } from '../lib/install-command'
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('registry install commands', () => {
+ it('uses the React namespace in production for each package manager', () => {
+ expect(getInstallCommands('dropzone-react', { production: true })).toEqual({
+ npm: 'npx shadcn@latest add @supabase/dropzone-react',
+ pnpm: 'pnpm dlx shadcn@latest add @supabase/dropzone-react',
+ yarn: 'yarn dlx shadcn@latest add @supabase/dropzone-react',
+ bun: 'bunx --bun shadcn@latest add @supabase/dropzone-react',
+ })
+ })
+
+ it('uses the explicit Vue CLI and absolute production URL without namespace setup', () => {
+ for (const name of ['dropzone-vue', 'dropzone-nuxtjs', 'infinite-query-composable']) {
+ const commands = getInstallCommands(name, { framework: 'vue', production: true })
+ expect(commands.npm).toBe(
+ `npx shadcn-vue@latest add https://supabase.com/library/r/${name}.json`
+ )
+ expect(
+ Object.values(commands).every((command) =>
+ command.includes('shadcn-vue@latest add https://')
+ )
+ ).toBe(true)
+ expect(Object.values(commands).every((command) => !command.includes('@supabase/'))).toBe(true)
+ }
+ })
+
+ it('does not guess the CLI from a registry name', () => {
+ expect(
+ getInstallCommands('vue-named-react-component', { framework: 'react', production: true }).npm
+ ).toBe('npx shadcn@latest add @supabase/vue-named-react-component')
+ })
+
+ it('uses the configured preview hostname and base path for either CLI', () => {
+ vi.stubEnv('NEXT_PUBLIC_VERCEL_TARGET_ENV', 'preview')
+ vi.stubEnv('NEXT_PUBLIC_VERCEL_BRANCH_URL', 'library-example.vercel.app')
+ vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/library')
+
+ expect(getInstallCommands('infinite-query-composable', { framework: 'vue' }).pnpm).toBe(
+ 'pnpm dlx shadcn-vue@latest add https://library-example.vercel.app/library/r/infinite-query-composable.json'
+ )
+ expect(getInstallCommands('dropzone-react').npm).toBe(
+ 'npx shadcn@latest add https://library-example.vercel.app/library/r/dropzone-react.json'
+ )
+ })
+
+ it('uses local registry URLs with and without a base path', () => {
+ vi.stubEnv('NEXT_PUBLIC_VERCEL_TARGET_ENV', 'development')
+ vi.stubEnv('NEXT_PUBLIC_BASE_PATH', undefined)
+
+ expect(getInstallCommands('dropzone-react').npm).toBe(
+ 'npx shadcn@latest add http://localhost:3004/r/dropzone-react.json'
+ )
+
+ vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/library')
+ expect(getInstallCommands('dropzone-react').npm).toBe(
+ 'npx shadcn@latest add http://localhost:3004/library/r/dropzone-react.json'
+ )
+ })
+})
diff --git a/apps/ui-library/lib/install-command.ts b/apps/ui-library/lib/install-command.ts
index 39185f6fa3a..85a7dff3221 100644
--- a/apps/ui-library/lib/install-command.ts
+++ b/apps/ui-library/lib/install-command.ts
@@ -1,38 +1,25 @@
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
export type ShadcnFramework = 'react' | 'vue'
-export function getShadcnFramework(name: string): ShadcnFramework {
- return name.includes('vue') || name.includes('nuxtjs') ? 'vue' : 'react'
-}
-
-export function getRegistryBaseUrl(env = process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV): string {
+function getRegistrySpecifier(name: string, framework: ShadcnFramework, env?: string): string {
if (env === 'production') {
- // Special alias for production, added in https://github.com/shadcn-ui/ui/pull/8161
- return '@supabase'
+ // The Supabase namespace is registered with shadcn, but not shadcn-vue.
+ return framework === 'vue' ? `https://supabase.com/library/r/${name}.json` : `@supabase/${name}`
}
- if (env === 'preview') {
- return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
- }
- return 'http://localhost:3004'
-}
-
-export function getRegistryComponentPath(
- name: string,
- env = process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV
-): string {
- if (env === 'production') {
- return `/${name}`
- }
- return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/r/${name}.json`
+ const origin =
+ env === 'preview'
+ ? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
+ : 'http://localhost:3004'
+ return `${origin}${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/r/${name}.json`
}
export function getInstallCommands(
name: string,
options?: { framework?: ShadcnFramework; production?: boolean }
): Record
{
- const framework = options?.framework ?? getShadcnFramework(name)
+ const framework = options?.framework ?? 'react'
const env = options?.production ? 'production' : process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV
- const specifier = `${getRegistryBaseUrl(env)}${getRegistryComponentPath(name, env)}`
+ const specifier = getRegistrySpecifier(name, framework, env)
const cli = framework === 'vue' ? 'shadcn-vue@latest' : 'shadcn@latest'
return {
diff --git a/apps/ui-library/scripts/library-mdx-to-markdown.test.ts b/apps/ui-library/lib/library-mdx-to-markdown.test.ts
similarity index 72%
rename from apps/ui-library/scripts/library-mdx-to-markdown.test.ts
rename to apps/ui-library/lib/library-mdx-to-markdown.test.ts
index d8de6abd293..26d9510cfad 100644
--- a/apps/ui-library/scripts/library-mdx-to-markdown.test.ts
+++ b/apps/ui-library/lib/library-mdx-to-markdown.test.ts
@@ -1,5 +1,4 @@
-import assert from 'node:assert/strict'
-import { describe, it } from 'node:test'
+import { describe, expect, it } from 'vitest'
import { transformLibraryMdx } from './library-mdx-to-markdown'
@@ -13,8 +12,7 @@ description: Password-based authentication block for Next.js
Hello world.
`)
- assert.match(
- output,
+ expect(output).toMatch(
/^# Password-based Authentication\n\nPassword-based authentication block for Next.js\n\nHello world.\n$/
)
})
@@ -28,22 +26,23 @@ description: Auth block
`)
- assert.match(
- output,
+ expect(output).toMatch(
/```bash\nnpx shadcn@latest add @supabase\/password-based-auth-nextjs\n```/
)
})
- it('uses shadcn-vue for Vue and Nuxt blocks', () => {
+ it('uses an explicit Vue CLI family and a production URL without a configured namespace', () => {
const output = transformLibraryMdx(`---
title: Dropzone
description: Vue dropzone
---
-
+
`)
- assert.match(output, /npx shadcn-vue@latest add @supabase\/dropzone-vue/)
+ expect(output).toMatch(
+ /npx shadcn-vue@latest add https:\/\/supabase\.com\/library\/r\/dropzone-vue.json/
+ )
})
it('keeps usage copy and omits interactive previews', () => {
@@ -61,10 +60,10 @@ Set \`NEXT_PUBLIC_SUPABASE_URL\` in \`.env.local\`.
This client is built for SSR.
`)
- assert.doesNotMatch(output, /BlockPreview/)
- assert.match(output, /## Usage/)
- assert.match(output, /NEXT_PUBLIC_SUPABASE_URL/)
- assert.match(output, /Warning: This client is built for SSR\./)
+ expect(output).not.toMatch(/BlockPreview/)
+ expect(output).toMatch(/## Usage/)
+ expect(output).toMatch(/NEXT_PUBLIC_SUPABASE_URL/)
+ expect(output).toMatch(/Warning: This client is built for SSR\./)
})
it('rewrites library docs links to absolute markdown URLs', () => {
@@ -76,7 +75,7 @@ description: Client
See the [React client](/library/docs/react/client).
`)
- assert.match(output, /https:\/\/supabase\.com\/library\/docs\/react\/client\.md/)
+ expect(output).toMatch(/https:\/\/supabase\.com\/library\/docs\/react\/client\.md/)
})
it('rewrites documentation links nested inside components', () => {
@@ -90,8 +89,7 @@ See the [React client](/library/docs/react/client) before installing.
`)
- assert.match(
- output,
+ expect(output).toMatch(
/Note: See the \[React client\]\(https:\/\/supabase\.com\/library\/docs\/react\/client\.md\) before installing\./
)
})
diff --git a/apps/ui-library/scripts/library-mdx-to-markdown.ts b/apps/ui-library/lib/library-mdx-to-markdown.ts
similarity index 100%
rename from apps/ui-library/scripts/library-mdx-to-markdown.ts
rename to apps/ui-library/lib/library-mdx-to-markdown.ts
diff --git a/apps/ui-library/scripts/markdown-schema.ts b/apps/ui-library/lib/markdown-schema.ts
similarity index 93%
rename from apps/ui-library/scripts/markdown-schema.ts
rename to apps/ui-library/lib/markdown-schema.ts
index f61f37a1527..982c3df8922 100644
--- a/apps/ui-library/scripts/markdown-schema.ts
+++ b/apps/ui-library/lib/markdown-schema.ts
@@ -29,7 +29,11 @@ function toAgentHref(href: string): string {
function BlockItem({ props }: HandlerContext): string {
const name = String(props.name ?? '')
if (!name) return ''
- const command = getInstallCommands(name, { production: true }).npm
+ 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')
}
diff --git a/apps/ui-library/lib/process-registry.ts b/apps/ui-library/lib/process-registry.ts
index 738d6ab3507..9137c740f69 100644
--- a/apps/ui-library/lib/process-registry.ts
+++ b/apps/ui-library/lib/process-registry.ts
@@ -1,4 +1,8 @@
import * as fs from 'fs'
+import path from 'node:path'
+import { registryItemSchema, type RegistryItem } from 'shadcn/schema'
+
+import { getInstalledPath, resolveRegistryItem, type RegistryFile } from './registry-resolution'
export interface RegistryNode {
name: string
@@ -9,30 +13,43 @@ export interface RegistryNode {
content?: string
}
-interface RegistryFile {
- path: string
- target?: string
- type: string
- content: string
+export function readRegistryItem(registryPath: string): RegistryItem {
+ try {
+ const item = registryItemSchema.parse(JSON.parse(fs.readFileSync(registryPath, 'utf-8')))
+ if (item.name !== path.basename(registryPath, '.json')) {
+ throw new Error(`Artifact name "${item.name}" does not match its filename`)
+ }
+ if (!item.files?.length || item.files.some((file) => typeof file.content !== 'string')) {
+ throw new Error('Expected generated registry files with source content')
+ }
+ return item
+ } catch (error) {
+ throw new Error(`Unable to read required registry artifact "${registryPath}"`, { cause: error })
+ }
}
-const DEFAULT_PATHS = {
- component: '/components',
- hook: '/hooks',
- util: '/lib',
-} as const
-
/**
* Converts a flat registry array into a hierarchical file tree structure
*/
export function generateRegistryTree(registryPath: string): RegistryNode[] {
- const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as { files: RegistryFile[] }
+ const root = readRegistryItem(registryPath)
+ const directory = path.dirname(registryPath)
+ const resolved = resolveRegistryItem(
+ (name) => (name === root.name ? root : readRegistryItem(path.join(directory, `${name}.json`))),
+ root.name
+ )
+ return registryFilesToTree(resolved.files)
+}
+
+function registryFilesToTree(files: RegistryFile[]): RegistryNode[] {
const tree: RegistryNode[] = []
- const sortedRegistry = [...registry.files].sort((a, b) => a.path.localeCompare(b.path))
+ const sortedRegistry = [...files].sort((a, b) =>
+ getInstalledPath(a).localeCompare(getInstalledPath(b))
+ )
for (const file of sortedRegistry) {
- const itemPath = (file.target || getDefaultPath(file)).replace(/^~\//, '')
+ const itemPath = getInstalledPath(file)
const pathParts = itemPath.split('/').filter(Boolean)
let currentLevel = tree
@@ -44,8 +61,8 @@ export function generateRegistryTree(registryPath: string): RegistryNode[] {
let node = currentLevel.find((n) => n.name === part)
// Remove any paths in the file content that point to the block directory.
- const content = file.content
- .replaceAll(/@\/registry\/default\/blocks\/.+?\//gi, '@/')
+ const content = file
+ .content!.replaceAll(/@\/registry\/default\/blocks\/.+?\//gi, '@/')
.replaceAll(/@\/registry\/default\/fixtures\//gi, '@/')
.replaceAll(/@\/registry\/default\//gi, '@/')
.replaceAll(/@\/clients\/.+?\//gi, '@/')
@@ -70,17 +87,3 @@ export function generateRegistryTree(registryPath: string): RegistryNode[] {
return tree
}
-
-/**
- * Determines the default path for an item based on its type
- */
-function getDefaultPath(item: RegistryFile): string {
- const type = item.type.toLowerCase() || ''
- const basePath = DEFAULT_PATHS[type as keyof typeof DEFAULT_PATHS] || ''
- // clean all paths that start with paths specific to this repo organization
- const filePath = item.path
- .replace(/registry\/default\/blocks\/.+?\//, '')
- .replace(/registry\/default\/clients\/.+?\//, '')
-
- return `${basePath}/${filePath}`
-}
diff --git a/apps/ui-library/lib/registry-resolution.test.ts b/apps/ui-library/lib/registry-resolution.test.ts
new file mode 100644
index 00000000000..16edb554223
--- /dev/null
+++ b/apps/ui-library/lib/registry-resolution.test.ts
@@ -0,0 +1,188 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { registrySchema, type RegistryItem } from 'shadcn/schema'
+import { describe, expect, it } from 'vitest'
+
+import { registry } from '../registry'
+import { registryItemAppend } from '../registry/utils'
+import { generateRegistryTree, type RegistryNode } from './process-registry'
+import {
+ getInstalledPath,
+ normalizeVueRegistryFiles,
+ resolveRegistryItem,
+} from './registry-resolution'
+
+function item(name: string, overrides: Partial = {}): RegistryItem {
+ return {
+ name,
+ type: 'registry:block',
+ files: [
+ { path: `lib/${name}.ts`, type: 'registry:lib', content: `export const ${name} = true` },
+ ],
+ ...overrides,
+ }
+}
+
+function lookup(items: readonly RegistryItem[]) {
+ return (name: string) => items.find((candidate) => candidate.name === name)
+}
+
+function treePaths(nodes: RegistryNode[]): string[] {
+ return nodes.flatMap((node) =>
+ node.type === 'file' ? [node.path.slice(1)] : treePaths(node.children ?? [])
+ )
+}
+
+describe('registry composition and resolution', () => {
+ it('preserves block and appended client instructions without undefined dependencies', () => {
+ const composed = registryItemAppend(item('block', { docs: 'Configure the block.' }), [
+ item('client', { docs: 'Set the client environment variables.' }),
+ item('utility'),
+ ])
+ expect(composed.docs).toBe('Configure the block.\n\nSet the client environment variables.')
+ expect(composed.dependencies).toEqual([])
+ expect(composed.registryDependencies).toEqual([])
+ })
+
+ it('resolves local dependency diamonds once and skips external UI dependencies', () => {
+ const items = [
+ item('root', {
+ registryDependencies: [
+ '@supabase/first',
+ 'https://supabase.com/library/r/second.json',
+ 'button',
+ ],
+ }),
+ item('first', { registryDependencies: ['@supabase/shared', 'card'] }),
+ item('second', { registryDependencies: ['@supabase/shared', 'button'] }),
+ item('shared'),
+ ]
+ const before = JSON.stringify(items)
+ const resolved = resolveRegistryItem(lookup(items), 'root')
+ expect(resolved.files.map((file) => file.path)).toEqual([
+ 'lib/root.ts',
+ 'lib/first.ts',
+ 'lib/shared.ts',
+ 'lib/second.ts',
+ ])
+ expect(JSON.stringify(items)).toBe(before)
+ })
+
+ it('fails with context for missing local dependencies and cycles', () => {
+ expect(() =>
+ resolveRegistryItem(
+ lookup([item('root', { registryDependencies: ['@supabase/missing'] })]),
+ 'root'
+ )
+ ).toThrow(/"root" references missing dependency "missing"/)
+ expect(() =>
+ resolveRegistryItem(
+ lookup([
+ item('root', { registryDependencies: ['@supabase/child'] }),
+ item('child', { registryDependencies: ['@supabase/root'] }),
+ ]),
+ 'root'
+ )
+ ).toThrow(/Registry dependency cycle: root -> child -> root/)
+ expect(() => resolveRegistryItem(lookup([]), 'root')).toThrow(/Missing registry item "root"/)
+ })
+
+ it('rejects destination collisions across composed files and local dependencies', () => {
+ const conflicting = item('dependency', {
+ files: [{ path: 'other.ts', target: 'lib/root.ts', type: 'registry:lib' }],
+ })
+ expect(() => registryItemAppend(item('root'), [conflicting])).toThrow(
+ /conflicting destination "lib\/root.ts"/
+ )
+ expect(() =>
+ resolveRegistryItem(
+ lookup([item('root', { registryDependencies: ['@supabase/dependency'] }), conflicting]),
+ 'root'
+ )
+ ).toThrow(/conflicting destination "lib\/root.ts"/)
+ })
+
+ it('normalizes source packaging and explicit targets consistently', () => {
+ expect(getInstalledPath({ path: 'registry/default/platform/example/lib/client.ts' })).toBe(
+ 'lib/client.ts'
+ )
+ expect(
+ getInstalledPath({
+ path: 'node_modules/@supabase/vue-blocks/registry/default/clients/vue/lib/supabase/client.ts',
+ })
+ ).toBe('lib/supabase/client.ts')
+ expect(getInstalledPath({ path: 'some/source.ts', target: './app/client.ts' })).toBe(
+ 'app/client.ts'
+ )
+ // `~/` keeps backend files out of the installing project's src directory.
+ expect(
+ getInstalledPath({
+ path: 'registry/default/blocks/mcp-server/supabase/functions/mcp-server/index.ts',
+ target: '~/supabase/functions/mcp-server/index.ts',
+ })
+ ).toBe('supabase/functions/mcp-server/index.ts')
+ expect(() => getInstalledPath({ path: 'source.ts', target: '../outside.ts' })).toThrow(
+ /Invalid installed path/
+ )
+ })
+
+ it('normalizes Vue library source paths for alias-aware installation without overriding targets', () => {
+ const source = registry.items.find((item) => item.name === 'supabase-client-nuxtjs')!
+ const before = JSON.stringify(source.files)
+ const files = normalizeVueRegistryFiles(source.files ?? [])
+ expect(files[0].path).toBe('lib/supabase/client.ts')
+ expect(files[0].target).toBeUndefined()
+ expect(files.slice(1)).toEqual(source.files?.slice(1))
+ expect(JSON.stringify(source.files)).toBe(before)
+ expect(normalizeVueRegistryFiles(files)).toEqual(files)
+ })
+
+ it('validates the published registry definitions and includes safe-next-path in auth inventory', () => {
+ registrySchema.parse(registry)
+ const getItem = lookup(registry.items)
+ for (const definition of registry.items) {
+ resolveRegistryItem(getItem, definition.name)
+ }
+ const auth = resolveRegistryItem(getItem, 'password-based-auth-nextjs')
+ expect(auth.files.some((file) => getInstalledPath(file) === 'lib/safe-next-path.ts')).toBe(true)
+ const tree = generateRegistryTree(
+ new URL('../public/r/password-based-auth-nextjs.json', import.meta.url).pathname
+ )
+ expect(treePaths(tree).sort()).toEqual(auth.files.map(getInstalledPath).sort())
+ })
+
+ it('requires valid root and local dependency artifacts with source content', ({
+ onTestFinished,
+ }) => {
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'library-registry-test-'))
+ onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
+ const registryPath = path.join(directory, 'root.json')
+ expect(() => generateRegistryTree(registryPath)).toThrow(
+ /required registry artifact.*root.json/
+ )
+ writeFileSync(registryPath, '{')
+ expect(() => generateRegistryTree(registryPath)).toThrow(
+ /required registry artifact.*root.json/
+ )
+ writeFileSync(
+ registryPath,
+ JSON.stringify(item('root', { registryDependencies: ['@supabase/child'] }))
+ )
+ expect(() => generateRegistryTree(registryPath)).toThrow(
+ /Registry item "root" references missing dependency "child"/
+ )
+ writeFileSync(path.join(directory, 'child.json'), JSON.stringify(item('child')))
+ expect(treePaths(generateRegistryTree(registryPath)).sort()).toEqual([
+ 'lib/child.ts',
+ 'lib/root.ts',
+ ])
+ writeFileSync(
+ path.join(directory, 'child.json'),
+ JSON.stringify(item('child', { files: [{ path: 'lib/child.ts', type: 'registry:lib' }] }))
+ )
+ expect(() => generateRegistryTree(registryPath)).toThrow(
+ /Registry item "root" references missing dependency "child"/
+ )
+ })
+})
diff --git a/apps/ui-library/lib/registry-resolution.ts b/apps/ui-library/lib/registry-resolution.ts
new file mode 100644
index 00000000000..299039d10cf
--- /dev/null
+++ b/apps/ui-library/lib/registry-resolution.ts
@@ -0,0 +1,114 @@
+import type { RegistryItem } from 'shadcn/schema'
+
+export type RegistryFile = NonNullable[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()
+ 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()]
+}
+
+/** 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[] } {
+ const root = getItem(name)
+ if (!root) throw new Error(`Missing registry item "${name}"`)
+
+ const visited = new Set()
+ 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) 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}"`) }
+}
diff --git a/apps/ui-library/middleware.test.ts b/apps/ui-library/middleware.test.ts
index 5c665689974..e02a847a5e6 100644
--- a/apps/ui-library/middleware.test.ts
+++ b/apps/ui-library/middleware.test.ts
@@ -1,6 +1,5 @@
-import assert from 'node:assert/strict'
-import { describe, it } from 'node:test'
import { NextRequest } from 'next/server'
+import { describe, expect, it } from 'vitest'
import { middleware } from './middleware'
@@ -14,8 +13,7 @@ describe('middleware markdown negotiation', () => {
it('rewrites to the markdown route when Accept prefers markdown', () => {
const response = middleware(request(DOCS_URL, { accept: 'text/markdown' }))
- assert.equal(
- response.headers.get('x-middleware-rewrite'),
+ expect(response.headers.get('x-middleware-rewrite')).toBe(
'https://supabase.com/library/api/docs-md/nextjs/client'
)
})
@@ -23,7 +21,7 @@ describe('middleware markdown negotiation', () => {
it('406s when Accept rejects both html and markdown', () => {
const response = middleware(request(DOCS_URL, { accept: 'application/json' }))
- assert.equal(response.status, 406)
+ expect(response.status).toBe(406)
})
it('passes Server Action requests through untouched', () => {
@@ -35,8 +33,8 @@ describe('middleware markdown negotiation', () => {
})
)
- assert.equal(response.status, 200)
- assert.equal(response.headers.get('x-middleware-rewrite'), null)
+ expect(response.status).toBe(200)
+ expect(response.headers.get('x-middleware-rewrite')).toBeNull()
})
it('serves html to browsers', () => {
@@ -44,7 +42,7 @@ describe('middleware markdown negotiation', () => {
request(DOCS_URL, { accept: 'text/html,application/xhtml+xml,*/*;q=0.8' })
)
- assert.equal(response.status, 200)
- assert.equal(response.headers.get('x-middleware-rewrite'), null)
+ expect(response.status).toBe(200)
+ expect(response.headers.get('x-middleware-rewrite')).toBeNull()
})
})
diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json
index e4c2b22aca5..6a9944a860f 100644
--- a/apps/ui-library/package.json
+++ b/apps/ui-library/package.json
@@ -14,15 +14,13 @@
"build:llms": "tsx ./scripts/build-llms-txt.ts",
"build:next": "next build --turbopack",
"build": "run-p build:content build:registry build:llms build:markdown && pnpm build:next",
- "test:markdown": "tsx --test scripts/library-mdx-to-markdown.test.ts",
- "test:headless-app": "vitest run --config vitest.config.ts",
+ "test": "pnpm build:markdown && vitest",
"test:headless-tools": "tsx scripts/test-headless-tools.mts",
- "test:middleware": "pnpm run build:markdown && tsx --test middleware.test.ts",
"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": "pnpm build:content && tsc --noEmit -p tsconfig.json"
+ "typecheck": "run-p build:content build:markdown && tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@hookform/resolvers": "^3.1.1",
diff --git a/apps/ui-library/public/r/password-based-auth-tanstack.json b/apps/ui-library/public/r/password-based-auth-tanstack.json
index 3f51ccb8487..b10ea15aaff 100644
--- a/apps/ui-library/public/r/password-based-auth-tanstack.json
+++ b/apps/ui-library/public/r/password-based-auth-tanstack.json
@@ -42,7 +42,7 @@
},
{
"path": "registry/default/blocks/password-based-auth-tanstack/routes/auth/confirm.ts",
- "content": "import { type EmailOtpType } from '@supabase/supabase-js'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { getRequest } from '@tanstack/react-start/server'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'\n\nconst confirmFn = createServerFn({ method: 'GET' })\n .inputValidator((searchParams: unknown) => {\n if (\n searchParams &&\n typeof searchParams === 'object' &&\n 'token_hash' in searchParams &&\n 'type' in searchParams &&\n 'next' in searchParams\n ) {\n return searchParams\n }\n throw new Error('Invalid search params')\n })\n .handler(async (ctx) => {\n const request = getRequest()\n\n if (!request) {\n throw redirect({ to: `/auth/error`, search: { error: 'No request' } })\n }\n\n const searchParams = ctx.data\n const token_hash = searchParams['token_hash'] as string\n const type = searchParams['type'] as EmailOtpType | null\n const _next = searchParams['next'] as string\n const origin = new URL(request.url).origin\n const next = safeNextPath(\n _next?.startsWith(`${origin}/`) ? _next.slice(origin.length) : _next,\n '/',\n origin\n )\n\n if (token_hash && type) {\n const supabase = createClient()\n\n const { error } = await supabase.auth.verifyOtp({\n type,\n token_hash,\n })\n console.log(error?.message)\n if (!error) {\n // redirect user to specified redirect URL or root of app\n throw redirect({ href: next })\n } else {\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: error?.message },\n })\n }\n }\n\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: 'No token hash or type' },\n })\n })\n\nexport const Route = createFileRoute('/auth/confirm')({\n preload: false,\n loader: (opts) => confirmFn({ data: opts.location.search }),\n})\n",
+ "content": "import { type EmailOtpType } from '@supabase/supabase-js'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { getRequest } from '@tanstack/react-start/server'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'\n\nconst confirmFn = createServerFn({ method: 'GET' })\n .inputValidator((searchParams: unknown) => {\n if (\n searchParams &&\n typeof searchParams === 'object' &&\n 'token_hash' in searchParams &&\n 'type' in searchParams &&\n 'next' in searchParams\n ) {\n return searchParams\n }\n throw new Error('Invalid search params')\n })\n .handler(async (ctx) => {\n const request = getRequest()\n\n if (!request) {\n throw redirect({ to: `/auth/error`, search: { error: 'No request' } })\n }\n\n const searchParams = ctx.data\n const token_hash = searchParams['token_hash'] as string\n const type = searchParams['type'] as EmailOtpType | null\n const _next = searchParams['next'] as string\n const origin = new URL(request.url).origin\n const next = safeNextPath(\n _next?.startsWith(`${origin}/`) ? _next.slice(origin.length) : _next,\n '/',\n origin\n )\n\n if (token_hash && type) {\n const supabase = createClient()\n\n const { error } = await supabase.auth.verifyOtp({\n type,\n token_hash,\n })\n if (!error) {\n // redirect user to specified redirect URL or root of app\n throw redirect({ href: next })\n } else {\n // redirect the user to an error page with some instructions\n console.log(error?.message)\n throw redirect({\n to: `/auth/error`,\n search: { error: error?.message },\n })\n }\n }\n\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: 'No token hash or type' },\n })\n })\n\nexport const Route = createFileRoute('/auth/confirm')({\n preload: false,\n loader: (opts) => confirmFn({ data: opts.location.search }),\n})\n",
"type": "registry:file",
"target": "routes/auth/confirm.ts"
},
diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json
index 631f8cdbeb9..e3631303662 100644
--- a/apps/ui-library/public/r/registry.json
+++ b/apps/ui-library/public/r/registry.json
@@ -2094,7 +2094,7 @@
"type": "registry:lib"
}
],
- "docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`.",
+ "docs": "This block provides TanStack DB integration with Supabase collections. The schemas and collections are generated based on your database schema.\n\nYou'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`.",
"envVars": {
"NEXT_PUBLIC_SUPABASE_URL": "",
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": ""
@@ -2402,7 +2402,7 @@
},
"files": [
{
- "path": "registry/default/clients/nuxtjs/lib/supabase/client.ts",
+ "path": "lib/supabase/client.ts",
"type": "registry:lib"
},
{
@@ -2439,7 +2439,7 @@
},
"files": [
{
- "path": "registry/default/clients/vue/lib/supabase/client.ts",
+ "path": "lib/supabase/client.ts",
"type": "registry:lib"
}
]
diff --git a/apps/ui-library/public/r/supabase-client-nuxtjs.json b/apps/ui-library/public/r/supabase-client-nuxtjs.json
index ba80599e34c..62a631378ff 100644
--- a/apps/ui-library/public/r/supabase-client-nuxtjs.json
+++ b/apps/ui-library/public/r/supabase-client-nuxtjs.json
@@ -11,7 +11,7 @@
"registryDependencies": [],
"files": [
{
- "path": "registry/default/clients/nuxtjs/lib/supabase/client.ts",
+ "path": "lib/supabase/client.ts",
"content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NUXT_PUBLIC_SUPABASE_URL!,\n process.env.NUXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n",
"type": "registry:lib"
},
diff --git a/apps/ui-library/public/r/supabase-client-vue.json b/apps/ui-library/public/r/supabase-client-vue.json
index 72943f4a08b..cd3370607a8 100644
--- a/apps/ui-library/public/r/supabase-client-vue.json
+++ b/apps/ui-library/public/r/supabase-client-vue.json
@@ -10,7 +10,7 @@
"registryDependencies": [],
"files": [
{
- "path": "registry/default/clients/vue/lib/supabase/client.ts",
+ "path": "lib/supabase/client.ts",
"content": "/// \nimport { createClient as createSupabaseClient } from '@supabase/supabase-js'\n\nexport function createClient() {\n return createSupabaseClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n",
"type": "registry:lib"
}
diff --git a/apps/ui-library/public/r/tanstack-db-nextjs.json b/apps/ui-library/public/r/tanstack-db-nextjs.json
index af5db958533..00721d99023 100644
--- a/apps/ui-library/public/r/tanstack-db-nextjs.json
+++ b/apps/ui-library/public/r/tanstack-db-nextjs.json
@@ -39,5 +39,5 @@
"NEXT_PUBLIC_SUPABASE_URL": "",
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": ""
},
- "docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`."
+ "docs": "This block provides TanStack DB integration with Supabase collections. The schemas and collections are generated based on your database schema.\n\nYou'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`."
}
\ No newline at end of file
diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts
index 3748445afc1..95ab3dd8b1e 100644
--- a/apps/ui-library/registry/blocks.ts
+++ b/apps/ui-library/registry/blocks.ts
@@ -39,11 +39,6 @@ const combine = (component: RegistryItem) => {
})
}
-const withClientAndDocs = (component: RegistryItem, client: RegistryItem) => ({
- ...registryItemAppend(component, [client]),
- docs: [component.docs, client.docs].filter(Boolean).join('\n\n'),
-})
-
const nextjsClient = clients.find((client) => client.name === 'supabase-client-nextjs')
const reactClient = clients.find((client) => client.name === 'supabase-client-react')
const tanstackClient = clients.find((client) => client.name === 'supabase-client-tanstack')
@@ -89,12 +84,12 @@ export const blocks = [
mcpServer as RegistryItem,
// Composes the auth, OAuth consent and MCP server blocks into one app.
- withClientAndDocs(headlessApp, tanstackClient!),
+ registryItemAppend(headlessApp, [tanstackClient!]),
- withClientAndDocs(oauthConsentNextjs as RegistryItem, nextjsClient!),
- withClientAndDocs(oauthConsentReact as RegistryItem, reactClient!),
- withClientAndDocs(oauthConsentReactRouter as RegistryItem, reactRouterClient!),
- withClientAndDocs(oauthConsentTanstack as RegistryItem, tanstackClient!),
+ registryItemAppend(oauthConsentNextjs as RegistryItem, [nextjsClient!]),
+ registryItemAppend(oauthConsentReact as RegistryItem, [reactClient!]),
+ registryItemAppend(oauthConsentReactRouter as RegistryItem, [reactRouterClient!]),
+ registryItemAppend(oauthConsentTanstack as RegistryItem, [tanstackClient!]),
// tanstack-db is served dynamically via API route, but we register it here for the static build
registryItemAppend(tanstackDbNextjs as RegistryItem, [nextjsClient!]),
diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/routes/auth/confirm.ts b/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/routes/auth/confirm.ts
index a213dfed2eb..a0e472d6308 100644
--- a/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/routes/auth/confirm.ts
+++ b/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/routes/auth/confirm.ts
@@ -44,12 +44,12 @@ const confirmFn = createServerFn({ method: 'GET' })
type,
token_hash,
})
- console.log(error?.message)
if (!error) {
// redirect user to specified redirect URL or root of app
throw redirect({ href: next })
} else {
// redirect the user to an error page with some instructions
+ console.log(error?.message)
throw redirect({
to: `/auth/error`,
search: { error: error?.message },
diff --git a/apps/ui-library/registry/utils.ts b/apps/ui-library/registry/utils.ts
index c4b1690cab0..9db6e26c566 100644
--- a/apps/ui-library/registry/utils.ts
+++ b/apps/ui-library/registry/utils.ts
@@ -1,22 +1,27 @@
import lodash from 'lodash'
import type { RegistryItem } from 'shadcn/schema'
-const { uniq, uniqBy } = lodash
+import { uniqueInstalledFiles } from '../lib/registry-resolution'
+
+const { uniq } = lodash
const registryItemAppend = (item: RegistryItem, items: RegistryItem[]) => {
const neededRegDependencies = [
...(item.registryDependencies || []),
- ...items.flatMap((i) => i.registryDependencies),
+ ...items.flatMap((i) => i.registryDependencies ?? []),
]
- const neededDependencies = [...(item.dependencies || []), ...items.flatMap((i) => i.dependencies)]
- const neededFiles = [...(item.files || []), ...items.flatMap((i) => i.files)]
+ const neededDependencies = [
+ ...(item.dependencies || []),
+ ...items.flatMap((i) => i.dependencies ?? []),
+ ]
+ const neededFiles = [...(item.files || []), ...items.flatMap((i) => i.files ?? [])]
const registryBlock = {
...item,
registryDependencies: uniq(neededRegDependencies),
dependencies: uniq(neededDependencies),
- files: uniqBy(neededFiles, (file) => file?.path),
- docs: (item.docs, items.flatMap((i) => i.docs)).filter(Boolean).join('\n\n'),
+ files: uniqueInstalledFiles(neededFiles, `Registry item "${item.name}"`),
+ docs: [item.docs, ...items.map((i) => i.docs)].filter(Boolean).join('\n\n'),
// merge all environment variables
envVars: {
...item.envVars,
diff --git a/apps/ui-library/scripts/build-markdown.ts b/apps/ui-library/scripts/build-markdown.ts
index 447f6259a61..43990d78ba4 100644
--- a/apps/ui-library/scripts/build-markdown.ts
+++ b/apps/ui-library/scripts/build-markdown.ts
@@ -1,7 +1,7 @@
import fs from 'node:fs/promises'
import path from 'node:path'
-import { transformLibraryMdx } from './library-mdx-to-markdown'
+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')
diff --git a/apps/ui-library/scripts/build-registry.mts b/apps/ui-library/scripts/build-registry.mts
index b152462c19d..41b0c4c1a5d 100644
--- a/apps/ui-library/scripts/build-registry.mts
+++ b/apps/ui-library/scripts/build-registry.mts
@@ -3,11 +3,24 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
+import { registrySchema, type RegistryItem } from 'shadcn/schema'
+
+import { resolveRegistryItem } from '../lib/registry-resolution'
import { registry } from '../registry/index'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const registryPath = path.join(__dirname, '..', 'public', 'r', 'registry.json')
+registrySchema.parse(registry)
+const items = new Map()
+for (const item of registry.items) {
+ if (items.has(item.name)) throw new Error(`Duplicate registry item "${item.name}"`)
+ items.set(item.name, item)
+}
+for (const name of items.keys()) {
+ resolveRegistryItem((itemName) => items.get(itemName), name)
+}
+
const cleanedRegistry = {
$schema: 'https://ui.shadcn.com/schema/registry.json',
...registry,
diff --git a/apps/ui-library/scripts/clean-registry.ts b/apps/ui-library/scripts/clean-registry.ts
index 2ad99680366..ee3c30f832e 100644
--- a/apps/ui-library/scripts/clean-registry.ts
+++ b/apps/ui-library/scripts/clean-registry.ts
@@ -1,12 +1,19 @@
import * as fs from 'fs'
import * as path from 'path'
+import { normalizeVueRegistryFiles } from '../lib/registry-resolution'
+
function processJsonFile(filePath: string) {
try {
// Read the file
const content = fs.readFileSync(filePath, 'utf8')
const json = JSON.parse(content)
+ // Normalize only after shadcn build has read the original package source files.
+ for (const item of Array.isArray(json.items) ? json.items : [json]) {
+ if (Array.isArray(item.files)) item.files = normalizeVueRegistryFiles(item.files)
+ }
+
// Convert to string to do replacement
let stringified = JSON.stringify(json, null, 2)
@@ -51,7 +58,7 @@ function processJsonFile(filePath: string) {
fs.writeFileSync(filePath, stringified)
console.log(`✓ Updated ${filePath}`)
} catch (error) {
- console.error(`Error processing ${filePath}:`, error)
+ throw new Error(`Unable to prepare registry artifact "${filePath}"`, { cause: error })
}
}
diff --git a/apps/ui-library/vitest.config.ts b/apps/ui-library/vitest.config.ts
index a1425221a5f..cb514b82d6d 100644
--- a/apps/ui-library/vitest.config.ts
+++ b/apps/ui-library/vitest.config.ts
@@ -5,7 +5,28 @@ export default defineConfig({
resolve: { alias: { '@': fileURLToPath(new URL('.', import.meta.url)) } },
oxc: { jsx: { runtime: 'automatic' } },
test: {
- environment: 'jsdom',
- include: ['tests/**/*.test.tsx'],
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: 'jsdom',
+ environment: 'jsdom',
+ include: ['tests/**/*.test.tsx'],
+ },
+ },
+ {
+ extends: true,
+ 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',
+ ],
+ },
+ },
+ ],
},
})