Merge branch 'main' into feat/loading-ui-registry

This commit is contained in:
Bartosz Zagrodzki
2026-04-21 13:20:14 +02:00
committed by GitHub
21 changed files with 801 additions and 99 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
fix chartColor in presets

View File

@@ -3,7 +3,7 @@ name: Write Beta Release comment
on:
workflow_run:
workflows: ["Release - Beta"]
workflows: ["Release"]
types:
- completed
@@ -11,12 +11,13 @@ jobs:
comment:
if: |
github.repository_owner == 'shadcn-ui' &&
${{ github.event.workflow_run.conclusion == 'success' }}
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
name: Write comment to the PR
steps:
- name: "Comment on PR"
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -53,7 +54,7 @@ jobs:
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -1,64 +0,0 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
jobs:
prerelease:
if: |
github.repository_owner == 'shadcn-ui' &&
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
name: Build & Publish a beta release to NPM
runs-on: ubuntu-latest
environment: Preview
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js

View File

@@ -2,24 +2,81 @@
name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
on:
pull_request:
types: [labeled]
branches:
- main
push:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
release:
if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create a PR for release workflow
prerelease:
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
name: Publish Beta to NPM
runs-on: ubuntu-latest
environment: Preview
permissions:
id-token: write
contents: read
steps:
- name: Checkout Repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js
release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
name: Create Version PR or Publish Stable Release
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -3,6 +3,21 @@ import { describe, expect, it } from "vitest"
import { parseDesignSystemConfig } from "./parse-config"
describe("parseDesignSystemConfig", () => {
it("defaults missing chartColor from the selected theme", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.chartColor).toBe("taupe")
})
it("honors explicit fontHeading and chartColor overrides when a preset is present", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(

View File

@@ -144,6 +144,106 @@ npm run dev
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
## Content negotiation
The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint — including the root of your domain — and serve different content depending on who is asking.
From a single URL, you can serve:
- **HTML** to browsers — a landing page, documentation, or marketing site.
- **JSON** to the `shadcn` CLI — an installable registry item.
- **Markdown** to AI agents and LLMs — a machine-readable version of your content.
The client signals its preference using the `Accept` request header, and your server decides what to return.
### Request headers
When the CLI makes a request to a registry, it sends the following headers:
- **User-Agent**: `shadcn`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
### Root hosting
By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage.
The examples below assume your built registry item is served at `/r/index.json`. Adjust the path to match your output.
In Next.js, express this as a rewrite in `next.config.ts`. This keeps the negotiation in the routing layer and avoids a Proxy function for this static rewrite:
```typescript title="next.config.ts" showLineNumbers
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: "/",
has: [
{
type: "header",
key: "accept",
value: "(.*)application/vnd\\.shadcn\\.v1\\+json(.*)",
},
],
destination: "/r/index.json",
},
{
source: "/",
has: [
{
type: "header",
key: "user-agent",
value: "shadcn",
},
],
destination: "/r/index.json",
},
],
}
},
async headers() {
return [
{
source: "/",
headers: [{ key: "Vary", value: "Accept, User-Agent" }],
},
]
},
}
export default nextConfig
```
Or, in an Express.js server:
```javascript title="server.js" showLineNumbers
app.get("/", (req, res) => {
res.vary("Accept")
res.vary("User-Agent")
// Check if the client prefers the shadcn vendor type.
if (req.accepts("application/vnd.shadcn.v1+json")) {
return res.json(registryData)
}
// Optional: Secondary check for the User-Agent.
if (req.get("User-Agent") === "shadcn") {
return res.json(registryData)
}
// Otherwise, serve your documentation or homepage.
res.send(htmlContent)
})
```
This enables:
- **Branded Registry URLs**: `shadcn add https://ui.example.com`
- **Shorter URLs**: Users type your domain root, not `/r/` or `/registry/` sub-paths.
- **Easy Mnemonics**: Easier for users to remember and share your registry.
## Publish your registry
To make your registry available to other developers, you can publish it by deploying your project to a public URL.

View File

@@ -76,7 +76,7 @@
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"server-only": "^0.0.1",
"shadcn": "4.3.0",
"shadcn": "4.3.1",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"swr": "^2.3.6",

View File

@@ -1036,5 +1036,17 @@
"homepage": "https://www.remocn.dev/",
"url": "https://www.remocn.dev/r/{name}.json",
"description": "Production-ready components for Remotion - text animations, backgrounds, transitions, UI blocks, and full scene compositions"
},
{
"name": "@exabase",
"homepage": "https://exawizards.com/exabase/design/",
"url": "https://exawizards.com/exabase/design/registry/{name}.json",
"description": "A collection of UI components based on the exaBase Design System, built with React and Tailwind CSS."
},
{
"name": "@aicanvas",
"homepage": "https://aicanvas.me",
"url": "https://aicanvas.me/r/{name}.json",
"description": "54 animated React components with AI reproduction prompts for Claude Code, Lovable, and v0. Free and open source."
}
]

