snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

View 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()
}

File diff suppressed because it is too large Load Diff

View 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`)
}
}

View 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)
}

View 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)
}

View 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
})

View 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)
})