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', + ], + }, + }, + ], }, })