#!/usr/bin/env node // claddcn — puts the cladd.io look onto an existing shadcn-svelte project. // // npx claddcn init [--cwd ] [--css ] [--only button,card] [--strip-vars] [--no-install] [--dry] // // What `init` does, in order: // 1. copies claddcn.css next to the Tailwind entry named in components.json // and imports it right after the other top-level @imports; // 2. with --strip-vars, removes the old :root / .dark / @theme inline token // blocks from that file (claddcn ships its own, and later ones would win); // 3. overwrites ui components with the cladd versions (same names, same API), // backing up whatever it replaces into .claddcn-backup//; // 4. teaches `cn()` the cladd scale so tailwind-merge doesn't drop classes; // 5. adds the Geist / Geist Mono fonts with the project's package manager. import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const REGISTRY_UI = path.join(ROOT, 'registry', 'ui'); const THEME_CSS = path.join(ROOT, 'src', 'claddcn.css'); const UTILS_TS = path.join(ROOT, 'registry', 'lib', 'utils.ts'); const args = process.argv.slice(2); const command = args[0]; const flag = (name) => args.includes(`--${name}`); const option = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; }; const cwd = path.resolve(option('cwd') ?? process.cwd()); const dry = flag('dry'); const log = (msg) => console.log(`claddcn ${msg}`); function write(file, content) { if (dry) return log(`[dry] write ${path.relative(cwd, file)}`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); } function copyDir(src, dest) { for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const from = path.join(src, entry.name); const to = path.join(dest, entry.name); if (entry.isDirectory()) copyDir(from, to); else write(to, fs.readFileSync(from)); } } function resolveAlias(alias, config) { const lib = config.aliases?.lib ?? '$lib'; if (alias.startsWith('$lib')) return path.join(cwd, 'src', 'lib', alias.slice('$lib'.length)); if (alias.startsWith(lib)) return path.join(cwd, 'src', 'lib', alias.slice(lib.length)); return path.join(cwd, alias); } // Removes a top-level block that starts with `selector {` by brace matching. function stripBlock(css, selector) { const re = new RegExp(`(^|\\n)${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{`); const m = re.exec(css); if (!m) return css; let depth = 0; let i = m.index + m[0].length - 1; for (; i < css.length; i++) { if (css[i] === '{') depth++; else if (css[i] === '}' && --depth === 0) break; } return css.slice(0, m.index) + css.slice(i + 1); } // Tailwind entries under src/, i.e. CSS files that `@import 'tailwindcss'`. function findTailwindEntries(dir = path.join(cwd, 'src'), found = []) { if (!fs.existsSync(dir)) return found; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name); if (entry.isDirectory()) findTailwindEntries(file, found); else if ( entry.name.endsWith('.css') && entry.name !== 'claddcn.css' && /^@import ['"]tailwindcss['"]/m.test(fs.readFileSync(file, 'utf8')) ) found.push(file); } return found; } // --css wins; then components.json; if that path is stale, the single Tailwind // entry under src/ (or the one that already imports claddcn). function resolveCss(config) { if (option('css')) { const file = path.join(cwd, option('css')); if (fs.existsSync(file)) return file; console.error(`claddcn ${option('css')} not found`); process.exit(1); } const configured = path.join(cwd, config.tailwind.css); if (fs.existsSync(configured)) return configured; const entries = findTailwindEntries(); const ours = entries.filter((f) => fs.readFileSync(f, 'utf8').includes('claddcn.css')); const pick = entries.length === 1 ? entries[0] : ours.length === 1 ? ours[0] : null; if (pick) { log(`components.json points at ${config.tailwind.css}, using ${path.relative(cwd, pick)}`); return pick; } console.error(`claddcn ${config.tailwind.css} not found. Pick the Tailwind entry with --css:`); for (const f of entries) console.error(` --css "${path.relative(cwd, f)}"`); process.exit(1); } function patchCss(cssFile) { let css = fs.readFileSync(cssFile, 'utf8'); if (!css.includes('claddcn.css')) { const imports = [...css.matchAll(/^@import[^;]+;\s*$/gm)]; const at = imports.length ? imports.at(-1).index + imports.at(-1)[0].length : 0; css = `${css.slice(0, at)}\n@import './claddcn.css';${css.slice(at)}`; log(`import added to ${path.relative(cwd, cssFile)}`); } if (flag('strip-vars')) { for (const selector of [':root', '.dark', '@theme inline']) css = stripBlock(css, selector); log('old :root / .dark / @theme inline blocks removed'); } else if (/(^|\n):root\s*\{[^}]*--background/.test(css)) { log('warning: this file still defines shadcn tokens after the import and will override'); log(' claddcn colors. Re-run with --strip-vars or delete those blocks by hand.'); } write(cssFile, css.replace(/\n{3,}/g, '\n\n')); } function patchUtils(utilsFile) { if (!fs.existsSync(utilsFile)) return write(utilsFile, fs.readFileSync(UTILS_TS)); const src = fs.readFileSync(utilsFile, 'utf8'); if (src.includes('extendTailwindMerge')) return log('cn() already extended, left as is'); const ours = fs.readFileSync(UTILS_TS, 'utf8'); const block = ours.slice(0, ours.indexOf('export function cn')); const next = src.replace( /import \{ clsx, type ClassValue \} from 'clsx';\s*\nimport \{ twMerge \} from 'tailwind-merge';\s*\n/, block ); if (next === src) { log( `warning: couldn't patch ${path.relative(cwd, utilsFile)} — merge registry/lib/utils.ts by hand` ); return; } write(utilsFile, next); log('cn() now knows the cladd scale'); } function install() { const has = (f) => fs.existsSync(path.join(cwd, f)); const pkg = '@fontsource-variable/geist @fontsource-variable/geist-mono'; const cmd = has('bun.lock') || has('bun.lockb') ? `bun add -d ${pkg}` : has('pnpm-lock.yaml') ? `pnpm add -D ${pkg}` : has('yarn.lock') ? `yarn add -D ${pkg}` : `npm i -D ${pkg}`; if (flag('no-install') || dry) return log(`skip install, run: ${cmd}`); log(cmd); execSync(cmd, { cwd, stdio: 'inherit' }); } function init() { const configFile = path.join(cwd, 'components.json'); if (!fs.existsSync(configFile)) { console.error(`claddcn no components.json in ${cwd} — is this a shadcn-svelte project?`); process.exit(1); } const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); const cssFile = resolveCss(config); const uiDir = resolveAlias(config.aliases?.ui ?? '$lib/components/ui', config); const utilsFile = `${resolveAlias(config.aliases?.utils ?? '$lib/utils', config)}.ts`; const only = option('only')?.split(','); const available = fs.readdirSync(REGISTRY_UI); const components = only ?? available; const missing = components.filter((c) => !available.includes(c)); if (missing.length) log(`unknown components skipped: ${missing.join(', ')}`); write(path.join(path.dirname(cssFile), 'claddcn.css'), fs.readFileSync(THEME_CSS)); patchCss(cssFile); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const backup = path.join(cwd, '.claddcn-backup', stamp); for (const name of components.filter((c) => available.includes(c))) { const target = path.join(uiDir, name); if (fs.existsSync(target) && !dry) { fs.mkdirSync(backup, { recursive: true }); fs.cpSync(target, path.join(backup, name), { recursive: true }); } copyDir(path.join(REGISTRY_UI, name), target); } log(`${components.length} components written to ${path.relative(cwd, uiDir)}`); if (fs.existsSync(backup)) log(`previous versions saved in ${path.relative(cwd, backup)}`); patchUtils(utilsFile); install(); log('done — the cladd look is in. Dark mode follows the `.dark` class (mode-watcher).'); } if (command === 'init') init(); else { console.log( `usage: claddcn init [--cwd ] [--css ] [--only a,b] [--strip-vars] [--no-install] [--dry]` ); process.exit(command ? 1 : 0); }