snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
269
apps/v4/scripts/build-icons.ts
Normal file
269
apps/v4/scripts/build-icons.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env tsx
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { iconLibraries, type IconLibraryName } from "shadcn/icons"
|
||||
|
||||
type IconUsage = Record<IconLibraryName, Set<string>>
|
||||
|
||||
// Canonical (lucide) icon name -> { library: icon name }.
|
||||
type IconMapping = Record<string, Record<string, string>>
|
||||
|
||||
function findTsxFiles(dir: string) {
|
||||
const files: string[] = []
|
||||
// Sort for a deterministic scan order: mapping conflicts resolve to the
|
||||
// first occurrence, which must not depend on filesystem enumeration order.
|
||||
const entries = fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...findTsxFiles(fullPath))
|
||||
} else if (entry.isFile() && entry.name.endsWith(".tsx")) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function scanIconUsage() {
|
||||
const iconUsage: IconUsage = Object.keys(iconLibraries).reduce((acc, key) => {
|
||||
acc[key as IconLibraryName] = new Set()
|
||||
return acc
|
||||
}, {} as IconUsage)
|
||||
const iconMapping: IconMapping = {}
|
||||
// Keyed to dedupe: the same conflict shows up once per placeholder
|
||||
// occurrence across bases, but only the first file is worth reporting.
|
||||
const warnings = new Map<string, string>()
|
||||
|
||||
const registryBasesDir = path.join(process.cwd(), "registry/bases")
|
||||
const files = findTsxFiles(registryBasesDir)
|
||||
const libraryNames = Object.values(iconLibraries)
|
||||
.map((lib) => lib.name)
|
||||
.join("|")
|
||||
const iconPlaceholderRegex = new RegExp(
|
||||
`<IconPlaceholder\\s+([^>]*?)(?:${libraryNames})=["']([^"']+)["']([^>]*?)\\/?>`,
|
||||
"g"
|
||||
)
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, "utf-8")
|
||||
|
||||
let match
|
||||
while ((match = iconPlaceholderRegex.exec(content)) !== null) {
|
||||
const fullMatch = match[0]
|
||||
const placeholderIcons: Partial<Record<IconLibraryName, string>> = {}
|
||||
|
||||
for (const [libraryName, config] of Object.entries(iconLibraries)) {
|
||||
const attrMatch = fullMatch.match(
|
||||
new RegExp(`${config.name}=["']([^"']+)["']`)
|
||||
)
|
||||
if (attrMatch) {
|
||||
iconUsage[libraryName as IconLibraryName].add(attrMatch[1])
|
||||
placeholderIcons[libraryName as IconLibraryName] = attrMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Lucide is the canonical key: every placeholder has a lucide prop.
|
||||
const canonical = placeholderIcons.lucide
|
||||
if (!canonical) {
|
||||
const relativeFile = path.relative(process.cwd(), file)
|
||||
warnings.set(
|
||||
`missing-lucide|${relativeFile}`,
|
||||
`IconPlaceholder without a lucide prop in ${relativeFile}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const entry = (iconMapping[canonical] ??= {})
|
||||
for (const [libraryName, iconName] of Object.entries(placeholderIcons)) {
|
||||
if (entry[libraryName] && entry[libraryName] !== iconName) {
|
||||
const key = `conflict|${libraryName}|${canonical}|${iconName}`
|
||||
if (!warnings.has(key)) {
|
||||
warnings.set(
|
||||
key,
|
||||
`Conflicting ${libraryName} mapping for ${canonical}: keeping ${entry[libraryName]}, ignoring ${iconName} (first seen in ${path.relative(process.cwd(), file)})`
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
entry[libraryName] = iconName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
iconUsage,
|
||||
iconMapping,
|
||||
warnings: Array.from(warnings.values()),
|
||||
}
|
||||
}
|
||||
|
||||
function generateIconFiles(iconUsage: IconUsage) {
|
||||
const outputDir = path.join(process.cwd(), "registry/icons")
|
||||
|
||||
const written: string[] = []
|
||||
|
||||
Object.entries(iconLibraries).forEach(([libraryName, config]) => {
|
||||
const icons = Array.from(iconUsage[libraryName as IconLibraryName]).sort()
|
||||
|
||||
if (icons.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const content = `// Auto-generated by scripts/build-icons.ts
|
||||
${icons.map((icon) => `export { ${icon} } from "${config.export}"`).join("\n")}
|
||||
`
|
||||
|
||||
const filename = `__${libraryName}__.ts`
|
||||
const filepath = path.join(outputDir, filename)
|
||||
|
||||
// Skip unchanged files to avoid mtime bumps that trigger
|
||||
// unnecessary Turbopack invalidations in watch mode.
|
||||
if (
|
||||
fs.existsSync(filepath) &&
|
||||
fs.readFileSync(filepath, "utf-8") === content
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, content)
|
||||
written.push(` - ${config.title}: ${icons.length} icons`)
|
||||
})
|
||||
|
||||
if (written.length > 0) {
|
||||
console.log("✓ Generated icon files:")
|
||||
written.forEach((line) => console.log(line))
|
||||
}
|
||||
}
|
||||
|
||||
function generateIconMapping(iconMapping: IconMapping, warnings: string[]) {
|
||||
const legacyPath = path.join(
|
||||
process.cwd(),
|
||||
"registry/icons/legacy-mapping.json"
|
||||
)
|
||||
const legacy: IconMapping = JSON.parse(fs.readFileSync(legacyPath, "utf-8"))
|
||||
|
||||
const output: IconMapping = {}
|
||||
const scanned: IconMapping = { ...iconMapping }
|
||||
const libraryOrder = Object.keys(iconLibraries)
|
||||
|
||||
// Legacy entries come first with their lucide/radix values untouched:
|
||||
// published CLI versions reverse-lookup this file by first match, so
|
||||
// entry order and legacy values are a compatibility contract.
|
||||
for (const [canonical, legacyEntry] of Object.entries(legacy)) {
|
||||
const entry: Record<string, string> = { ...legacyEntry }
|
||||
|
||||
// Registry code uses lucide's suffixed aliases (e.g. CheckIcon for Check).
|
||||
const scannedKey =
|
||||
canonical in scanned
|
||||
? canonical
|
||||
: `${canonical}Icon` in scanned
|
||||
? `${canonical}Icon`
|
||||
: undefined
|
||||
|
||||
if (scannedKey) {
|
||||
for (const library of libraryOrder) {
|
||||
if (!entry[library] && scanned[scannedKey][library]) {
|
||||
entry[library] = scanned[scannedKey][library]
|
||||
}
|
||||
}
|
||||
delete scanned[scannedKey]
|
||||
}
|
||||
|
||||
output[canonical] = entry
|
||||
}
|
||||
|
||||
for (const canonical of Object.keys(scanned).sort()) {
|
||||
const entry: Record<string, string> = {}
|
||||
for (const library of libraryOrder) {
|
||||
if (scanned[canonical][library]) {
|
||||
entry[library] = scanned[canonical][library]
|
||||
}
|
||||
}
|
||||
output[canonical] = entry
|
||||
}
|
||||
|
||||
const outputPath = path.join(process.cwd(), "public/r/icons/index.json")
|
||||
const content = JSON.stringify(output, null, 2)
|
||||
|
||||
if (
|
||||
fs.existsSync(outputPath) &&
|
||||
fs.readFileSync(outputPath, "utf-8") === content
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, content)
|
||||
console.log(
|
||||
`✓ Generated icon mapping: ${Object.keys(output).length} icons (${
|
||||
Object.keys(legacy).length
|
||||
} legacy)`
|
||||
)
|
||||
|
||||
// Only surface scan warnings when the mapping actually changed: they are
|
||||
// informational (first occurrence wins) and would otherwise repeat on
|
||||
// every rebuild in watch mode.
|
||||
if (warnings.length > 0) {
|
||||
if (isVerbose) {
|
||||
warnings.forEach((warning) => console.warn(`⚠ ${warning}`))
|
||||
} else {
|
||||
console.warn(
|
||||
`⚠ ${warnings.length} icon mapping conflicts (first occurrence wins). Run with --verbose for details.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { iconUsage, iconMapping, warnings } = scanIconUsage()
|
||||
generateIconFiles(iconUsage)
|
||||
generateIconMapping(iconMapping, warnings)
|
||||
}
|
||||
|
||||
const isWatchMode = process.argv.includes("--watch")
|
||||
const isVerbose = process.argv.includes("--verbose")
|
||||
|
||||
if (isWatchMode) {
|
||||
const REGISTRY_DIR = path.join(process.cwd(), "registry/bases")
|
||||
|
||||
async function startWatcher() {
|
||||
const { default: chokidar } = await import("chokidar")
|
||||
|
||||
main()
|
||||
|
||||
const watcher = chokidar.watch(REGISTRY_DIR, {
|
||||
ignored: /(^|[/\\])\../,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
const rebuild = (filename: string) => {
|
||||
if (!filename.endsWith(".tsx")) return
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error("❌ Icons build failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
console.error("❌ Watcher error:", error)
|
||||
})
|
||||
|
||||
watcher.on("change", rebuild)
|
||||
watcher.on("add", rebuild)
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await watcher.close()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
startWatcher()
|
||||
} else {
|
||||
main()
|
||||
}
|
||||
1840
apps/v4/scripts/build-registry.mts
Normal file
1840
apps/v4/scripts/build-registry.mts
Normal file
File diff suppressed because it is too large
Load Diff
417
apps/v4/scripts/build-test-app.mts
Normal file
417
apps/v4/scripts/build-test-app.mts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Build Test Apps Script
|
||||
*
|
||||
* This script populates the ui-test-apps repository with components and examples
|
||||
* from the shadcn/ui registry for testing different style configurations.
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. Clone the test apps repo: git clone https://github.com/shadcn-ui/ui-test-apps
|
||||
* 2. Place it at ../../../ui-test-apps (relative to apps/v4) or set TEST_APPS_PATH
|
||||
*
|
||||
* Usage:
|
||||
* pnpm test:apps [STYLE]
|
||||
*
|
||||
* Examples:
|
||||
* pnpm test:apps nova # Build with nova style
|
||||
* pnpm test:apps maia # Build with maia style
|
||||
* pnpm test:apps vega # Build with vega style
|
||||
*
|
||||
* Available styles: vega, nova, maia, lyra, mira
|
||||
*
|
||||
* What it does:
|
||||
* 1. Copies UI components to next-radix/components/ui and next-base/components/ui
|
||||
* 2. Copies hooks to next-radix/hooks and next-base/hooks
|
||||
* 3. Transforms canonical CSS classes (cn-*) to actual Tailwind classes
|
||||
* 4. Transforms IconPlaceholder to lucide-react icons
|
||||
* 5. Generates example pages at app/{example}/page.tsx
|
||||
* 6. Generates block pages at app/blocks/{block}/page.tsx
|
||||
* 7. Updates the STYLE constant in layout.tsx
|
||||
*
|
||||
* After running:
|
||||
* cd ../../../ui-test-apps
|
||||
* pnpm install
|
||||
* pnpm dev
|
||||
*
|
||||
* Then visit:
|
||||
* - http://localhost:3000 (next-radix)
|
||||
* - http://localhost:3001 (next-base)
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { rimraf } from "rimraf"
|
||||
import { registrySchema } from "shadcn/schema"
|
||||
import {
|
||||
createStyleMap,
|
||||
transformIcons,
|
||||
transformStyle,
|
||||
} from "shadcn/utils"
|
||||
import { Project, ScriptKind } from "ts-morph"
|
||||
|
||||
import { BASES, type Base } from "@/registry/bases"
|
||||
import { STYLES } from "@/registry/styles"
|
||||
|
||||
// Default path to test apps repo.
|
||||
const DEFAULT_TEST_APPS_PATH = "../../../ui-test-apps"
|
||||
|
||||
// Parse CLI arguments.
|
||||
const args = process.argv.slice(2)
|
||||
const styleName = args[0]
|
||||
const testAppsPath =
|
||||
args[1] || process.env.TEST_APPS_PATH || DEFAULT_TEST_APPS_PATH
|
||||
|
||||
if (!styleName) {
|
||||
console.error("❌ Usage: pnpm test:apps [STYLE] [TEST_APPS_PATH]")
|
||||
console.error(" Example: pnpm test:apps nova")
|
||||
console.error("")
|
||||
console.error(` Available styles: ${STYLES.map((s) => s.name).join(", ")}`)
|
||||
console.error("")
|
||||
console.error(` Default test apps path: ${DEFAULT_TEST_APPS_PATH}`)
|
||||
console.error(` Override with: pnpm test:apps nova /path/to/ui-test-apps`)
|
||||
console.error(` Or set TEST_APPS_PATH environment variable`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Validate style exists.
|
||||
const style = STYLES.find((s) => s.name === styleName)
|
||||
if (!style) {
|
||||
console.error(`❌ Unknown style: "${styleName}"`)
|
||||
console.error(` Available styles: ${STYLES.map((s) => s.name).join(", ")}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Resolve test apps path.
|
||||
const resolvedTestAppsPath = path.resolve(process.cwd(), testAppsPath)
|
||||
|
||||
// Check if test apps path exists.
|
||||
try {
|
||||
await fs.access(resolvedTestAppsPath)
|
||||
} catch {
|
||||
console.error(`❌ Test apps path not found: ${resolvedTestAppsPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`🏗️ Building test apps with style "${styleName}"...`)
|
||||
console.log(` Test apps path: ${resolvedTestAppsPath}`)
|
||||
|
||||
// Create ts-morph project for icon transformation.
|
||||
const project = new Project({
|
||||
useInMemoryFileSystem: true,
|
||||
})
|
||||
|
||||
try {
|
||||
// Build bases with the selected style.
|
||||
console.log("\n📦 Building bases...")
|
||||
const builtRegistries = await buildBasesWithStyle(Array.from(BASES), style)
|
||||
|
||||
// Process each base.
|
||||
for (const { base, registryItems, styleMap } of builtRegistries) {
|
||||
const testAppDir = path.join(resolvedTestAppsPath, `next-${base.name}`)
|
||||
|
||||
// Check if test app exists.
|
||||
try {
|
||||
await fs.access(testAppDir)
|
||||
} catch {
|
||||
console.log(` ⚠️ ${testAppDir} not found, skipping.`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`\n📋 Processing ${base.name}...`)
|
||||
|
||||
// Clear existing generated content.
|
||||
await rimraf(path.join(testAppDir, "components/ui"))
|
||||
await rimraf(path.join(testAppDir, "hooks"))
|
||||
|
||||
// Clear example routes (directories in app/).
|
||||
const appDir = path.join(testAppDir, "app")
|
||||
const appContents = await fs.readdir(appDir)
|
||||
for (const item of appContents) {
|
||||
const itemPath = path.join(appDir, item)
|
||||
const stat = await fs.stat(itemPath)
|
||||
if (stat.isDirectory()) {
|
||||
await rimraf(itemPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy UI components.
|
||||
console.log(` 📁 Copying UI components...`)
|
||||
await copyTransformedFiles(
|
||||
path.join(process.cwd(), `registry/bases/${base.name}/ui`),
|
||||
path.join(testAppDir, "components/ui"),
|
||||
base,
|
||||
styleMap
|
||||
)
|
||||
|
||||
// Copy hooks.
|
||||
console.log(` 📁 Copying hooks...`)
|
||||
await copyTransformedFiles(
|
||||
path.join(process.cwd(), `registry/bases/${base.name}/hooks`),
|
||||
path.join(testAppDir, "hooks"),
|
||||
base,
|
||||
styleMap
|
||||
)
|
||||
|
||||
// Generate example pages.
|
||||
console.log(` 📄 Generating example pages...`)
|
||||
const examplesDir = path.join(
|
||||
process.cwd(),
|
||||
`registry/bases/${base.name}/examples`
|
||||
)
|
||||
let exampleFiles: string[] = []
|
||||
try {
|
||||
exampleFiles = (await fs.readdir(examplesDir)).filter(
|
||||
(f) =>
|
||||
f.endsWith(".tsx") &&
|
||||
!f.startsWith("_") &&
|
||||
f !== "component-example.tsx" // Skip the generic component example.
|
||||
)
|
||||
} catch {
|
||||
console.log(` ⚠️ No examples directory found for ${base.name}.`)
|
||||
}
|
||||
|
||||
const generatedExamples: string[] = []
|
||||
|
||||
for (const exampleFile of exampleFiles) {
|
||||
// Extract example name: "accordion-example.tsx" -> "accordion".
|
||||
const exampleName = exampleFile
|
||||
.replace("-example.tsx", "")
|
||||
.replace(".tsx", "")
|
||||
const sourcePath = path.join(examplesDir, exampleFile)
|
||||
const targetDir = path.join(appDir, exampleName)
|
||||
const targetPath = path.join(targetDir, "page.tsx")
|
||||
|
||||
// Read and transform the example file.
|
||||
let content = await fs.readFile(sourcePath, "utf-8")
|
||||
|
||||
// Apply style transformation.
|
||||
content = await transformStyle(content, { styleMap })
|
||||
|
||||
// Transform icons using shadcn transformer.
|
||||
content = await applyIconTransform(content, exampleFile)
|
||||
|
||||
// Rewrite imports.
|
||||
content = rewriteImports(content, base.name)
|
||||
|
||||
// Transform the default export function name to Page.
|
||||
content = transformDefaultExport(content)
|
||||
|
||||
// Write the transformed example as a page.
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await fs.writeFile(targetPath, content)
|
||||
generatedExamples.push(exampleName)
|
||||
}
|
||||
|
||||
console.log(` ✅ Generated ${generatedExamples.length} example pages`)
|
||||
|
||||
// Generate block pages.
|
||||
console.log(` 📄 Generating block pages...`)
|
||||
const blocksDir = path.join(
|
||||
process.cwd(),
|
||||
`registry/bases/${base.name}/blocks`
|
||||
)
|
||||
let blockFiles: string[] = []
|
||||
try {
|
||||
blockFiles = (await fs.readdir(blocksDir)).filter(
|
||||
(f) => f.endsWith(".tsx") && !f.startsWith("_")
|
||||
)
|
||||
} catch {
|
||||
console.log(` ⚠️ No blocks directory found for ${base.name}.`)
|
||||
}
|
||||
|
||||
const generatedBlocks: string[] = []
|
||||
|
||||
for (const blockFile of blockFiles) {
|
||||
// Extract block name: "chatgpt.tsx" -> "chatgpt".
|
||||
const blockName = blockFile.replace(".tsx", "")
|
||||
const sourcePath = path.join(blocksDir, blockFile)
|
||||
const targetDir = path.join(appDir, `blocks/${blockName}`)
|
||||
const targetPath = path.join(targetDir, "page.tsx")
|
||||
|
||||
// Read and transform the block file.
|
||||
let content = await fs.readFile(sourcePath, "utf-8")
|
||||
|
||||
// Apply style transformation.
|
||||
content = await transformStyle(content, { styleMap })
|
||||
|
||||
// Transform icons using shadcn transformer.
|
||||
content = await applyIconTransform(content, blockFile)
|
||||
|
||||
// Rewrite imports.
|
||||
content = rewriteImports(content, base.name)
|
||||
|
||||
// Transform the default export function name to Page.
|
||||
content = transformDefaultExport(content)
|
||||
|
||||
// Write the transformed block as a page.
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await fs.writeFile(targetPath, content)
|
||||
generatedBlocks.push(blockName)
|
||||
}
|
||||
|
||||
console.log(` ✅ Generated ${generatedBlocks.length} block pages`)
|
||||
|
||||
// Update STYLE constant in layout.tsx.
|
||||
await updateLayoutStyle(testAppDir, styleName)
|
||||
}
|
||||
|
||||
console.log(`\n✅ Test apps built successfully!`)
|
||||
console.log(`\n📌 Next steps:`)
|
||||
console.log(` cd ${resolvedTestAppsPath}`)
|
||||
console.log(` pnpm install`)
|
||||
console.log(` pnpm dev`)
|
||||
} catch (error) {
|
||||
console.error("\n❌ Build failed:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function buildBasesWithStyle(bases: Base[], style: (typeof STYLES)[0]) {
|
||||
// Load style map.
|
||||
const styleContent = await fs.readFile(
|
||||
path.join(process.cwd(), `registry/styles/style-${style.name}.css`),
|
||||
"utf8"
|
||||
)
|
||||
const styleMap = createStyleMap(styleContent)
|
||||
|
||||
// Load registries for each base.
|
||||
const results: Array<{
|
||||
base: Base
|
||||
registryItems: Array<{ name: string; files?: Array<{ path: string }> }>
|
||||
styleMap: Record<string, string>
|
||||
}> = []
|
||||
|
||||
for (const base of bases) {
|
||||
const { registry: baseRegistry } = await import(
|
||||
`../registry/bases/${base.name}/registry.ts`
|
||||
)
|
||||
const result = registrySchema.safeParse(baseRegistry)
|
||||
if (!result.success) {
|
||||
console.error(`❌ Registry validation failed for ${base.name}:`)
|
||||
console.error(result.error.format())
|
||||
throw new Error(`Invalid registry schema for ${base.name}`)
|
||||
}
|
||||
const registryItems = result.data.items.filter(
|
||||
(item) => item.type !== "registry:internal"
|
||||
)
|
||||
results.push({ base, registryItems, styleMap })
|
||||
console.log(` ✅ Loaded ${base.name} registry`)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function copyTransformedFiles(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
base: Base,
|
||||
styleMap: Record<string, string>
|
||||
) {
|
||||
try {
|
||||
await fs.access(sourceDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
|
||||
const files = await fs.readdir(sourceDir)
|
||||
for (const file of files) {
|
||||
// Skip _registry.ts files.
|
||||
if (file.startsWith("_")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const targetPath = path.join(targetDir, file)
|
||||
const stat = await fs.stat(sourcePath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await copyTransformedFiles(sourcePath, targetPath, base, styleMap)
|
||||
} else {
|
||||
let content = await fs.readFile(sourcePath, "utf-8")
|
||||
|
||||
// Apply style transformation for .tsx and .ts files.
|
||||
if (file.endsWith(".tsx") || file.endsWith(".ts")) {
|
||||
content = await transformStyle(content, { styleMap })
|
||||
content = await applyIconTransform(content, file)
|
||||
content = rewriteImports(content, base.name)
|
||||
}
|
||||
|
||||
await fs.writeFile(targetPath, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyIconTransform(content: string, filename: string) {
|
||||
const sourceFile = project.createSourceFile(filename, content, {
|
||||
scriptKind: ScriptKind.TSX,
|
||||
overwrite: true,
|
||||
})
|
||||
|
||||
// Create a minimal config with just iconLibrary.
|
||||
// transformIcons only uses config.iconLibrary, so we can safely cast this.
|
||||
type TransformIconsConfig = Parameters<typeof transformIcons>[0]["config"]
|
||||
const config = { iconLibrary: "lucide" } as TransformIconsConfig
|
||||
|
||||
await transformIcons({
|
||||
sourceFile,
|
||||
config,
|
||||
filename,
|
||||
raw: content,
|
||||
})
|
||||
|
||||
return sourceFile.getText()
|
||||
}
|
||||
|
||||
function rewriteImports(content: string, baseName: string) {
|
||||
// Rewrite base registry imports to test app paths.
|
||||
content = content.replace(
|
||||
new RegExp(`@/registry/bases/${baseName}/ui/`, "g"),
|
||||
"@/components/ui/"
|
||||
)
|
||||
content = content.replace(
|
||||
new RegExp(`@/registry/bases/${baseName}/lib/`, "g"),
|
||||
"@/lib/"
|
||||
)
|
||||
content = content.replace(
|
||||
new RegExp(`@/registry/bases/${baseName}/hooks/`, "g"),
|
||||
"@/hooks/"
|
||||
)
|
||||
content = content.replace(
|
||||
new RegExp(`@/registry/bases/${baseName}/components/`, "g"),
|
||||
"@/components/"
|
||||
)
|
||||
|
||||
// Remove imports from @/app (like IconPlaceholder).
|
||||
content = content.replace(/^import.*from\s+["']@\/app\/.*["'].*\n/gm, "")
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function transformDefaultExport(content: string) {
|
||||
// Replace "export default function XxxExample" with "export default function Page".
|
||||
content = content.replace(
|
||||
/export\s+default\s+function\s+\w+\s*\(/,
|
||||
"export default function Page("
|
||||
)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async function updateLayoutStyle(testAppDir: string, styleName: string) {
|
||||
const layoutPath = path.join(testAppDir, "app/layout.tsx")
|
||||
|
||||
try {
|
||||
let content = await fs.readFile(layoutPath, "utf-8")
|
||||
|
||||
// Replace the STYLE constant value.
|
||||
content = content.replace(
|
||||
/const STYLE = ["'][^"']*["']/,
|
||||
`const STYLE = "${styleName}"`
|
||||
)
|
||||
|
||||
await fs.writeFile(layoutPath, content)
|
||||
console.log(` ✅ Updated STYLE in layout.tsx to "${styleName}"`)
|
||||
} catch {
|
||||
console.log(` ⚠️ Could not update layout.tsx`)
|
||||
}
|
||||
}
|
||||
114
apps/v4/scripts/capture-explore.mts
Normal file
114
apps/v4/scripts/capture-explore.mts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import path from "path"
|
||||
import puppeteer from "puppeteer"
|
||||
import { decodePreset } from "shadcn/preset"
|
||||
|
||||
import { EXPLORE_PRESETS } from "../lib/explore"
|
||||
|
||||
const PRESETS_PATH = path.join(process.cwd(), "public/presets")
|
||||
const force = process.argv.includes("--force")
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Capture explore preset screenshots.
|
||||
// ----------------------------------------------------------------------------
|
||||
async function captureScreenshots() {
|
||||
// Ensure output directory exists.
|
||||
if (!existsSync(PRESETS_PATH)) {
|
||||
mkdirSync(PRESETS_PATH, { recursive: true })
|
||||
}
|
||||
|
||||
const presets = force
|
||||
? EXPLORE_PRESETS
|
||||
: EXPLORE_PRESETS.filter((code) => {
|
||||
const lightPath = path.join(PRESETS_PATH, `${code}-light.png`)
|
||||
const darkPath = path.join(PRESETS_PATH, `${code}-dark.png`)
|
||||
return !existsSync(lightPath) || !existsSync(darkPath)
|
||||
})
|
||||
|
||||
if (presets.length === 0) {
|
||||
console.log("✨ All screenshots exist, nothing to capture")
|
||||
return
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
defaultViewport: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
})
|
||||
|
||||
for (const code of presets) {
|
||||
const decoded = decodePreset(code)
|
||||
if (!decoded) {
|
||||
console.warn(`⚠ Skipping invalid preset code: ${code}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pageUrl = `http://localhost:4000/preview/radix/preview?preset=${code}`
|
||||
|
||||
const page = await browser.newPage()
|
||||
await page.goto(pageUrl, {
|
||||
waitUntil: "networkidle2",
|
||||
})
|
||||
|
||||
console.log(`- Capturing ${code}...`)
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
const screenshotPath = path.join(
|
||||
PRESETS_PATH,
|
||||
`${code}-${theme}.png`
|
||||
)
|
||||
|
||||
if (!force && existsSync(screenshotPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set theme and reload page.
|
||||
await page.evaluate((currentTheme) => {
|
||||
localStorage.setItem("theme", currentTheme)
|
||||
}, theme)
|
||||
|
||||
await page.reload({ waitUntil: "networkidle2" })
|
||||
|
||||
// Wait for animations to complete.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// Hide Tailwind indicator.
|
||||
await page.evaluate(() => {
|
||||
const indicator = document.querySelector("[data-tailwind-indicator]")
|
||||
if (indicator) {
|
||||
indicator.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Capture the target element.
|
||||
const element = await page.$('[data-slot="capture-target"]')
|
||||
if (element) {
|
||||
await element.screenshot({
|
||||
path: screenshotPath,
|
||||
})
|
||||
} else {
|
||||
console.warn(`⚠ No [data-slot="capture-target"] found for ${code} (${theme}), capturing full page`)
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await page.close()
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 Capturing explore preset screenshots...")
|
||||
|
||||
await captureScreenshots()
|
||||
|
||||
console.log("✅ Done!")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
100
apps/v4/scripts/capture-registry.mts
Normal file
100
apps/v4/scripts/capture-registry.mts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import puppeteer from "puppeteer"
|
||||
|
||||
import { getAllBlockIds } from "../lib/blocks"
|
||||
|
||||
const REGISTRY_PATH = path.join(process.cwd(), "public/r")
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Capture screenshots.
|
||||
// ----------------------------------------------------------------------------
|
||||
async function captureScreenshots() {
|
||||
const blockIds = await getAllBlockIds()
|
||||
const blocks = blockIds.filter((block) => {
|
||||
// Check if screenshots already exist
|
||||
const lightPath = path.join(
|
||||
REGISTRY_PATH,
|
||||
`styles/new-york/${block}-light.png`
|
||||
)
|
||||
const darkPath = path.join(
|
||||
REGISTRY_PATH,
|
||||
`styles/new-york/${block}-dark.png`
|
||||
)
|
||||
return !existsSync(lightPath) || !existsSync(darkPath)
|
||||
})
|
||||
|
||||
if (blocks.length === 0) {
|
||||
console.log("✨ All screenshots exist, nothing to capture")
|
||||
return
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
defaultViewport: {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
})
|
||||
|
||||
for (const block of blocks) {
|
||||
const pageUrl = `http://localhost:4000/view/${block}`
|
||||
|
||||
const page = await browser.newPage()
|
||||
await page.goto(pageUrl, {
|
||||
waitUntil: "networkidle2",
|
||||
})
|
||||
|
||||
console.log(`- Capturing ${block}...`)
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const screenshotPath = path.join(
|
||||
REGISTRY_PATH,
|
||||
`styles/new-york/${block}${theme === "dark" ? "-dark" : "-light"}.png`
|
||||
)
|
||||
|
||||
if (existsSync(screenshotPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set theme and reload page
|
||||
await page.evaluate((currentTheme) => {
|
||||
localStorage.setItem("theme", currentTheme)
|
||||
}, theme)
|
||||
|
||||
await page.reload({ waitUntil: "networkidle2" })
|
||||
|
||||
// Wait for animations to complete
|
||||
if (block.startsWith("chart") || block.startsWith("dashboard")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
// Hide Tailwind indicator
|
||||
await page.evaluate(() => {
|
||||
const indicator = document.querySelector("[data-tailwind-indicator]")
|
||||
if (indicator) {
|
||||
indicator.remove()
|
||||
}
|
||||
})
|
||||
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
})
|
||||
}
|
||||
|
||||
await page.close()
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 Capturing screenshots...")
|
||||
|
||||
await captureScreenshots()
|
||||
|
||||
console.log("✅ Done!")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
294
apps/v4/scripts/monitor-registries.mts
Normal file
294
apps/v4/scripts/monitor-registries.mts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { promises as fs } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
import { registryDirectorySchema } from "../lib/registry-directory"
|
||||
import {
|
||||
cleanRegistryHealthHistory,
|
||||
loadRegistryMonitorState,
|
||||
publishRegistryHealth,
|
||||
} from "../lib/registry-health/blob"
|
||||
import { runLocalCliDryRun } from "../lib/registry-health/dry-run"
|
||||
import {
|
||||
runRegistryMonitor,
|
||||
type RegistryMonitorMode,
|
||||
} from "../lib/registry-health/monitor"
|
||||
import {
|
||||
registryMonitorOutputSchema,
|
||||
registryMonitorStateSchema,
|
||||
} from "../lib/registry-health/schema"
|
||||
import directory from "../registry/directory.json"
|
||||
|
||||
const PHASES = new Set(["prepare", "check", "publish"] as const)
|
||||
const MODES = new Set<RegistryMonitorMode>([
|
||||
"auto",
|
||||
"hourly",
|
||||
"daily",
|
||||
"weekly",
|
||||
"all",
|
||||
])
|
||||
const WORK_DIRECTORY = path.resolve(process.cwd(), ".registry-health")
|
||||
const PREVIOUS_STATE_PATH = path.join(WORK_DIRECTORY, "previous-state.json")
|
||||
const MONITOR_OUTPUT_PATH = path.join(WORK_DIRECTORY, "monitor-output.json")
|
||||
const BLOB_ENVIRONMENT_VARIABLES = [
|
||||
"BLOB_READ_WRITE_TOKEN",
|
||||
"VERCEL_OIDC_TOKEN",
|
||||
"BLOB_STORE_ID",
|
||||
] as const
|
||||
|
||||
type MonitorPhase = "prepare" | "check" | "publish"
|
||||
|
||||
function getArgument(name: string) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index >= 0) {
|
||||
const value = process.argv[index + 1]
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`Missing value for ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const argument = process.argv.find((value) => value.startsWith(`${name}=`))
|
||||
if (!argument) return undefined
|
||||
|
||||
const value = argument.slice(name.length + 1)
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getPhase() {
|
||||
const value = getArgument("--phase")
|
||||
if (!value) return null
|
||||
|
||||
if (!PHASES.has(value as MonitorPhase)) {
|
||||
throw new Error(`Invalid monitor phase: ${value}`)
|
||||
}
|
||||
|
||||
return value as MonitorPhase
|
||||
}
|
||||
|
||||
function getMode() {
|
||||
const value = getArgument("--mode") ?? process.env.MONITOR_MODE
|
||||
const mode = (value ?? "auto") as RegistryMonitorMode
|
||||
|
||||
if (!MODES.has(mode)) {
|
||||
throw new Error(`Invalid monitor mode: ${value}`)
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
function getBlobToken() {
|
||||
const token = process.env.BLOB_READ_WRITE_TOKEN
|
||||
const hasVercelOidc =
|
||||
process.env.VERCEL_OIDC_TOKEN && process.env.BLOB_STORE_ID
|
||||
|
||||
if (!token && !hasVercelOidc) {
|
||||
throw new Error(
|
||||
"Blob authentication is missing. Configure BLOB_READ_WRITE_TOKEN outside Vercel or Vercel Blob OIDC inside a linked project."
|
||||
)
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
function assertCredentialFreeCheck() {
|
||||
const exposed = BLOB_ENVIRONMENT_VARIABLES.filter((name) => process.env[name])
|
||||
|
||||
if (exposed.length > 0) {
|
||||
throw new Error(
|
||||
`The check phase must not receive Blob credentials: ${exposed.join(", ")}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(pathname: string) {
|
||||
return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown
|
||||
}
|
||||
|
||||
async function writeJson(pathname: string, value: unknown) {
|
||||
await fs.mkdir(path.dirname(pathname), { recursive: true })
|
||||
const temporaryPath = `${pathname}.${process.pid}.tmp`
|
||||
await fs.writeFile(temporaryPath, JSON.stringify(value))
|
||||
await fs.rename(temporaryPath, pathname)
|
||||
}
|
||||
|
||||
async function writeStepSummary({
|
||||
latestPath,
|
||||
mode,
|
||||
totals,
|
||||
diagnostics,
|
||||
deleted,
|
||||
}: {
|
||||
latestPath: string
|
||||
mode: RegistryMonitorMode
|
||||
totals: {
|
||||
registries: number
|
||||
reachable: number
|
||||
unavailable: number
|
||||
challenges: number
|
||||
itemChecks: number
|
||||
dryRuns: number
|
||||
}
|
||||
diagnostics: string[]
|
||||
deleted: number
|
||||
}) {
|
||||
if (!process.env.GITHUB_STEP_SUMMARY) return
|
||||
|
||||
const rows = [
|
||||
"## Registry health monitor",
|
||||
"",
|
||||
`- Mode: \`${mode}\``,
|
||||
`- Registries: ${totals.registries}`,
|
||||
`- Reachable indexes: ${totals.reachable}`,
|
||||
`- Unavailable indexes: ${totals.unavailable}`,
|
||||
`- Bot challenges: ${totals.challenges}`,
|
||||
`- Item checks: ${totals.itemChecks}`,
|
||||
`- Dry runs: ${totals.dryRuns}`,
|
||||
`- Expired history deleted: ${deleted}`,
|
||||
`- Latest snapshot: ${latestPath}`,
|
||||
]
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
rows.push("", "### Diagnostics", "")
|
||||
rows.push(...diagnostics.map((diagnostic) => `- ${diagnostic}`))
|
||||
}
|
||||
|
||||
await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${rows.join("\n")}\n`)
|
||||
}
|
||||
|
||||
async function prepareMonitor() {
|
||||
const token = getBlobToken()
|
||||
const previousState = await loadRegistryMonitorState({ token })
|
||||
|
||||
await fs.rm(MONITOR_OUTPUT_PATH, { force: true })
|
||||
await writeJson(PREVIOUS_STATE_PATH, previousState)
|
||||
console.log(
|
||||
previousState
|
||||
? "Registry health state downloaded."
|
||||
: "No previous registry health state found."
|
||||
)
|
||||
}
|
||||
|
||||
async function checkRegistries() {
|
||||
assertCredentialFreeCheck()
|
||||
|
||||
const previousStateValue = await readJson(PREVIOUS_STATE_PATH)
|
||||
const previousState =
|
||||
previousStateValue === null
|
||||
? null
|
||||
: registryMonitorStateSchema.parse(previousStateValue)
|
||||
const parsedDirectory = registryDirectorySchema.parse(directory)
|
||||
const cliPath = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shadcn/dist/index.js"
|
||||
)
|
||||
const result = await runRegistryMonitor({
|
||||
directory: parsedDirectory,
|
||||
previousState,
|
||||
mode: getMode(),
|
||||
runDryRun: (options) => runLocalCliDryRun({ ...options, cliPath }),
|
||||
})
|
||||
|
||||
await writeJson(MONITOR_OUTPUT_PATH, result)
|
||||
console.log(
|
||||
`Registry checks completed for ${result.run.totals.registries} registries.`
|
||||
)
|
||||
}
|
||||
|
||||
async function publishMonitor() {
|
||||
const token = getBlobToken()
|
||||
const result = registryMonitorOutputSchema.parse(
|
||||
await readJson(MONITOR_OUTPUT_PATH)
|
||||
)
|
||||
const publication = await publishRegistryHealth({
|
||||
...result,
|
||||
token,
|
||||
})
|
||||
const cleanup = await cleanRegistryHealthHistory({ token })
|
||||
|
||||
await writeStepSummary({
|
||||
latestPath: publication.latestPath,
|
||||
mode: result.run.mode,
|
||||
totals: result.run.totals,
|
||||
diagnostics: result.run.diagnostics,
|
||||
deleted: cleanup.deleted,
|
||||
})
|
||||
await fs.rm(WORK_DIRECTORY, { recursive: true, force: true })
|
||||
console.log(`Registry health snapshot published: ${publication.latestPath}`)
|
||||
}
|
||||
|
||||
async function runPackageScript(
|
||||
script: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
) {
|
||||
const isWindows = process.platform === "win32"
|
||||
const command = isWindows ? "pnpm.cmd" : "pnpm"
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, ["run", script], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
shell: isWindows,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("error", reject)
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runAllPhases() {
|
||||
await runPackageScript("registry:health:prepare", process.env)
|
||||
|
||||
const checkEnvironment = { ...process.env }
|
||||
for (const name of BLOB_ENVIRONMENT_VARIABLES) {
|
||||
delete checkEnvironment[name]
|
||||
}
|
||||
delete checkEnvironment.GITHUB_STEP_SUMMARY
|
||||
|
||||
await runPackageScript("registry:health:check", checkEnvironment)
|
||||
await runPackageScript("registry:health:publish", process.env)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const phase = getPhase()
|
||||
|
||||
if (!phase) {
|
||||
await runAllPhases()
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === "prepare") {
|
||||
await prepareMonitor()
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === "check") {
|
||||
await checkRegistries()
|
||||
return
|
||||
}
|
||||
|
||||
await publishMonitor()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
"Registry health monitor failed:",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
53
apps/v4/scripts/validate-registries.mts
Normal file
53
apps/v4/scripts/validate-registries.mts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
|
||||
import {
|
||||
createPublicRegistryDirectory,
|
||||
publicRegistryDirectorySchema,
|
||||
registryDirectorySchema,
|
||||
} from "../lib/registry-directory"
|
||||
|
||||
async function main() {
|
||||
let hasErrors = false
|
||||
|
||||
// 1. Validate directory.json.
|
||||
const directoryFile = path.join(process.cwd(), "registry/directory.json")
|
||||
const directoryContent = await fs.readFile(directoryFile, "utf-8")
|
||||
const directoryData = JSON.parse(directoryContent)
|
||||
|
||||
const directoryResult = registryDirectorySchema.safeParse(directoryData)
|
||||
if (!directoryResult.success) {
|
||||
console.error("❌ directory.json validation failed:")
|
||||
console.error(directoryResult.error.format())
|
||||
hasErrors = true
|
||||
} else {
|
||||
console.log("✅ directory.json is valid")
|
||||
}
|
||||
|
||||
// 2. Validate the public registries payload served by /r/registries.json.
|
||||
if (directoryResult.success) {
|
||||
const registriesResult = publicRegistryDirectorySchema.safeParse(
|
||||
createPublicRegistryDirectory(directoryResult.data)
|
||||
)
|
||||
|
||||
if (!registriesResult.success) {
|
||||
console.error("❌ /r/registries.json validation failed:")
|
||||
console.error(registriesResult.error.format())
|
||||
hasErrors = true
|
||||
} else {
|
||||
console.log("✅ /r/registries.json payload is valid")
|
||||
console.log("✅ /r/registries.json includes all directory entries")
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("\n✅ All registries passed validation.")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("❌ Error:", error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user