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
26 changes: 23 additions & 3 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"dependencies": {
"chalk": "^5.6.2",
"commander": "^14.0.1",
"inquirer": "^12.9.6"
"inquirer": "^12.9.6",
"lucide-react": "^0.553.0"
},
"devDependencies": {
"@types/node": "^24.7.1",
Expand Down
136 changes: 85 additions & 51 deletions cli/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import { Command } from "commander";
import { mkdir, writeFile } from "fs/promises";
import { join, resolve } from "path";
import path, { join, resolve } from "path";
import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
import { fetchWithTimeout } from "../utils/fetch.js";
import type { InputType } from "../utils/types.js";
import { ASSET_URL, DEFAULT_FILES } from "../utils/constants.js";
import { ASSET_URL, DEFAULT_FILES, TIMEOUTMS } from "../utils/constants.js";
import { writeConfigFile } from "./init.js";
import inquirer from "inquirer";

function loadConfig(configPath: string): InputType {
async function loadConfig(configPath: string): Promise<InputType> {
const defaults: InputType = {
path: "app/components/ui",
theme: "trakteer",
pm: "npm",
};

if (!existsSync(configPath)) return defaults;

try {
const raw = readFileSync(configPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<InputType> | null;
if (!parsed) return defaults;
return { ...defaults, ...parsed };
} catch (err) {
console.warn(chalk.yellow(`Failed to read ${configPath}, using defaults`));
if (!existsSync(configPath)) {
await writeConfigFile(configPath, defaults);
console.log(`${chalk.yellow(`ℹ`)} Added default ui.json`);
return defaults;
}

const parsed = JSON.parse(
readFileSync(configPath, "utf-8")
) as Partial<InputType> | null;

return { ...defaults, ...parsed };
}

async function writeComponentFiles(
Expand All @@ -34,69 +36,101 @@ async function writeComponentFiles(
mkdirFn = mkdir,
writeFileFn = writeFile
) {
await mkdirFn(outputDir, { recursive: true });
const fileContents: Record<string, string> = {};

for (const filename of files) {
try {
const content = await fetchBase(filename);
await writeFileFn(join(outputDir, filename), content, "utf8");
console.log(chalk.green(`Wrote ${join(outputDir, filename)}`));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(chalk.yellow(`Skipping ${filename}: ${msg}`));
fileContents[filename] = await fetchBase(filename);
} catch (err) {
throw new Error(`Component not found`);
}
}

if (existsSync(outputDir)) {
const { overwrite } = await inquirer.prompt([
{
type: "confirm",
name: "overwrite",
message: `Overwrite ${path.relative(process.cwd(), outputDir)}?`,
default: false,
},
]);

if (!overwrite) {
return false;
}
}

await mkdirFn(outputDir, { recursive: true });

for (const filename of files) {
await writeFileFn(
join(outputDir, filename),
fileContents[filename]!,
"utf8"
);
}

return true;
}

export async function runAdd(
name: string,
options?: {
cwd?: string;
assetUrl?: string;
files?: string[];
timeoutMs?: number;
config: InputType,
options: {
assetUrl: string;
files: string[];
timeoutMs: number;
}
) {
const cwd = options?.cwd ?? process.cwd();
const configPath = resolve(cwd, "ui.json");
const config = loadConfig(configPath);

const files = options?.files ?? DEFAULT_FILES;
const assetBase = options?.assetUrl ?? ASSET_URL;
const timeoutMs = options?.timeoutMs ?? 10000;
const { assetUrl, files, timeoutMs } = options;

const fetchBase = async (filename: string) => {
const url = `${assetBase}/raw/${config.theme}/${name}/${filename}`;
const url = `${assetUrl}/raw/${config.theme}/${name}/${filename}`;
const res = await fetchWithTimeout(url, timeoutMs);
if (!res.ok)
throw new Error(
`Failed to fetch ${url}: ${res.status} ${res.statusText}`
);

if (!res.ok) {
throw new Error("Not found");
}

return res.text();
};

const outputPath = join(resolve(cwd), `${config.path}/${name}`);
await writeComponentFiles(outputPath, files, fetchBase);
const outputPath = join(resolve(process.cwd()), `${config.path}/${name}`);
const success = await writeComponentFiles(outputPath, files, fetchBase);

return { success: true, outputPath };
return { success, outputPath };
}

export function add(program: Command) {
program
.command("add <name>")
.command("add <names...>")
.description("Add a new component to your path")
.action(async (name: string) => {
try {
const result = await runAdd(name);
if (result.success) {
console.log(
chalk.blue(`Component ${name} added at ${result.outputPath}`)
);
.action(async (names: string[]) => {
const config = await loadConfig(resolve(process.cwd(), "ui.json"));

for (const name of names) {
try {
const result = await runAdd(name, config, {
assetUrl: ASSET_URL,
timeoutMs: TIMEOUTMS,
files: DEFAULT_FILES,
});

if (result.success) {
console.log(
`${chalk.green(`✔`)} Added ${path.relative(
process.cwd(),
result.outputPath
)}`
);
} else {
console.error(`${chalk.yellow(`⚠`)} Skipping ${name}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`${chalk.red(`✖`)} ${message}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(chalk.red(`Add failed: ${message}`));
process.exitCode = 1;
}
});
}
23 changes: 12 additions & 11 deletions cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,14 @@ async function promptInitOptions(): Promise<InputType> {

function installPackage(pm: string, pkg: string) {
return new Promise<void>((resolve, reject) => {
exec(`${pm} install ${pkg}`, (err, stdout, stderr) => {
exec(`${pm} install ${pkg}`, (err) => {
if (err) return reject(err);
console.log(stdout);
console.error(stderr);
resolve();
});
});
}

async function writeConfigFile(configPath: string, payload: unknown) {
export async function writeConfigFile(configPath: string, payload: InputType) {
const data = JSON.stringify(payload, null, 2);
await writeFile(configPath, data, "utf8");
}
Expand All @@ -95,11 +93,11 @@ export function init(ui: Command) {

const configPath = path.resolve(process.cwd(), "ui.json");
await writeConfigFile(configPath, answers);
console.log(chalk.blue(`Created ui.json at ${configPath}`));
console.log(`${chalk.green(`✔`)} Adding ${chalk.green("ui.json")}`);

const url = `${ASSET_URL}/raw/${answers.theme}/index.css`;
console.log(chalk.gray(`Fetching theme CSS from ${url} ...`));
const res = await fetchWithTimeout(url, 15_000);
console.log(`${chalk.green(`✔`)} Fetching theme`);
const res = await fetchWithTimeout(url, 10000);

if (!res.ok) {
throw new Error(
Expand All @@ -114,14 +112,17 @@ export function init(ui: Command) {

await writeAsset(outputPath, "index.css", content);

console.log(chalk.gray(`Installing lucide-react ...`));
await installPackage(answers.pm, "lucide-react");
console.log(chalk.green("lucide-react installed."));
console.log(`${chalk.green(`✔`)} Installing dependencies`);

console.log(chalk.green("Initialization complete."));
console.log(
`\n${chalk.green(
"Success!"
)} Project initialization completed.\nYou may now add components`
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(chalk.red("Init failed:"), message);
console.error(`${chalk.red(`✖`)} ${message}`);
try {
process.exitCode = 1;
} catch {}
Expand Down
1 change: 1 addition & 0 deletions cli/src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const ASSET_URL = "https://fydemy-ui.vercel.app";
export const DEFAULT_FILES = ["index.tsx", "index.css"];
export const TIMEOUTMS = 10000;
5 changes: 5 additions & 0 deletions cli/ui.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"path": "app/components/ui",
"theme": "trakteer",
"pm": "npm"
}
36 changes: 36 additions & 0 deletions ui/accordion/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
div.accordion[data-theme="trakteer"] {
font-family: "Pontano Sans", sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}

div.accordion[data-theme="trakteer"]
> div.accordion_item
> div.accordion_header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--trakteer-dark);
font-size: 18px;
font-weight: 600;
}

div.accordion[data-theme="trakteer"] > div.accordion_item_active {
border-radius: 10px;
border: 1px solid var(--trakteer-dark);
}

div.accordion[data-theme="trakteer"]
> div.accordion_item_active
> div.accordion_header {
border-bottom: none;
padding-bottom: 0;
}

div.accordion[data-theme="trakteer"]
> div.accordion_item
div.accordion_content {
padding: 0 1rem;
}
Loading
Loading