diff --git a/apps/docs/app/api/registry/[component]/route.ts b/apps/docs/app/api/registry/[component]/route.ts index 713a03bf..eccd8c61 100644 --- a/apps/docs/app/api/registry/[component]/route.ts +++ b/apps/docs/app/api/registry/[component]/route.ts @@ -1,483 +1,13 @@ -/** biome-ignore-all lint/suspicious/noConsole: "server only" */ - -import type { NextRequest } from "next/server"; -import type { Registry, RegistryItem } from "shadcn/schema"; - -import { track } from "@vercel/analytics/server"; -import { NextResponse } from "next/server"; -import { promises as fs, readdirSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Project } from "ts-morph"; - -const getRegistryUrl = (request: NextRequest) => { - const host = request.headers.get("host"); - const protocol = host?.includes("localhost") ? "http" : "https"; - return `${protocol}://${host}`; -}; - -const packageDir = join(process.cwd(), "..", "..", "packages", "elements"); -const packagePath = join(packageDir, "package.json"); -const packageJson = JSON.parse(await readFile(packagePath, "utf8")); - -const examplesDir = join( - process.cwd(), - "..", - "..", - "packages", - "examples", - "src" -); - -const internalDependencies = Object.keys(packageJson.dependencies || {}).filter( - (dep) => dep.startsWith("@repo") && dep !== "@repo/shadcn-ui" -); - -const dependenciesSet = new Set( - Object.keys(packageJson.dependencies || {}).filter( - (dep) => - ![ - "react", - "react-dom", - "@repo/shadcn-ui", - ...internalDependencies, - ].includes(dep) - ) -); - -const devDependenciesSet = new Set( - Object.keys(packageJson.devDependencies || {}).filter( - (dep) => - ![ - "@repo/typescript-config", - "@types/react", - "@types/react-dom", - "typescript", - ].includes(dep) - ) -); - -// Registry should auto-add ai sdk v5 as a dependency -dependenciesSet.add("ai"); -dependenciesSet.add("@ai-sdk/react"); -dependenciesSet.add("zod"); - -const dependencies = new Set(dependenciesSet); -const devDependencies = [...devDependenciesSet]; - -// Normalize to package root (supports scoped and deep subpath imports) -const getBasePackageName = (specifier: string) => { - if (specifier.startsWith("@")) { - const parts = specifier.split("/"); - return parts.slice(0, 2).join("/"); - } - return specifier.split("/")[0]; -}; - -// Build a mapping from runtime package -> its @types devDependency package(s) -const TYPES_PREFIX = "@types/"; -const typesDevDepsMap = new Map(); -for (const devDep of devDependencies) { - if (devDep.startsWith(TYPES_PREFIX)) { - const name = devDep.slice(TYPES_PREFIX.length); - // Scoped packages in DefinitelyTyped use __ separator, e.g. @types/babel__core for @babel/core - const runtime = name.includes("__") ? name.replace("__", "/") : name; - const list = typesDevDepsMap.get(runtime) ?? []; - list.push(devDep); - typesDevDepsMap.set(runtime, list); - } -} - -const srcDir = join(packageDir, "src"); - -const packageFiles = readdirSync(srcDir, { withFileTypes: true }); -const tsxFiles = packageFiles.filter( - (file) => file.isFile() && file.name.endsWith(".tsx") -); - -const exampleFiles = readdirSync(examplesDir, { withFileTypes: true }); -const exampleTsxFiles = exampleFiles.filter( - (file) => file.isFile() && file.name.endsWith(".tsx") -); - -const files: { - type: string; - path: string; - content: string; -}[] = []; - -const fileContents = await Promise.all( - tsxFiles.map(async (tsxFile) => { - const filePath = join(srcDir, tsxFile.name); - const content = await fs.readFile(filePath, "utf8"); - const parsedContent = content - .replaceAll("@repo/shadcn-ui/components/ui/", "@/registry/default/ui/") - .replaceAll("@repo/shadcn-ui/lib/", "@/lib/"); - - return { - content: parsedContent, - path: `registry/default/ai-elements/${tsxFile.name}`, - type: "registry:component", - }; - }) -); - -const exampleContents = await Promise.all( - exampleTsxFiles.map(async (exampleFile) => { - const filePath = join(examplesDir, exampleFile.name); - const content = await fs.readFile(filePath, "utf8"); - const parsedContent = content - .replaceAll("@repo/shadcn-ui/components/ui/", "@/registry/default/ui/") - .replaceAll("@repo/shadcn-ui/lib/", "@/lib/") - .replaceAll("@repo/elements/", "@/components/ai-elements/"); - - return { - content: parsedContent, - path: `registry/default/examples/${exampleFile.name}`, - type: "registry:block", - }; - }) -); - -files.push(...fileContents, ...exampleContents); - -const registryDependenciesSet = new Set(); - -// Extract shadcn/ui components from file content -const shadcnComponents = - files - .map((f) => f.content) - .join("\n") - .match(/@\/registry\/default\/ui\/([a-z-]+)/g) - ?.map((path) => path.split("/").pop()) - .filter((name): name is string => Boolean(name)) || []; - -// Extract AI element components from file content -const aiElementComponents = - files - .map((f) => f.content) - .join("\n") - .match(/@\/components\/ai-elements\/([a-z-]+)/g) - ?.map((path) => path.split("/").pop()) - .filter((name): name is string => Boolean(name)) || []; - -// Add shadcn/ui components to set -for (const component of shadcnComponents) { - registryDependenciesSet.add(component); -} - -// Add AI element components to set (these become registry dependencies) -for (const component of aiElementComponents) { - registryDependenciesSet.add(component); -} - -// Create items for the root registry response -const componentItems: RegistryItem[] = tsxFiles.map((componentFile) => { - const componentName = componentFile.name.replace(".tsx", ""); - - const item: RegistryItem = { - description: `AI-powered ${componentName.replace("-", " ")} component.`, - files: [ - { - path: `registry/default/ai-elements/${componentFile.name}`, - target: `components/ai-elements/${componentFile.name}.tsx`, - type: "registry:component", - }, - ], - name: componentName, - title: componentName - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "), - type: "registry:component", - }; - - return item; -}); - -const exampleItems: RegistryItem[] = exampleTsxFiles.map((exampleFile) => { - const exampleName = exampleFile.name.replace(".tsx", ""); - - const item: RegistryItem = { - description: `Example implementation of ${exampleName.replace("-", " ")}.`, - files: [ - { - path: `registry/default/examples/${exampleFile.name}`, - target: `components/ai-elements/examples/${exampleFile.name}.tsx`, - type: "registry:block", - }, - ], - name: `example-${exampleName}`, - title: `${exampleName - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ")} Example`, - type: "registry:block", - }; - - return item; +import { createRegistryGET, createRegistryHandler } from "../_lib/create-registry"; + +const registryPromise = createRegistryHandler({ + elementsPackage: "elements", + uiPackageName: "@repo/shadcn-ui", + registryStyle: "default", + uiImportPrefix: "@/registry/default/ui/", + utilsImportPrefix: "@/lib/", + apiBasePath: "/api/registry", + examplesPackage: "examples", }); -const items: RegistryItem[] = [...componentItems, ...exampleItems]; - -const getResponse = (registryUrl: string): Registry => ({ - homepage: new URL("/elements", registryUrl).toString(), - items, - name: "ai-elements", -}); - -interface RequestProps { - params: Promise<{ component: string }>; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Registry route requires handling many component cases -export const GET = async (request: NextRequest, { params }: RequestProps) => { - const { component } = await params; - const parsedComponent = component.replace(".json", ""); - const registryUrl = getRegistryUrl(request); - const response = getResponse(registryUrl); - - if (parsedComponent === "registry") { - try { - track("registry:registry"); - } catch (error) { - console.warn("Failed to track registry:registry:", error); - } - return NextResponse.json(response); - } - - // Handle "all.json" - bundle all components into a single RegistryItem - if (parsedComponent === "all") { - try { - track("registry:all"); - } catch (error) { - console.warn("Failed to track registry:all:", error); - } - - // Collect all dependencies and registry dependencies from all components - const allDependencies = new Set(); - const allDevDependencies = new Set(); - const allRegistryDependencies = new Set(); - const allFiles: RegistryItem["files"] = []; - - // Process each component file - for (const componentFile of tsxFiles) { - const file = files.find( - (f) => f.path === `registry/default/ai-elements/${componentFile.name}` - ); - - if (file) { - allFiles.push({ - content: file.content, - path: file.path, - target: `components/ai-elements/${componentFile.name}`, - type: file.type as RegistryItem["type"], - }); - - // Parse imports for dependencies - const project = new Project({ useInMemoryFileSystem: true }); - try { - const sourceFile = project.createSourceFile(file.path, file.content); - const imports = sourceFile - .getImportDeclarations() - .map((d) => d.getModuleSpecifierValue()); - - for (const moduleName of imports) { - if (!moduleName) { - continue; - } - - const pkg = getBasePackageName(moduleName); - // Check if it's a regular dependency - if (dependencies.has(pkg)) { - allDependencies.add(pkg); - // Check if it has a corresponding @types/ package - const typePkgs = typesDevDepsMap.get(pkg); - if (typePkgs) { - for (const t of typePkgs) { - allDevDependencies.add(t); - } - } - } - - // Check if it's a dev dependency - if (devDependencies.includes(moduleName)) { - allDevDependencies.add(moduleName); - } - - // Check if it's a registry dependency (shadcn/ui components) - if (moduleName.startsWith("@/registry/default/ui/")) { - const componentName = moduleName.split("/").pop(); - if (componentName) { - allRegistryDependencies.add(componentName); - } - } - } - } catch (error) { - console.warn(`Failed to parse imports for ${file.path}:`, error); - } - } - } - - const allComponentsItem: RegistryItem = { - $schema: "https://ui.shadcn.com/schema/registry-item.json", - dependencies: [...allDependencies], - description: "Bundle containing all AI-powered components.", - devDependencies: [...allDevDependencies], - files: allFiles, - name: "all", - registryDependencies: [...allRegistryDependencies], - title: "All AI Elements", - type: "registry:component", - }; - - return NextResponse.json(allComponentsItem); - } - - try { - track(`registry:${parsedComponent}`); - } catch (error) { - console.warn(`Failed to track ${parsedComponent}:`, error); - } - - // Only process the parsedComponent, not an array - - // Find the item for the requested component or example - const item = response.items.find((i) => i.name === parsedComponent); - - if (!item) { - return NextResponse.json( - { error: `Component "${parsedComponent}" not found.` }, - { status: 404 } - ); - } - - // Find the corresponding file content - let file: { type: string; path: string; content: string } | undefined; - if (item.type === "registry:component") { - file = files.find( - (f) => f.path === `registry/default/ai-elements/${parsedComponent}.tsx` - ); - } else if ( - item.type === "registry:block" && - parsedComponent.startsWith("example-") - ) { - const exampleFileName = `${parsedComponent.replace("example-", "")}.tsx`; - file = files.find( - (f) => f.path === `registry/default/examples/${exampleFileName}` - ); - } - - if (!file) { - return NextResponse.json( - { error: `File for "${parsedComponent}" not found.` }, - { status: 404 } - ); - } - - // Parse imports for the single component to determine actual dependencies - const usedDependencies = new Set(); - const usedDevDependencies = new Set(); - const usedRegistryDependencies = new Set(); - - const project = new Project({ useInMemoryFileSystem: true }); - - try { - const sourceFile = project.createSourceFile(file.path, file.content); - const imports = sourceFile - .getImportDeclarations() - .map((d) => d.getModuleSpecifierValue()); - - for (const moduleName of imports) { - if (!moduleName) { - continue; - } - - // Check if it's a relative dependency - if (moduleName.startsWith("./")) { - const relativePath = moduleName.split("/").pop(); - if (relativePath) { - usedRegistryDependencies.add( - new URL( - `/api/registry/${relativePath}.json`, - registryUrl - ).toString() - ); - } - } - - const pkg = getBasePackageName(moduleName); - // Check if it's a regular dependency - if (dependencies.has(pkg)) { - usedDependencies.add(pkg); - // Check if it has a corresponding @types/ package - const typePkgs = typesDevDepsMap.get(pkg); - if (typePkgs) { - for (const t of typePkgs) { - usedDevDependencies.add(t); - } - } - } - - // Check if it's a dev dependency (though less common in component files) - if (devDependencies.includes(moduleName)) { - usedDevDependencies.add(moduleName); - } - - // Check if it's a registry dependency (shadcn/ui components) - if (moduleName.startsWith("@/registry/default/ui/")) { - const componentName = moduleName.split("/").pop(); - if (componentName) { - usedRegistryDependencies.add(componentName); - } - } - - // Check if it's an AI element dependency - if (moduleName.startsWith("@/components/ai-elements/")) { - const componentName = moduleName.split("/").pop(); - if (componentName) { - usedRegistryDependencies.add( - new URL(`/${componentName}.json`, registryUrl).toString() - ); - } - } - } - } catch (error) { - console.warn(`Failed to parse imports for ${file.path}:`, error); - } - - // Add internal dependencies for the requested component - for (const dep of internalDependencies) { - const packageName = dep.replace("@repo/", ""); - usedRegistryDependencies.add( - new URL(`/elements/${packageName}.json`, registryUrl).toString() - ); - } - - const itemResponse: RegistryItem = { - $schema: "https://ui.shadcn.com/schema/registry-item.json", - dependencies: [...usedDependencies], - description: item.description, - devDependencies: [...usedDevDependencies], - files: [ - { - content: file.content, - path: file.path, - target: `components/ai-elements/${item.name}.tsx`, - type: file.type as Exclude< - RegistryItem["type"], - "registry:base" | "registry:font" - >, - }, - ], - name: item.name, - registryDependencies: [...usedRegistryDependencies], - title: item.title, - type: item.type as Exclude< - RegistryItem["type"], - "registry:base" | "registry:font" - >, - }; - - return NextResponse.json(itemResponse); -}; +export const GET = createRegistryGET(registryPromise); diff --git a/apps/docs/app/api/registry/_lib/create-registry.ts b/apps/docs/app/api/registry/_lib/create-registry.ts new file mode 100644 index 00000000..1c8160ae --- /dev/null +++ b/apps/docs/app/api/registry/_lib/create-registry.ts @@ -0,0 +1,497 @@ +/** biome-ignore-all lint/suspicious/noConsole: "server only" */ + +import type { NextRequest } from "next/server"; +import type { Registry, RegistryItem } from "shadcn/schema"; + +import { track } from "@vercel/analytics/server"; +import { NextResponse } from "next/server"; +import { promises as fs, readdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Project } from "ts-morph"; + +const getRegistryUrl = (request: NextRequest) => { + const host = request.headers.get("host"); + const protocol = host?.includes("localhost") ? "http" : "https"; + return `${protocol}://${host}`; +}; + +// Normalize to package root (supports scoped and deep subpath imports) +const getBasePackageName = (specifier: string) => { + if (specifier.startsWith("@")) { + const parts = specifier.split("/"); + return parts.slice(0, 2).join("/"); + } + return specifier.split("/")[0]; +}; + +interface RegistryConfig { + /** Name of the elements package directory, e.g. "elements" or "elements-base" */ + elementsPackage: string; + /** Name of the shadcn-ui package to exclude from deps, e.g. "@repo/shadcn-ui" or "@repo/base-ui" */ + uiPackageName: string; + /** Registry path style prefix used in file paths, e.g. "default" or "base" */ + registryStyle: string; + /** Import path prefix for UI components in output, e.g. "@/registry/default/ui/" */ + uiImportPrefix: string; + /** Import path prefix for utils in output, e.g. "@/lib/" */ + utilsImportPrefix: string; + /** Base path for the registry API, e.g. "/api/registry" or "/api/registry/base" */ + apiBasePath: string; + /** Name of the examples package directory, e.g. "examples" or "examples-base" */ + examplesPackage: string; +} + +export async function createRegistryHandler(config: RegistryConfig) { + const { + elementsPackage, + uiPackageName, + registryStyle, + uiImportPrefix, + utilsImportPrefix, + apiBasePath, + examplesPackage, + } = config; + + const packageDir = join(process.cwd(), "..", "..", "packages", elementsPackage); + const packagePath = join(packageDir, "package.json"); + const packageJson = JSON.parse(await readFile(packagePath, "utf8")); + + const examplesDir = join( + process.cwd(), + "..", + "..", + "packages", + examplesPackage, + "src" + ); + + const internalDependencies = Object.keys(packageJson.dependencies || {}).filter( + (dep) => dep.startsWith("@repo") && dep !== uiPackageName + ); + + const dependenciesSet = new Set( + Object.keys(packageJson.dependencies || {}).filter( + (dep) => + ![ + "react", + "react-dom", + uiPackageName, + ...internalDependencies, + ].includes(dep) + ) + ); + + const devDependenciesSet = new Set( + Object.keys(packageJson.devDependencies || {}).filter( + (dep) => + ![ + "@repo/typescript-config", + "@types/react", + "@types/react-dom", + "typescript", + ].includes(dep) + ) + ); + + // Registry should auto-add ai sdk v5 as a dependency + dependenciesSet.add("ai"); + dependenciesSet.add("@ai-sdk/react"); + dependenciesSet.add("zod"); + + const dependencies = new Set(dependenciesSet); + const devDependencies = [...devDependenciesSet]; + + // Build a mapping from runtime package -> its @types devDependency package(s) + const TYPES_PREFIX = "@types/"; + const typesDevDepsMap = new Map(); + for (const devDep of devDependencies) { + if (devDep.startsWith(TYPES_PREFIX)) { + const name = devDep.slice(TYPES_PREFIX.length); + const runtime = name.includes("__") ? name.replace("__", "/") : name; + const list = typesDevDepsMap.get(runtime) ?? []; + list.push(devDep); + typesDevDepsMap.set(runtime, list); + } + } + + const srcDir = join(packageDir, "src"); + + const packageFiles = readdirSync(srcDir, { withFileTypes: true }); + const tsxFiles = packageFiles.filter( + (file) => file.isFile() && file.name.endsWith(".tsx") + ); + + const exampleFiles = readdirSync(examplesDir, { withFileTypes: true }); + const exampleTsxFiles = exampleFiles.filter( + (file) => file.isFile() && file.name.endsWith(".tsx") + ); + + const uiSourceImport = `${uiPackageName}/components/ui/`; + const libSourceImport = `${uiPackageName}/lib/`; + const elementsSourceImport = elementsPackage === "elements" ? "@repo/elements/" : "@repo/elements-base/"; + + const files: { + type: string; + path: string; + content: string; + }[] = []; + + const fileContents = await Promise.all( + tsxFiles.map(async (tsxFile) => { + const filePath = join(srcDir, tsxFile.name); + const content = await fs.readFile(filePath, "utf8"); + const parsedContent = content + .replaceAll(uiSourceImport, uiImportPrefix) + .replaceAll(libSourceImport, utilsImportPrefix); + + return { + content: parsedContent, + path: `registry/${registryStyle}/ai-elements/${tsxFile.name}`, + type: "registry:component", + }; + }) + ); + + const exampleContents = await Promise.all( + exampleTsxFiles.map(async (exampleFile) => { + const filePath = join(examplesDir, exampleFile.name); + const content = await fs.readFile(filePath, "utf8"); + const parsedContent = content + .replaceAll(uiSourceImport, uiImportPrefix) + .replaceAll(libSourceImport, utilsImportPrefix) + .replaceAll(elementsSourceImport, "@/components/ai-elements/"); + + return { + content: parsedContent, + path: `registry/${registryStyle}/examples/${exampleFile.name}`, + type: "registry:block", + }; + }) + ); + + files.push(...fileContents, ...exampleContents); + + // Create items for the root registry response + const componentItems: RegistryItem[] = tsxFiles.map((componentFile) => { + const componentName = componentFile.name.replace(".tsx", ""); + + const item: RegistryItem = { + description: `AI-powered ${componentName.replace("-", " ")} component.`, + files: [ + { + path: `registry/${registryStyle}/ai-elements/${componentFile.name}`, + target: `components/ai-elements/${componentFile.name}.tsx`, + type: "registry:component", + }, + ], + name: componentName, + title: componentName + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + type: "registry:component", + }; + + return item; + }); + + const exampleItems: RegistryItem[] = exampleTsxFiles.map((exampleFile) => { + const exampleName = exampleFile.name.replace(".tsx", ""); + + const item: RegistryItem = { + description: `Example implementation of ${exampleName.replace("-", " ")}.`, + files: [ + { + path: `registry/${registryStyle}/examples/${exampleFile.name}`, + target: `components/ai-elements/examples/${exampleFile.name}.tsx`, + type: "registry:block", + }, + ], + name: `example-${exampleName}`, + title: `${exampleName + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ")} Example`, + type: "registry:block", + }; + + return item; + }); + + const items: RegistryItem[] = [...componentItems, ...exampleItems]; + + return { + items, + tsxFiles, + files, + dependencies, + devDependencies, + typesDevDepsMap, + internalDependencies, + getRegistryUrl, + getBasePackageName, + registryStyle, + apiBasePath, + }; +} + +interface RequestProps { + params: Promise<{ component: string }>; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Registry route requires handling many component cases +export function createRegistryGET(registryPromise: ReturnType) { + return async (request: NextRequest, { params }: RequestProps) => { + const registry = await registryPromise; + const { + items, + tsxFiles, + files, + dependencies, + devDependencies, + typesDevDepsMap, + internalDependencies, + getRegistryUrl: getUrl, + getBasePackageName: getBasePkg, + registryStyle, + apiBasePath, + } = registry; + + const { component } = await params; + const parsedComponent = component.replace(".json", ""); + const registryUrl = getUrl(request); + + const getResponse = (): Registry => ({ + homepage: new URL("/elements", registryUrl).toString(), + items, + name: "ai-elements", + }); + + if (parsedComponent === "registry") { + try { + track(`registry:${registryStyle}:registry`); + } catch (error) { + console.warn("Failed to track registry:", error); + } + return NextResponse.json(getResponse()); + } + + // Handle "all.json" - bundle all components into a single RegistryItem + if (parsedComponent === "all") { + try { + track(`registry:${registryStyle}:all`); + } catch (error) { + console.warn("Failed to track registry:all:", error); + } + + const allDependencies = new Set(); + const allDevDependencies = new Set(); + const allRegistryDependencies = new Set(); + const allFiles: RegistryItem["files"] = []; + + for (const componentFile of tsxFiles) { + const file = files.find( + (f) => f.path === `registry/${registryStyle}/ai-elements/${componentFile.name}` + ); + + if (file) { + allFiles.push({ + content: file.content, + path: file.path, + target: `components/ai-elements/${componentFile.name}`, + type: file.type as RegistryItem["type"], + }); + + const project = new Project({ useInMemoryFileSystem: true }); + try { + const sourceFile = project.createSourceFile(file.path, file.content); + const imports = sourceFile + .getImportDeclarations() + .map((d) => d.getModuleSpecifierValue()); + + for (const moduleName of imports) { + if (!moduleName) { + continue; + } + + const pkg = getBasePkg(moduleName); + if (dependencies.has(pkg)) { + allDependencies.add(pkg); + const typePkgs = typesDevDepsMap.get(pkg); + if (typePkgs) { + for (const t of typePkgs) { + allDevDependencies.add(t); + } + } + } + + if (devDependencies.includes(moduleName)) { + allDevDependencies.add(moduleName); + } + + if (moduleName.startsWith(`@/registry/${registryStyle}/ui/`)) { + const componentName = moduleName.split("/").pop(); + if (componentName) { + allRegistryDependencies.add(componentName); + } + } + } + } catch (error) { + console.warn(`Failed to parse imports for ${file.path}:`, error); + } + } + } + + const allComponentsItem: RegistryItem = { + $schema: "https://ui.shadcn.com/schema/registry-item.json", + dependencies: [...allDependencies], + description: "Bundle containing all AI-powered components.", + devDependencies: [...allDevDependencies], + files: allFiles, + name: "all", + registryDependencies: [...allRegistryDependencies], + title: "All AI Elements", + type: "registry:component", + }; + + return NextResponse.json(allComponentsItem); + } + + try { + track(`registry:${registryStyle}:${parsedComponent}`); + } catch (error) { + console.warn(`Failed to track ${parsedComponent}:`, error); + } + + const response = getResponse(); + const item = response.items.find((i) => i.name === parsedComponent); + + if (!item) { + return NextResponse.json( + { error: `Component "${parsedComponent}" not found.` }, + { status: 404 } + ); + } + + let file: { type: string; path: string; content: string } | undefined; + if (item.type === "registry:component") { + file = files.find( + (f) => f.path === `registry/${registryStyle}/ai-elements/${parsedComponent}.tsx` + ); + } else if ( + item.type === "registry:block" && + parsedComponent.startsWith("example-") + ) { + const exampleFileName = `${parsedComponent.replace("example-", "")}.tsx`; + file = files.find( + (f) => f.path === `registry/${registryStyle}/examples/${exampleFileName}` + ); + } + + if (!file) { + return NextResponse.json( + { error: `File for "${parsedComponent}" not found.` }, + { status: 404 } + ); + } + + const usedDependencies = new Set(); + const usedDevDependencies = new Set(); + const usedRegistryDependencies = new Set(); + + const project = new Project({ useInMemoryFileSystem: true }); + + try { + const sourceFile = project.createSourceFile(file.path, file.content); + const imports = sourceFile + .getImportDeclarations() + .map((d) => d.getModuleSpecifierValue()); + + for (const moduleName of imports) { + if (!moduleName) { + continue; + } + + if (moduleName.startsWith("./")) { + const relativePath = moduleName.split("/").pop(); + if (relativePath) { + usedRegistryDependencies.add( + new URL( + `${apiBasePath}/${relativePath}.json`, + registryUrl + ).toString() + ); + } + } + + const pkg = getBasePkg(moduleName); + if (dependencies.has(pkg)) { + usedDependencies.add(pkg); + const typePkgs = typesDevDepsMap.get(pkg); + if (typePkgs) { + for (const t of typePkgs) { + usedDevDependencies.add(t); + } + } + } + + if (devDependencies.includes(moduleName)) { + usedDevDependencies.add(moduleName); + } + + if (moduleName.startsWith(`@/registry/${registryStyle}/ui/`)) { + const componentName = moduleName.split("/").pop(); + if (componentName) { + usedRegistryDependencies.add(componentName); + } + } + + if (moduleName.startsWith("@/components/ai-elements/")) { + const componentName = moduleName.split("/").pop(); + if (componentName) { + usedRegistryDependencies.add( + new URL(`${apiBasePath}/${componentName}.json`, registryUrl).toString() + ); + } + } + } + } catch (error) { + console.warn(`Failed to parse imports for ${file.path}:`, error); + } + + for (const dep of internalDependencies) { + const packageName = dep.replace("@repo/", ""); + usedRegistryDependencies.add( + new URL(`/elements/${packageName}.json`, registryUrl).toString() + ); + } + + const itemResponse: RegistryItem = { + $schema: "https://ui.shadcn.com/schema/registry-item.json", + dependencies: [...usedDependencies], + description: item.description, + devDependencies: [...usedDevDependencies], + files: [ + { + content: file.content, + path: file.path, + target: `components/ai-elements/${item.name}.tsx`, + type: file.type as Exclude< + RegistryItem["type"], + "registry:base" | "registry:font" + >, + }, + ], + name: item.name, + registryDependencies: [...usedRegistryDependencies], + title: item.title, + type: item.type as Exclude< + RegistryItem["type"], + "registry:base" | "registry:font" + >, + }; + + return NextResponse.json(itemResponse); + }; +} diff --git a/apps/docs/app/api/registry/base/[component]/route.ts b/apps/docs/app/api/registry/base/[component]/route.ts new file mode 100644 index 00000000..7d4d7425 --- /dev/null +++ b/apps/docs/app/api/registry/base/[component]/route.ts @@ -0,0 +1,13 @@ +import { createRegistryGET, createRegistryHandler } from "../../_lib/create-registry"; + +const registryPromise = createRegistryHandler({ + elementsPackage: "elements-base", + uiPackageName: "@repo/base-ui", + registryStyle: "base", + uiImportPrefix: "@/registry/base/ui/", + utilsImportPrefix: "@/lib/", + apiBasePath: "/api/registry/base", + examplesPackage: "examples-base", +}); + +export const GET = createRegistryGET(registryPromise); diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 9f7c3408..05b168be 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "shadcn/tailwind.css"; @import "./styles/geistdocs.css"; @source "../**/*.{ts,tsx,mdx}"; diff --git a/apps/docs/components.json b/apps/docs/components.json index 3a642555..5bdc1188 100644 --- a/apps/docs/components.json +++ b/apps/docs/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "radix-vega", "rsc": true, "tsx": true, "tailwind": { diff --git a/apps/docs/components/custom/elements-installer-tabs.tsx b/apps/docs/components/custom/elements-installer-tabs.tsx new file mode 100644 index 00000000..c1b70ca4 --- /dev/null +++ b/apps/docs/components/custom/elements-installer-tabs.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { CodeBlock } from "@/components/geistdocs/code-block"; +import { + CodeBlockTab, + CodeBlockTabs, + CodeBlockTabsList, + CodeBlockTabsTrigger, +} from "@/components/geistdocs/code-block-tabs"; +import { useUILibrary } from "@/hooks/geistdocs/use-ui-library"; + +interface VariantData { + elementsCommand: string; + shadcnCommand: string; + highlightedCode: string; + hasSource: boolean; +} + +interface ElementsInstallerTabsProps { + radix: VariantData; + base: VariantData; +} + +const HighlightedCodePreview = ({ + highlightedCode, +}: { + highlightedCode: string; +}) => ( + // oxlint-disable-next-line eslint-plugin-react(no-danger) +
+);
+
+export const ElementsInstallerTabs = ({
+  radix,
+  base,
+}: ElementsInstallerTabsProps) => {
+  const { library } = useUILibrary();
+  const data = library === "base" ? base : radix;
+
+  return (
+    
+ + + + AI Elements + + shadcn CLI + {data.hasSource && ( + Manual + )} + + + {data.elementsCommand} + + + {data.shadcnCommand} + + {data.hasSource && ( + + + + + + )} + +
+ ); +}; diff --git a/apps/docs/components/custom/elements-installer.tsx b/apps/docs/components/custom/elements-installer.tsx index 74ad2b83..05cb05f7 100644 --- a/apps/docs/components/custom/elements-installer.tsx +++ b/apps/docs/components/custom/elements-installer.tsx @@ -3,22 +3,18 @@ import { readFile } from "node:fs/promises"; // oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) import { join } from "node:path"; - import { codeToHtml } from "shiki"; -import { CodeBlock } from "@/components/geistdocs/code-block"; -import { - CodeBlockTab, - CodeBlockTabs, - CodeBlockTabsList, - CodeBlockTabsTrigger, -} from "@/components/geistdocs/code-block-tabs"; +import { ElementsInstallerTabs } from "./elements-installer-tabs"; interface ElementsInstallerProps { path?: string; } -const loadSourceCode = async (componentPath: string): Promise => { +const loadSourceCode = async ( + packageName: string, + componentPath: string +): Promise => { try { const code = await readFile( join( @@ -26,11 +22,11 @@ const loadSourceCode = async (componentPath: string): Promise => { "..", "..", "packages", - "elements", + packageName, "src", `${componentPath}.tsx` ), - "utf8" + "utf-8" ); return code .replaceAll("@ai-studio/shadcn-ui/", "@/") @@ -44,61 +40,56 @@ const loadSourceCode = async (componentPath: string): Promise => { } }; -const getCommands = (path?: string) => { +const getCommands = (path?: string, variant?: "radix" | "base") => { + const registryPrefix = + variant === "base" ? "@ai-elements/base/" : "@ai-elements/"; const elementsCommand = path ? `npx ai-elements@latest add ${path}` : "npx ai-elements@latest"; const shadcnCommand = path - ? `npx shadcn@latest add @ai-elements/${path}` - : "npx shadcn@latest add @ai-elements/all"; + ? `npx shadcn@latest add ${registryPrefix}${path}` + : `npx shadcn@latest add ${registryPrefix}all`; return { elementsCommand, shadcnCommand }; }; -interface HighlightedCodePreviewProps { - highlightedCode: string; -} +export const ElementsInstaller = async ({ path }: ElementsInstallerProps) => { + const [radixSource, baseSource] = await Promise.all([ + path ? loadSourceCode("elements", path) : Promise.resolve(""), + path ? loadSourceCode("elements-base", path) : Promise.resolve(""), + ]); -const HighlightedCodePreview = ({ - highlightedCode, -}: HighlightedCodePreviewProps) => ( - // oxlint-disable-next-line eslint-plugin-react(no-danger) -
-);
+  const radixCommands = getCommands(path, "radix");
+  const baseCommands = getCommands(path, "base");
 
