@voilajsx/uikit
Version:
Cross-platform React components with beautiful themes and OKLCH color sciences - Now with mobile (Capacitor) support
587 lines (498 loc) ⢠17 kB
JavaScript
/**
* @fileoverview UIKit Bundle Command - Direct theme bundling functionality
* @description Core theme bundling with watch mode and verbose logging
* @package @voilajsx/uikit
*/
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 webAssetsPath = path.resolve(process.cwd(), 'src/web/assets');
const webThemesPath = path.resolve(process.cwd(), 'src/web/themes');
const useWebAssets = fs.existsSync(webAssetsPath);
const useWebThemes = fs.existsSync(webThemesPath);
const THEME_SEARCH_PATHS = useWebAssets
? [
'src/web/assets/themes/presets',
'src/web/assets/themes',
'themes/presets',
'themes',
]
: useWebThemes
? [
'src/web/themes/presets',
'src/web/themes',
'src/web/styles/themes',
'themes/presets',
'themes',
]
: [
'src/themes/presets',
'src/themes',
'themes/presets',
'themes',
'src/styles/themes',
'styles/themes',
];
/**
* Log with optional verbose mode
*/
function log(message, isVerbose = false, verbose = false) {
if (!isVerbose || verbose) {
console.log(message);
}
}
/**
* Generate unified @theme directive for all themes
*/
function generateUnifiedThemeCSS(themes) {
if (!themes || themes.length === 0) {
return '';
}
// Collect all design tokens from all themes
const allDesignTokens = new Set();
const allColorVars = new Set();
themes.forEach(theme => {
if (theme.design) {
Object.entries(theme.design).forEach(([key, value]) => {
const cssKey = `--voila-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
allDesignTokens.add(` ${cssKey}: ${value};`);
});
}
// Collect all unique color variable names
Object.keys(theme.light || {}).forEach(key => {
const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
allColorVars.add(cssKey);
});
});
// Use the first theme as the base for light mode colors
const baseTheme = themes[0];
const lightVariables = Object.entries(baseTheme.light)
.map(([key, value]) => {
const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
return ` ${cssKey}: ${value};`;
})
.join('\n');
// Generate enhanced design tokens section
let enhancedTokens = '';
if (allDesignTokens.size > 0) {
enhancedTokens = `\n /* š Enhanced Design Tokens from all themes */\n${Array.from(allDesignTokens).join('\n')}\n`;
}
return `/* Define all theme colors in @theme - this creates the Tailwind utilities */
@theme {
--color-radius: 0.75rem;${enhancedTokens}
/* Base Colors (using ${baseTheme.name} as default) */
${lightVariables}
}`;
}
/**
* Generate theme-specific light mode color overrides
*/
function generateThemeLightModes(themes) {
return themes.map(theme => {
// Skip base theme since its colors are already in @theme
if (theme.id === 'base') 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');
return `/* Light mode colors for ${theme.name} theme */
.theme-${theme.id} {
${lightVariables}
}`;
}).filter(Boolean).join('\n\n');
}
/**
* Generate theme-specific dark mode overrides
*/
function generateThemeDarkModes(themes) {
return themes.map(theme => {
const darkVariables = Object.entries(theme.dark)
.map(([key, value]) => {
const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
return ` ${cssKey}: ${value};`;
})
.join('\n');
let customStyles = '';
if (theme.customStyles) {
const stylesWithoutImports = removeFontImports(theme.customStyles);
if (stylesWithoutImports) {
customStyles = `\n${stylesWithoutImports}`;
}
}
return `/* Dark mode overrides for ${theme.name} theme */
.theme-${theme.id}.dark {
${darkVariables}
}${customStyles}`;
}).join('\n\n');
}
/**
* Remove font imports from customStyles to avoid duplication
*/
function removeFontImports(customStyles) {
if (!customStyles) return '';
return customStyles.replace(/@import\s+url\([^)]+\);\s*/g, '').trim();
}
/**
* 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;
// Handle named exports (studioTheme, auroraTheme, etc.)
if (!theme && themeModule) {
const namedExports = Object.keys(themeModule).filter(
(key) =>
key !== 'default' &&
typeof themeModule[key] === 'object' &&
themeModule[key].id &&
themeModule[key].name
);
if (namedExports.length > 0) {
theme = themeModule[namedExports[0]];
}
}
} catch (esmError) {
// 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) {
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(verbose = false) {
const themes = [];
const cwd = process.cwd();
const foundFiles = [];
log('š Searching for theme files...', true, verbose);
for (const themesDir of THEME_SEARCH_PATHS) {
try {
const fullPath = path.resolve(cwd, themesDir);
if (!fs.existsSync(fullPath)) {
log(` ā ${themesDir}/ - not found`, true, verbose);
continue;
}
const files = fs.readdirSync(fullPath).filter(
(file) =>
(file.endsWith('.js') || file.endsWith('.ts')) &&
!file.startsWith('index.') &&
!file.includes('theme-bundler') && // Skip build scripts
!file.includes('.d.ts') && // Skip TypeScript declarations
!file.includes('.test.') && // Skip test files
!file.includes('.spec.') // Skip spec files
);
if (files.length === 0) {
log(` š ${themesDir}/ - no theme files`, true, verbose);
continue;
}
log(
` š ${themesDir}/ - found ${files.length} files: ${files.join(
', '
)}`,
true,
verbose
);
for (const file of files) {
try {
const themePath = path.resolve(fullPath, file);
log(` š Loading ${file}...`, true, verbose);
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, verbose);
} else {
console.warn(` ā ļø ${file} - invalid theme structure`);
log(` Expected: { id, name, light: {}, dark: {} }`, true, verbose);
}
} catch (err) {
console.warn(` ā ${file} - ${err.message}`);
}
}
} catch (err) {
log(` ā ${themesDir}/ - error: ${err.message}`, true, verbose);
}
}
return { themes, foundFiles };
}
/**
* Read existing globals.css and preserve non-theme content
*/
function readExistingGlobals(outputPath, verbose = false) {
if (!fs.existsSync(outputPath)) {
log(`š Creating new globals.css file`, true, verbose);
return { beforeThemes: '', afterThemes: '' };
}
const content = fs.readFileSync(outputPath, 'utf8');
// Look for theme markers - support both old and new formats
const oldStartMarker = '/* THEME_DECLARATIONS_PLACEHOLDER */';
const actualStartMarker = '/* šØ Bundled Themes for @voilajsx/uikit';
const endMarker = '/* END_THEME_DECLARATIONS */';
// Check for actual theme bundle start marker first
let startIndex = content.indexOf(actualStartMarker);
if (startIndex === -1) {
// Fall back to old placeholder marker
startIndex = content.indexOf(oldStartMarker);
}
if (startIndex === -1) {
// No existing themes - check if this looks like first-time usage
const hasThemeWillBundled = content.includes('/* Themes will be bundled here */');
if (hasThemeWillBundled) {
log(`š Found placeholder comment, replacing with themes`, true, verbose);
return {
beforeThemes: '',
afterThemes: '',
};
}
// No theme markers found - append to end
log(`š No theme marker found, will append themes to end`, true, verbose);
return {
beforeThemes: content.trim() + '\n\n',
afterThemes: '',
};
}
// Find the LAST end marker to handle multiple theme sections
let endIndex = -1;
let searchFrom = 0;
while (true) {
const nextIndex = content.indexOf(endMarker, searchFrom);
if (nextIndex === -1) break;
endIndex = nextIndex;
searchFrom = nextIndex + 1;
}
if (endIndex === -1) {
// No end marker found - replace from start marker to end of file
log(`š Found start marker but no end marker, replacing to end of file`, true, verbose);
return {
beforeThemes: content.substring(0, startIndex).trim() + '\n\n',
afterThemes: '',
};
}
// Both markers found - replace ALL theme sections between first start and last end
const actualEndIndex = endIndex + endMarker.length;
log(`š Found theme sections, replacing from first start to last end marker`, true, verbose);
return {
beforeThemes: content.substring(0, startIndex).trim() + '\n\n',
afterThemes: content.substring(actualEndIndex),
};
}
/**
* Core bundle themes functionality
*/
async function bundleThemesCore(options = {}) {
const startTime = Date.now();
const { output, verbose = false } = options;
try {
const { themes } = await loadThemes(verbose);
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š§ Run with --verbose to see detailed loading attempts');
return false;
}
// Generate unified @theme directive
const unifiedThemeCSS = generateUnifiedThemeCSS(themes);
// Generate theme-specific light mode color overrides
const lightModeCSS = generateThemeLightModes(themes);
// Generate theme-specific dark mode overrides
const darkModeCSS = generateThemeDarkModes(themes);
if (!unifiedThemeCSS) {
console.log('ā No valid CSS generated from themes');
return false;
}
// Determine output path
const webAssetsPath = path.resolve(process.cwd(), 'src/web/assets');
const webPath = path.resolve(process.cwd(), 'src/web');
const useWebAssets = fs.existsSync(webAssetsPath);
const useWebStructure = fs.existsSync(webPath);
let defaultOutput;
if (useWebAssets) {
defaultOutput = 'src/web/assets/styles/globals.css';
} else if (useWebStructure) {
defaultOutput = 'src/web/styles/globals.css';
} else {
defaultOutput = 'src/styles/globals.css';
}
const outputFile = output || defaultOutput;
const outputPath = path.resolve(process.cwd(), outputFile);
const outputDir = path.dirname(outputPath);
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
log(`š Created directory: ${outputDir}`, true, verbose);
}
let finalContent;
if (outputPath.includes('globals.css')) {
// Preserve existing globals.css content
const { beforeThemes, afterThemes } = readExistingGlobals(outputPath, verbose);
finalContent = `${beforeThemes}
/* šØ Bundled Themes for @voilajsx/uikit - Generated by voila-bundle */
/* Generated: ${new Date().toISOString()} */
/* Themes: ${themes.map((t) => t.name).join(', ')} */
/* DO NOT EDIT THIS SECTION MANUALLY - Run "npx uikit bundle" to regenerate */
${unifiedThemeCSS}
${lightModeCSS}
${darkModeCSS}
/* END_THEME_DECLARATIONS */
${afterThemes}`;
} else {
// Create standalone theme file
finalContent = `/*
* šØ Bundled Themes for @voilajsx/uikit
*
* Auto-generated by uikit bundle
* Generated: ${new Date().toISOString()}
* Themes: ${themes.length}
*
* DO NOT EDIT THIS FILE DIRECTLY
* Edit your theme source files and run "npx uikit bundle" to regenerate
*/
@import "tailwindcss";
@import "./fonts.css";
/* Modern @theme directive for Tailwind CSS v4+ */
${unifiedThemeCSS}
${lightModeCSS}
${darkModeCSS}
`;
}
// Write the file
fs.writeFileSync(outputPath, finalContent);
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: ${(finalContent.length / 1024).toFixed(1)}kb`);
if (outputPath.includes('globals.css')) {
console.log('\nš” Themes added to globals.css - import in your app');
} else {
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(options) {
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 bundleThemesCore(options);
console.log('š Watching for changes...\n');
}, debounceMs);
};
// Initial build
const success = await bundleThemesCore(options);
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.endsWith('.ts')) &&
!filename.includes('theme-bundler') &&
!filename.includes('.d.ts')
) {
log(`š ${eventType}: ${themesDir}/${filename}`, true, options.verbose);
rebuild();
}
}
);
watchers.push(watcher);
log(`š Watching: ${themesDir}/`, true, options.verbose);
} 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 bundle themes function - CLI entry point
*/
export async function bundleThemes(options) {
console.log('šØ UIKit Theme Bundler\n');
try {
if (options.watch) {
await watchMode(options);
} else {
const success = await bundleThemesCore(options);
if (!success) {
process.exit(1);
}
if (!options.watch) {
console.log('\nš” Next steps:');
console.log(' - Themes are now bundled in your globals.css');
console.log(' - Use theme classes like .theme-base, .theme-elegant');
console.log(' - Run "npx uikit serve" to start development server');
}
}
} catch (error) {
console.error('ā Bundle command failed:', error.message);
process.exit(1);
}
}