Compare commits

...

1 Commits

Author SHA1 Message Date
shadcn
f5b964460f feat(shadcn): experiment append action 2025-04-17 14:00:25 +04:00
5 changed files with 395 additions and 8 deletions

View File

@@ -85,6 +85,11 @@
"target": {
"type": "string",
"description": "The target path of the file. This is the path to the file in the project."
},
"action": {
"type": "string",
"enum": ["append", "prepend"],
"description": "The action to perform on the target file. Can be append or prepend."
}
},
"if": {

View File

@@ -65,6 +65,10 @@ export const build = new Command()
// Loop through each file in the files array.
for (const file of registryItem.files) {
if (file["content"]) {
continue
}
file["content"] = await fs.readFile(
path.resolve(resolvePaths.cwd, file.path),
"utf-8"

View File

@@ -19,6 +19,10 @@ export const registryItemTypeSchema = z.enum([
"registry:internal",
])
export const registryItemFileActionSchema = z
.enum(["append", "prepend"])
.optional()
export const registryItemFileSchema = z.discriminatedUnion("type", [
// Target is required for registry:file and registry:page
z.object({
@@ -26,12 +30,14 @@ export const registryItemFileSchema = z.discriminatedUnion("type", [
content: z.string().optional(),
type: z.enum(["registry:file", "registry:page"]),
target: z.string(),
action: registryItemFileActionSchema,
}),
z.object({
path: z.string(),
content: z.string().optional(),
type: registryItemTypeSchema.exclude(["registry:file", "registry:page"]),
target: z.string().optional(),
action: registryItemFileActionSchema,
}),
])

View File

@@ -1,7 +1,11 @@
import { existsSync, promises as fs } from "fs"
import path, { basename } from "path"
import { getRegistryBaseColor } from "@/src/registry/api"
import { RegistryItem, registryItemFileSchema } from "@/src/registry/schema"
import {
RegistryItem,
registryItemFileActionSchema,
registryItemFileSchema,
} from "@/src/registry/schema"
import { Config } from "@/src/utils/get-config"
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
@@ -16,6 +20,31 @@ import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefi
import prompts from "prompts"
import { z } from "zod"
async function applyFileAction(
filePath: string,
content: string,
action: z.infer<typeof registryItemFileActionSchema>
) {
if (!action) {
return content
}
// Only try to read existing content if the file exists
if (existsSync(filePath)) {
const existingContent = await fs.readFile(filePath, "utf-8")
if (action === "append") {
return `${existingContent}\n${content}`
}
if (action === "prepend") {
return `${content}\n${existingContent}`
}
}
return content
}
export async function updateFiles(
files: RegistryItem["files"],
config: Config,
@@ -109,10 +138,29 @@ export async function updateFiles(
getNormalizedFileContent(existingFileContent),
getNormalizedFileContent(content),
])
// Check if content is already the same
if (normalizedExisting === normalizedNew) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
// Also check if action is already applied
if (file.action) {
if (file.action === "append") {
// Check if the content is already appended
if (normalizedExisting.endsWith(normalizedNew)) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
} else if (file.action === "prepend") {
// Check if the content is already prepended
if (normalizedExisting.startsWith(normalizedNew)) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
}
}
}
if (existingFile && !options.overwrite) {
@@ -147,10 +195,24 @@ export async function updateFiles(
await fs.mkdir(targetDir, { recursive: true })
}
await fs.writeFile(filePath, content, "utf-8")
existingFile
? filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
: filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath))
let finalContent = content
if (existingFile && file.action) {
const existingFileContent = await fs.readFile(filePath, "utf-8")
if (file.action === "append") {
finalContent = `${existingFileContent}\n${content}`
} else if (file.action === "prepend") {
finalContent = `${content}\n${existingFileContent}`
}
}
await fs.writeFile(filePath, finalContent, "utf-8")
const relativePath = path.relative(config.resolvedPaths.cwd, filePath)
if (existingFile) {
filesUpdated.push(relativePath)
} else {
filesCreated.push(relativePath)
}
}
const hasUpdatedFiles = filesCreated.length || filesUpdated.length
@@ -192,7 +254,7 @@ export async function updateFiles(
if (filesSkipped.length) {
spinner(
`Skipped ${filesSkipped.length} ${
filesUpdated.length === 1 ? "file" : "files"
filesSkipped.length === 1 ? "file" : "files"
}: (files might be identical, use --overwrite to overwrite)`,
{
silent: options.silent,
@@ -332,7 +394,10 @@ export function resolveNestedFilePath(
return fileSegments.slice(commonDirIndex + 1).join("/")
}
export async function getNormalizedFileContent(content: string) {
export async function getNormalizedFileContent(content: string | undefined) {
if (!content) {
return ""
}
return content.replace(/\r\n/g, "\n").trim()
}

View File

@@ -1,7 +1,17 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterAll, afterEach, describe, expect, test, vi } from "vitest"
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest"
import { getConfig } from "../../../src/utils/get-config"
import * as transformers from "../../../src/utils/transformers"
import {
findCommonRoot,
resolveFilePath,
@@ -9,12 +19,17 @@ import {
updateFiles,
} from "../../../src/utils/updaters/update-files"
vi.mock("../../../src/utils/transformers", () => ({
transform: vi.fn().mockImplementation((opts) => Promise.resolve(opts.raw)),
}))
vi.mock("fs/promises", async () => {
const actual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
return {
...actual,
readFile: vi.fn(),
writeFile: vi.fn(),
}
})
@@ -23,8 +38,10 @@ vi.mock("fs", async () => {
const actual = (await vi.importActual("fs")) as typeof import("fs")
return {
...actual,
existsSync: vi.fn(),
promises: {
...actual.promises,
readFile: vi.fn(),
writeFile: vi.fn(),
},
}
@@ -731,6 +748,30 @@ describe("updateFiles", () => {
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
// Set up mocks for transform
vi.mocked(transformers.transform).mockImplementation((opts) => {
if (opts.raw.includes("Button")) {
return Promise.resolve(opts.raw)
}
return Promise.resolve(opts.raw)
})
// Mock existsSync to check for existing files
vi.mocked(existsSync).mockImplementation((path) => {
return path.toString().includes("button.tsx")
})
// Mock readFile to return content for comparison
vi.mocked(fs.readFile).mockImplementation((path) => {
if (path.toString().includes("button.tsx")) {
return Promise.resolve(`export function Button() {
return <button>Click me</button>
}`)
}
return Promise.resolve("")
})
expect(
await updateFiles(
[
@@ -772,6 +813,27 @@ return <div>Hello World</div>
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
// Set up mocks for transform
vi.mocked(transformers.transform).mockImplementation((opts) => {
return Promise.resolve(opts.raw)
})
// Mock existsSync to check for existing files
vi.mocked(existsSync).mockImplementation((path) => {
return path.toString().includes("button.tsx")
})
// Mock readFile to return content for comparison
vi.mocked(fs.readFile).mockImplementation((path) => {
if (path.toString().includes("button.tsx")) {
return Promise.resolve(`export function Button() {
return <button>I'm different</button>
}`)
}
return Promise.resolve("")
})
expect(
await updateFiles(
[
@@ -809,3 +871,248 @@ return <div>Hello World</div>
`)
})
})
describe("file actions", () => {
test("should append content to existing file", async () => {
// Set up mocks
vi.mocked(transformers.transform).mockResolvedValue("new-content")
vi.mocked(existsSync)
.mockReturnValueOnce(true) // First check for file existence
.mockReturnValueOnce(true) // Directory exists check
const existingContent = "existing-content"
vi.mocked(fs.readFile)
.mockResolvedValueOnce(existingContent) // For content comparison check
.mockResolvedValueOnce(existingContent) // For append operation
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: "original-content", // This will be transformed to "new-content"
action: "append",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was updated not created
expect(result.filesUpdated).toHaveLength(1)
expect(result.filesCreated).toHaveLength(0)
expect(result.filesSkipped).toHaveLength(0)
// Check writeFile was called correctly for append
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
`${existingContent}\nnew-content`,
"utf-8"
)
})
test("should prepend content to existing file", async () => {
// Set up mocks
vi.mocked(transformers.transform).mockResolvedValue("new-content")
vi.mocked(existsSync)
.mockReturnValueOnce(true) // First check for file existence
.mockReturnValueOnce(true) // Directory exists check
const existingContent = "existing-content"
vi.mocked(fs.readFile)
.mockResolvedValueOnce(existingContent) // For content comparison check
.mockResolvedValueOnce(existingContent) // For prepend operation
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: "original-content", // This will be transformed to "new-content"
action: "prepend",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was updated not created
expect(result.filesUpdated).toHaveLength(1)
expect(result.filesCreated).toHaveLength(0)
expect(result.filesSkipped).toHaveLength(0)
// Check writeFile was called correctly for prepend
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
`new-content\n${existingContent}`,
"utf-8"
)
})
test("should update file when content is different", async () => {
// Set up mocks
vi.mocked(transformers.transform).mockResolvedValue("new-content")
vi.mocked(existsSync)
.mockReturnValueOnce(true) // First check for file existence
.mockReturnValueOnce(true) // Directory exists check
const existingContent = "existing-content"
vi.mocked(fs.readFile).mockResolvedValueOnce(existingContent) // For content comparison
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: "original-content", // This will be transformed to "new-content"
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was updated not created
expect(result.filesUpdated).toHaveLength(1)
expect(result.filesCreated).toHaveLength(0)
expect(result.filesSkipped).toHaveLength(0)
// Check writeFile was called with new content
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
"new-content",
"utf-8"
)
})
test("should skip file when content is the same", async () => {
// Set up mocks
const content = "same-content"
vi.mocked(transformers.transform).mockResolvedValue(content)
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue(content)
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content,
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was skipped
expect(result.filesSkipped).toHaveLength(1)
expect(result.filesUpdated).toHaveLength(0)
expect(result.filesCreated).toHaveLength(0)
// Verify writeFile was not called
expect(fs.writeFile).not.toHaveBeenCalled()
})
test("should skip file when content is already appended", async () => {
// Set up mocks
const newContent = "new-content"
const existingContent = "existing-content\nnew-content" // Already has the content appended
vi.mocked(transformers.transform).mockResolvedValue(newContent)
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue(existingContent)
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: newContent,
action: "append",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// The file should be skipped because the content is already appended
expect(result.filesSkipped).toHaveLength(1)
expect(result.filesUpdated).toHaveLength(0)
expect(result.filesCreated).toHaveLength(0)
// Verify writeFile was not called
expect(fs.writeFile).not.toHaveBeenCalled()
})
test("should skip file when content is already prepended", async () => {
// Set up mocks
const newContent = "new-content"
const existingContent = "new-content\nexisting-content" // Already has the content prepended
vi.mocked(transformers.transform).mockResolvedValue(newContent)
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue(existingContent)
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: newContent,
action: "prepend",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// The file should be skipped because the content is already prepended
expect(result.filesSkipped).toHaveLength(1)
expect(result.filesUpdated).toHaveLength(0)
expect(result.filesCreated).toHaveLength(0)
// Verify writeFile was not called
expect(fs.writeFile).not.toHaveBeenCalled()
})
})