-export const ElementsInstaller = async ({ path }: ElementsInstallerProps) => {
-  const sourceCode = path ? await loadSourceCode(path) : "";
-  const { elementsCommand, shadcnCommand } = getCommands(path);
-  const highlightedCode = await codeToHtml(sourceCode, {
-    lang: "tsx",
-    themes: { dark: "github-dark", light: "github-light" },
-  });
+  const [radixHighlighted, baseHighlighted] = await Promise.all([
+    radixSource
+      ? codeToHtml(radixSource, {
+          lang: "tsx",
+          themes: { dark: "github-dark", light: "github-light" },
+        })
+      : Promise.resolve(""),
+    baseSource
+      ? codeToHtml(baseSource, {
+          lang: "tsx",
+          themes: { dark: "github-dark", light: "github-light" },
+        })
+      : Promise.resolve(""),
+  ]);
 
   return (
-    
- - - - AI Elements - - shadcn CLI - {sourceCode && ( - Manual - )} - - - {elementsCommand} - - - {shadcnCommand} - - {sourceCode && ( - - - - - - )} - -
+ ); }; diff --git a/apps/docs/components/custom/preview-library-switch.tsx b/apps/docs/components/custom/preview-library-switch.tsx new file mode 100644 index 00000000..cabe716c --- /dev/null +++ b/apps/docs/components/custom/preview-library-switch.tsx @@ -0,0 +1,18 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useUILibrary } from "@/hooks/geistdocs/use-ui-library"; + +interface PreviewLibrarySwitchProps { + radix: ReactNode; + base: ReactNode; +} + +export const PreviewLibrarySwitch = ({ + radix, + base, +}: PreviewLibrarySwitchProps) => { + const { library } = useUILibrary(); + return <>{library === "base" ? base : radix}; +}; diff --git a/apps/docs/components/custom/preview.tsx b/apps/docs/components/custom/preview.tsx index 6646b0c8..ecb73d6a 100644 --- a/apps/docs/components/custom/preview.tsx +++ b/apps/docs/components/custom/preview.tsx @@ -1,9 +1,3 @@ -// Server component - Node.js modules are valid here -// oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) -import { readFile } from "node:fs/promises"; -// oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) -import { join } from "node:path"; - import { CodeBlock } from "@repo/elements/src/code-block"; import { ResizableHandle, @@ -11,6 +5,11 @@ import { ResizablePanelGroup, } from "@repo/shadcn-ui/components/ui/resizable"; import { cn } from "@repo/shadcn-ui/lib/utils"; +// Server component - Node.js modules are valid here +// oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) +import { readFile } from "node:fs/promises"; +// oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) +import { join } from "node:path"; import { CodeBlockTab, @@ -19,39 +18,90 @@ import { CodeBlockTabsTrigger, } from "@/components/geistdocs/code-block-tabs"; +import { PreviewLibrarySwitch } from "./preview-library-switch"; + interface ComponentPreviewProps { path: string; className?: string; } +const loadCode = async (pkg: string, path: string) => { + try { + return await readFile( + join(process.cwd(), "..", "..", "packages", pkg, "src", `${path}.tsx`), + "utf8" + ); + } catch { + return ""; + } +}; + export const Preview = async ({ path, className }: ComponentPreviewProps) => { - const code = await readFile( - join( - process.cwd(), - "..", - "..", - "packages", - "examples", - "src", - `${path}.tsx` - ), - "utf8" - ); + const [radixCode, baseCode] = await Promise.all([ + loadCode("examples", path), + loadCode("examples-base", path), + ]); - const Component = await import(`@repo/examples/src/${path}.tsx`).then( - (module) => module.default + // Dynamic imports must use inline template literals so the bundler + // can resolve the glob pattern for each package. + const RadixComponent = await import(`@repo/examples/src/${path}.tsx`).then( + (m) => m.default ); - if (!Component) { + const BaseComponent = await import( + `@repo/examples-base/src/${path}.tsx` + ).then((m) => m.default); + + if (!RadixComponent) { throw new Error( `No default export found for example: ${path}. Check that @repo/examples/src/${path}.tsx exports a default component.` ); } - const parsedCode = code + if (!BaseComponent) { + throw new Error( + `No default export found for example: ${path}. Check that @repo/examples-base/src/${path}.tsx exports a default component.` + ); + } + + const radixParsedCode = radixCode .replaceAll("@repo/shadcn-ui/", "@/") .replaceAll("@repo/elements/", "@/components/ai-elements/"); + const baseParsedCode = baseCode + .replaceAll("@repo/base-ui/", "@/") + .replaceAll("@repo/elements-base/", "@/components/ai-elements/"); + + const radixPreview = ( + + +
+ +
+
+ + +
+ ); + + const basePreview = BaseComponent ? ( + + +
+ +
+
+ + +
+ ) : null; + return ( @@ -59,27 +109,32 @@ export const Preview = async ({ path, className }: ComponentPreviewProps) => { Code - - -
- -
-
- - -
+
-
- -
+ + + + } + base={ +
+ +
+ } + />
); diff --git a/apps/docs/components/custom/source-code.tsx b/apps/docs/components/custom/source-code.tsx index d9a6d1e2..9d5b8a01 100644 --- a/apps/docs/components/custom/source-code.tsx +++ b/apps/docs/components/custom/source-code.tsx @@ -1,10 +1,9 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; // Server component - Node.js modules are valid here // oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) import { readFile } from "node:fs/promises"; // oxlint-disable-next-line eslint-plugin-import(no-nodejs-modules) import { join } from "node:path"; - -import { cn } from "@repo/shadcn-ui/lib/utils"; import { codeToHtml } from "shiki"; import { CodeBlock } from "../geistdocs/code-block"; @@ -25,7 +24,7 @@ export const SourceCode = async ({ path, className }: SourceCodeProps) => { "src", `${path}.tsx` ), - "utf8" + "utf-8" ); const parsedCode = code diff --git a/apps/docs/components/geistdocs/navbar.tsx b/apps/docs/components/geistdocs/navbar.tsx index e62aeed7..64da6dc1 100644 --- a/apps/docs/components/geistdocs/navbar.tsx +++ b/apps/docs/components/geistdocs/navbar.tsx @@ -8,6 +8,7 @@ import { DesktopMenu } from "./desktop-menu"; import { SlashIcon } from "./icons"; import { MobileMenu } from "./mobile-menu"; import { SearchButton } from "./search"; +import { UILibrarySwitcher } from "./ui-library-switcher"; export const Navbar = () => (
@@ -23,6 +24,7 @@ export const Navbar = () => (
+ diff --git a/apps/docs/components/geistdocs/ui-library-switcher.tsx b/apps/docs/components/geistdocs/ui-library-switcher.tsx new file mode 100644 index 00000000..d131c0cd --- /dev/null +++ b/apps/docs/components/geistdocs/ui-library-switcher.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { cn } from "@repo/shadcn-ui/lib/utils"; + +import { + type UILibrary, + useUILibrary, +} from "@/hooks/geistdocs/use-ui-library"; + +const options: { value: UILibrary; label: string }[] = [ + { value: "radix", label: "Radix UI" }, + { value: "base", label: "Base UI" }, +]; + +export const UILibrarySwitcher = ({ className }: { className?: string }) => { + const { library, setLibrary } = useUILibrary(); + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; diff --git a/apps/docs/content/components/(chatbot)/confirmation.mdx b/apps/docs/content/components/(chatbot)/confirmation.mdx index 1ddcb700..af6e2696 100644 --- a/apps/docs/content/components/(chatbot)/confirmation.mdx +++ b/apps/docs/content/components/(chatbot)/confirmation.mdx @@ -245,7 +245,8 @@ A styled description element for displaying a title or label within the confirma ", }, }} diff --git a/apps/docs/content/components/(chatbot)/message.mdx b/apps/docs/content/components/(chatbot)/message.mdx index ed189847..668a021c 100644 --- a/apps/docs/content/components/(chatbot)/message.mdx +++ b/apps/docs/content/components/(chatbot)/message.mdx @@ -314,7 +314,8 @@ export default ActionsDemo; ", }, }} diff --git a/apps/docs/content/components/(chatbot)/model-selector.mdx b/apps/docs/content/components/(chatbot)/model-selector.mdx index 6c07518b..842a446a 100644 --- a/apps/docs/content/components/(chatbot)/model-selector.mdx +++ b/apps/docs/content/components/(chatbot)/model-selector.mdx @@ -130,6 +130,12 @@ The `ModelSelector` component provides a searchable command palette interface fo ( + "geistdocs:ui-library", + "base" +); + +export const useUILibrary = () => { + const [library, setLibrary] = useAtom(uiLibraryAtom); + return { library, setLibrary }; +}; diff --git a/apps/docs/package.json b/apps/docs/package.json index 866ade8d..d18bf256 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -12,8 +12,11 @@ "@ai-sdk/react": "^3.0.41", "@icons-pack/react-simple-icons": "^13.8.0", "@orama/tokenizers": "^3.1.16", + "@repo/base-ui": "workspace:*", "@repo/elements": "workspace:*", + "@repo/elements-base": "workspace:*", "@repo/examples": "workspace:*", + "@repo/examples-base": "workspace:*", "@repo/shadcn-ui": "workspace:*", "@streamdown/cjk": "^1.0.1", "@streamdown/code": "^1.0.1", @@ -45,7 +48,6 @@ "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-mdx": "^3.1.1", - "shadcn": "^3.8.2", "shiki": "3.22.0", "sonner": "^2.0.7", "streamdown": "^2.1.0", @@ -60,8 +62,9 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "postcss": "^8.5.6", + "shadcn": "^3.8.2", "tailwindcss": "^4.1.17", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 27a32a6b..e286815c 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "test:coverage": "turbo run test:coverage", "check": "ultracite check", "fix": "ultracite fix", - "bump-deps": "npx npm-check-updates --deep -u -x recharts,react-resizable-panels && pnpm install", - "bump-ui": "npx shadcn@latest add --all --overwrite -c packages/shadcn-ui && npx shadcn@latest migrate radix -c packages/shadcn-ui", + "bump-deps": "npx npm-check-updates --deep -u && pnpm install", + "bump-ui": "npx shadcn@latest add --all --overwrite -c packages/shadcn-ui && npx shadcn@latest add --all --overwrite -c packages/base-ui", "changeset": "changeset", "generate-skills": "pnpm --filter @repo/scripts generate-skills" }, @@ -21,7 +21,7 @@ "oxlint": "^1.43.0", "turbo": "2.8.14", "typescript": "5.9.3", - "ultracite": "7.1.4", + "ultracite": "7.4.4", "vitest": "^4.0.17" }, "engines": { diff --git a/packages/base-ui/components.json b/packages/base-ui/components.json new file mode 100644 index 00000000..ce3cc801 --- /dev/null +++ b/packages/base-ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-vega", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../apps/docs/app/global.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@repo/base-ui/components", + "utils": "@repo/base-ui/lib/utils", + "hooks": "@repo/base-ui/hooks", + "lib": "@repo/base-ui/lib", + "ui": "@repo/base-ui/components/ui" + }, + "iconLibrary": "lucide" +} diff --git a/packages/base-ui/components/ui/accordion.tsx b/packages/base-ui/components/ui/accordion.tsx new file mode 100644 index 00000000..eaa94ed3 --- /dev/null +++ b/packages/base-ui/components/ui/accordion.tsx @@ -0,0 +1,72 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" + +import { cn } from "@repo/base-ui/lib/utils" +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/base-ui/components/ui/alert-dialog.tsx b/packages/base-ui/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..b1fb67d3 --- /dev/null +++ b/packages/base-ui/components/ui/alert-dialog.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@repo/base-ui/lib/utils" +import { Button } from "@repo/base-ui/components/ui/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/packages/base-ui/components/ui/chart.tsx b/packages/base-ui/components/ui/chart.tsx new file mode 100644 index 00000000..58d63dfa --- /dev/null +++ b/packages/base-ui/components/ui/chart.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "@repo/base-ui/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +