Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ pnpm-lock.yaml.js
**/package-lock.json

# brandcn specific
**/components
**/packages/brandcn/components
2 changes: 1 addition & 1 deletion packages/brandcn/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "brandcn",
"description": "brandcn is a CLI tool that lets you quickly pull and add high-quality brand logos to your project — just like adding components with shadcn/ui.",
"version": "0.1.14",
"version": "0.1.16",
"author": "yonaries",
"bin": {
"brandcn": "./bin/run.js"
Expand Down
15 changes: 10 additions & 5 deletions packages/brandcn/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as p from '@clack/prompts'
import {Command, Flags} from '@oclif/core'

import {LogoOperationResult, processLogos, setCustomTargetDirectory, targetDirectoryExists} from '../utils/fs.js'
import type {LogoOperationResult} from '../types/logos.js'

import {getDefaultDirectoryPath, processLogos, setCustomTargetDirectory, targetDirectoryExists} from '../utils/fs.js'
import {displayError, displayUsage, LogoSpinner} from '../utils/log.js'
import {validateLogoNames} from '../utils/validate.js'

Expand Down Expand Up @@ -64,19 +66,22 @@ export default class Add extends Command {
if (!directoryExists) {
p.intro('🎨 brandcn')

const defaultPath = getDefaultDirectoryPath()
const directory = await p.text({
message: 'Would you like to specify a custom directory?',
placeholder: 'components/logos',
placeholder: defaultPath,
})

if (p.isCancel(directory)) {
p.cancel('Operation cancelled.')
this.exit(0)
}

if (directory !== 'components/logos') {
setCustomTargetDirectory(directory)
}
const chosenDir = (directory && directory.toString().trim().length > 0)
? directory.toString().trim()
: defaultPath

setCustomTargetDirectory(chosenDir)
}

const spinner = new LogoSpinner(`Processing ${validation.validNames.length} logo(s)...`)
Expand Down
17 changes: 17 additions & 0 deletions packages/brandcn/src/types/logos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type VariantType = 'dark' | 'light' | 'wordmark'

export interface ProcessLogosOptions {
dark?: boolean
light?: boolean
wordmark?: boolean
}

export interface LogoOperationResult {
error?: string
logoName: string
reason?: string
skipped?: boolean
success: boolean
}


145 changes: 128 additions & 17 deletions packages/brandcn/src/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {constants} from 'node:fs'
import path from 'node:path'
import {fileURLToPath} from 'node:url'

import type {LogoOperationResult, ProcessLogosOptions} from '../types/logos.js'

// Global variable to store custom target directory
let customTargetDirectory: null | string = null

Expand All @@ -17,11 +19,36 @@ export function getTargetLogosPath(): string {
return path.resolve(process.cwd(), customTargetDirectory)
}

return path.resolve(process.cwd(), 'components/logos')
// Try reading configured outputDir from package.json
const configured = getConfiguredOutputDir()
if (configured) {
return configured
}

// Check if src folder exists first
const defaultPath = getDefaultDirectoryPath()

return path.resolve(process.cwd(), defaultPath)
}

export function setCustomTargetDirectory(directory: string): void {
customTargetDirectory = directory
const normalized = directory?.toString().trim() || 'components/logos'
customTargetDirectory = normalized
persistConfiguredOutputDir(normalized)
}

export function getDefaultDirectoryPath(): string {
// Check if src folder exists first
const srcPath = path.resolve(process.cwd(), 'src')
try {
if (fs.pathExistsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
return 'src/components/logos'
}
} catch {
// If we can't access src folder, fall back to default
}

return 'components/logos'
}

export async function targetDirectoryExists(): Promise<boolean> {
Expand Down Expand Up @@ -79,14 +106,6 @@ export async function getAvailableLogos(): Promise<string[]> {
}
}

export type VariantType = 'dark' | 'light' | 'wordmark'

export interface ProcessLogosOptions {
dark?: boolean
light?: boolean
wordmark?: boolean
}

export function findLogoVariants(brandName: string, availableLogos: string[]): string[] {
const normalizedBrand = brandName.toLowerCase()

Expand Down Expand Up @@ -134,13 +153,6 @@ export function filterByVariants(logoNames: string[], options: ProcessLogosOptio
})
}

