create-hokage-js-app
Version:
🔥 Best CLI tool to create a MERN stack template. Quick, clean, and customizable.
451 lines (398 loc) • 12.8 kB
JavaScript
#!/usr/bin/env node
/**
* Apply shadcn/ui overlay onto copied Tailwind templates.
*/
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 shadcnRoot = path.join(root, 'templates', 'shadcn-ui');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function writeFile(filePath, contents) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, contents);
}
function removeQuiet(filePath) {
try {
fs.unlinkSync(filePath);
} catch {
// ignore
}
}
const TAILWIND_CONFIG = `import animate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
animation: {
gradient: "gradient 15s ease infinite",
float: "float 15s infinite ease-in-out",
"logo-spin": "logo-spin 20s linear infinite",
"fade-in": "fadeIn 0.5s ease-out",
"pulse-slow": "pulse 2s infinite",
},
keyframes: {
gradient: {
"0%, 100%": { backgroundPosition: "0% 50%" },
"50%": { backgroundPosition: "100% 50%" },
},
},
},
},
plugins: [animate],
};
`;
const APP_CSS = `@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--radius: 0.5rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes float {
0% { transform: translateY(0) translateX(0) rotate(0deg); }
25% { transform: translateY(-20px) translateX(15px) rotate(5deg); }
50% { transform: translateY(-40px) translateX(0) rotate(0deg); }
75% { transform: translateY(-20px) translateX(-15px) rotate(-5deg); }
100% { transform: translateY(0) translateX(0) rotate(0deg); }
}
@keyframes logo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { opacity: 0.8; }
50% { opacity: 1; }
100% { opacity: 0.8; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`;
const UTILS_TS = `import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
`;
const UTILS_JS = `import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
`;
const BUTTON_TSX = `import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
`;
const BUTTON_JSX = `import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
`;
function componentsJson(tsx) {
return `${JSON.stringify(
{
$schema: 'https://ui.shadcn.com/schema.json',
style: 'new-york',
rsc: false,
tsx,
tailwind: {
config: 'tailwind.config.js',
css: 'src/styles/App.css',
baseColor: 'slate',
cssVariables: true,
prefix: '',
},
aliases: {
components: '@/components',
utils: '@/lib/utils',
ui: '@/components/ui',
lib: '@/lib',
hooks: '@/hooks',
},
iconLibrary: 'lucide',
},
null,
2,
)}\n`;
}
function viteConfig(useSwc) {
const reactImport = useSwc
? "import react from '@vitejs/plugin-react-swc'"
: "import react from '@vitejs/plugin-react'";
return `import path from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
${reactImport}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
`;
}
const JSCONFIG = `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
`;
function patchTsconfigPaths(filePath) {
if (!fs.existsSync(filePath)) return;
let source = fs.readFileSync(filePath, 'utf8');
if (source.includes('"@/*"')) return;
if (/\"compilerOptions\"\s*:\s*\{/.test(source)) {
source = source.replace(
/(\"compilerOptions\"\s*:\s*\{)/,
`$1\n "baseUrl": ".",\n "paths": {\n "@/*": ["./src/*"]\n },`,
);
} else {
source = source.replace(
/\{\s*/,
`{\n "compilerOptions": {\n "baseUrl": ".",\n "paths": {\n "@/*": ["./src/*"]\n }\n },\n `,
);
}
fs.writeFileSync(filePath, source);
}
function patchLoginSubmit(filePath, isTs) {
if (!fs.existsSync(filePath)) return;
let source = fs.readFileSync(filePath, 'utf8');
if (source.includes('@/components/ui/button')) return;
const importLine = isTs
? "import { Button } from '@/components/ui/button';\n"
: "import { Button } from '@/components/ui/button';\n";
if (!source.includes(importLine.trim())) {
source = source.replace(
"import axios from 'axios';",
`import axios from 'axios';\n${importLine.trim()}`,
);
}
source = source.replace(
/<button[\s\S]*?type="submit"[\s\S]*?>\s*\{isLoading \? 'Processing\.\.\.' : 'SIGN IN'\}\s*<\/button>/,
`<Button type="submit" className="w-[350px]" disabled={isLoading}>
{isLoading ? 'Processing...' : 'SIGN IN'}
</Button>`,
);
fs.writeFileSync(filePath, source);
}
function isJsFrontend(templateName) {
return templateName === 'js-template' || templateName === 'js-frontend-ts-backend';
}
async function applyTemplate(templateName) {
const clientDir = path.join(shadcnRoot, templateName, 'client');
const jsFrontend = isJsFrontend(templateName);
const useSwc = jsFrontend;
writeFile(path.join(clientDir, 'tailwind.config.js'), TAILWIND_CONFIG);
writeFile(path.join(clientDir, 'src', 'styles', 'App.css'), APP_CSS);
writeFile(path.join(clientDir, 'components.json'), componentsJson(!jsFrontend));
writeFile(
path.join(clientDir, jsFrontend ? 'vite.config.js' : 'vite.config.ts'),
viteConfig(useSwc),
);
ensureDir(path.join(clientDir, 'src', 'lib'));
ensureDir(path.join(clientDir, 'src', 'components', 'ui'));
if (jsFrontend) {
writeFile(path.join(clientDir, 'src', 'lib', 'utils.js'), UTILS_JS);
writeFile(path.join(clientDir, 'src', 'components', 'ui', 'button.jsx'), BUTTON_JSX);
writeFile(path.join(clientDir, 'jsconfig.json'), JSCONFIG);
patchLoginSubmit(path.join(clientDir, 'src', 'pages', 'Login.jsx'), false);
removeQuiet(path.join(clientDir, 'vite.config.ts'));
} else {
writeFile(path.join(clientDir, 'src', 'lib', 'utils.ts'), UTILS_TS);
writeFile(path.join(clientDir, 'src', 'components', 'ui', 'button.tsx'), BUTTON_TSX);
patchTsconfigPaths(path.join(clientDir, 'tsconfig.json'));
patchTsconfigPaths(path.join(clientDir, 'tsconfig.app.json'));
patchLoginSubmit(path.join(clientDir, 'src', 'pages', 'Login.tsx'), true);
}
}
const templates = [
'js-template',
'ts-template',
'js-frontend-ts-backend',
'ts-frontend-js-backend',
];
for (const name of templates) {
await applyTemplate(name);
console.log(`patched ${name}`);
}