feat: update registry build commands (#10880)

* feat: update registry build commands

* fix
This commit is contained in:
shadcn
2026-06-06 23:19:46 +04:00
committed by GitHub
parent f47d48f316
commit 8da4592308
3 changed files with 399 additions and 24 deletions

View File

@@ -141,6 +141,11 @@ When adding or modifying components, please ensure that:
2. You update the documentation.
3. You run `pnpm registry:build` to update the registry.
See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the
registry pipeline is structured and for the faster targeted build modes
(`--style`, `--registry`, `--examples`, `--indexes`) you can use while
iterating locally. Always run the full `pnpm registry:build` before committing.
## Commit Convention
Before you create a Pull Request, please check whether your commits comply with

100
apps/v4/registry/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Registry
This directory is the source of truth for the v4 component registry. The build
pipeline (`../scripts/build-registry.mts`) reads the authored source here and
generates the runtime indexes, the local `styles/` consumed by the docs app, and
the installable output under `public/r/`.
## Source of truth (authored by hand)
- **`bases/base/`, `bases/radix/`** — the two authored base registries (Base UI
and Radix). Each holds a `registry.ts` plus `ui/`, `lib/`, `hooks/`, `blocks/`,
`examples/`, and `internal/`. Shared surfaces should stay in sync across both
bases — see [`bases/README.md`](./bases/README.md).
- **`styles/style-*.css`** — the style token files (`nova`, `sera`, `vega`, …).
Each defines the design tokens for one style.
- **`new-york-v4/`** — the legacy source registry. Unlike the generated
combinations below, its `registry.ts` and component files are authored
directly and committed.
- **`../examples/base`, `../examples/radix`** — authored component demos. See
[`../examples/README.md`](../examples/README.md).
## Generated output (do not edit by hand)
Persistent (committed):
- `bases/__index__.tsx` — runtime lookup for the authored bases.
- `__index__.tsx` — runtime lookup across legacy styles and every base/style
combination.
- `__blocks__.json` — block metadata index.
- `../examples/__index__.tsx` — runtime lookup for demos.
- `../styles/<style>/ui/*` — compiled components for each base/style
combination, imported by the docs app.
- `../styles/<style>/ui-rtl/*` — RTL variants, generated for `base-nova` and
`radix-nova` only.
- `../public/r/*` — installable registry JSON served by the website and the CLI.
Temporary (created during the build, then cleaned up):
- `<style>/*` — per-combination registries (e.g. `base-nova/`).
- `../registry-<style>.json`
## The style model
There are two kinds of "styles", and the distinction drives the build flags:
- **Generated combinations** — every base (`base`, `radix`) crossed with every
style token (`nova`, `sera`, …) produces a combination like `base-nova` or
`radix-sera`. These are generated from the authored bases plus the style CSS;
nothing under `registry/<combination>/` is committed.
- **Legacy source registry** — `new-york-v4` is authored directly and committed.
It is not generated from a base/style combination.
## Building
Run from `apps/v4`:
```bash
pnpm registry:build
```
This runs the full pipeline: build the bases, generate every combination, write
the runtime indexes, export `public/r/` for every style, copy the compiled UI
into `styles/`, and build the RTL styles. It is the canonical build — generated
output is prettier-formatted. **Run this before committing or for production.**
### Fast targeted builds
The targeted flags below are for quick local iteration. To stay fast they
**skip formatting** the generated output, so they can leave generated files
unformatted (and produce large but harmless `git diff` churn). The full
`pnpm registry:build` above re-canonicalizes everything, so run it before you
commit.
For local iteration you can rebuild only the artifact you changed:
```bash
pnpm registry:build --examples # examples/__index__.tsx
pnpm registry:build --indexes # runtime registry indexes
pnpm registry:build --style base-nova # styles/base-nova/ui (+ ui-rtl)
pnpm registry:build --style all # every generated combination
pnpm registry:build --registry base-nova # public/r/styles/base-nova
pnpm registry:build --registry all # every style, incl. new-york-v4
```
| Flag | Rebuilds | Run after |
| ------------------------- | -------------------------------------------------------------------------------- | ------------------------------------ |
| `--examples` | `../examples/__index__.tsx` | adding, removing, or renaming a demo |
| `--indexes` | `bases/__index__.tsx`, `__index__.tsx`, `__blocks__.json`, `public/r/index.json` | changing registry or block metadata |
| `--style <style\|all>` | `../styles/<style>/ui` and `ui-rtl` | editing authored base UI/components |
| `--registry <style\|all>` | `../public/r/styles/<style>` | changing what the CLI installs |
Notes:
- Flags can be combined, e.g. `--style base-nova --registry base-nova`.
- `all` targets every supported style.
- Editing an existing example file usually does **not** need a rebuild — only
adding, removing, or renaming one (which changes the index) does.
- `--style new-york-v4` is rejected because it is a legacy source registry, not a
generated combination. Use `--registry new-york-v4` instead.
- Unknown targets fail with the list of valid style ids.

View File

@@ -5,6 +5,7 @@ import { createRequire } from "module"
import { availableParallelism } from "os"
import path from "path"
import { fileURLToPath } from "url"
import { parseArgs } from "util"
import prettier from "prettier"
import { rimraf } from "rimraf"
import { registrySchema, type RegistryItem } from "shadcn/schema"
@@ -55,6 +56,13 @@ import { STYLES } from "@/registry/styles"
* 7. Build styles/<style>/ui-rtl for base-nova and radix-nova only.
* 8. Format the generated persistent outputs.
* 9. Clean up the temporary registry/<base-style> trees and registry-*.json.
*
* Targeted modes (see parseBuildOptions):
* - --examples rebuilds examples/__index__.tsx only.
* - --indexes rebuilds the runtime registry indexes only.
* - --style <style|all> rebuilds local styles/<style>/ui (+ ui-rtl).
* - --registry <style|all> rebuilds installable public/r/styles/<style>.
* Running with no options performs the full build described above.
*/
const STYLE_COMBINATIONS = Array.from(BASES).flatMap((base) =>
@@ -98,6 +106,12 @@ type TransformCacheManifestEntry = {
const transformCacheManifest = new Map<string, TransformCacheManifestEntry>()
let transformCacheDirty = false
let prettierConfigPromise: Promise<prettier.Options | null> | null = null
// Generated output is prettier-formatted in the full (prod) build. Targeted dev
// builds skip formatting for speed; the next full build re-canonicalizes
// everything. The transform cache always stores formatted content (see
// getCachedStyledContent), so a full build never reads an unformatted entry.
let shouldFormatOutput = true
const resolveFromScript = createRequire(import.meta.url).resolve
const iconProject = new Project({
@@ -125,6 +139,101 @@ function getStyleCombination(styleName: string) {
return STYLE_COMBINATIONS.find((style) => style.name === styleName) ?? null
}
type BuildOptions = {
examples: boolean
indexes: boolean
style: "all" | string | null
registry: "all" | string | null
}
const USAGE = `Usage: registry:build [options]
Run with no options for a full registry build, or target a single artifact:
--examples Rebuild examples/__index__.tsx only.
--indexes Rebuild the runtime registry indexes only.
--style <style|all> Rebuild local generated style files under styles/<style>/ui.
--registry <style|all> Rebuild installable registry JSON under public/r/styles/<style>.
<style> must be "all" or a known final style id (e.g. base-nova, radix-nova, base-sera, new-york-v4).
Flags can be combined, e.g. --style base-nova --registry base-nova.`
function getKnownStyleNames() {
return new Set(getStylesToBuild().map((style) => style.name))
}
function assertKnownTarget(flag: "--style" | "--registry", target: string) {
if (target === "all") {
return
}
const knownStyleNames = getKnownStyleNames()
if (!knownStyleNames.has(target)) {
const valid = ["all", ...Array.from(knownStyleNames)].join(", ")
throw new Error(
`Unknown ${flag} target "${target}". Valid targets: ${valid}.\n\n${USAGE}`
)
}
}
function parseBuildOptions(argv: string[]): BuildOptions {
let values: {
examples?: boolean
indexes?: boolean
style?: string
registry?: string
}
try {
;({ values } = parseArgs({
args: argv,
options: {
examples: { type: "boolean" },
indexes: { type: "boolean" },
style: { type: "string" },
registry: { type: "string" },
},
allowPositionals: false,
strict: true,
}))
} catch (error) {
throw new Error(`${(error as Error).message}\n\n${USAGE}`)
}
if (values.style !== undefined) {
assertKnownTarget("--style", values.style)
}
if (values.registry !== undefined) {
assertKnownTarget("--registry", values.registry)
}
return {
examples: values.examples ?? false,
indexes: values.indexes ?? false,
style: values.style ?? null,
registry: values.registry ?? null,
}
}
function isFullBuild(options: BuildOptions) {
return (
!options.examples &&
!options.indexes &&
options.style === null &&
options.registry === null
)
}
function getTargetStyles(target: "all" | string | null) {
const stylesToBuild = getStylesToBuild()
if (target === "all") {
return stylesToBuild
}
return stylesToBuild.filter((style) => style.name === target)
}
function stripFileExtension(filePath: string) {
return filePath.replace(/\.(tsx|ts|json|mdx)$/, "")
}
@@ -272,7 +381,7 @@ async function writeIfChanged(filePath: string, content: string) {
return true
}
async function formatGeneratedSource(content: string, filePath: string) {
async function formatSource(content: string, filePath: string) {
prettierConfigPromise ??= prettier.resolveConfig(
path.join(process.cwd(), "package.json")
)
@@ -285,6 +394,14 @@ async function formatGeneratedSource(content: string, filePath: string) {
})
}
async function formatGeneratedSource(content: string, filePath: string) {
if (!shouldFormatOutput) {
return content
}
return formatSource(content, filePath)
}
async function formatGeneratedJson(value: unknown, filePath: string) {
return formatGeneratedSource(JSON.stringify(value, null, 2), filePath)
}
@@ -383,7 +500,9 @@ async function getCachedStyledContent({
new RegExp(`@/registry/bases/${baseName}/`, "g"),
`@/registry/${styleName}/`
)
transformedContent = await formatGeneratedSource(
// Always format cached content so a later full build never reads an
// unformatted entry produced by a targeted dev build.
transformedContent = await formatSource(
transformedContent,
path.join(getTemporaryRegistryRoot(styleName), filePath)
)
@@ -430,10 +549,26 @@ async function runWithConcurrency<T, R>(
try {
const totalStart = performance.now()
const options = parseBuildOptions(process.argv.slice(2))
if (isFullBuild(options)) {
await runFullBuild()
} else {
await runTargetedBuild(options)
}
const elapsed = ((performance.now() - totalStart) / 1000).toFixed(2)
console.log(`\n✅ Build complete in ${elapsed}s!`)
} catch (error) {
await saveTransformCache().catch(console.error)
console.error(error)
process.exit(1)
}
async function runFullBuild() {
await loadTransformCache()
console.log("🏗️ Building bases...")
console.log("\n🏗️ Building bases...")
await buildBasesIndex(Array.from(BASES))
await buildBases(Array.from(BASES))
@@ -475,15 +610,120 @@ try {
await buildRtlStyles()
console.log("\n🧹 Cleaning up...")
await cleanUp(stylesToBuild)
await cleanUpTemporaryFiles(stylesToBuild.map((style) => style.name))
await saveTransformCache()
}
const elapsed = ((performance.now() - totalStart) / 1000).toFixed(2)
console.log(`\n✅ Build complete in ${elapsed}s!`)
} catch (error) {
await saveTransformCache().catch(console.error)
console.error(error)
process.exit(1)
async function runTargetedBuild(options: BuildOptions) {
// Targeted builds are for quick dev iteration: skip prettier on generated
// output. The full (prod) build re-formats everything to its canonical state.
shouldFormatOutput = false
await loadTransformCache()
// Phases run in dependency-safe order: indexes and examples write the runtime
// lookup files first, the targeted style build copies compiled ui into
// styles/<style>, and the targeted registry build exports public/r last.
if (options.indexes) {
await runIndexesBuild()
}
if (options.examples) {
await runExamplesBuild()
}
if (options.style !== null) {
await runTargetedStyleBuild(options.style)
}
if (options.registry !== null) {
await runTargetedRegistryBuild(options.registry)
}
await saveTransformCache()
}
async function runIndexesBuild() {
console.log("🏗️ Building registry/bases/__index__.tsx...")
await buildBasesIndex(Array.from(BASES))
// buildBlocksIndex imports @/registry/__index__, so the registry index must
// be regenerated before the blocks index.
console.log("\n📦 Building registry/__index__.tsx...")
await buildRegistryIndex(getStylesToBuild())
console.log("\n🗂 Building registry/__blocks__.json...")
await buildBlocksIndex()
console.log("\n📦 Building public/r/index.json...")
await buildIndex()
}
async function runExamplesBuild() {
console.log("📋 Building examples/__index__.tsx...")
await buildExamplesIndex()
}
async function runTargetedStyleBuild(target: "all" | string) {
if (target !== "all" && !getStyleCombination(target)) {
throw new Error(
`--style ${target} is not supported because it is a legacy source registry. Use --registry ${target}.`
)
}
// styles/<style>/ui only exists for generated base/style combinations, so we
// skip legacy source styles (e.g. new-york-v4) when targeting "all".
const targetStyles = getTargetStyles(target).filter((style) =>
getStyleCombination(style.name)
)
const targetStyleNames = new Set(targetStyles.map((style) => style.name))
if (targetStyleNames.size === 0) {
console.log(" No generated styles to build.")
return
}
console.log("💅 Building styles...")
await buildBases(Array.from(BASES), targetStyleNames)
console.log("\n📋 Copying compiled ui to styles...")
await copyUIToStyles(targetStyleNames)
console.log("\n🔄 Building RTL styles...")
await buildRtlStyles(targetStyleNames)
console.log("\n🧹 Cleaning up...")
await cleanUpTemporaryFiles(Array.from(targetStyleNames))
}
async function runTargetedRegistryBuild(target: "all" | string) {
const targetStyles = getTargetStyles(target)
const comboStyleNames = new Set(
targetStyles
.filter((style) => getStyleCombination(style.name))
.map((style) => style.name)
)
// Only generated base/style combinations need a temporary registry/<style>
// tree. Legacy source styles (e.g. new-york-v4) already ship registry.ts.
if (comboStyleNames.size > 0) {
console.log("🏗️ Building bases...")
await buildBases(Array.from(BASES), comboStyleNames)
}
console.log("\n💅 Building registry...")
await runWithConcurrency(
targetStyles,
CLI_BUILD_CONCURRENCY,
async (style) => {
await buildRegistryJsonFile(style.name)
await buildRegistry(style.name)
console.log(`${style.name}`)
}
)
console.log("\n🧹 Cleaning up...")
await cleanUpTemporaryFiles(targetStyles.map((style) => style.name))
}
async function buildBasesIndex(bases: Base[]) {
@@ -575,10 +815,21 @@ export const Index: Record<string, Record<string, any>> = {`
)
}
async function buildBases(bases: Base[]) {
async function buildBases(bases: Base[], targetStyleNames?: Set<string>) {
// For targeted builds, only load bases that contribute a requested
// combination. Otherwise a single-base target (e.g. --style base-nova) would
// still import and read every source file for the other base.
const basesToBuild = targetStyleNames
? bases.filter((base) =>
STYLES.some((style) =>
targetStyleNames.has(`${base.name}-${style.name}`)
)
)
: bases
const [baseImports, styleMaps, transformCacheHash] = await Promise.all([
Promise.all(
bases.map(async (base) => {
basesToBuild.map(async (base) => {
const { registry: baseRegistry } = await import(
`../registry/bases/${base.name}/registry.ts`
)
@@ -656,6 +907,11 @@ async function buildBases(bases: Base[]) {
sourceFiles,
} of baseImports) {
for (const { style, styleHash, styleMap } of styleMaps) {
const styleName = `${base.name}-${style.name}`
if (targetStyleNames && !targetStyleNames.has(styleName)) {
continue
}
combinations.push({
base,
style,
@@ -995,15 +1251,21 @@ async function buildBlocksIndex() {
)
}
async function cleanUp(stylesToBuild: { name: string; title: string }[]) {
const cleanupTasks: Promise<boolean>[] = stylesToBuild.map((style) =>
rimraf(path.join(process.cwd(), `registry-${style.name}.json`))
)
async function cleanUpTemporaryFiles(styleNames: string[]) {
const cleanupTasks: Promise<boolean>[] = []
for (const style of STYLE_COMBINATIONS) {
const tempRegistryRoot = getTemporaryRegistryRoot(style.name)
console.log(` 🗑️ registry/${style.name}`)
cleanupTasks.push(rimraf(tempRegistryRoot))
for (const styleName of styleNames) {
cleanupTasks.push(
rimraf(path.join(process.cwd(), `registry-${styleName}.json`))
)
// Only generated combinations have a temporary registry/<style> tree.
// Legacy source styles (e.g. new-york-v4) own registry/<style> and must
// never be removed.
if (getStyleCombination(styleName)) {
console.log(` 🗑️ registry/${styleName}`)
cleanupTasks.push(rimraf(getTemporaryRegistryRoot(styleName)))
}
}
await Promise.all(cleanupTasks)
@@ -1047,9 +1309,13 @@ async function applyIconTransform(content: string, filename: string) {
return sourceFile.getText()
}
async function copyUIToStyles() {
async function copyUIToStyles(targetStyleNames?: Set<string>) {
const styleCombinations = targetStyleNames
? STYLE_COMBINATIONS.filter((style) => targetStyleNames.has(style.name))
: STYLE_COMBINATIONS
await runWithConcurrency(
STYLE_COMBINATIONS,
styleCombinations,
COPY_CONCURRENCY,
async ({ name: styleName }) => {
const sourceDir = path.join(getTemporaryRegistryRoot(styleName), "ui")
@@ -1093,9 +1359,13 @@ async function copyUIToStyles() {
)
}
async function buildRtlStyles() {
async function buildRtlStyles(targetStyleNames?: Set<string>) {
await runWithConcurrency(
STYLE_COMBINATIONS.filter((style) => shouldGenerateRtlStyles(style.name)),
STYLE_COMBINATIONS.filter(
(style) =>
shouldGenerateRtlStyles(style.name) &&
(!targetStyleNames || targetStyleNames.has(style.name))
),
COPY_CONCURRENCY,
async ({ name: styleName }) => {
const sourceDir = path.join(getPersistentStyleRoot(styleName), "ui")