@jade-garden/cli
Version:
Generate CSS with Jade Garden and Tailwind CSS
560 lines (547 loc) • 21.7 kB
JavaScript
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