@voilajsx/uikit
Version:
Cross-platform React components with beautiful themes and OKLCH color science
419 lines (356 loc) ⢠10.8 kB
JavaScript
/**
* @fileoverview voila-bundle CLI - Theme bundling tool
* @description Bundles multiple theme presets into unified CSS for consumer projects
* @package @voilajsx/uikit
* @file /bin/theme-bundler.js
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create require function for loading CommonJS modules
const require = createRequire(import.meta.url);
// Consumer project theme directories to search
const THEME_SEARCH_PATHS = [
'src/themes',
'src/themes/presets',
'themes',
'themes/presets',
'src/styles/themes',
'styles/themes',
];
// CLI Arguments
const args = process.argv.slice(2);
const outputFile =
args.find((arg) => arg.startsWith('--output='))?.split('=')[1] ||
'src/themes.css';
const watch = args.includes('--watch');
const verbose = args.includes('--verbose') || args.includes('-v');
const help = args.includes('--help') || args.includes('-h');
/**
* Show CLI help
*/
function showHelp() {
console.log(`
šØ voila-bundle - Theme Bundler for @voilajsx/uikit
USAGE:
npx voila-bundle [options]
OPTIONS:
--output=<path> Output file path (default: src/themes.css)
--watch Watch for changes and rebuild automatically
--verbose, -v Verbose logging
--help, -h Show this help
EXAMPLES:
npx voila-bundle
npx voila-bundle --output=public/themes.css
npx voila-bundle --watch --verbose
THEME STRUCTURE:
Your theme files should export a default object with this structure:
export default {
name: 'My Theme',
id: 'my-theme',
light: {
background: 'oklch(1 0 0)',
foreground: 'oklch(0.2 0.1 250)',
// ... more colors
},
dark: {
background: 'oklch(0.1 0.1 250)',
foreground: 'oklch(0.9 0.1 250)',
// ... more colors
}
}
SEARCH PATHS:
voila-bundle will search these directories for theme files:
${THEME_SEARCH_PATHS.map((p) => ` - ${p}/`).join('\n')}
`);
}
/**
* Log with optional verbose mode
*/
function log(message, isVerbose = false) {
if (!isVerbose || verbose) {
console.log(message);
}
}
/**
* Generate CSS for a single theme
*/
function generateThemeCSS(theme) {
if (!theme || !theme.id || !theme.light || !theme.dark) {
console.warn(
`ā ļø Invalid theme structure for ${
theme?.id || 'unknown'
} - missing required properties`
);
return '';
}
const lightVariables = Object.entries(theme.light)
.map(([key, value]) => {
const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
return ` ${cssKey}: ${value};`;
})
.join('\n');
const darkVariables = Object.entries(theme.dark)
.map(([key, value]) => {
const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
return ` ${cssKey}: ${value};`;
})
.join('\n');
return `/* ${theme.name} Theme */
.theme-${theme.id} {
${lightVariables}
}
.theme-${theme.id}.dark {
${darkVariables}
}`;
}
/**
* Load a single theme file with both ESM and CommonJS support
*/
async function loadThemeFile(themePath, fileName) {
try {
// Clear any cached version first
delete require.cache[themePath];
let theme = null;
// Try ES modules first
try {
const themeUrl = `file://${themePath.replace(
/\\/g,
'/'
)}?timestamp=${Date.now()}`;
const themeModule = await import(themeUrl);
theme = themeModule.default || themeModule;
} catch (esmError) {
log(` ESM failed for ${fileName}: ${esmError.message}`, true);
// Fall back to CommonJS
try {
theme = require(themePath);
// Handle both module.exports = theme and module.exports.default = theme
if (theme.default) {
theme = theme.default;
}
} catch (cjsError) {
log(` CJS failed for ${fileName}: ${cjsError.message}`, true);
throw new Error(
`Both ESM and CJS import failed. ESM: ${esmError.message}, CJS: ${cjsError.message}`
);
}
}
return theme;
} catch (err) {
throw new Error(`Failed to load ${fileName}: ${err.message}`);
}
}
/**
* Find and load themes from consumer project
*/
async function loadThemes() {
const themes = [];
const cwd = process.cwd();
const foundFiles = [];
log('š Searching for theme files...', true);
for (const themesDir of THEME_SEARCH_PATHS) {
try {
const fullPath = path.resolve(cwd, themesDir);
if (!fs.existsSync(fullPath)) {
log(` ā ${themesDir}/ - not found`, true);
continue;
}
const files = fs.readdirSync(fullPath).filter(
(file) =>
file.endsWith('.js') &&
!file.startsWith('index.') &&
!file.includes('theme-generator') // Skip build scripts
);
if (files.length === 0) {
log(` š ${themesDir}/ - no theme files`, true);
continue;
}
log(
` š ${themesDir}/ - found ${files.length} files: ${files.join(
', '
)}`,
true
);
for (const file of files) {
try {
const themePath = path.resolve(fullPath, file);
log(` š Loading ${file}...`, true);
const theme = await loadThemeFile(themePath, file);
if (theme && theme.id && theme.name && theme.light && theme.dark) {
themes.push(theme);
foundFiles.push(`${themesDir}/${file}`);
log(` ā
${theme.name} (${theme.id})`, true);
} else {
console.warn(` ā ļø ${file} - invalid theme structure`);
log(` Expected: { id, name, light: {}, dark: {} }`, true);
log(
` Got: ${JSON.stringify(
Object.keys(theme || {}),
null,
2
)}`,
true
);
}
} catch (err) {
console.warn(` ā ${file} - ${err.message}`);
log(` Full error: ${err.stack}`, true);
}
}
} catch (err) {
log(` ā ${themesDir}/ - error: ${err.message}`, true);
}
}
return { themes, foundFiles };
}
/**
* Bundle themes into CSS
*/
async function bundleThemes() {
const startTime = Date.now();
try {
const { themes, foundFiles } = await loadThemes();
if (themes.length === 0) {
console.log('ā No valid themes found!');
console.log(
'\nš Make sure your theme files are in one of these directories:'
);
THEME_SEARCH_PATHS.forEach((dir) => console.log(` - ${dir}/`));
console.log('\nš” Theme files should export a default object like:');
console.log(
' export default { id: "my-theme", name: "My Theme", light: {...}, dark: {...} }'
);
console.log('\nš§ Run with --verbose to see detailed loading attempts');
return false;
}
// Generate CSS
const themeCSS = themes.map(generateThemeCSS).filter(Boolean).join('\n\n');
if (!themeCSS) {
console.log('ā No valid CSS generated from themes');
return false;
}
// Create output content
const outputContent = `/*
* šØ Bundled Themes for @voilajsx/uikit
*
* Auto-generated by voila-bundle
* Generated: ${new Date().toISOString()}
* Themes: ${themes.length}
*
* DO NOT EDIT THIS FILE DIRECTLY
* Edit your theme source files and run "npx voila-bundle" to regenerate
*/
${themeCSS}
`;
// Ensure output directory exists
const outputPath = path.resolve(process.cwd(), outputFile);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
log(`š Created directory: ${outputDir}`, true);
}
// Write the file
fs.writeFileSync(outputPath, outputContent);
const duration = Date.now() - startTime;
console.log(
`\nā
Successfully bundled ${themes.length} themes in ${duration}ms`
);
themes.forEach((theme) => console.log(` šØ ${theme.name} (${theme.id})`));
console.log(`\nš¦ Output: ${outputPath}`);
console.log(`š Size: ${(outputContent.length / 1024).toFixed(1)}kb`);
if (!watch) {
console.log('\nš” Import in your app: import "./themes.css"');
}
return true;
} catch (err) {
console.error('ā Bundle failed:', err.message);
if (verbose) {
console.error(err.stack);
}
return false;
}
}
/**
* Watch mode - rebuild on file changes
*/
async function watchMode() {
console.log('š Watch mode enabled - monitoring theme files...\n');
let timeout;
const debounceMs = 300;
const rebuild = () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
console.log('\nš Change detected, rebuilding...');
await bundleThemes();
console.log('š Watching for changes...\n');
}, debounceMs);
};
// Initial build
const success = await bundleThemes();
if (!success) return;
// Watch all theme directories
const cwd = process.cwd();
const watchers = [];
for (const themesDir of THEME_SEARCH_PATHS) {
const fullPath = path.resolve(cwd, themesDir);
if (fs.existsSync(fullPath)) {
try {
const watcher = fs.watch(
fullPath,
{ recursive: true },
(eventType, filename) => {
if (
filename &&
filename.endsWith('.js') &&
!filename.includes('theme-generator')
) {
log(`š ${eventType}: ${themesDir}/${filename}`, true);
rebuild();
}
}
);
watchers.push(watcher);
log(`š Watching: ${themesDir}/`, true);
} catch (err) {
console.warn(`ā ļø Could not watch ${themesDir}/: ${err.message}`);
}
}
}
if (watchers.length === 0) {
console.log('ā No directories to watch');
return;
}
console.log('š Watching for changes... (Press Ctrl+C to stop)\n');
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\nš Stopping watch mode...');
watchers.forEach((watcher) => watcher.close());
process.exit(0);
});
}
/**
* Main CLI function
*/
async function main() {
if (help) {
showHelp();
return;
}
console.log('šØ voila-bundle - Theme Bundler for @voilajsx/uikit\n');
if (watch) {
await watchMode();
} else {
const success = await bundleThemes();
process.exit(success ? 0 : 1);
}
}
// Run CLI
main().catch((err) => {
console.error('š„ Unexpected error:', err);
process.exit(1);
});