Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-fonts-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"shadcn": patch
---

Preserve binary registry files by encoding their contents as base64 during builds and decoding them during installation.
5 changes: 5 additions & 0 deletions apps/v4/public/schema/registry-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@
"type": "string",
"description": "The content of the file."
},
"encoding": {
"type": "string",
"enum": ["base64"],
"description": "The encoding used for the file content. Binary files use base64."
},
"type": {
"type": "string",
"enum": [
Expand Down
43 changes: 42 additions & 1 deletion packages/shadcn/src/commands/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,50 @@ describe("build command", () => {
],
})
})

it("base64-encodes binary registry files", async () => {
const font = Buffer.from([0x77, 0x4f, 0x46, 0x32, 0x00, 0xff, 0x80, 0x01])
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [
{
name: "font",
type: "registry:item",
files: [
{
path: "font.woff2",
type: "registry:file",
target: "public/font.woff2",
},
],
},
],
}),
"font.woff2": font,
})

await build.parseAsync(
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
{ from: "node" }
)

const item = JSON.parse(
await fs.readFile(path.join(cwd, "public/r/font.json"), "utf-8")
)

expect(item.files[0]).toEqual({
path: "font.woff2",
content: font.toString("base64"),
encoding: "base64",
type: "registry:file",
target: "public/font.woff2",
})
})
})

