UNPKG

@jade-garden/cli

Version:

Generate CSS with Jade Garden and Tailwind CSS

560 lines (547 loc) 21.7 kB
#!/usr/bin/env node import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { cancel, intro, log, outro } from "@clack/prompts"; import { compile } from "tailwindcss"; import { cx } from "jade-garden"; import babelPresetTypeScript from "@babel/preset-typescript"; import { loadConfig } from "c12"; import parseArgs from "minimist"; //#region src/utils/logs.ts const p = process || {}, argv = p.argv || [], env = p.env || {}; const isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || p.stdout?.isTTY && env.TERM !== "dumb" || !!env.CI); const formatter = (open, close, replace = open) => (input) => { let string = `${input}`, index = string.indexOf(close, open.length); return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close; }; const replaceClose = (string, close, replace, index) => { let result = "", cursor = 0; do { result += string.substring(cursor, index) + replace; cursor = index + close.length; index = string.indexOf(close, cursor); } while (~index); return result + string.substring(cursor); }; const f = isColorSupported ? formatter : () => String; const bold = f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"); const bgBlack = f("\x1B[40m", "\x1B[49m"); const redBright = f("\x1B[91m", "\x1B[39m"); const greenBright = f("\x1B[92m", "\x1B[39m"); const yellowBright = f("\x1B[93m", "\x1B[39m"); const cyanBright = f("\x1B[96m", "\x1B[39m"); const error = (message, useLog = true) => { const msg = bgBlack(redBright(` ${useLog ? `${bold("[ERROR]")}: ` : ""}${message} `)); if (!useLog) return msg; log.info(msg); }; const info = (message, useLog = true) => { const msg = bgBlack(cyanBright(` ${useLog ? `${bold("[INFO]")}: ` : ""}${message} `)); if (!useLog) return msg; log.info(msg); }; const INFO = { ConfigNameConflict(componentName, componentDir) { info(`Duplicate "name" property detected. Renaming "${componentName}" in "${componentDir}[${componentName}]".`); } }; const success = (message, useLog = true) => { const msg = ` ${bgBlack(greenBright(message))} `; if (!useLog) return msg; log.success(msg); }; const warning = (message, useLog = true) => { const msg = bgBlack(yellowBright(` ${useLog ? `${bold("[WARN]")}: ` : ""}${message} `)); if (!useLog) return msg; log.warn(msg); }; const WARNING = { NoBaseOrSlots(componentName, componentDir) { warning(`The style configuration in "${componentDir}[${componentName}]" requires a "base" and/or "slots" property.`); }, NoConfig() { warning("Could not detect your config. Specifiy a relative path with the '--config' flag, default export your config, and set `components`."); }, NoName(componentDir) { warning(`A style configuration in ${componentDir} requires a "name" property to output file.`); }, NotArray(componentDir) { warning(`The value in "components.${componentDir}" is not an array.`); }, ReservedDirKeyword(componentDir) { warning(`Key "${componentDir}" in "components" is a reserved keyword.`); }, ReservedNameKeyword(componentName, componentDir) { warning(`"${componentName}" in "components.${componentDir}" is a reserved keyword.`); }, StyleNameConflict(componentName, componentDir) { warning(`Duplicate "name" property detected. Rename "${componentName}" in "${componentDir}[${componentName}]" to output file.`); } }; const logs = { error, info, success, warning }; //#endregion //#region src/utils/comments.ts const chunkStr = (str, maxLen = 80) => { const strArr = str.replace(/[\r\n]+/g, " ").split(" ").filter(Boolean); const chunks = []; let chunk = []; let charLen = 0; for (const str$1 of strArr) if (charLen < maxLen) { charLen += str$1.length; chunk.push(str$1); } else { charLen = 0; chunks.push(chunk); chunk = []; } if (chunk.length > 0) chunks.push(chunk); return chunks; }; const comments = (params) => { const { firstComment, lastComment, metaConfig } = params; const keys = [ "name", "version", "description", "deprecated", "see", "license" ]; let result = ""; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (!(key in metaConfig)) continue; const value = metaConfig[key]; if (typeof value === "string" || typeof value === "boolean") { const startsWithoutWrite = !result.length; if (startsWithoutWrite) result += firstComment; result += `${i > 0 && !startsWithoutWrite ? " *\n" : ""} * @${key}\n`; if (typeof value === "string") { const chunks = chunkStr(value); for (const chunk of chunks) result += ` * ${chunk.join(" ")}\n`; } } } result += !result.length ? "" : lastComment; return result; }; const generateComments = (params) => { const { rawConfig, type, isComponent = false } = params; const metaConfig = { deprecated: isComponent ? rawConfig?.deprecated ?? void 0 : void 0, description: rawConfig?.description ?? void 0, license: !isComponent ? rawConfig?.license ?? void 0 : void 0, name: rawConfig?.name ?? void 0, see: rawConfig?.see ?? void 0, version: !isComponent ? rawConfig?.version ?? void 0 : void 0 }; return comments({ firstComment: type === "config" ? "/**\n" : "/* -----------------------------------------------------------------------------\n", lastComment: type === "config" ? " */\n" : " * -----------------------------------------------------------------------------*/\n", metaConfig }); }; //#endregion //#region src/utils/index.ts const cancelBuild = () => { cancel(logs.warning("Canceling.", false)); process.exit(0); }; /** * **The `kebabCase` function taken from [es-toolkit](https://github.com/toss/es-toolkit/blob/main/src/string/kebabCase.ts)**. * Converts a string to kebab case. * * @param {string} str - The string to convert. * @returns {string} The kebab-cased string. */ const kebabCase = (str) => { const words = Array.from(str.match(/\p{Lu}?\p{Ll}+|[0-9]+|\p{Lu}+(?!\p{Ll})|\p{Emoji_Presentation}|\p{Extended_Pictographic}|\p{L}+/gu) ?? []); return words.map((word) => word.toLowerCase()).join("-"); }; //#endregion //#region src/generators/config.ts const generateConfig = (params) => { const { componentMetaConfig, styleConfig, type } = params; const importStatement = `import { ${type} } from "../utils";`; const exportStatement = `export const ${styleConfig.name} = ${type}(${JSON.stringify(styleConfig, null, 2)});\n`; return `${importStatement}\n\n${generateComments({ rawConfig: componentMetaConfig, type: "config", isComponent: true })}${exportStatement}`; }; const writeConfigs = (options, outDirPath) => { const { components, configOutput, metaConfig, silent } = options; const dirs = []; for (const componentDir of Object.keys(components)) { if (componentDir === "index" || componentDir === "utils") { if (!silent) WARNING.ReservedDirKeyword(componentDir); continue; } const configsArr = components[componentDir]; if (!configsArr || !Array.isArray(configsArr)) { if (!silent) WARNING.NotArray(componentDir); continue; } const outDirWrite = `${outDirPath}/${componentDir}`; if (!existsSync(outDirWrite)) mkdirSync(outDirWrite, { recursive: true }); const configNames = {}; let indexFile = ""; const exportComponent = []; let exportsFile = ""; for (const { metaConfig: componentMetaConfig, styleConfig } of configsArr) { const { name: componentName } = styleConfig; if (!componentName) { if (!silent) WARNING.NoName(componentDir); continue; } else if (componentName === "exports" || componentName === "index") { if (!silent) WARNING.ReservedNameKeyword(componentName, componentDir); continue; } let fileName = kebabCase(componentName); if (Object.hasOwn(configNames, componentName)) { const newFileName = `${fileName}-${configNames[componentName]}`; if (!silent) INFO.ConfigNameConflict(componentName, componentDir); styleConfig.name = newFileName; fileName = newFileName; configNames[componentName] += 1; } else configNames[componentName] = 1; const outFile = `${outDirWrite}/${fileName}.${configOutput}`; if ("slots" in styleConfig) { exportsFile += `export { ${componentName} } from "./${fileName}";\n`; indexFile += `import { ${componentName} } from "./${fileName}";\n`; writeFileSync(outFile, generateConfig({ componentMetaConfig, styleConfig, type: "sva" })); exportComponent.push(componentName); } else if ("base" in styleConfig) { exportsFile += `export { ${componentName} } from "./${fileName}";\n`; indexFile += `import { ${componentName} } from "./${fileName}";\n`; writeFileSync(outFile, generateConfig({ componentMetaConfig, styleConfig, type: "cva" })); exportComponent.push(componentName); } else if (!silent) WARNING.NoBaseOrSlots(componentName, componentDir); } if (indexFile) { writeFileSync(`${outDirWrite}/index.${configOutput}`, `${indexFile}\nexport const ${componentDir} = [\n ${exportComponent.join(",\n ")}\n];\n`); writeFileSync(`${outDirWrite}/exports.${configOutput}`, exportsFile); dirs.push(componentDir); } else rmSync(outDirWrite, { recursive: true }); } if (dirs.length > 0) { writeFileSync(`${outDirPath}/index.${configOutput}`, `${generateComments({ rawConfig: metaConfig, type: "config" })}${dirs.reduce((state, dir) => { state += `import { ${dir} } from "./${dir}";\n`; return state; }, "")} // For convenience, this exports all \`components\` for use in \`unplugin-jade-garden\`. export const jgComponents = { ${dirs.join(",\n ")} }; // Uncomment the code below if you want to export \`components\` individually. ${dirs.reduce((state, dir) => { state += `// export * from "./${dir}/exports";\n`; return state; }, "")} `); writeFileSync(`${outDirPath}/utils.${configOutput}`, `/* ----------------------------------------------------------------------------- * Jade Garden 🌿 * * \`cva\` and \`sva\` functions are exported by default. * Uncomment the code below if your class names require modifications. * -----------------------------------------------------------------------------*/ export { cva, sva } from "jade-garden"; // import { createCVA, createSVA } from "jade-garden"; // export const cva = createCVA(); // export const sva = createSVA(); `); } }; //#endregion //#region src/generators/stylesheet/cva.ts const generateCVAStylesheet = (styleConfig, createOptions) => { const mergeFn = createOptions?.mergeFn ?? cx; const prefix = createOptions?.prefix; const componentName = `${prefix ? `${prefix}\\:` : ""}${kebabCase(styleConfig.name)}`; let cssOutput = ""; if (styleConfig.base) { const applyRules = mergeFn(styleConfig.base); cssOutput = ` /* Base */\n .${componentName} {\n @apply ${applyRules};\n }`; } if (styleConfig.compoundVariants) { let isFirst = true; for (const compoundVariant of styleConfig.compoundVariants) { const variantConditions = Object.keys(compoundVariant).filter((key) => key !== "class" && key !== "className").map((key) => { return `.${componentName}.${componentName}__${kebabCase(key)}--${kebabCase(compoundVariant[key])}`; }).join(",\n "); const applyRules = mergeFn(compoundVariant.class, compoundVariant.className); if (variantConditions && applyRules) { cssOutput += `${cssOutput.length ? "\n\n" : ""}${isFirst ? " /* Compound Variants */\n " : " "}${variantConditions} {\n @apply ${applyRules};\n }`; if (isFirst) isFirst = false; } } } if (styleConfig.variants) { let isFirst = true; for (const variantName in styleConfig.variants) { const variantTypes = styleConfig.variants[variantName]; for (const variantType in variantTypes) { const applyRules = mergeFn(variantTypes[variantType]); if (applyRules) { cssOutput += `${cssOutput.length ? "\n\n" : ""}${isFirst ? " /* Variants */\n " : " "}.${componentName}.${componentName}__${kebabCase(variantName)}--${kebabCase(variantType)} {\n @apply ${applyRules};\n }`; if (isFirst) isFirst = false; } } } } return `@layer components {\n${cssOutput}\n}\n`; }; //#endregion //#region src/generators/stylesheet/sva.ts const generateSVAStylesheet = (styleConfig, createOptions) => { const mergeFn = createOptions?.mergeFn ?? cx; const prefix = createOptions?.prefix; const componentName = `${prefix ? `${prefix}\\:` : ""}${kebabCase(styleConfig.name)}`; let cssOutput = ""; if (Array.isArray(styleConfig.compoundSlots)) { let isFirst = true; for (const compoundSlot of styleConfig.compoundSlots) { const variantConditions = Object.keys(compoundSlot).filter((key) => key !== "slots" && key !== "class" && key !== "className").map((key) => `__${kebabCase(key)}--${kebabCase(compoundSlot[key])}`).join(""); const combinedSelectors = compoundSlot.slots.map((slot) => `.${componentName}--${kebabCase(String(slot))}${variantConditions}`).join(",\n "); const applyRules = mergeFn(compoundSlot.class, compoundSlot.className); if (applyRules) { cssOutput += `${!cssOutput.length ? "" : "\n\n"}${isFirst ? " /* Compound Slots */\n " : " "}${combinedSelectors} {\n @apply ${applyRules};\n }`; if (isFirst) isFirst = false; } } } if (Array.isArray(styleConfig.slots) && typeof styleConfig.base === "object" && !Array.isArray(styleConfig.base)) { let isFirst = true; for (const slot of styleConfig.slots) { if (!(slot in styleConfig.base)) continue; const applyRules = mergeFn(styleConfig.base[slot]); if (applyRules) { cssOutput += `${!cssOutput.length ? "" : "\n\n"}${isFirst ? " /* Slots */\n " : " "}.${componentName}--${kebabCase(slot)} {\n @apply ${applyRules};\n }`; if (isFirst) isFirst = false; } } } if (Array.isArray(styleConfig.compoundVariants)) { let isFirst = true; for (const compoundVariant of styleConfig.compoundVariants) for (const slot of styleConfig.slots) if (compoundVariant.class?.[slot] || compoundVariant.className?.[slot]) { const componentSlot = `.${componentName}--${kebabCase(slot)}`; const variantConditions = Object.keys(compoundVariant).filter((key) => key !== "class" && key !== "className" && key !== slot).map((key) => `${componentSlot}__${kebabCase(key)}--${kebabCase(compoundVariant[key])}`).join(""); const applyRules = mergeFn(compoundVariant.class?.[slot], compoundVariant.className?.[slot]); if (applyRules) { cssOutput += `${!cssOutput.length ? "" : "\n\n"}${isFirst ? " /* Compound Variants */\n " : " "}${componentSlot}${variantConditions} {\n @apply ${applyRules};\n }`; if (isFirst) isFirst = false; } } } if (typeof styleConfig.variants === "object" && !Array.isArray(styleConfig.variants)) { let isFirst = true; for (const variantName in styleConfig.variants) { const variantTypes = styleConfig.variants[variantName]; for (const variantType in variantTypes) { const slots = variantTypes[variantType]; for (const slot in slots) { const applyRules = mergeFn(slots[slot]); if (applyRules) { const componentSlot = `.${componentName}--${kebabCase(slot)}`; cssOutput += `${!cssOutput.length ? "" : "\n\n"}${isFirst ? " /* Slot Variants */\n " : " "}${componentSlot}${componentSlot}__${kebabCase(variantName)}--${kebabCase(variantType)} {\n @apply ${applyRules};\n }`; } } } } } return `@layer components {\n${cssOutput}\n}\n`; }; //#endregion //#region src/generators/stylesheet/index.ts const writeStylesheets = async (options, outDirPath) => { const { compile: compile$1, components, createOptions, entry, metaConfig, silent } = options; let writtenDirs = ""; for (const componentDir of Object.keys(components)) { if (componentDir === "index" || componentDir === "utils") { if (!silent) WARNING.ReservedDirKeyword(componentDir); continue; } const configsArr = components[componentDir]; if (!configsArr || !Array.isArray(configsArr)) { if (!silent) WARNING.NotArray(componentDir); continue; } const outDirWrite = `${outDirPath}/${componentDir}`; if (!existsSync(outDirWrite)) mkdirSync(outDirWrite, { recursive: true }); const configNames = {}; let indexFile = ""; for (const { metaConfig: componentMetaConfig, styleConfig } of configsArr) { const { name: componentName } = styleConfig; if (!componentName) { if (!silent) WARNING.NoName(componentDir); continue; } else if (componentName === "exports" || componentName === "index") { if (!silent) WARNING.ReservedNameKeyword(componentName, componentDir); continue; } const fileName = kebabCase(componentName); if (Object.hasOwn(configNames, componentName)) { if (!silent) WARNING.StyleNameConflict(componentName, componentDir); configNames[componentName] += 1; continue; } else configNames[componentName] = 1; let fileToWrite = ""; const outFile = `${outDirWrite}/${fileName}.css`; if ("slots" in styleConfig) { indexFile += `@import "./${fileName}.css";\n`; fileToWrite = generateSVAStylesheet(styleConfig, createOptions); } else if ("base" in styleConfig) { indexFile += `@import "./${fileName}.css";\n`; fileToWrite = generateCVAStylesheet(styleConfig, createOptions); } else if (!silent) WARNING.NoBaseOrSlots(componentName, componentDir); if (fileToWrite) { const comments$1 = generateComments({ rawConfig: componentMetaConfig, type: "stylesheet", isComponent: true }); if (compile$1) try { const tailwindFile = readFileSync(entry, { encoding: "utf-8" }); const { build: buildStyles } = await compile(`${tailwindFile}${fileToWrite}`); writeFileSync(outFile, `${comments$1}${buildStyles([])}`); } catch { writeFileSync(outFile, `${comments$1}${fileToWrite}`); } else writeFileSync(outFile, `${comments$1}${fileToWrite}`); } } if (indexFile) { writeFileSync(`${outDirWrite}/index.css`, indexFile); writtenDirs += `@import "./${componentDir}/index.css";\n`; } else rmSync(outDirWrite, { recursive: true }); } if (writtenDirs) writeFileSync(`${outDirPath}/index.css`, `${generateComments({ rawConfig: metaConfig, type: "stylesheet" })}${writtenDirs}`); }; //#endregion //#region src/utils/get-config.ts let possiblePaths = [ "jade-garden.config.js", "jade-garden.config.ts", "jade-garden.js", "jade-garden.ts", "jade.config.js", "jade.config.ts", "jade.js", "jade.ts" ]; possiblePaths = [ ...possiblePaths, ...possiblePaths.map((it) => `configs/${it}`), ...possiblePaths.map((it) => `styles/${it}`), ...possiblePaths.map((it) => `css/${it}`), ...possiblePaths.map((it) => `lib/${it}`) ]; possiblePaths = [ ...possiblePaths, ...possiblePaths.map((it) => `src/${it}`), ...possiblePaths.map((it) => `app/${it}`) ]; const loadConfig$1 = async (configFilePath) => { const { config } = await loadConfig({ configFile: configFilePath, jitiOptions: { transformOptions: { babel: { presets: [[babelPresetTypeScript, { allExtensions: true }]] } }, extensions: [".ts", ".js"] } }); return config; }; const getConfig = async () => { try { let rawConfig = null; const configPath = parseArgs(process.argv.slice(2))?.config; if (configPath) { if (typeof configPath === "string") { const config = await loadConfig$1(configPath); if (!("components" in config)) { logs.warning(`Couldn't read your config in "${configPath}". Make sure to default export your config and set \`components\`.`); cancelBuild(); } rawConfig = config; } else if (Array.isArray(configPath) && !configPath.length) { logs.warning("Specifiy only one relative path with the '--config' flag"); cancelBuild(); } } else { for (const possiblePath of possiblePaths) { const config = await loadConfig$1(possiblePath); if ("components" in config) { rawConfig = config; break; } } if (rawConfig === null || !("components" in rawConfig)) { WARNING.NoConfig(); cancelBuild(); } } return rawConfig; } catch { logs.error("There was an issue attempting to read your config."); cancelBuild(); } }; //#endregion //#region src/index.ts process.on("SIGINT", cancelBuild); process.on("SIGTERM", cancelBuild); const main = async () => { console.clear(); intro(logs.success("Jade Garden 🌿", false)); const rawConfig = await getConfig(); if (!rawConfig) { WARNING.NoConfig(); cancelBuild(); } const config = { clean: rawConfig?.clean ?? false, compile: rawConfig?.compile ?? false, components: rawConfig?.components ?? {}, configOutput: rawConfig?.configOutput ?? "ts", createOptions: rawConfig?.createOptions ?? {}, entry: rawConfig?.entry ?? process.cwd(), metaConfig: rawConfig?.metaConfig ?? {}, outDir: rawConfig?.outDir ?? `${process.cwd()}/jade-garden`, silent: rawConfig?.silent ?? false }; if (typeof config.components !== "object" || Array.isArray(config.components)) { if (!config.silent) logs.warning("`components` must be a object."); cancelBuild(); } const { clean, createOptions } = config; const useStylesheet = createOptions.useStylesheet ?? false; const outDirPath = join(config.outDir); if (clean && existsSync(outDirPath)) rmSync(outDirPath, { recursive: true }); if (useStylesheet) writeStylesheets(config, outDirPath); else writeConfigs(config, outDirPath); if (!config.silent) logs.success(`Complete! ${useStylesheet ? "Stylesheets" : "Configs"} have been successfully generated.`); outro(logs.success("Excited to see what you grow 🌱", false)); }; main().catch((error$1) => { logs.error(`Error running @jade-garden/cli:\n${error$1}`); process.exit(1); }); //#endregion