#!/usr/bin/env node import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import StyleDictionary from 'style-dictionary'; import { cssThemeWithOverridesFormat } from '../formats/cssThemeWithOverridesFormat.js'; import { crossBrandParser } from '../parsers/crossBrandParser.js'; // Get __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * CLI tool for building themed CSS variables with dynamic version and token set */ interface BuildThemeOptions { version: string; tokenSet?: string; sourceDir?: string; output?: string; help?: boolean; } /** * Generate a meaningful theme filename from token set name */ function generateThemeFilename(tokenSet: string): string { // Default to theme-orange.css for the main use case if (tokenSet === 'cross-brand-light-color') { return 'theme-orange.css'; } // For other token sets, generate filename from token set name return `theme-${tokenSet.replace(/[^a-z0-9-]/gi, '-')}.css`; } /** * Parse command line arguments */ function parseArgs(): BuildThemeOptions { const args = process.argv.slice(2); const options: BuildThemeOptions = { version: '', tokenSet: 'cross-brand-light-color', sourceDir: undefined, output: '', // Will be set after parsing }; for (let i = 0; i < args.length; i++) { const arg = args[i]; switch (arg) { case '--version': case '-v': options.version = args[++i] || ''; break; case '--token-set': case '-t': options.tokenSet = args[++i] || 'cross-brand-light-color'; break; case '--output': case '-o': options.output = args[++i] || options.output; break; case '--source-dir': case '-s': options.sourceDir = args[++i]; break; case '--help': case '-h': options.help = true; break; default: if (!options.version) { options.version = arg || ''; } } } // Set default output filename if not specified if (!options.output) { options.output = generateThemeFilename(options.tokenSet!); } return options; } /** * Display help information */ function showHelp() { console.log(` MFUI Theme Builder CLI Builds themed CSS variables with automatic version detection or explicit versioning. USAGE: npx mfui-build-theme [version] [options] ARGUMENTS: [version] Version string (optional - auto-detected from project) Examples: '2-0-2', 'latest', 'v1-5-0', '2.18.0' OPTIONS: -v, --version Explicitly specify version (overrides auto-detection) -t, --token-set Token set name (default: 'cross-brand-light-color') -s, --source-dir Custom source directory for token files (overrides built-in tokens) -o, --output Output file path (default: 'theme-orange.css') -h, --help Show this help message VERSION DETECTION: 🔍 The CLI automatically detects your MFUI components version from: - dependencies in package.json - devDependencies in package.json - peerDependencies in package.json ✅ Searches up the directory tree to find the nearest package.json 📦 Looks for: @moneyforward/mfui-components EXAMPLES: # Auto-detect version and build theme (recommended) npx mfui-build-theme # Auto-detect with custom output npx mfui-build-theme -o theme-orange.css # Explicit version override npx mfui-build-theme 2-18-0 # Build with custom token set npx mfui-build-theme -t cross-brand-dark-color # Build from custom source directory (auto-detects token set from directory name) npx mfui-build-theme -s ./my-theme-tokens # Build with custom source and output (simplified - no need for -t) npx mfui-build-theme -s ./custom-tokens -o theme-custom.css # Build with explicit version and custom source npx mfui-build-theme 2-0-2 -s ./my-theme -o dist/theme.css TOKEN SETS: Available token sets depend on your tokens/ directory structure: - cross-brand-light-color (default) - cross-brand-dark-color - Any other theme directory in tokens/ CUSTOM SOURCE DIRECTORY: 📁 Use --source-dir to specify custom token files outside the built-in tokens/ 🎯 Token set name is automatically detected from directory name 📋 CLI intelligently processes theme-related JSON files (primary, accent, base, etc.) 🎨 Generates version-specific CSS variables to override default MFUI component themes Example structure: ./my-custom-theme/ # Directory name becomes token set name ├── primary.json # Primary color overrides ├── accent.json # Accent color overrides ├── base.json # Base color overrides ├── signal-red.json # Error color overrides └── README.md # Non-theme files are ignored OUTPUT FORMAT: Generates optimized CSS variables: - Version-specific variables: --mfui--colors-mfui\\.color\\.\\.\\. `); } /** * Auto-detect MFUI components version from actual installed package or workspace */ async function detectMfuiVersion(startDir: string = process.cwd()): Promise { let currentDir = startDir; // Search up the directory tree for package.json with MFUI components while (currentDir !== path.dirname(currentDir)) { try { const packageJsonPath = path.join(currentDir, 'package.json'); // Check if we're in the MFUI monorepo workspace try { const workspaceIndicators = [ path.join(currentDir, 'pnpm-workspace.yaml'), path.join(currentDir, 'packages', 'components', 'package.json'), ]; for (const indicator of workspaceIndicators) { try { await fs.access(indicator); // We're in a workspace, check for components package directly const componentsPackagePath = path.join(currentDir, 'packages', 'components', 'package.json'); const componentsPackageContent = await fs.readFile(componentsPackagePath, 'utf-8'); const componentsPackage = JSON.parse(componentsPackageContent); const workspaceVersion = componentsPackage.version; if (workspaceVersion && componentsPackage.name === '@moneyforward/mfui-components') { console.log(`🔍 Auto-detected MFUI components version from monorepo workspace: ${workspaceVersion}`); return workspaceVersion; } } catch (workspaceError) { // Continue checking other indicators } } } catch (workspaceError) { // Not in workspace, continue with standard detection } // First, try to get the actual installed version from node_modules const nodeModulesPath = path.join(currentDir, 'node_modules', '@moneyforward', 'mfui-components', 'package.json'); try { const installedPackageContent = await fs.readFile(nodeModulesPath, 'utf-8'); const installedPackage = JSON.parse(installedPackageContent); const actualVersion = installedPackage.version; if (actualVersion) { console.log(`🔍 Auto-detected MFUI components version from installed package: ${actualVersion}`); return actualVersion; } } catch (nodeModulesError) { // node_modules check failed, continue to package.json check } // Fallback: check package.json for version constraints try { const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8'); const packageJson = JSON.parse(packageJsonContent); // Check dependencies and devDependencies for MFUI components const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies, }; const mfuiVersion = dependencies['@moneyforward/mfui-components']; if (mfuiVersion) { // Remove semver prefixes like ^, ~, >=, etc. const cleanVersion = mfuiVersion.replace(/^[\^~>=<]+/, ''); console.log(`⚠️ Fallback: Using version constraint from package.json: ${cleanVersion}`); console.log(`💡 Note: This might not match the actual installed version`); return cleanVersion; } } catch (packageJsonError) { // package.json check failed } } catch (error) { // Continue searching in parent directory } currentDir = path.dirname(currentDir); } return null; } /** * Normalize version format */ function normalizeVersion(version: string): string { // Handle semantic version format (v2.0.2 -> 2-0-2) const semanticMatch = version.match(/^v?(\d+)\.(\d+)\.(\d+)$/); if (semanticMatch) { const [, major, minor, patch] = semanticMatch; return `${major}-${minor}-${patch}`; } // Handle dash format (2-0-2) or any other format return version.replace(/\./g, '-'); } /** * Detect if we're running in a monorepo environment */ async function isMonorepoEnvironment(): Promise { let currentDir = process.cwd(); const root = path.parse(currentDir).root; // Search up the directory tree for monorepo indicators while (currentDir !== root) { try { // Look for pnpm-workspace.yaml (MFUI uses pnpm) const workspaceFile = path.join(currentDir, 'pnpm-workspace.yaml'); await fs.access(workspaceFile); return true; } catch (error) { // Continue searching } try { // Look for MFUI-specific structure (packages/design-tokens) const designTokensPath = path.join(currentDir, 'packages', 'design-tokens'); await fs.access(designTokensPath); return true; } catch (error) { // Continue searching } currentDir = path.dirname(currentDir); } return false; } /** * Build themed CSS with Style Dictionary */ async function buildTheme(options: BuildThemeOptions): Promise { const { version, tokenSet, sourceDir, output } = options; const normalizedVersion = normalizeVersion(version); // Determine source pattern - handle both development and npm package contexts let actualSourceDir = sourceDir; // If no sourceDir specified, try to find built-in tokens if (!actualSourceDir) { // Try different possible locations for tokens const possibleTokenPaths = [ 'tokens', // Development context '../tokens', // If running from dist/cli path.join(__dirname, '../tokens'), // Relative to bundled CLI path.join(__dirname, '../../tokens'), // Alternative path ]; for (const tokenPath of possibleTokenPaths) { try { const fullPath = path.resolve(tokenPath); const stat = await fs.stat(fullPath); if (stat.isDirectory()) { actualSourceDir = fullPath; break; } } catch (error) { // Continue searching } } } // Auto-detect token set from custom directory name if not explicitly provided let effectiveTokenSet = tokenSet; let finalOutput = output; if (sourceDir && tokenSet === 'cross-brand-light-color') { // Extract directory name as token set name for user-specified source directories only const resolvedSourceDir = path.resolve(sourceDir); const builtinTokensPath = path.resolve('tokens'); if (resolvedSourceDir !== builtinTokensPath) { effectiveTokenSet = path.basename(resolvedSourceDir); console.log(`🎯 Auto-detected token set: ${effectiveTokenSet} from directory: ${sourceDir}`); // Update output filename if it was auto-generated if (output === generateThemeFilename(tokenSet!)) { finalOutput = generateThemeFilename(effectiveTokenSet); } } } // Detect environment and set build path accordingly const isMonorepo = await isMonorepoEnvironment(); const userCwd = process.env['MFUI_USER_CWD'] || process.cwd(); // Determine if user specified custom output path const isCustomOutput = options.output !== generateThemeFilename(options.tokenSet!); // Set buildPath based on whether output is custom and environment const buildPath = isMonorepo && !isCustomOutput ? `${userCwd}/dist/css/` : `${userCwd}/`; const sourcePattern = actualSourceDir ? [`${path.resolve(userCwd, actualSourceDir)}/**/*.json`] : ['tokens/**/*.json']; const sourceDescription = sourceDir ? `custom source: ${sourceDir}` : `built-in tokens${actualSourceDir ? ` (${actualSourceDir})` : ''}`; console.log( `Building theme with version: ${normalizedVersion}, token set: ${effectiveTokenSet}, ${sourceDescription}`, ); // Register custom parser and format StyleDictionary.registerParser(crossBrandParser); StyleDictionary.registerFormat({ name: 'cssThemeWithOverridesFormat', format: cssThemeWithOverridesFormat, }); // Create Style Dictionary instance // Always use the custom theme format for proper MFUI variable naming const formatConfig = { formats: { cssThemeWithOverridesFormat }, format: 'cssThemeWithOverridesFormat', }; const sd = new StyleDictionary({ source: sourcePattern, parsers: ['w3cTokenJsonParser', 'crossBrandParser'], platforms: { css: { transformGroup: 'css', transforms: ['color/hex8'], buildPath, files: [ { destination: finalOutput!, format: formatConfig.format, filter: (token) => { const shouldInclude = (() => { if (sourceDir) { const resolvedSourceDir = path.resolve(sourceDir); const builtinTokensPath = path.resolve('tokens'); if (resolvedSourceDir !== builtinTokensPath) { // For custom user-specified sources, process all tokens return true; } else { // For built-in tokens accessed via sourceDir, use standard filtering const include = token.filePath?.includes(effectiveTokenSet ?? '') ?? false; return include; } } else { // For built-in tokens (default behavior), use standard token set filtering const include = token.filePath?.includes(effectiveTokenSet ?? '') ?? false; return include; } })(); return shouldInclude; }, options: { version: normalizedVersion, tokenSet: effectiveTokenSet }, }, ], }, }, }); await sd.hasInitialized; try { await sd.buildPlatform('css'); const outputPath = isMonorepo && !isCustomOutput ? `dist/css/${finalOutput}` : finalOutput!; console.log(`✔︎ Theme built successfully: ${outputPath}`); } catch (error) { console.error('Error building theme:', error); process.exit(1); } } /** * Main CLI function */ async function main() { try { const options = parseArgs(); if (options.help) { showHelp(); process.exit(0); } // Auto-detect version if not provided if (!options.version) { console.log('🔍 No version specified, auto-detecting from project...'); const userCwd = process.env['MFUI_USER_CWD'] || process.cwd(); const detectedVersion = await detectMfuiVersion(userCwd); if (detectedVersion) { options.version = detectedVersion; console.log(`✅ Using auto-detected version: ${detectedVersion}`); } else { console.error('❌ Could not auto-detect MFUI components version'); console.error('📦 Make sure @moneyforward/mfui-components is installed in your project'); console.error('💡 Or specify version manually: npx mfui-build-theme 2-18-0'); console.error('📖 Use --help for usage information'); process.exit(1); } } await buildTheme(options); } catch (error) { if (error instanceof Error) { console.error(`Error: ${error.message}`); } else { console.error('An unexpected error occurred'); } process.exit(1); } } // Run CLI - this file is meant to be executed as a binary main(); export { buildTheme, normalizeVersion };