async function createFixture(files: Record<string, string>) {
async function createFixture(files: Record<string, string | Buffer>) {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-build-"))

await Promise.all(
Expand Down
30 changes: 24 additions & 6 deletions packages/shadcn/src/registry/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,14 @@ export async function createRegistryItem(
result.itemSourcesByItem,
fallbackDir
)
file.content = await readRegistryItemFileContent(
item.name,
sourceFile.path,
sourcePath,
source
Object.assign(
file,
await readRegistryItemFileContent(
item.name,
sourceFile.path,
sourcePath,
source
)
)
})
)
Expand All @@ -190,7 +193,22 @@ async function readRegistryItemFileContent(
source: RegistryItemSource | undefined
) {
try {
return await fs.readFile(sourcePath, "utf-8")
const content = await fs.readFile(sourcePath)
const utf8Content = content.toString("utf-8")

if (
content.includes(0) ||
!content.equals(Buffer.from(utf8Content, "utf-8"))
) {
return {
content: content.toString("base64"),
encoding: "base64" as const,
}
}

return {
content: utf8Content,
}
} catch (error) {
throw new RegistryLocalFileError(sourcePath, error, {
message: `Failed to read file "${filePath}" for registry item "${itemName}" (${formatItemSource(
Expand Down
2 changes: 2 additions & 0 deletions packages/shadcn/src/registry/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,14 @@ export const registryItemFileSchema = z.discriminatedUnion("type", [
z.object({
path: z.string(),
content: z.string().optional(),
encoding: z.literal("base64").optional(),
type: z.enum(["registry:file", "registry:page"]),
target: z.string(),
}),
z.object({
path: z.string(),
content: z.string().optional(),
encoding: z.literal("base64").optional(),
type: registryItemTypeSchema.exclude(["registry:file", "registry:page"]),
target: z.string().optional(),
}),
Expand Down
33 changes: 33 additions & 0 deletions packages/shadcn/src/utils/updaters/update-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2149,6 +2149,39 @@ export function CustomComponent() {
expect(writtenContent).toContain('"use client"')
})

it("should decode base64-encoded binary registry files", async () => {
const config = (await getConfig(getFixturesDir("vite-with-tailwind")))!
const binaryContent = Buffer.from([
0x77, 0x4f, 0x46, 0x32, 0x00, 0xff, 0x80, 0x01,
])

const result = await updateFiles(
[
{
path: "font.woff2",
type: "registry:file",
target: "~/public/font.woff2",
content: binaryContent.toString("base64"),
encoding: "base64",
},
],
config,
{
overwrite: true,
silent: true,
}
)

expect(result.filesCreated).toContain(path.join("public", "font.woff2"))

const writtenContent = (fs.writeFile as any).mock.calls.find((call: any) =>
call[0].endsWith("font.woff2")
)?.[1]

expect(Buffer.isBuffer(writtenContent)).toBe(true)
expect(writtenContent).toEqual(binaryContent)
})

it("should preserve 'use client' directive for universal item files (registry:item)", async () => {
const config = (await getConfig(getFixturesDir("vite-with-tailwind")))!
const result = await updateFiles(
Expand Down
128 changes: 74 additions & 54 deletions packages/shadcn/src/utils/updaters/update-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export async function updateFiles(
let filesSkipped: string[] = []
let envVarsAdded: string[] = []
let envFile: string | null = null
const binaryFilePaths = new Set<string>()

for (let index = 0; index < files.length; index++) {
const file = files[index]
Expand Down Expand Up @@ -167,64 +168,76 @@ export async function updateFiles(
// to preserve their original content as they're meant to be framework-agnostic
const isUniversalItemFile =
file.type === "registry:file" || file.type === "registry:item"
const content =
isEnvFile(filePath) || isUniversalItemFile
? file.content
: await transform(
{
filename: file.path,
raw: file.content,
config,
baseColor,
transformJsx: !config.tsx,
isRemote: options.isRemote,
supportedFontMarkers: options.supportedFontMarkers,
},
[
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
transformIcons,
transformMenu,
transformAsChild,
transformRtl,
...(_isNext16Middleware(filePath, projectInfo, config)
? [transformNext]
: []),
transformFont,
transformCleanup,
]
)
const content: string | Buffer =
file.encoding === "base64"
? Buffer.from(file.content, "base64")
: isEnvFile(filePath) || isUniversalItemFile
? file.content
: await transform(
{
filename: file.path,
raw: file.content,
config,
baseColor,
transformJsx: !config.tsx,
isRemote: options.isRemote,
supportedFontMarkers: options.supportedFontMarkers,
},
[
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
transformIcons,
transformMenu,
transformAsChild,
transformRtl,
...(_isNext16Middleware(filePath, projectInfo, config)
? [transformNext]
: []),
transformFont,
transformCleanup,
]
)
const isTextEnvFile = typeof content === "string" && isEnvFile(filePath)

// Skip the file if it already exists and the content is the same.
// Exception: Don't skip .env files as we merge content instead of replacing
if (existingFile && !isEnvFile(filePath)) {
const resolvedContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project: importRewriteProject,
projectInfo,
resolvedPath: filePath,
tsConfig,
})
const existingFileContent = await fs.readFile(filePath, "utf-8")

if (
isContentSame(existingFileContent, resolvedContent, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
if (existingFile && !isTextEnvFile) {
if (Buffer.isBuffer(content)) {
const existingFileContent = await fs.readFile(filePath)
if (existingFileContent.equals(content)) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
binaryFilePaths.add(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
} else {
const resolvedContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project: importRewriteProject,
projectInfo,
resolvedPath: filePath,
tsConfig,
})
) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
const existingFileContent = await fs.readFile(filePath, "utf-8")

if (
isContentSame(existingFileContent, resolvedContent, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
})
) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
}
}

// Skip overwrite prompt for .env files - we'll handle them specially
if (existingFile && !options.overwrite && !isEnvFile(filePath)) {
if (existingFile && !options.overwrite && !isTextEnvFile) {
if (!options.interactive) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
Expand Down Expand Up @@ -267,7 +280,7 @@ export async function updateFiles(
}

// Special handling for .env files - append only new keys
if (isEnvFile(filePath) && existingFile) {
if (isTextEnvFile && existingFile) {
const existingFileContent = await fs.readFile(filePath, "utf-8")
const mergedContent = mergeEnvContent(existingFileContent, content)
envVarsAdded = getNewEnvKeys(existingFileContent, content)
Expand All @@ -283,13 +296,16 @@ export async function updateFiles(
continue
}

await fs.writeFile(filePath, content, "utf-8")
await fs.writeFile(filePath, content)
if (Buffer.isBuffer(content)) {
binaryFilePaths.add(path.relative(config.resolvedPaths.cwd, filePath))
}

// Handle file creation logging
if (!existingFile) {
filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath))

if (isEnvFile(filePath)) {
if (isTextEnvFile) {
envVarsAdded = Object.keys(parseEnvContent(content))
envFile = path.relative(config.resolvedPaths.cwd, filePath)
}
Expand All @@ -301,7 +317,11 @@ export async function updateFiles(
const allFiles = options.interactive
? [...filesCreated, ...filesUpdated, ...filesSkipped]
: [...filesCreated, ...filesUpdated]
const updatedFiles = await resolveImports(allFiles, config, plannedFilePaths)
const updatedFiles = await resolveImports(
allFiles.filter((filePath) => !binaryFilePaths.has(filePath)),
config,
plannedFilePaths
)

// Let's update filesUpdated with the updated files.
filesUpdated.push(...updatedFiles)
Expand Down
Loading