component-gen-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Command, Option } from "commander";
6
+ import { generateComponent } from "../src/commands/generate.js";
7
+ import { FRAMEWORKS } from "../src/generators/index.js";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const pkgPath = join(__dirname, "..", "package.json");
11
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name("cg")
17
+ .description("Generate UI components for popular JavaScript frameworks")
18
+ .version(pkg.version ?? "0.0.0");
19
+
20
+ program
21
+ .command("generate")
22
+ .alias("g")
23
+ .argument("<name>", "Component name (PascalCase recommended, e.g. UserCard)")
24
+ .addOption(
25
+ new Option("-f, --framework <fw>", `Framework (${FRAMEWORKS.join("|")})`)
26
+ .choices([...FRAMEWORKS])
27
+ .makeOptionMandatory(true)
28
+ )
29
+ .option("-o, --out <dir>", "Output directory (default: current directory)", ".")
30
+ .option("--ts, --typescript", "Use TypeScript where applicable", false)
31
+ .option("--scss", "Use SCSS for style files (Angular / optional CSS splits)", false)
32
+ .option("--client", "Next.js: add \"use client\" directive", false)
33
+ .description("Create a component file (or Angular multi-file scaffold)")
34
+ .action(async (name, opts) => {
35
+ try {
36
+ const result = await generateComponent(name, opts);
37
+ console.log(result.message);
38
+ if (result.files?.length) {
39
+ for (const f of result.files) console.log(` ${f}`);
40
+ }
41
+ } catch (err) {
42
+ console.error(err instanceof Error ? err.message : String(err));
43
+ process.exitCode = 1;
44
+ }
45
+ });
46
+
47
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "component-gen-cli",
3
+ "version": "1.0.0",
4
+ "description": "Generate framework components for React, Vue, Angular, Svelte, Solid, and Next.js",
5
+ "type": "module",
6
+ "bin": {
7
+ "cg": "bin/cli.js",
8
+ "component-gen": "bin/cli.js"
9
+ },
10
+ "main": "src/index.js",
11
+ "exports": {
12
+ ".": "./src/index.js"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "start": "node bin/cli.js",
21
+ "test": "node --test"
22
+ },
23
+ "keywords": [
24
+ "cli",
25
+ "generator",
26
+ "react",
27
+ "vue",
28
+ "angular",
29
+ "svelte",
30
+ "solid",
31
+ "nextjs",
32
+ "scaffold",
33
+ "component"
34
+ ],
35
+ "author": "",
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "dependencies": {
41
+ "commander": "^13.1.0"
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ import { FRAMEWORKS, buildFiles } from "../generators/index.js";
2
+ import { parseComponentName } from "../lib/naming.js";
3
+ import { writeTree } from "../lib/fs.js";
4
+
5
+ /**
6
+ * @typedef {{ framework: string; out: string; typescript?: boolean; scss?: boolean; client?: boolean; ts?: boolean }} GenerateOpts
7
+ */
8
+
9
+ /**
10
+ * @param {string} name
11
+ * @param {GenerateOpts} opts
12
+ */
13
+ export async function generateComponent(name, opts) {
14
+ const fw = String(opts.framework ?? "").toLowerCase();
15
+
16
+ const allowed = FRAMEWORKS;
17
+ if (!allowed.includes(fw)) {
18
+ throw new Error(`Unknown framework "${opts.framework}". Use one of: ${allowed.join(", ")}`);
19
+ }
20
+
21
+ const identifiers = parseComponentName(name);
22
+
23
+ /** @type {GenerateOpts & { typescript: boolean }} */
24
+ const full = {
25
+ ...opts,
26
+ framework: fw,
27
+ typescript: Boolean(opts.typescript ?? opts.ts),
28
+ scss: Boolean(opts.scss),
29
+ client: Boolean(opts.client),
30
+ out: opts.out ?? ".",
31
+ };
32
+
33
+ const files = buildFiles(fw, identifiers, full);
34
+ const written = await writeTree(full.out, files);
35
+
36
+ const count = written.length;
37
+ const messageCount = `${count === 1 ? "1 file" : `${count} files`}`;
38
+ return {
39
+ message: `${messageCount} wrote for ${fw} component ${identifiers.pascal}.`,
40
+ files: written,
41
+ };
42
+ }
@@ -0,0 +1,44 @@
1
+ /** @typedef {import("../lib/naming.js").Identifiers} Identifiers */
2
+
3
+ /**
4
+ * @param {Identifiers} ids
5
+ * @param {{ scss?: boolean }} opts
6
+ */
7
+ export function angularFiles(ids, opts) {
8
+ const styleExt = opts.scss ? "scss" : "css";
9
+ const folder = ids.kebab;
10
+ const selector = `app-${ids.kebab}`;
11
+ const className = `${ids.pascal}Component`;
12
+
13
+ const tsPath = `${folder}/${ids.kebab}.component.ts`;
14
+ const htmlPath = `${folder}/${ids.kebab}.component.html`;
15
+ const stylePath = `${folder}/${ids.kebab}.component.${styleExt}`;
16
+
17
+ const tsContent = `import { Component } from "@angular/core";
18
+
19
+ @Component({
20
+ selector: "${selector}",
21
+ standalone: true,
22
+ imports: [],
23
+ templateUrl: "./${ids.kebab}.component.html",
24
+ styleUrl: "./${ids.kebab}.component.${styleExt}",
25
+ })
26
+ export class ${className} {}
27
+ `;
28
+
29
+ const htmlContent = `<section class="${ids.kebab}">
30
+ <p>${ids.pascal}</p>
31
+ </section>
32
+ `;
33
+
34
+ const styleContent = `.${ids.kebab} {
35
+ }
36
+
37
+ `;
38
+
39
+ return {
40
+ [tsPath]: tsContent,
41
+ [htmlPath]: htmlContent,
42
+ [stylePath]: styleContent.trimEnd() + `\n`,
43
+ };
44
+ }
@@ -0,0 +1,42 @@
1
+ /** @typedef {{ pascal: string; kebab: string; camel: string; tag: string }} Identifiers */
2
+
3
+ import { angularFiles } from "./angular.js";
4
+ import { nextFiles } from "./next.js";
5
+ import { reactLikeFiles } from "./react-like.js";
6
+ import { svelteFiles } from "./svelte.js";
7
+ import { vueFiles } from "./vue.js";
8
+
9
+ export const FRAMEWORKS = /** @type {const} */ ([
10
+ "react",
11
+ "vue",
12
+ "angular",
13
+ "svelte",
14
+ "solid",
15
+ "next",
16
+ ]);
17
+
18
+ /**
19
+ * @param {typeof FRAMEWORKS[number]} fw
20
+ * @param {Identifiers} ids
21
+ * @param {{ typescript: boolean; scss?: boolean; client?: boolean }} opts
22
+ */
23
+ export function buildFiles(fw, ids, opts) {
24
+ switch (fw) {
25
+ case "react":
26
+ return reactLikeFiles(ids, { ...opts, solid: false });
27
+ case "solid":
28
+ return reactLikeFiles(ids, { ...opts, solid: true });
29
+ case "next":
30
+ return nextFiles(ids, opts);
31
+ case "vue":
32
+ return vueFiles(ids, opts);
33
+ case "angular":
34
+ return angularFiles(ids, opts);
35
+ case "svelte":
36
+ return svelteFiles(ids, opts);
37
+ default: {
38
+ const _exhaustive = fw;
39
+ throw new Error(`Unsupported framework: ${_exhaustive}`);
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,41 @@
1
+ /** @typedef {import("../lib/naming.js").Identifiers} Identifiers */
2
+
3
+ /** @param {boolean} typescript */
4
+ function extTs(typescript) {
5
+ return typescript ? "tsx" : "jsx";
6
+ }
7
+
8
+ /**
9
+ * @param {Identifiers} ids
10
+ * @param {{ typescript: boolean; client?: boolean }} opts
11
+ */
12
+ export function nextFiles(ids, opts) {
13
+ const ext = extTs(opts.typescript);
14
+ const fname = `${ids.pascal}.${ext}`;
15
+ let head = "";
16
+
17
+ if (opts.client) {
18
+ head = "'use client';\n\n";
19
+ }
20
+
21
+ if (opts.typescript) {
22
+ head += `import type { ReactNode } from "react";
23
+ `;
24
+ }
25
+
26
+ const nl = `
27
+ `;
28
+ const propsType = opts.typescript
29
+ ? `type ${ids.pascal}Props = {
30
+ children?: ReactNode;
31
+ };${nl}${nl}`
32
+ : "";
33
+
34
+ const param = opts.typescript ? `(props: ${ids.pascal}Props)` : "(props)";
35
+ const body = `{${nl} return <div>${ids.pascal}</div>;${nl}}`;
36
+
37
+ const content = `${head}${propsType}export function ${ids.pascal}${param}${body}${nl}${nl}export default ${ids.pascal};
38
+ `;
39
+
40
+ return { [fname]: content };
41
+ }
@@ -0,0 +1,76 @@
1
+ /** @typedef {import("../lib/naming.js").Identifiers} Identifiers */
2
+
3
+ /** @param {boolean} typescript */
4
+ function extTs(typescript) {
5
+ return typescript ? "tsx" : "jsx";
6
+ }
7
+
8
+ /**
9
+ * @param {Identifiers} ids
10
+ * @param {{ typescript: boolean; solid: boolean }} opts
11
+ */
12
+ export function reactLikeFiles(ids, opts) {
13
+ const ext = extTs(opts.typescript);
14
+ const fname = `${ids.pascal}.${ext}`;
15
+
16
+ if (opts.solid) {
17
+ return { [fname]: solidTemplate(ids, opts) };
18
+ }
19
+
20
+ return { [fname]: reactTemplate(ids, opts) };
21
+ }
22
+
23
+ /**
24
+ * @param {Identifiers} ids
25
+ * @param {{ typescript: boolean }} opts
26
+ */
27
+ function reactTemplate(ids, opts) {
28
+ const nl = `
29
+ `;
30
+ let head = "";
31
+
32
+ if (opts.typescript) {
33
+ head = `import type { ReactNode } from "react";
34
+ `;
35
+ }
36
+
37
+ const propsType = opts.typescript
38
+ ? `type ${ids.pascal}Props = {
39
+ children?: ReactNode;
40
+ };${nl}${nl}`
41
+ : "";
42
+
43
+ const param = opts.typescript ? `(props: ${ids.pascal}Props)` : "(props)";
44
+ const body = `{${nl} return <div>${ids.pascal}</div>;${nl}}`;
45
+
46
+ return `${head}${propsType}export function ${ids.pascal}${param}${body}${nl}${nl}export default ${ids.pascal};
47
+ `;
48
+ }
49
+
50
+ /**
51
+ * @param {Identifiers} ids
52
+ * @param {{ typescript: boolean }} opts
53
+ */
54
+ function solidTemplate(ids, opts) {
55
+ const nl = `
56
+ `;
57
+ let head = "";
58
+
59
+ if (opts.typescript) {
60
+ head = `import type { Component } from "solid-js";
61
+ `;
62
+ }
63
+
64
+ const propsType = opts.typescript ? `export type ${ids.pascal}Props = {};${nl}${nl}` : "";
65
+
66
+ const annotation = opts.typescript
67
+ ? `: Component<${ids.pascal}Props>`
68
+ : "";
69
+ const body = `{${nl} return <div>${ids.pascal}</div>;${nl}}`;
70
+
71
+ const exportLine = opts.typescript
72
+ ? `${head}${propsType}export const ${ids.pascal}${annotation} = (_props`
73
+ : `${head}export const ${ids.pascal} = (_props`;
74
+
75
+ return `${exportLine})${body}${nl}`;
76
+ }
@@ -0,0 +1,32 @@
1
+ /** @typedef {import("../lib/naming.js").Identifiers} Identifiers */
2
+
3
+ /**
4
+ * @param {Identifiers} ids
5
+ * @param {{ typescript: boolean }} opts
6
+ */
7
+ export function svelteFiles(ids, opts) {
8
+ const fname = `${ids.pascal}.svelte`;
9
+
10
+ const scriptTag = opts.typescript ? `<script lang="ts">` : `<script>`;
11
+ const nl = `\n`;
12
+
13
+ const propsLine = opts.typescript
14
+ ? ` export let label = "${ids.pascal}"`
15
+ : ` export let label = "${ids.pascal}"`;
16
+
17
+ const content = `${scriptTag}
18
+ ${propsLine};
19
+ </script>
20
+
21
+ <div class="${ids.kebab}">
22
+ {label}
23
+ </div>
24
+
25
+ <style>
26
+ .${ids.kebab} {
27
+ }
28
+ </style>
29
+ `;
30
+
31
+ return { [fname]: content };
32
+ }
@@ -0,0 +1,33 @@
1
+ /** @typedef {import("../lib/naming.js").Identifiers} Identifiers */
2
+
3
+ /**
4
+ * @param {Identifiers} ids
5
+ * @param {{ typescript: boolean }} opts
6
+ */
7
+ export function vueFiles(ids, opts) {
8
+ const fname = `${ids.pascal}.vue`;
9
+
10
+ const scriptOpen = opts.typescript ? '<script setup lang="ts">' : "<script setup>";
11
+ const nl = `\n`;
12
+
13
+ const content = `<template>
14
+ <div class="${ids.kebab}">
15
+ {{ message }}
16
+ </div>
17
+ </template>
18
+
19
+ ${scriptOpen}
20
+ import { ref } from "vue";
21
+
22
+ const message = ref("${ids.pascal}");
23
+ ${opts.typescript ? `// define typed props here${nl}` : ""}
24
+ </script>
25
+
26
+ <style scoped>
27
+ .${ids.kebab} {
28
+ }
29
+ </style>
30
+ `;
31
+
32
+ return { [fname]: content };
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { generateComponent } from "./commands/generate.js";
2
+ export { parseComponentName, toPascalCase } from "./lib/naming.js";
3
+ export { FRAMEWORKS } from "./generators/index.js";
package/src/lib/fs.js ADDED
@@ -0,0 +1,24 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { writeFile } from "node:fs/promises";
4
+
5
+ /**
6
+ * @param {string} cwd
7
+ * @param {Record<string, string>} files Relative path → content
8
+ */
9
+ export async function writeTree(cwd, files) {
10
+ const base = resolve(cwd);
11
+ const written = [];
12
+
13
+ for (const [relPath, content] of Object.entries(files)) {
14
+ const absolute = resolve(base, relPath);
15
+ if (!absolute.startsWith(base)) {
16
+ throw new Error(`Refused to write outside output directory: ${relPath}`);
17
+ }
18
+ await mkdir(dirname(absolute), { recursive: true });
19
+ await writeFile(absolute, content.endsWith("\n") ? content : `${content}\n`, "utf8");
20
+ written.push(absolute);
21
+ }
22
+
23
+ return written;
24
+ }
@@ -0,0 +1,64 @@
1
+ /** @param {string} raw User input component name */
2
+
3
+ export function parseComponentName(raw) {
4
+ const trimmed = String(raw ?? "").trim();
5
+ if (!trimmed) throw new Error("Component name is required.");
6
+
7
+ const pascal = toPascalCase(trimmed);
8
+ if (/^\d/.test(pascal)) {
9
+ throw new Error("Component name cannot start with a digit.");
10
+ }
11
+ const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
12
+ const kebab = toKebabCase(pascal);
13
+
14
+ return { pascal, kebab, camel, tag: kebab };
15
+ }
16
+
17
+ /** PascalCase-ish → kebab-case */
18
+ /** @param {string} input */
19
+ export function toKebabCase(input) {
20
+ return input
21
+ .replace(/([a-z\d])([A-Z])/g, "$1-$2")
22
+ .replace(/_/g, "-")
23
+ .toLowerCase();
24
+ }
25
+
26
+ /** @param {string} one Alphanumeric word possibly camelCase/PascalCase */
27
+ function splitWords(one) {
28
+ return one
29
+
30
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
31
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
32
+ .split(/[^\w$]+/)
33
+ .filter(Boolean);
34
+ }
35
+
36
+ /** @param {string} input */
37
+ export function toPascalCase(input) {
38
+ const raw = String(input).trim().replace(/^[^\w$]*|[^\w$]*$/g, "");
39
+ if (!raw) throw new Error(`Invalid component name: "${input}"`);
40
+
41
+ const segments = raw.split(/[^a-zA-Z0-9$]+/).filter(Boolean);
42
+
43
+ const words =
44
+ segments.length === 1
45
+ ? splitWords(segments[0])
46
+
47
+ : segments.flatMap(splitWords).filter(Boolean);
48
+
49
+ if (!words.length) throw new Error(`Invalid component name: "${input}"`);
50
+
51
+ const pascal = words.map(capitalizeSegment).join("");
52
+
53
+ return pascal;
54
+ }
55
+
56
+ /** @param {string} w */
57
+ function capitalizeSegment(w) {
58
+ const head = w.charAt(0).toUpperCase();
59
+ const tail = w.slice(1).toLowerCase();
60
+ const result = `${head}${tail}`;
61
+ const looksLikeAcronym = /^[A-Z]{2,}\d*$/.test(w);
62
+
63
+ return looksLikeAcronym ? w : result;
64
+ }