212 lines
8.2 KiB
JavaScript
Executable File
212 lines
8.2 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// claddcn — puts the cladd.io look onto an existing shadcn-svelte project.
|
|
//
|
|
// npx claddcn init [--cwd <dir>] [--css <file>] [--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/<timestamp>/;
|
|
// 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 <dir>] [--css <file>] [--only a,b] [--strip-vars] [--no-install] [--dry]`
|
|
);
|
|
process.exit(command ? 1 : 0);
|
|
}
|