View File

@@ -65,6 +65,23 @@ describe("buildRegistryBase", () => {
expect(result.chartColor).toBe("neutral")
})
it("defaults chartColor to the selected theme when omitted", () => {
const result = designSystemConfigSchema.parse({
base: "base",
style: "sera",
iconLibrary: "lucide",
baseColor: "taupe",
theme: "taupe",
font: "noto-sans",
fontHeading: "playfair-display",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
})
expect(result.chartColor).toBe("taupe")
})
it("rejects chartColor values that are unavailable for the selected base color", () => {
const result = designSystemConfigSchema.safeParse({
base: "radix",

View File

@@ -98,7 +98,7 @@ export const designSystemConfigSchema = z
theme: z.enum(THEMES.map((t) => t.name) as [ThemeName, ...ThemeName[]]),
chartColor: z
.enum(THEMES.map((t) => t.name) as [ChartColorName, ...ChartColorName[]])
.default("neutral"),
.optional(),
font: z.enum(fontValues).default("inter"),
fontHeading: z.enum(fontHeadingValues).default("inherit"),
item: z.string().optional(),
@@ -136,6 +136,10 @@ export const designSystemConfigSchema = z
.default("next")
.optional(),
})
.transform((data) => ({
...data,
chartColor: data.chartColor ?? data.theme,
}))
.refine(
(data) => {
const availableThemes = getThemesForBaseColor(data.baseColor)

View File

@@ -1210,5 +1210,19 @@
"url": "https://www.remocn.dev/r/{name}.json",
"description": "Production-ready components for Remotion - text animations, backgrounds, transitions, UI blocks, and full scene compositions",
"logo": "<svg width='100' height='100' viewBox='0 0 100 100' fill='none' xmlns='http://www.w3.org/2000/svg'><rect width='100' height='100' rx='14' fill='black'/><path d='M83.9141 45.7661C86.9141 47.4982 86.9141 51.829 83.9141 53.561L31.4141 83.8716C28.4141 85.6036 24.6641 83.4382 24.6641 79.9741L24.6641 19.353C24.6641 15.8889 28.4141 13.7235 31.4141 15.4556L83.9141 45.7661Z' fill='white' stroke='white'/><path d='M68.998 31.3979C68.998 50.3798 68.998 63.307 68.998 67.3979' stroke='black' stroke-width='10'/><path d='M53.0625 16.75C53.0625 48.2803 53.0625 69.7535 53.0625 76.5488' stroke='black' stroke-width='10'/><path d='M37.127 9.88379C37.127 51.1081 37.127 79.1833 37.127 88.0679' stroke='black' stroke-width='10'/><path d='M31.1318 15.8618L43.1074 22.7646' stroke='white' stroke-width='2'/><path d='M63.291 34.439L75.2617 41.3579' stroke='white' stroke-width='2'/><path d='M46.6641 74.4712L59.208 67.2573' stroke='white' stroke-width='2'/><path d='M76.0205 57.5371L82.9848 53.5226' stroke='white' stroke-width='2'/></svg>"
},
{
"name": "@exabase",
"homepage": "https://exawizards.com/exabase/design/",
"url": "https://exawizards.com/exabase/design/registry/{name}.json",
"description": "A collection of UI components based on the exaBase Design System, built with React and Tailwind CSS.",
"logo": "<svg width='189' height='287' viewBox='0 0 189 287' fill='#1400C8'><path d='M15.077 103.074L14.579 104.228C-4.78838 149.063 -4.78838 152.305 14.579 197.167C21.5789 213.431 47.7538 274.025 47.7538 274.025L48.2518 272.872C67.6192 228.036 67.6192 224.795 48.2518 179.959C41.2519 163.696 15.077 103.074 15.077 103.074Z'></path><path d='M53.0599 0.607147L52.6725 1.76099C33.3052 46.5962 33.3052 49.8379 52.6725 94.7006L135.288 286.321L135.786 285.168C155.154 240.332 155.154 237.091 135.786 192.256L53.0599 0.607147Z'></path><path d='M140.594 12.9031L140.096 14.057C120.729 58.8922 120.729 62.1339 140.096 106.997C147.096 123.26 173.271 183.855 173.271 183.855L173.769 182.701C193.137 137.866 193.137 134.624 173.769 89.7889C166.769 73.5252 140.594 12.9031 140.594 12.9031Z'></path></svg>"
},
{
"name": "@aicanvas",
"homepage": "https://aicanvas.me",
"url": "https://aicanvas.me/r/{name}.json",
"description": "54 animated React components with AI reproduction prompts for Claude Code, Lovable, and v0. Free and open source.",
"logo": "<svg width=\"28\" height=\"24\" viewBox=\"0 0 28 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M19.8513 0C20.5626 0 21.2204 0.377823 21.5788 0.992258L22.75 3L26.8243 9.98452C27.5508 11.23 27.5508 12.77 26.8243 14.0155L22.75 21L21.5788 23.0077C21.2204 23.6222 20.5626 24 19.8513 24H8.14874C7.43741 24 6.7796 23.6222 6.42118 23.0077L0.587849 13.0077C0.224593 12.385 0.224593 11.615 0.58785 10.9923L6.42118 0.992257C6.7796 0.377822 7.43741 0 8.14874 0H19.8513ZM4 12L9 21H18.25L13 12L18.25 3H9L4 12Z\" fill=\"url(#paint0_linear_185_248)\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M9 21L4 12H13L18.25 21H9Z\" fill=\"#1E1E1E\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M13 12H4L9 3H18.25L13 12Z\" fill=\"#4F4F4C\"/><defs><linearGradient id=\"paint0_linear_185_248\" x1=\"9\" y1=\"3\" x2=\"24.8756\" y2=\"17\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#666662\"/><stop offset=\"1\" stop-color=\"#4F4F4C\"/></linearGradient></defs></svg>"
}
]

View File

@@ -1,8 +1,10 @@
import { spawn } from "child_process"
import { createHash } from "crypto"
import { promises as fs } from "fs"
import { createRequire } from "module"
import { availableParallelism } from "os"
import path from "path"
import { fileURLToPath } from "url"
import prettier from "prettier"
import { rimraf } from "rimraf"
import { registrySchema, type RegistryItem } from "shadcn/schema"
@@ -72,7 +74,7 @@ const CLI_BUILD_CONCURRENCY = Math.max(
1,
Math.min(Math.floor(CPU_COUNT / 2), 4)
)
const TRANSFORM_CACHE_VERSION = "2"
const TRANSFORM_CACHE_VERSION = "3"
const CACHE_ROOT = path.join(
process.cwd(),
"node_modules/.cache/build-registry"
@@ -82,10 +84,21 @@ const TRANSFORM_CACHE_MANIFEST_PATH = path.join(
CACHE_ROOT,
"transform-manifest.json"
)
const GENERATED_REGISTRY_CACHE_PATHS = new Set([
"registry/__blocks__.json",
"registry/__index__.tsx",
"registry/bases/__index__.tsx",
])
const transformCacheManifest = new Map<string, string>()
type TransformCacheManifestEntry = {
inputHash: string
outputHash: string
}
const transformCacheManifest = new Map<string, TransformCacheManifestEntry>()
let transformCacheDirty = false
let prettierConfigPromise: Promise<prettier.Options | null> | null = null
const resolveFromScript = createRequire(import.meta.url).resolve
const iconProject = new Project({
compilerOptions: {},
@@ -153,6 +166,88 @@ function hashContent(...parts: string[]) {
return hash.digest("hex")
}
async function getTransformCacheHash() {
const [implementationHash, registryHash] = await Promise.all([
getTransformImplementationHash(),
getAuthoredRegistryHash(),
])
return hashContent(implementationHash, registryHash)
}
async function getTransformImplementationHash() {
const dependencyFiles = [
fileURLToPath(import.meta.url),
resolveFromScript("shadcn/utils"),
path.resolve(process.cwd(), "../../pnpm-lock.yaml"),
]
const dependencyContent = await Promise.all(
dependencyFiles.map(async (filePath) => {
const content = await readFileIfExists(filePath)
const relativePath = toPosixPath(path.relative(process.cwd(), filePath))
return `${relativePath}\0${content ?? "missing"}`
})
)
return hashContent(...dependencyContent)
}
async function getAuthoredRegistryHash() {
const registryRoot = path.join(process.cwd(), "registry")
const filePaths = await getCacheableRegistryFiles(registryRoot)
const fileContent = await Promise.all(
filePaths.map(async (filePath) => {
const relativePath = toPosixPath(path.relative(process.cwd(), filePath))
const content = await fs.readFile(filePath, "utf8")
return `${relativePath}\0${content}`
})
)
return hashContent(...fileContent)
}
async function getCacheableRegistryFiles(dirPath: string): Promise<string[]> {
const entries = await readDirectoryEntries(dirPath)
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dirPath, entry.name)
const relativePath = toPosixPath(path.relative(process.cwd(), entryPath))
if (shouldSkipRegistryCachePath(relativePath)) {
return []
}
if (entry.isDirectory()) {
return getCacheableRegistryFiles(entryPath)
}
if (!entry.isFile()) {
return []
}
return [entryPath]
})
)
return files.flat().sort((a, b) => a.localeCompare(b))
}
function shouldSkipRegistryCachePath(relativePath: string) {
if (GENERATED_REGISTRY_CACHE_PATHS.has(relativePath)) {
return true
}
return STYLE_COMBINATIONS.some((style) =>
relativePath.startsWith(`registry/${style.name}/`)
)
}
function toPosixPath(filePath: string) {
return filePath.split(path.sep).join("/")
}
async function readFileIfExists(filePath: string) {
try {
return await fs.readFile(filePath, "utf8")
@@ -200,13 +295,28 @@ async function loadTransformCache() {
return
}
const payload = JSON.parse(existingManifest) as Record<string, string>
const payload = JSON.parse(existingManifest) as Record<string, unknown>
for (const [key, value] of Object.entries(payload)) {
transformCacheManifest.set(key, value)
if (isTransformCacheManifestEntry(value)) {
transformCacheManifest.set(key, value)
}
}
}
function isTransformCacheManifestEntry(
value: unknown
): value is TransformCacheManifestEntry {
return (
typeof value === "object" &&
value !== null &&
"inputHash" in value &&
"outputHash" in value &&
typeof value.inputHash === "string" &&
typeof value.outputHash === "string"
)
}
async function saveTransformCache() {
if (!transformCacheDirty) {
return
@@ -234,6 +344,7 @@ async function getCachedStyledContent({
filePath,
source,
styleHash,
transformCacheHash,
styleMap,
}: {
styleName: string
@@ -241,6 +352,7 @@ async function getCachedStyledContent({
filePath: string
source: string
styleHash: string
transformCacheHash: string
styleMap: Record<string, string>
}) {
const cacheKey = `${styleName}:${filePath}`
@@ -250,13 +362,18 @@ async function getCachedStyledContent({
styleName,
baseName,
filePath,
transformCacheHash,
styleHash,
source
)
if (transformCacheManifest.get(cacheKey) === inputHash) {
const cachedEntry = transformCacheManifest.get(cacheKey)
if (cachedEntry?.inputHash === inputHash) {
const cachedContent = await readFileIfExists(cachePath)
if (cachedContent !== null) {
if (
cachedContent !== null &&
hashContent(cachedContent) === cachedEntry.outputHash
) {
return cachedContent
}
}
@@ -274,8 +391,13 @@ async function getCachedStyledContent({
await fs.mkdir(path.dirname(cachePath), { recursive: true })
await fs.writeFile(cachePath, transformedContent)
if (transformCacheManifest.get(cacheKey) !== inputHash) {
transformCacheManifest.set(cacheKey, inputHash)
const outputHash = hashContent(transformedContent)
const nextEntry = { inputHash, outputHash }
if (
cachedEntry?.inputHash !== nextEntry.inputHash ||
cachedEntry?.outputHash !== nextEntry.outputHash
) {
transformCacheManifest.set(cacheKey, nextEntry)
transformCacheDirty = true
}
@@ -457,7 +579,7 @@ export const Index: Record<string, Record<string, any>> = {`
}
async function buildBases(bases: Base[]) {
const [baseImports, styleMaps] = await Promise.all([
const [baseImports, styleMaps, transformCacheHash] = await Promise.all([
Promise.all(
bases.map(async (base) => {
const { registry: baseRegistry } = await import(
@@ -516,6 +638,7 @@ async function buildBases(bases: Base[]) {
}
})
),
getTransformCacheHash(),
])
const combinations: Array<{
@@ -525,6 +648,7 @@ async function buildBases(bases: Base[]) {
registryItems: (typeof baseImports)[number]["registryItems"]
sourceFiles: (typeof baseImports)[number]["sourceFiles"]
styleHash: string
transformCacheHash: string
styleMap: Record<string, string>
}> = []
@@ -542,6 +666,7 @@ async function buildBases(bases: Base[]) {
registryItems,
sourceFiles,
styleHash,
transformCacheHash,
styleMap,
})
}
@@ -557,6 +682,7 @@ async function buildBases(bases: Base[]) {
registryItems,
sourceFiles,
styleHash,
transformCacheHash,
styleMap,
}) => {
const styleName = `${base.name}-${style.name}`
@@ -597,6 +723,7 @@ async function buildBases(bases: Base[]) {
filePath: file.path,
source,
styleHash,
transformCacheHash,
styleMap,
})
: source

View File

@@ -1,5 +1,13 @@
# @shadcn/ui
## 4.3.1
### Patch Changes
- [#10436](https://github.com/shadcn-ui/ui/pull/10436) [`b7cfc364aca36bc90f8efa86773bc81011502036`](https://github.com/shadcn-ui/ui/commit/b7cfc364aca36bc90f8efa86773bc81011502036) Thanks [@shadcn](https://github.com/shadcn)! - Ensure `init` only runs template post-init hooks for newly created projects.
- [#10179](https://github.com/shadcn-ui/ui/pull/10179) [`d00605c5fb5fe3cfbcb68cea65398430cdd819f8`](https://github.com/shadcn-ui/ui/commit/d00605c5fb5fe3cfbcb68cea65398430cdd819f8) Thanks [@EthanThatOneKid](https://github.com/EthanThatOneKid)! - Send `Accept: application/vnd.shadcn.v1+json, application/json;q=0.9` and `User-Agent: shadcn` on registry fetches so servers using HTTP content negotiation can reliably serve JSON to the CLI. Fixes #10164.
## 4.3.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "shadcn",
"version": "4.3.0",
"version": "4.3.1",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"

View File

@@ -0,0 +1,254 @@
import { mkdir, mkdtemp, rm } from "fs/promises"
import os from "os"
import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { templates } from "@/src/templates"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { MISSING_DIR_OR_EMPTY_PROJECT } from "@/src/utils/errors"
import {
getProjectConfig,
getProjectInfo,
getProjectTailwindVersionFromConfig,
} from "@/src/utils/get-project-info"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { runInit } from "./init"
vi.mock("@/src/preflights/preflight-init", () => ({
preFlightInit: vi.fn(),
}))
vi.mock("@/src/utils/create-project", () => ({
createProject: vi.fn(),
}))
vi.mock("@/src/utils/add-components", () => ({
addComponents: vi.fn(),
}))
vi.mock("@/src/utils/registries", () => ({
ensureRegistriesInConfig: vi.fn(),
}))
vi.mock("@/src/registry/api", () => ({
getRegistryBaseColors: vi.fn().mockResolvedValue([
{
label: "Zinc",
name: "zinc",
},
]),
getRegistryStyles: vi.fn().mockResolvedValue([
{
label: "New York",
name: "new-york",
},
]),
}))
vi.mock("@/src/utils/get-config", () => ({
DEFAULT_COMPONENTS: "@/components",
DEFAULT_TAILWIND_CONFIG: "tailwind.config.js",
DEFAULT_TAILWIND_CSS: "app/globals.css",
DEFAULT_UTILS: "@/lib/utils",
explorer: {
clearCaches: vi.fn(),
},
getConfig: vi.fn(),
getWorkspaceConfig: vi.fn().mockResolvedValue(null),
resolveConfigPaths: vi.fn(
async (cwd: string, config: Record<string, unknown>) => ({
...config,
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.js"),
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
})
),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectComponents: vi.fn().mockResolvedValue([]),
getProjectConfig: vi.fn(),
getProjectInfo: vi.fn(),
getProjectTailwindVersionFromConfig: vi.fn(),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
success: (value: string) => value,
warn: (value: string) => value,
},
}))
vi.mock("prompts", () => ({
default: vi.fn(),
}))
const projectInfo = {
framework: {
label: "Vite",
links: {},
name: "vite",
},
frameworkVersion: null,
isRSC: false,
isSrcDir: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
aliasPrefix: "@",
}
function createProjectConfig(cwd: string) {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
rtl: false,
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
}
}
function createInitOptions(cwd: string) {
return {
cwd,
yes: true,
defaults: true,
force: false,
reinstall: false,
silent: true,
isNewProject: false,
cssVariables: true,
installStyleIndex: true,
template: "vite",
} as Parameters<typeof runInit>[0]
}
describe("runInit", () => {
let cwd: string
let originalPostInit: typeof templates.vite.postInit
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "shadcn-init-test-"))
originalPostInit = templates.vite.postInit
vi.mocked(getProjectInfo).mockResolvedValue(projectInfo as any)
vi.mocked(getProjectTailwindVersionFromConfig).mockResolvedValue("v4")
vi.mocked(getProjectConfig).mockImplementation(async (projectCwd) =>
createProjectConfig(projectCwd)
)
vi.mocked(ensureRegistriesInConfig).mockImplementation(
async (_components, config) => ({ config, newRegistries: [] })
)
vi.mocked(addComponents).mockResolvedValue(undefined)
})
afterEach(async () => {
templates.vite.postInit = originalPostInit
vi.clearAllMocks()
await rm(cwd, { recursive: true, force: true })
})
it("does not run template postInit for existing projects with an explicit template", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {},
projectInfo: projectInfo as any,
})
await runInit(createInitOptions(cwd))
expect(postInit).not.toHaveBeenCalled()
})
it("runs template postInit after creating a new project", async () => {
const projectPath = path.join(cwd, "vite-app")
await mkdir(projectPath)
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {
[MISSING_DIR_OR_EMPTY_PROJECT]: true,
},
projectInfo: null,
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "vite-app",
template: "vite",
})
await runInit(createInitOptions(cwd))
expect(postInit).toHaveBeenCalledWith({ projectPath })
})
it("does not run template postInit when isNewProject is true but createProject was skipped", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
await runInit({
...createInitOptions(cwd),
skipPreflight: true,
isNewProject: true,
})
expect(createProject).not.toHaveBeenCalled()
expect(postInit).not.toHaveBeenCalled()
})
})

View File

@@ -606,6 +606,8 @@ export async function runInit(
projectInfo = await getProjectInfo(options.cwd)
}
const didCreateProject = Boolean(newProjectTemplate)
// Use the template from project creation if available,
// or fall back to the explicit --template flag.
const templateKey = newProjectTemplate ?? explicitTemplate
@@ -632,8 +634,10 @@ export async function runInit(
silent: options.silent,
})
// Run postInit for new projects (e.g. git init).
await selectedTemplate.postInit({ projectPath: options.cwd })
// Run postInit only for newly scaffolded projects (e.g. git init).
if (didCreateProject) {
await selectedTemplate.postInit({ projectPath: options.cwd })
}
return result
}
@@ -770,8 +774,8 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
// Run postInit for new projects without a custom init (e.g. git init).
if (selectedTemplate) {
// Run postInit for newly scaffolded projects without a custom init (e.g. git init).
if (selectedTemplate && didCreateProject) {
await selectedTemplate.postInit({ projectPath: options.cwd })
}

View File

@@ -1,7 +1,7 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import { resolveCreateUrl, resolveInitUrl } from "./presets"
import { DEFAULT_PRESETS, resolveCreateUrl, resolveInitUrl } from "./presets"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
@@ -79,6 +79,12 @@ describe("buildInitUrl", () => {
expect(parsed.searchParams.get("chartColor")).toBe("emerald")
})
it("should include chartColor from default presets", () => {
const url = resolveInitUrl({ ...DEFAULT_PRESETS.sera, base: "base" })
const parsed = new URL(url)
expect(parsed.searchParams.get("chartColor")).toBe("taupe")
})
it("should not include chartColor when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)

View File

@@ -19,6 +19,7 @@ export const DEFAULT_PRESETS = {
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "geist",
fontHeading: "inherit",
@@ -34,6 +35,7 @@ export const DEFAULT_PRESETS = {
style: "vega",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
@@ -49,6 +51,7 @@ export const DEFAULT_PRESETS = {
style: "maia",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "figtree",
fontHeading: "inherit",
@@ -64,6 +67,7 @@ export const DEFAULT_PRESETS = {
style: "lyra",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "phosphor",
font: "jetbrains-mono",
fontHeading: "inherit",
@@ -79,6 +83,7 @@ export const DEFAULT_PRESETS = {
style: "mira",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "inter",
fontHeading: "inherit",
@@ -94,6 +99,7 @@ export const DEFAULT_PRESETS = {
style: "luma",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
@@ -109,6 +115,7 @@ export const DEFAULT_PRESETS = {
style: "sera",
baseColor: "taupe",
theme: "taupe",
chartColor: "taupe",
iconLibrary: "lucide",
font: "noto-sans",
fontHeading: "playfair-display",

View File

@@ -10,6 +10,7 @@ import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
import { clearRegistryContext, setRegistryHeaders } from "./context"
import { clearRegistryCache, fetchRegistry } from "./fetcher"
const server = setupServer(
@@ -204,6 +205,134 @@ describe("fetchRegistry", () => {
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
it("should send specific Accept and User-Agent headers", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(`${REGISTRY_URL}/header-test.json`, ({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "header-test",
type: "registry:ui",
})
})
)
await fetchRegistry(["header-test.json"], { useCache: false })
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
expect(userAgentHeader).toBe("shadcn")
})
it("should allow per-registry headers to override the default Accept and User-Agent", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(`${REGISTRY_URL}/override-test.json`, ({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "override-test",
type: "registry:ui",
})
})
)
setRegistryHeaders({
[`${REGISTRY_URL}/override-test.json`]: {
Accept: "application/custom+json",
"User-Agent": "custom-client/1.0",
},
})
await fetchRegistry(["override-test.json"], { useCache: false })
expect(acceptHeader).toBe("application/custom+json")
expect(userAgentHeader).toBe("custom-client/1.0")
clearRegistryContext()
})
it("should allow lowercase per-registry headers to override the default Accept and User-Agent", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(
`${REGISTRY_URL}/lowercase-override-test.json`,
({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "lowercase-override-test",
type: "registry:ui",
})
}
)
)
setRegistryHeaders({
[`${REGISTRY_URL}/lowercase-override-test.json`]: {
accept: "application/custom+json",
"user-agent": "custom-client/1.0",
},
})
await fetchRegistry(["lowercase-override-test.json"], { useCache: false })
expect(acceptHeader).toBe("application/custom+json")
expect(userAgentHeader).toBe("custom-client/1.0")
clearRegistryContext()
})
it("should send specific Accept header for direct external URLs", async () => {
let acceptHeader: string | null = null
server.use(
http.get("https://external.com/registry/item.json", ({ request }) => {
acceptHeader = request.headers.get("accept")
return HttpResponse.json({
name: "item",
type: "registry:ui",
})
})
)
await fetchRegistry(["https://external.com/registry/item.json"], {
useCache: false,
})
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
})
it("should successfully fetch when the server requires the specific Shadcn Accept header (Content Negotiation)", async () => {
server.use(
http.get(`${REGISTRY_URL}/content-negotiation.json`, ({ request }) => {
const accept = request.headers.get("accept")
if (!accept?.includes("application/vnd.shadcn.v1+json")) {
return new HttpResponse(
"<!DOCTYPE html><html><body>Error: Specific header missing</body></html>",
{
status: 200,
headers: { "Content-Type": "text/html" },
}
)
}
return HttpResponse.json({
name: "content-negotiation",
type: "registry:ui",
})
})
)
const [result] = await fetchRegistry(["content-negotiation.json"], {
useCache: false,
})
expect(result).toMatchObject({ name: "content-negotiation" })
})
})
describe("clearRegistryCache", () => {

View File

@@ -14,7 +14,7 @@ import {
} from "@/src/registry/errors"
import { registryItemSchema } from "@/src/schema"
import { HttpsProxyAgent } from "https-proxy-agent"
import fetch from "node-fetch"
import fetch, { Headers } from "node-fetch"
import { z } from "zod"
const agent = process.env.https_proxy
@@ -50,12 +50,18 @@ export async function fetchRegistry(
const fetchPromise = (async () => {
// Get headers from context for this URL.
const headers = getRegistryHeadersFromContext(url)
const requestHeaders = new Headers({
Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
"User-Agent": "shadcn",
})
for (const [key, value] of Object.entries(headers)) {
requestHeaders.set(key, value)
}
const response = await fetch(url, {
agent,
headers: {
...headers,
},
headers: requestHeaders,
})
if (!response.ok) {

2
pnpm-lock.yaml generated
View File

@@ -284,7 +284,7 @@ importers:
specifier: ^0.0.1
version: 0.0.1
shadcn:
specifier: 4.3.0
specifier: 4.3.1
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1