export interface LogoOperationResult {
error?: string
logoName: string
reason?: string
skipped?: boolean
success: boolean
}

export async function processLogos(
logoNames: string[],
Expand Down Expand Up @@ -224,3 +236,102 @@ export function getVariantType(logoName: string, baseName: string): null | strin

return null
}

// Configuration helpers
function findNearestPackageJson(startDir: string = process.cwd()): null | string {
let currentDir = startDir
while (true) {
const candidate = path.join(currentDir, 'package.json')
if (fs.pathExistsSync(candidate)) return candidate
const parent = path.dirname(currentDir)
if (parent === currentDir) break
currentDir = parent
}

return null
}

function readBrandcnOutputDirFrom(pkgPath: string): null | string {
try {
const pkg = fs.readJSONSync(pkgPath)
const output = pkg?.brandcn?.outputDir
if (typeof output === 'string' && output.trim().length > 0) {
return path.resolve(path.dirname(pkgPath), output.trim())
}

return null
} catch {
return null
}
}

function listWorkspacePackageJsonCandidates(): string[] {
const roots = ['apps', 'packages']
const results: string[] = []
for (const root of roots) {
const rootPath = path.resolve(process.cwd(), root)
if (!fs.pathExistsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) continue
const entries = fs.readdirSync(rootPath)
for (const entry of entries) {
const pkgJson = path.join(rootPath, entry, 'package.json')
if (fs.pathExistsSync(pkgJson)) results.push(pkgJson)
}
}

return results
}

function getConfiguredOutputDir(): null | string {
try {
const nearest = findNearestPackageJson()
const absFromNearest = nearest ? readBrandcnOutputDirFrom(nearest) : null
if (absFromNearest) return absFromNearest

const workspacePkgs = listWorkspacePackageJsonCandidates()
const absCandidates = workspacePkgs
.map((pkgPath) => readBrandcnOutputDirFrom(pkgPath))
.filter(Boolean) as string[]
if (absCandidates.length === 1) return absCandidates[0]

return null
} catch {
return null
}
}

interface PackageJsonLike {
[key: string]: unknown
brandcn?: {
[key: string]: unknown
outputDir?: string
}
}

function persistConfiguredOutputDir(directory: string): void {
try {
const normalized = directory?.toString().trim() || 'components/logos'
const absTarget = path.resolve(process.cwd(), normalized)
const nearestFromTarget = findNearestPackageJson(path.dirname(absTarget))
const nearestFromCwd = findNearestPackageJson()
const pkgPath = nearestFromTarget || nearestFromCwd
if (!pkgPath) return
let pkg: PackageJsonLike = {}
try {
pkg = fs.readJSONSync(pkgPath)
} catch {
pkg = {}
}

const nextPkg = {
...pkg,
brandcn: {
...pkg.brandcn,
outputDir: normalized,
},
}

fs.writeJSONSync(pkgPath, nextPkg, {spaces: 2})
} catch {
// Ignore persistence errors; CLI can still operate with in-memory value
}
}
10 changes: 9 additions & 1 deletion packages/brandcn/test/utils/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,18 @@ describe('fs utilities', () => {
})

describe('getTargetLogosPath', () => {
it('should return path to logos directory in current working directory', () => {
it('should return path to logos directory in current working directory when no src folder exists', () => {
const targetPath = getTargetLogosPath()
expect(targetPath).toBe(path.resolve(process.cwd(), 'components/logos'))
})

it('should return path to logos directory in src folder when src folder exists', () => {
// Create an src directory
mkdirSync('./src', {recursive: true})

const targetPath = getTargetLogosPath()
expect(targetPath).toBe(path.resolve(process.cwd(), 'src/components/logos'))
})
})

describe('logoExistsInLibrary', () => {
Expand Down