vueless
Version:
Vue Styleless UI Component Library, powered by Tailwind CSS.
322 lines (269 loc) • 12.2 kB
JavaScript
import path from "node:path";
import { cwd } from "node:process";
import { existsSync } from "node:fs";
import { readFile, unlink, writeFile, mkdir } from "node:fs/promises";
import { merge } from "lodash-es";
import { getDefaultComponentConfig, getMergedComponentConfig, getDirFiles } from "./helper.js";
import { getVuelessConfig } from "./vuelessConfig.js";
import {
COMPONENTS,
INTERNAL_ENV,
STORYBOOK_ENV,
STATE_COLORS,
COLOR_SHADES,
PRIMARY_COLORS,
NEUTRAL_COLORS,
SYSTEM_CONFIG_KEY,
DYNAMIC_COLOR_PATTERN,
VUELESS_TAILWIND_SAFELIST,
DEFAULT_LIGHT_THEME,
DEFAULT_DARK_THEME,
DEFAULT_PRIMARY_COLOR,
DEFAULT_NEUTRAL_COLOR,
} from "../../constants.js";
/**
* Removes the Tailwind CSS safelist file if it exists.
*
* This method checks for the presence of a pre-defined safelist file in the cache.
* If the file is found, it deletes the file asynchronously.
*
* @return {Promise<void>} A promise that resolves after the safelist file is removed
* or does nothing if the file does not exist.
*/
export async function clearTailwindSafelist() {
const safelistPath = path.join(cwd(), VUELESS_TAILWIND_SAFELIST);
if (existsSync(safelistPath)) {
await unlink(safelistPath);
}
}
/**
* Creates a TailwindCSS safelist file based on the given environment, source directory, and target files.
* The safelist includes classes and color variables essential for runtime color switching and component styles.
*
* @param {Object} options - The function options.
* @param {string} options.env - Current environment, used to tailor safelist generation.
* @param {string} options.srcDir - Source directory path containing files with styles to be included in the safelist.
* @param {string[]} [options.targetFiles=[]] - Optional array of target file paths to include in the safelist generation.
* @return {Promise<void>} A promise that resolves when the safelist file is successfully created and written to disk.
*/
export async function createTailwindSafelist({ env, srcDir, targetFiles, basePath } = {}) {
const isStorybookEnv = env === STORYBOOK_ENV;
const isInternalEnv = env === INTERNAL_ENV;
const vuelessConfig = await getVuelessConfig(basePath);
/* Safelist dynamic color classes in components. */
const classes = await getComponentsSafelistClasses(vuelessConfig, {
env: { isStorybookEnv, isInternalEnv },
targetFiles,
srcDir,
});
/* Safelist all color shades to allow runtime color switching feature. */
const runtimeColorCSSVariables = getRuntimeColorCSSVariables(
vuelessConfig,
isStorybookEnv,
isInternalEnv,
);
/* Safelist primary and neutral color variables. */
let brandColorCSSVariables = getBrandColorCSSVariables(vuelessConfig, isInternalEnv);
/* Safelist all color variables to allow runtime color switching feature. */
const themeCSSVariables = getThemeCSSVariables(vuelessConfig);
const safelist = [
...new Set([
...classes,
...themeCSSVariables,
...brandColorCSSVariables,
...runtimeColorCSSVariables,
]),
];
const safelistPath = path.join(cwd(), VUELESS_TAILWIND_SAFELIST);
const safelistDir = path.dirname(safelistPath);
/* Cache safelist into the file. */
await mkdir(safelistDir, { recursive: true });
await writeFile(safelistPath, safelist.join("\n"));
}
/**
* Generates a list of runtime CSS color variables based on the environment
* and available configuration.
*
* @param {object} vuelessConfig - The Vueless config object.
* @param {boolean} isStorybookEnv - Indicates if the method is executed in a Storybook environment.
* @param {boolean} isInternalEnv - Indicates if the method is executed in an Internal environment.
* @return {string[]} An array of strings, each representing CSS variable definitions for colors and shades.
*/
function getRuntimeColorCSSVariables(vuelessConfig, isStorybookEnv, isInternalEnv) {
if (!isStorybookEnv && !isInternalEnv && !vuelessConfig.runtimeColors) return [];
const colors = vuelessConfig.runtimeColors?.length
? vuelessConfig.runtimeColors
: [...PRIMARY_COLORS, ...NEUTRAL_COLORS];
return COLOR_SHADES.map((shade) => {
return colors.map((color) => `--color-${color}-${shade}`).join("\n");
});
}
/**
* Generates an array of CSS variable strings for `primary` and `neutral` colors based on environment settings.
*
* @param {object} vuelessConfig - The Vueless config object.
* @param {boolean} isInternalEnv - Indicates whether the current environment is internal.
* @return {string[]} An array of CSS variable strings representing brand colors and their shades.
*/
function getBrandColorCSSVariables(vuelessConfig, isInternalEnv) {
if (isInternalEnv) return [];
const colors = [
vuelessConfig.primary ?? DEFAULT_PRIMARY_COLOR,
vuelessConfig.neutral ?? DEFAULT_NEUTRAL_COLOR,
];
return COLOR_SHADES.map((shade) => {
return colors.map((color) => `--color-${color}-${shade}`).join("\n");
});
}
/**
* Retrieves an array of CSS variable values from the light and dark theme configurations.
*
* This method combines theme settings from default and custom configurations and
* returns an array of variable values for each theme.
*
* @param {object} vuelessConfig - The Vueless config object.
* @return {Array} An array containing CSS variable values from the light and dark themes.
*/
function getThemeCSSVariables(vuelessConfig) {
const lightThemeConfig = merge({}, DEFAULT_LIGHT_THEME, vuelessConfig.lightTheme);
const darkThemeConfig = merge({}, DEFAULT_DARK_THEME, vuelessConfig.darkTheme);
return [
...Object.values(lightThemeConfig).map((value) => value),
...Object.values(darkThemeConfig).map((value) => value),
];
}
/**
* Asynchronously retrieves the safelist classes for components and nested components.
* This involves reading configuration files, merging configurations, and generating safelisted
* CSS classes based on component usage, environment conditions, and configured colors.
*
* @param {Object} params - The parameters for the function.
* @param {object} vuelessConfig - The Vueless config object.
* @param {Array<string>} params.targetFiles - List of file paths to check for component usage.
* @param {Object} params.env - Environment variables for distinguishing between different environments.
* @param {boolean} params.env.isStorybookEnv - Indicates whether the function is running in a Storybook environment.
* @param {boolean} params.env.isInternalEnv - Indicates whether the function is running in an Internal environment.
* @param {string} params.srcDir - The source directory path to search for configuration files.
*
* @return {Promise<Array<string>>} A promise that resolves to an array of safelisted CSS class names
* for all matched components and nested components.
*/
async function getComponentsSafelistClasses(vuelessConfig, { targetFiles = [], env, srcDir }) {
const { isStorybookEnv, isInternalEnv } = env;
let srcVueFiles = [];
const vuelessVueFiles = await getDirFiles(srcDir, ".vue");
if (!isInternalEnv) {
const vueFiles = targetFiles.map((componentPath) => getDirFiles(componentPath, ".vue"));
srcVueFiles = (await Promise.all(vueFiles)).flat();
}
const files = [...srcVueFiles, ...vuelessVueFiles];
const vuelessConfigFiles = [
...(await getDirFiles(srcDir, "/config.ts")),
...(await getDirFiles(srcDir, "/config.js")),
].flat();
const classes = [];
const colors = vuelessConfig.colors?.length ? vuelessConfig.colors : STATE_COLORS;
const componentNames = Object.keys(COMPONENTS);
for await (const componentName of componentNames) {
const isCurrentComponentUsed = await isComponentUsed(componentName, files);
const defaultConfig = await retrieveComponentDefaultConfig(componentName, vuelessConfigFiles);
const match = JSON.stringify(defaultConfig).match(/\{U\w+\}/g) || [];
/* parent component */
if (isCurrentComponentUsed || isStorybookEnv || isInternalEnv) {
const mergedConfig = await getMergedComponentConfig(componentName);
const componentSafelistClasses = getSafelistClasses(mergedConfig, colors);
classes.push(...componentSafelistClasses);
}
/* nested components */
const nestedComponents = match.map((nestedComponentPattern) =>
nestedComponentPattern.replaceAll(/[{}]/g, ""),
);
if ((isCurrentComponentUsed || isStorybookEnv || isInternalEnv) && nestedComponents.length) {
for await (const nestedComponent of nestedComponents) {
const mergedConfig = await getMergedComponentConfig(nestedComponent);
const nestedComponentSafelistClasses = getSafelistClasses(mergedConfig, colors);
classes.push(...nestedComponentSafelistClasses);
}
}
}
return classes;
}
/**
* Extracts and consolidates a safelist of classes from the provided configuration object.
*
* @param {Object} config - The configuration object containing classes to safelist.
* @return {string[]} An array of safelisted class names after processing the configuration.
*/
function getClassesToSafelist(config) {
const safelistItems = [];
for (const key in config) {
if (key === SYSTEM_CONFIG_KEY.defaults) continue;
if (Object.hasOwn(config, key)) {
const classes = config[key];
if (typeof classes === "object" && Array.isArray(classes)) {
safelistItems.push(...classes.map(getClassesToSafelist));
}
if (typeof classes === "object" && !Array.isArray(classes)) {
safelistItems.push(...getClassesToSafelist(classes));
}
if (typeof classes === "string") {
safelistItems.push(
...classes.split(" ").filter((classItem) => classItem.includes(DYNAMIC_COLOR_PATTERN)),
);
}
}
}
return safelistItems.flat().map((item) => item.replaceAll("\\n", "").trim());
}
/**
* Generates a set of safelisted CSS classes by replacing dynamic color patterns
* in the provided safelist with specified colors and a default color.
*
* @param {Object} config - The configuration object, which contains settings and defaults.
* @param {Array<string>} colors - An array of color strings to replace dynamic color patterns.
* @return {Set<string>} A set of safelisted CSS classes with replaced dynamic colors.
*/
function getSafelistClasses(config, colors) {
const classes = new Set();
const defaultColor = config.defaults?.color || "";
getClassesToSafelist(config).map((safelistClass) => {
[...colors, defaultColor].forEach((color) => {
classes.add(safelistClass.replace(DYNAMIC_COLOR_PATTERN, color));
});
});
return classes;
}
/**
* Retrieves the default config for a specific component.
*
* @param {string} componentName - The name of the component.
* @param {string[]} vuelessConfigFiles - An array of file paths to search for the component's configuration file.
* @return {Promise<Object>} A promise that resolves to the component's default configuration object.
*/
async function retrieveComponentDefaultConfig(componentName, vuelessConfigFiles) {
const configPath = vuelessConfigFiles.find((filePath) => {
return filePath.includes(`${COMPONENTS[componentName]}/`);
});
return await getDefaultComponentConfig(componentName, configPath);
}
/**
* Checks if a specific component is used within a collection of files.
*
* @param {string} componentName - The name of the component to search for.
* @param {string[]} files - An array of file paths to search for the component usage.
* @return {Promise<boolean>} A promise that resolves to true if the component is used in any of the files, otherwise false.
*/
async function isComponentUsed(componentName, files) {
let isComponentUsed = false;
for await (const file of files) {
if (!existsSync(file)) continue;
const fileContent = await readFile(file, "utf-8");
const componentRegExp = new RegExp(`<${componentName}[^>]+>`, "g");
const matchedComponent = fileContent.match(componentRegExp);
if (!isComponentUsed && matchedComponent) {
isComponentUsed = Boolean(matchedComponent);
break;
}
}
return isComponentUsed;
}