import type { z } from 'zod' import type { Registry, RegistryEntry, registryItemTypeSchema, } from '../src/registry/schema' // @sts-nocheck import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' import { template } from 'lodash-es' import { rimraf } from 'rimraf' import { registry } from '../src/registry' import { buildRegistry as crawlContent } from '../src/registry/crawl-content' import { baseColors } from '../src/registry/registry-base-colors' import { colorMapping, colors } from '../src/registry/registry-colors' import { iconLibraries, icons } from '../src/registry/registry-icons' import { styles } from '../src/registry/registry-styles' import { registryEntrySchema, registrySchema, } from '../src/registry/schema' const REGISTRY_PATH = path.join(process.cwd(), 'src/public/r') const REGISTRY_INDEX_WHITELIST: z.infer[] = [ 'registry:ui', 'registry:lib', 'registry:hook', 'registry:theme', 'registry:block', 'registry:example', ] // const project = new Project({ // compilerOptions: {}, // }) // async function createTempSourceFile(filename: string) { // const dir = await fs.mkdtemp(path.join(tmpdir(), 'shadcn-')) // return path.join(dir, filename) // } // ---------------------------------------------------------------------------- // Build __registry__/index.ts. // ---------------------------------------------------------------------------- async function buildRegistry(registry: Registry) { let index = `// @ts-nocheck // This file is autogenerated by scripts/build-registry.ts // Do not edit this file directly. export const Index: Record = { ` for (const style of styles) { index += ` "${style.name}": {` // Build style index. for (const item of registry) { const resolveFiles = item.files?.map( file => `registry/${style.name}/${ typeof file === 'string' ? file : file.path }`, ) if (!resolveFiles) { continue } const type = item.type.split(':')[1] const sourceFilename = '' // const chunks: any = [] // if (item.type === 'registry:block') { // const file = resolveFiles[0] // let raw: string // try { // if (file) { // const filename = path.basename(file) // } // raw = await fs.readFile(file, 'utf8') // } // catch (error) { // continue // } // // const tempFile = await createTempSourceFile(filename) // // const sourceFile = project.createSourceFile(tempFile, raw, { // // scriptKind: ScriptKind.TS, // // }) // // const description = sourceFile // // .getVariableDeclaration('description') // // ?.getInitializerOrThrow() // // .asKindOrThrow(SyntaxKind.StringLiteral) // // .getLiteralValue() // // item.description = description ?? '' // // // Find all imports. // // const imports = new Map< // // string, // // { // // module: string // // text: string // // isDefault?: boolean // // } // // >() // // sourceFile.getImportDeclarations().forEach((node) => { // // const module = node.getModuleSpecifier().getLiteralValue() // // node.getNamedImports().forEach((item) => { // // imports.set(item.getText(), { // // module, // // text: node.getText(), // // }) // // }) // // const defaultImport = node.getDefaultImport() // // if (defaultImport) { // // imports.set(defaultImport.getText(), { // // module, // // text: defaultImport.getText(), // // isDefault: true, // // }) // // } // // }) // // Find all opening tags with x-chunk attribute. // // const components = sourceFile // // .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) // // .filter((node) => { // // return node.getAttribute('x-chunk') !== undefined // // }) // // chunks = await Promise.all( // // components.map(async (component, index) => { // // const chunkName = `${item.name}-chunk-${index}` // // // Get the value of x-chunk attribute. // // const attr = component // // .getAttributeOrThrow('x-chunk') // // .asKindOrThrow(SyntaxKind.JsxAttribute) // // const description = attr // // .getInitializerOrThrow() // // .asKindOrThrow(SyntaxKind.StringLiteral) // // .getLiteralValue() // // // Delete the x-chunk attribute. // // attr.remove() // // // Add a new attribute to the component. // // component.addAttribute({ // // name: 'x-chunk', // // initializer: `"${chunkName}"`, // // }) // // // Get the value of x-chunk-container attribute. // // const containerAttr = component // // .getAttribute('x-chunk-container') // // ?.asKindOrThrow(SyntaxKind.JsxAttribute) // // const containerClassName = containerAttr // // ?.getInitializer() // // ?.asKindOrThrow(SyntaxKind.StringLiteral) // // .getLiteralValue() // // containerAttr?.remove() // // const parentJsxElement = component.getParentIfKindOrThrow( // // SyntaxKind.JsxElement, // // ) // // // Find all opening tags on component. // // const children = parentJsxElement // // .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) // // .map((node) => { // // return node.getTagNameNode().getText() // // }) // // .concat( // // parentJsxElement // // .getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement) // // .map((node) => { // // return node.getTagNameNode().getText() // // }), // // ) // // const componentImports = new Map< // // string, // // string | string[] | Set // // >() // // children.forEach((child) => { // // const importLine = imports.get(child) // // if (importLine) { // // const imports = componentImports.get(importLine.module) || [] // // const newImports = importLine.isDefault // // ? importLine.text // // : new Set([...imports, child]) // // componentImports.set( // // importLine.module, // // importLine?.isDefault ? newImports : Array.from(newImports), // // ) // // } // // }) // // const componnetImportLines = Array.from( // // componentImports.keys(), // // ).map((key) => { // // const values = componentImports.get(key) // // const specifier = Array.isArray(values) // // ? `{${values.join(',')}}` // // : values // // return `import ${specifier} from "${key}"` // // }) // // const code = ` // // 'use client' // // ${componnetImportLines.join('\n')} // // export default function Component() { // // return (${parentJsxElement.getText()}) // // }` // // const targetFile = file.replace(item.name, `${chunkName}`) // // const targetFilePath = path.join( // // cwd(), // // `registry/${style.name}/${type}/${chunkName}.ts`, // // ) // // // Write component file. // // rimraf.sync(targetFilePath) // // await fs.writeFile(targetFilePath, code, 'utf8') // // return { // // name: chunkName, // // description, // // component: `React.lazy(() => import("@/registry/${style.name}/${type}/${chunkName}")),`, // // file: targetFile, // // container: { // // className: containerClassName, // // }, // // } // // }), // // ) // // // Write the source file for blocks only. // sourceFilename = `__registry__/${style.name}/${type}/${item.name}.ts` // if (item.files) { // const files = item.files.map(file => // typeof file === 'string' // ? { type: 'registry:page', path: file } // : file, // ) // if (files?.length) { // sourceFilename = `__registry__/${style.name}/${files[0].path}` // } // } // const sourcePath = path.join(process.cwd(), sourceFilename) // if (!existsSync(sourcePath)) { // await fs.mkdir(sourcePath, { recursive: true }) // } // rimraf.sync(sourcePath) // // await fs.writeFile(sourcePath, sourceFile.getText()) // await fs.writeFile(sourcePath, raw) // } let componentPath = `@/registry/${style.name}/${type}/${item.name}` if (item.files) { const files = item.files.map(file => typeof file === 'string' ? { type: 'registry:page', path: file } : file, ) if (files?.length) { componentPath = `@/registry/${style.name}/${files[0].path}` } } index += ` "${item.name}": { name: "${item.name}", description: "${item.description ?? ''}", type: "${item.type}", registryDependencies: ${JSON.stringify(item.registryDependencies)}, files: [${item.files?.map((file) => { const filePath = `registry/${style.name}/${ typeof file === 'string' ? file : file.path }` const resolvedFilePath = path.resolve(filePath) return typeof file === 'string' ? `"${resolvedFilePath}"` : `{ path: "${filePath}", type: "${file.type}", target: "${file.target ?? ''}" }` })}], component: () => import("${componentPath}").then((m) => m.default), source: "${sourceFilename}", category: "${item.category ?? ''}", subcategory: "${item.subcategory ?? ''}" },` } // TODO: maybe implement chunk? // chunks: [${chunks.map( // chunk => `{ // name: "${chunk.name}", // description: "${chunk.description ?? 'No description'}", // component: ${chunk.component} // file: "${chunk.file}", // container: { // className: "${chunk.container.className}" // } // }`, // )}] index += ` },` } index += ` } ` // ---------------------------------------------------------------------------- // Build registry/index.json. // ---------------------------------------------------------------------------- const items = registry .filter(item => ['registry:ui'].includes(item.type)) .map((item) => { return { ...item, files: item.files?.map((_file) => { const file = { path: _file.path, type: item.type } return file }), } }) const registryJson = JSON.stringify(items, null, 2) rimraf.sync(path.join(REGISTRY_PATH, 'index.json')) await fs.writeFile( path.join(REGISTRY_PATH, 'index.json'), registryJson, 'utf8', ) // Write style index. rimraf.sync(path.join(process.cwd(), '__registry__/index.ts')) await fs.writeFile(path.join(process.cwd(), '__registry__/index.ts'), index) } // ---------------------------------------------------------------------------- // Build registry/styles/[style]/[name].json. // ---------------------------------------------------------------------------- async function buildStyles(registry: Registry) { for (const style of styles) { const targetPath = path.join(REGISTRY_PATH, 'styles', style.name) // Create directory if it doesn't exist. if (!existsSync(targetPath)) { await fs.mkdir(targetPath, { recursive: true }) } for (const item of registry) { if (!REGISTRY_INDEX_WHITELIST.includes(item.type)) { continue } let files if (item.files) { files = await Promise.all( item.files.map(async (_file) => { const file = { path: _file.path, type: _file.type, content: '', target: _file.target ?? '', } let content: string try { content = await fs.readFile( path.join(process.cwd(), 'src', 'registry', style.name, file.path), 'utf8', ) } catch (error) { return } // TODO: remove meta content // const tempFile = await createTempSourceFile(file.path) // const sourceFile = project.createSourceFile(tempFile, content, { // scriptKind: ScriptKind.TS, // }) // sourceFile.getVariableDeclaration('iframeHeight')?.remove() // sourceFile.getVariableDeclaration('containerClassName')?.remove() // sourceFile.getVariableDeclaration('description')?.remove() const target = file.target || '' // if ((!target || target === '') && item.name.startsWith('v0-')) { // const fileName = file.path.split('/').pop() // if ( // file.type === 'registry:block' // || file.type === 'registry:component' // || file.type === 'registry:example' // ) { // target = `components/${fileName}` // } // if (file.type === 'registry:ui') { // target = `components/ui/${fileName}` // } // if (file.type === 'registry:hook') { // target = `hooks/${fileName}` // } // if (file.type === 'registry:lib') { // target = `lib/${fileName}` // } // } return { path: file.path, type: file.type, // content: sourceFile.getText(), content, target, } }), ) } // if (item.type === 'registry:block' && item.name === 'Sidebar01') // console.log(item.name, item.files?.[0], files?.[0]) const payload = registryEntrySchema .omit({ // source: true, category: true, subcategory: true, // chunks: true, }) .safeParse({ ...item, files, }) if (payload.success) { await fs.writeFile( path.join(targetPath, `${item.name}.json`), `${JSON.stringify(payload.data, null, 2)}\r\n`, 'utf8', ) } } } // ---------------------------------------------------------------------------- // Build registry/styles/index.json. // ---------------------------------------------------------------------------- const stylesJson = JSON.stringify(styles, null, 2) await fs.writeFile( path.join(REGISTRY_PATH, 'styles/index.json'), stylesJson, 'utf8', ) } // ---------------------------------------------------------------------------- // Build registry/styles/[name]/index.json. // ---------------------------------------------------------------------------- async function buildStylesIndex() { for (const style of styles) { const targetPath = path.join(REGISTRY_PATH, 'styles', style.name) const dependencies = [ 'tailwindcss-animate', 'class-variance-authority', 'lucide-vue-next', ] // TODO: Remove this when we migrate to lucide-vue-next. // if (style.name === "new-york") { // dependencies.push("@radix-ui/react-icons") // } const payload: RegistryEntry = { name: style.name, type: 'registry:style', dependencies, registryDependencies: ['utils'], tailwind: { config: { plugins: [`require("tailwindcss-animate")`], }, }, cssVars: {}, files: [], } await fs.writeFile( path.join(targetPath, 'index.json'), JSON.stringify(payload, null, 2), 'utf8', ) } } // ---------------------------------------------------------------------------- // Build registry/colors/index.json. // ---------------------------------------------------------------------------- async function buildThemes() { const colorsTargetPath = path.join(REGISTRY_PATH, 'colors') rimraf.sync(colorsTargetPath) if (!existsSync(colorsTargetPath)) { await fs.mkdir(colorsTargetPath, { recursive: true }) } const colorsData: Record = {} for (const [color, value] of Object.entries(colors)) { if (typeof value === 'string') { colorsData[color] = value continue } if (Array.isArray(value)) { colorsData[color] = value.map(item => ({ ...item, rgbChannel: item.rgb.replace(/^rgb\((\d+),(\d+),(\d+)\)$/, '$1 $2 $3'), hslChannel: item.hsl.replace( /^hsl\(([\d.]+),([\d.]+%),([\d.]+%)\)$/, '$1 $2 $3', ), })) continue } if (typeof value === 'object') { colorsData[color] = { ...value, rgbChannel: value.rgb.replace(/^rgb\((\d+),(\d+),(\d+)\)$/, '$1 $2 $3'), hslChannel: value.hsl.replace( /^hsl\(([\d.]+),([\d.]+%),([\d.]+%)\)$/, '$1 $2 $3', ), } continue } } await fs.writeFile( path.join(colorsTargetPath, 'index.json'), `${JSON.stringify(colorsData, null, 2)}\r\n`, 'utf8', ) // ---------------------------------------------------------------------------- // Build registry/colors/[base].json. // ---------------------------------------------------------------------------- const BASE_STYLES = `@tailwind base; @tailwind components; @tailwind utilities; ` const BASE_STYLES_WITH_VARIABLES = `@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: <%- colors.light["background"] %>; --foreground: <%- colors.light["foreground"] %>; --card: <%- colors.light["card"] %>; --card-foreground: <%- colors.light["card-foreground"] %>; --popover: <%- colors.light["popover"] %>; --popover-foreground: <%- colors.light["popover-foreground"] %>; --primary: <%- colors.light["primary"] %>; --primary-foreground: <%- colors.light["primary-foreground"] %>; --secondary: <%- colors.light["secondary"] %>; --secondary-foreground: <%- colors.light["secondary-foreground"] %>; --muted: <%- colors.light["muted"] %>; --muted-foreground: <%- colors.light["muted-foreground"] %>; --accent: <%- colors.light["accent"] %>; --accent-foreground: <%- colors.light["accent-foreground"] %>; --destructive: <%- colors.light["destructive"] %>; --destructive-foreground: <%- colors.light["destructive-foreground"] %>; --border: <%- colors.light["border"] %>; --input: <%- colors.light["input"] %>; --ring: <%- colors.light["ring"] %>; --radius: 0.5rem; --chart-1: <%- colors.light["chart-1"] %>; --chart-2: <%- colors.light["chart-2"] %>; --chart-3: <%- colors.light["chart-3"] %>; --chart-4: <%- colors.light["chart-4"] %>; --chart-5: <%- colors.light["chart-5"] %>; } .dark { --background: <%- colors.dark["background"] %>; --foreground: <%- colors.dark["foreground"] %>; --card: <%- colors.dark["card"] %>; --card-foreground: <%- colors.dark["card-foreground"] %>; --popover: <%- colors.dark["popover"] %>; --popover-foreground: <%- colors.dark["popover-foreground"] %>; --primary: <%- colors.dark["primary"] %>; --primary-foreground: <%- colors.dark["primary-foreground"] %>; --secondary: <%- colors.dark["secondary"] %>; --secondary-foreground: <%- colors.dark["secondary-foreground"] %>; --muted: <%- colors.dark["muted"] %>; --muted-foreground: <%- colors.dark["muted-foreground"] %>; --accent: <%- colors.dark["accent"] %>; --accent-foreground: <%- colors.dark["accent-foreground"] %>; --destructive: <%- colors.dark["destructive"] %>; --destructive-foreground: <%- colors.dark["destructive-foreground"] %>; --border: <%- colors.dark["border"] %>; --input: <%- colors.dark["input"] %>; --ring: <%- colors.dark["ring"] %>; --chart-1: <%- colors.dark["chart-1"] %>; --chart-2: <%- colors.dark["chart-2"] %>; --chart-3: <%- colors.dark["chart-3"] %>; --chart-4: <%- colors.dark["chart-4"] %>; --chart-5: <%- colors.dark["chart-5"] %>; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } }` for (const baseColor of ['slate', 'gray', 'zinc', 'neutral', 'stone']) { const base: Record = { inlineColors: {}, cssVars: {}, } for (const [mode, values] of Object.entries(colorMapping)) { base.inlineColors[mode] = {} base.cssVars[mode] = {} for (const [key, value] of Object.entries(values)) { if (typeof value === 'string') { // Chart colors do not have a 1-to-1 mapping with tailwind colors. if (key.startsWith('chart-')) { base.cssVars[mode][key] = value continue } const resolvedColor = value.replace(/\{\{base\}\}-/g, `${baseColor}-`) base.inlineColors[mode][key] = resolvedColor const [resolvedBase, scale] = resolvedColor.split('-') const color = scale ? colorsData[resolvedBase].find( (item: any) => item.scale === Number.parseInt(scale), ) : colorsData[resolvedBase] if (color) { base.cssVars[mode][key] = color.hslChannel } } } } // Build css vars. base.inlineColorsTemplate = template(BASE_STYLES)({}) base.cssVarsTemplate = template(BASE_STYLES_WITH_VARIABLES)({ colors: base.cssVars, }) await fs.writeFile( path.join(REGISTRY_PATH, `colors/${baseColor}.json`), `${JSON.stringify(base, null, 2)}\r\n`, 'utf8', ) // ---------------------------------------------------------------------------- // Build registry/themes.css // ---------------------------------------------------------------------------- const THEME_STYLES_WITH_VARIABLES = ` .theme-<%- theme %> { --background: <%- colors.light["background"] %>; --foreground: <%- colors.light["foreground"] %>; --muted: <%- colors.light["muted"] %>; --muted-foreground: <%- colors.light["muted-foreground"] %>; --popover: <%- colors.light["popover"] %>; --popover-foreground: <%- colors.light["popover-foreground"] %>; --card: <%- colors.light["card"] %>; --card-foreground: <%- colors.light["card-foreground"] %>; --border: <%- colors.light["border"] %>; --input: <%- colors.light["input"] %>; --primary: <%- colors.light["primary"] %>; --primary-foreground: <%- colors.light["primary-foreground"] %>; --secondary: <%- colors.light["secondary"] %>; --secondary-foreground: <%- colors.light["secondary-foreground"] %>; --accent: <%- colors.light["accent"] %>; --accent-foreground: <%- colors.light["accent-foreground"] %>; --destructive: <%- colors.light["destructive"] %>; --destructive-foreground: <%- colors.light["destructive-foreground"] %>; --ring: <%- colors.light["ring"] %>; --radius: <%- colors.light["radius"] %>; } .dark .theme-<%- theme %> { --background: <%- colors.dark["background"] %>; --foreground: <%- colors.dark["foreground"] %>; --muted: <%- colors.dark["muted"] %>; --muted-foreground: <%- colors.dark["muted-foreground"] %>; --popover: <%- colors.dark["popover"] %>; --popover-foreground: <%- colors.dark["popover-foreground"] %>; --card: <%- colors.dark["card"] %>; --card-foreground: <%- colors.dark["card-foreground"] %>; --border: <%- colors.dark["border"] %>; --input: <%- colors.dark["input"] %>; --primary: <%- colors.dark["primary"] %>; --primary-foreground: <%- colors.dark["primary-foreground"] %>; --secondary: <%- colors.dark["secondary"] %>; --secondary-foreground: <%- colors.dark["secondary-foreground"] %>; --accent: <%- colors.dark["accent"] %>; --accent-foreground: <%- colors.dark["accent-foreground"] %>; --destructive: <%- colors.dark["destructive"] %>; --destructive-foreground: <%- colors.dark["destructive-foreground"] %>; --ring: <%- colors.dark["ring"] %>; }` const themeCSS = [] for (const theme of baseColors) { themeCSS.push( template(THEME_STYLES_WITH_VARIABLES)({ colors: theme.cssVars, theme: theme.name, }), ) } await fs.writeFile( path.join(REGISTRY_PATH, `themes.css`), themeCSS.join('\n'), 'utf8', ) // ---------------------------------------------------------------------------- // Build registry/themes/[theme].json // ---------------------------------------------------------------------------- rimraf.sync(path.join(REGISTRY_PATH, 'themes')) for (const baseColor of ['slate', 'gray', 'zinc', 'neutral', 'stone']) { const payload: Record = { name: baseColor, label: baseColor.charAt(0).toUpperCase() + baseColor.slice(1), cssVars: {}, } for (const [mode, values] of Object.entries(colorMapping)) { payload.cssVars[mode] = {} for (const [key, value] of Object.entries(values)) { if (typeof value === 'string') { const resolvedColor = value.replace(/\{\{base\}\}-/g, `${baseColor}-`) payload.cssVars[mode][key] = resolvedColor const [resolvedBase, scale] = resolvedColor.split('-') const color = scale ? colorsData[resolvedBase].find( (item: any) => item.scale === Number.parseInt(scale), ) : colorsData[resolvedBase] if (color) { payload.cssVars[mode][key] = color.hslChannel } } } } const targetPath = path.join(REGISTRY_PATH, 'themes') // Create directory if it doesn't exist. if (!existsSync(targetPath)) { await fs.mkdir(targetPath, { recursive: true }) } await fs.writeFile( path.join(targetPath, `${payload.name}.json`), JSON.stringify(payload, null, 2), 'utf8', ) } } } // ---------------------------------------------------------------------------- // Build registry/icons/index.json. // ---------------------------------------------------------------------------- async function buildIcons() { const iconsTargetPath = path.join(REGISTRY_PATH, 'icons') rimraf.sync(iconsTargetPath) if (!existsSync(iconsTargetPath)) { await fs.mkdir(iconsTargetPath, { recursive: true }) } const iconsData = icons await fs.writeFile( path.join(iconsTargetPath, 'index.json'), JSON.stringify(iconsData, null, 2), 'utf8', ) } // ---------------------------------------------------------------------------- // Build __registry__/icons.ts. // ---------------------------------------------------------------------------- async function buildRegistryIcons() { let index = `// @ts-nocheck // This file is autogenerated by scripts/build-registry.ts // Do not edit this file directly. import * as React from "react" export const Icons = { ` for (const [icon, libraries] of Object.entries(icons)) { index += ` "${icon}": {` for (const [library, componentName] of Object.entries(libraries)) { const packageName = iconLibraries[library as keyof typeof iconLibraries].package if (packageName) { index += ` ${library}: () => import("${packageName}").then(mod => ({ default: mod.${componentName} })),` } } index += ` },` } index += ` } ` // Write style index. rimraf.sync(path.join(process.cwd(), '__registry__/icons.ts')) await fs.writeFile( path.join(process.cwd(), '__registry__/icons.ts'), index, 'utf8', ) } try { const content = await crawlContent() const result = registrySchema.safeParse([...registry, ...content]) await fs.writeFile( path.join(REGISTRY_PATH, 'temp.json'), JSON.stringify(result.data ?? '', null, 2), 'utf8', ) if (!result.success) { console.error(result.error) process.exit(1) } await buildRegistry(result.data) await buildStyles(result.data) await buildStylesIndex() await buildThemes() // await buildRegistryIcons() // await buildIcons() // eslint-disable-next-line no-console console.log('✅ Done!') } catch (error) { console.error(error) process.exit(1) }