UNPKG

tailwind-ast-scoper

Version:

Scoped Tailwind config manager using AST. Safely handle multi-config, v3/v4, and white-label Tailwind projects.

622 lines (584 loc) 26.9 kB
const fs = require('fs/promises'); const path = require('path'); const merge = require('lodash.merge'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const chokidar = require('chokidar'); const { glob } = require('glob'); const { performance } = require('perf_hooks'); const crypto = require('crypto'); const kleur = require('kleur'); // --- Global State --- let virtualClassSet = new Set(); const scopedKeys = {}; const usageMap = {}; const configMap = {}; let allPlugins = []; const safelistSet = new Set(); const changedFiles = new Set(); const scopedClasses = new Set(); const ROOT_DIR = path.resolve(process.cwd()); const CONFIG_DIR = path.join(ROOT_DIR, 'tailwind-configs'); const CORE_CONFIG_PATH = path.join(ROOT_DIR, 'core', 'tailwind.config.js'); const PAGES_DIR = path.join(ROOT_DIR, 'src', 'pages'); const PREFIX_MAP_PATH = path.join(CONFIG_DIR, 'prefix-map.json'); const SCOPED_DEBUG_PATH = path.join(ROOT_DIR, 'src', 'styles', '_scoped-debug.jsx'); const VIRTUAL_CSS_PATH = path.join(ROOT_DIR, 'src', 'styles', '_scoped-virtual.css'); const TAILWIND_CONFIG_OUTPUT_PATH = path.join(ROOT_DIR, 'tailwind.config.js'); const REPORT_PATH = path.resolve(process.cwd(), 'scoping-report.txt'); // --- Logging Helpers --- let globalVerbose = true; function log(...args) { if (globalVerbose) console.log(kleur.gray('ℹ️ [INFO]'), ...args); } function logSuccess(...args) { if (globalVerbose) console.log(kleur.green('✅ [SUCCESS]'), ...args); } function logError(...args) { console.error(kleur.red('🛑 [ERROR]'), ...args); } function logWarn(...args) { if (globalVerbose) console.warn(kleur.yellow('⚠️ [WARN]'), ...args); } function logStep(...args) { if (globalVerbose) console.log(kleur.cyan('🚀 [STEP]'), ...args); } function logFile(...args) { if (globalVerbose) console.log(kleur.magenta('📄 [FILE]'), ...args); } function logConfig(...args) { if (globalVerbose) console.log(kleur.blue('🔧 [CONFIG]'), ...args); } function logBuild(...args) { if (globalVerbose) console.log(kleur.bgMagenta().white('🏁 [BUILD]'), ...args); } // --- Utility --- function clearRequireCache(filePath) { if (require.cache[filePath]) delete require.cache[filePath]; logWarn('Cleared require cache:', filePath); } // --- CSS Virtual Layer Generator --- // --- CSS Virtual Layer Generator (نسخه جامع و قابل توسعه) --- async function writeVirtualCSS(virtualClassSet, colors, spacing) { const fontWeightMap = { 'thin': 100, 'extralight': 200, 'light': 300, 'normal': 400, 'medium': 500, 'semibold': 600, 'bold': 700, 'extrabold': 800, 'black': 900, 'bold': 700 }; const textAlignMap = { 'left': 'left', 'center': 'center', 'right': 'right', 'justify': 'justify' }; const fontSizeMap = { 'xs': '0.75rem', 'sm': '0.875rem', 'base': '1rem', 'lg': '1.125rem', 'xl': '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem', '5xl': '3rem', '6xl': '3.75rem' }; let lines = [ '/* 🔧 AUTO-GENERATED BY AST-SCOPER (DO NOT EDIT MANUALLY) */', '' ]; for (const cls of virtualClassSet) { // --- Background Color --- if (cls.startsWith('bg-')) { const key = cls.slice(3); if (colors[key]) lines.push(`.${cls} { background-color: ${colors[key]}; }`); else if (key === 'white') lines.push(`.${cls} { background-color: #fff; }`); else if (key === 'black') lines.push(`.${cls} { background-color: #000; }`); } // --- Text Color --- if (cls.startsWith('text-')) { const key = cls.slice(5); if (colors[key]) lines.push(`.${cls} { color: ${colors[key]}; }`); else if (key === 'white') lines.push(`.${cls} { color: #fff; }`); else if (key === 'black') lines.push(`.${cls} { color: #000; }`); else if (fontSizeMap[key]) lines.push(`.${cls} { font-size: ${fontSizeMap[key]}; }`); } // --- Font Weight --- if (cls.startsWith('font-')) { const key = cls.slice(5); if (fontWeightMap[key]) lines.push(`.${cls} { font-weight: ${fontWeightMap[key]}; }`); } // --- Text Align --- if (cls.startsWith('text-') && textAlignMap[cls.slice(5)]) { lines.push(`.${cls} { text-align: ${textAlignMap[cls.slice(5)]}; }`); } // --- Border Color --- if (cls.startsWith('border-')) { const key = cls.slice(7); if (colors[key]) lines.push(`.${cls} { border-color: ${colors[key]}; }`); } // --- Padding (p-, px-, py-) --- if (cls.startsWith('p-') && !cls.startsWith('px-') && !cls.startsWith('py-')) { const key = cls.slice(2); if (spacing[key]) lines.push(`.${cls} { padding: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { padding: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('px-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { padding-left: ${spacing[key]}; padding-right: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { padding-left: ${Number(key)*0.25}rem; padding-right: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('py-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { padding-top: ${spacing[key]}; padding-bottom: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { padding-top: ${Number(key)*0.25}rem; padding-bottom: ${Number(key)*0.25}rem; }`); } // --- Margin (m-, mx-, my-, mb-, mt-, ml-, mr-) --- if (cls.startsWith('m-') && !cls.startsWith('mx-') && !cls.startsWith('my-')) { const key = cls.slice(2); if (spacing[key]) lines.push(`.${cls} { margin: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('mx-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { margin-left: ${spacing[key]}; margin-right: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin-left: ${Number(key)*0.25}rem; margin-right: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('my-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { margin-top: ${spacing[key]}; margin-bottom: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin-top: ${Number(key)*0.25}rem; margin-bottom: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('mb-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { margin-bottom: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin-bottom: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('mt-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { margin-top: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin-top: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('ml-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { margin-left: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin-left: ${Number(key)*0.25}rem; }`); } if (cls.startsWith('mr-')) { const key = cls.slice(3); if (spacing[key]) lines.push(`.${cls} { margin-right: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { margin-right: ${Number(key)*0.25}rem; }`); } // --- Border radius (rounded, rounded-lg, ...) --- if (cls === 'rounded') lines.push(`.rounded { border-radius: 0.25rem; }`); if (cls === 'rounded-lg') lines.push(`.rounded-lg { border-radius: 0.5rem; }`); if (cls === 'rounded-full') lines.push(`.rounded-full { border-radius: 9999px; }`); // --- Shadow --- if (cls === 'shadow') lines.push(`.shadow { box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px 0 rgba(0,0,0,0.06); }`); if (cls === 'shadow-lg') lines.push(`.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1),0 4px 6px -2px rgba(0,0,0,0.05); }`); // --- Display --- if (cls === 'flex') lines.push(`.flex { display: flex; }`); if (cls === 'block') lines.push(`.block { display: block; }`); if (cls === 'inline-block') lines.push(`.inline-block { display: inline-block; }`); // --- Gap --- if (cls.startsWith('gap-')) { const key = cls.slice(4); if (spacing[key]) lines.push(`.${cls} { gap: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { gap: ${Number(key)*0.25}rem; }`); } // --- W/H --- if (cls.startsWith('w-')) { const key = cls.slice(2); if (spacing[key]) lines.push(`.${cls} { width: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { width: ${Number(key)*0.25}rem; }`); else if (key === 'full') lines.push(`.${cls} { width: 100%; }`); } if (cls.startsWith('h-')) { const key = cls.slice(2); if (spacing[key]) lines.push(`.${cls} { height: ${spacing[key]}; }`); else if (!isNaN(key)) lines.push(`.${cls} { height: ${Number(key)*0.25}rem; }`); else if (key === 'full') lines.push(`.${cls} { height: 100%; }`); } // --- Z-index --- if (cls.startsWith('z-')) { const key = cls.slice(2); if (!isNaN(key)) lines.push(`.${cls} { z-index: ${key}; }`); } // --- Opacity --- if (cls.startsWith('opacity-')) { const key = cls.slice(8); if (!isNaN(key)) lines.push(`.${cls} { opacity: ${Number(key)/100}; }`); } // --- Static custom classes --- if (cls === 'text-white') lines.push(`.text-white { color: #fff; }`); if (cls === 'text-center') lines.push(`.text-center { text-align: center; }`); if (cls === 'font-bold') lines.push(`.font-bold { font-weight: 700; }`); if (cls === 'mb-4') lines.push(`.mb-4 { margin-bottom: 1rem; }`); if (cls === 'mt-4') lines.push(`.mt-4 { margin-top: 1rem; }`); if (cls === 'text-3xl') lines.push(`.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }`); if (cls === 'text-lg') lines.push(`.text-lg { font-size: 1.125rem; line-height: 1.75rem; }`); // ... هر utility دیگر که در پروژه داری یا اضافه شود } lines.push(''); const cssContent = lines.join('\n'); await fs.writeFile(VIRTUAL_CSS_PATH, cssContent, 'utf-8'); logSuccess('Virtual scoped CSS written →', kleur.underline(VIRTUAL_CSS_PATH)); } // --- Report Writer --- async function writeReport(reportPath, changedFiles, scopedClasses, finalConfig, reportType = "txt") { const reportData = { processedFiles: Array.from(changedFiles), scopedClasses: Array.from(scopedClasses), finalThemeExtend: finalConfig.theme?.extend || {}, }; try { if (reportType === "json") { await fs.writeFile(reportPath.replace(/\.txt$/, ".json"), JSON.stringify(reportData, null, 2)); logSuccess(`Report written as JSON → ${kleur.underline(reportPath.replace(/\.txt$/, ".json"))}`); } else if (reportType === "html") { await fs.writeFile(reportPath.replace(/\.txt$/, ".html"), `<html><body><h2>Tailwind AST Scoper Report</h2> <h3>Processed Files</h3> <ul>${Array.from(changedFiles).map(f => `<li>${f}</li>`).join('')}</ul> <h3>Scoped Classes</h3> <ul>${Array.from(scopedClasses).map(c => `<li>${c}</li>`).join('')}</ul> <h3>Final Theme Extend</h3> <pre>${JSON.stringify(finalConfig.theme?.extend || {}, null, 2)}</pre> </body></html>`); logSuccess(`Report written as HTML → ${kleur.underline(reportPath.replace(/\.txt$/, ".html"))}`); } else { await fs.writeFile(reportPath, `🔧 Tailwind AST Scoper Report (${new Date().toLocaleString()})\n\n` + `Files Modified:\n${[...changedFiles].join('\n') || 'None'}\n\n` + `Scoped Classes:\n${[...scopedClasses].join('\n') || 'None'}\n\n` + `Final Merged Tailwind Theme Extend:\n${JSON.stringify(finalConfig.theme?.extend || {}, null, 2)}\n`); logSuccess(`Report written as TXT → ${kleur.underline(reportPath)}`); } } catch (err) { logError('Failed to write report:', err.message); } } // --- Scoping Logic (as before) --- function applyScoping(cls, scoped) { for (const [section, fields] of Object.entries(scoped)) { for (const [original, replacement] of Object.entries(fields)) { if (cls === original) return replacement; if (cls.startsWith(`${original}-`)) return `${replacement}${cls.slice(original.length)}`; const utilityMatch = cls.match(/^([a-z]+-)?(.+)$/); if (utilityMatch) { const prefix = utilityMatch[1] || ''; const base = utilityMatch[2]; if (base === original) return `${prefix}${replacement}`; } } } return cls; } // --- JSX Processor (as before) --- async function processJSXFile(filePath, pageName, prefix, options) { const { dryRun } = options; const scoped = scopedKeys[prefix]; if (!scoped) { logWarn(`No scoped keys for prefix '${prefix}' (${pageName})`); return; } try { let code = await fs.readFile(filePath, 'utf-8'); logFile('Processing:', kleur.underline(filePath), '| page:', pageName, '| prefix:', prefix); const originalCodeHash = crypto.createHash('md5').update(code).digest('hex'); const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); let modifiedAst = false; traverse(ast, { JSXAttribute(path) { if (path.node.name.name !== 'className') return; const value = path.node.value; const applyToString = (str) => { const classList = str.split(/\s+/).filter(Boolean); log(' [className] Before:', kleur.bold(classList.join(' '))); const newClassList = classList.map((cls) => applyScoping(cls, scoped)); newClassList.forEach((c) => virtualClassSet.add(c)); newClassList.forEach((c) => scopedClasses.add(c)); log(' [className] After:', kleur.green(newClassList.join(' '))); return newClassList.join(' '); }; if (value.type === 'StringLiteral') { const newClassString = applyToString(value.value); if (value.value !== newClassString) { logSuccess(` [StringLiteral] Changed: "${value.value}" → "${newClassString}"`); value.value = newClassString; modifiedAst = true; } } else if (value.type === 'JSXExpressionContainer') { const exp = value.expression; if (exp.type === 'TemplateLiteral') { for (const quasis of exp.quasis) { const newString = applyToString(quasis.value.raw); if (quasis.value.raw !== newString) { logSuccess(` [TemplateLiteral] Changed: "${quasis.value.raw}" → "${newString}"`); quasis.value.raw = quasis.value.cooked = newString; modifiedAst = true; } } } else if (exp.type === 'ConditionalExpression') { ['consequent', 'alternate'].forEach(part => { const segment = exp[part]; if (segment.type === 'StringLiteral') { const newString = applyToString(segment.value); if (segment.value !== newString) { logSuccess(` [Conditional] Changed: "${segment.value}" → "${newString}"`); segment.value = newString; modifiedAst = true; } } }); } else if (exp.type === 'CallExpression' && ['clsx', 'classnames'].includes(exp.callee.name)) { for (const arg of exp.arguments) { if (arg.type === 'StringLiteral') { const newString = applyToString(arg.value); if (arg.value !== newString) { logSuccess(` [clsx/classnames] Changed: "${arg.value}" → "${newString}"`); arg.value = newString; modifiedAst = true; } } } } } }, }); if (modifiedAst) { const output = generate(ast, {}, code); const newCodeHash = crypto.createHash('md5').update(output.code).digest('hex'); if (originalCodeHash !== newCodeHash) { if (!dryRun) { await fs.writeFile(filePath, output.code, 'utf-8'); changedFiles.add(filePath); logSuccess(`JSX file updated:`, kleur.underline(filePath)); } else logWarn(`[dry-run] Would update: ${filePath}`); } } log(' [processJSXFile] virtualClassSet:', Array.from(virtualClassSet)); } catch (err) { logError(`AST update failed for ${filePath}:`, err.message); } } // --- Main Build Function --- async function buildScoper(options = {}) { virtualClassSet = new Set(); for (const key in scopedKeys) delete scopedKeys[key]; for (const key in usageMap) delete usageMap[key]; for (const key in configMap) delete configMap[key]; allPlugins = []; safelistSet.clear(); changedFiles.clear(); scopedClasses.clear(); globalVerbose = options.verbose !== undefined ? options.verbose : true; const { configDir = 'tailwind-configs', pagesDir = 'src/pages', pattern = '**/*.jsx', dryRun = false, report = 'txt' } = options; const currentConfigDir = path.resolve(ROOT_DIR, configDir); const currentPagesDir = path.resolve(ROOT_DIR, pagesDir); const currentPrefixMapPath = path.resolve(currentConfigDir, 'prefix-map.json'); const currentScopedDebugPath = path.resolve(currentPagesDir, '../styles/_scoped-debug.jsx'); const currentReportPath = path.resolve(process.cwd(), 'scoping-report.txt'); logBuild('\n--- TAILWIND AST SCOPER BUILD ---'); logConfig('[Paths]', { ROOT_DIR, CONFIG_DIR: currentConfigDir, PAGES_DIR: currentPagesDir, PREFIX_MAP_PATH: currentPrefixMapPath, SCOPED_DEBUG_PATH: currentScopedDebugPath, VIRTUAL_CSS_PATH, TAILWIND_CONFIG_OUTPUT_PATH, REPORT_PATH: currentReportPath, }); const startTime = performance.now(); // Load configs let configFiles = []; try { configFiles = (await fs.readdir(currentConfigDir)).filter(f => f.endsWith('.config.js') || f.endsWith('.config.cjs')); logConfig('[ConfigFiles]', configFiles); } catch (err) { logError('Failed to read config directory:', err.message); return; } for (const file of configFiles) { const prefix = file.replace(/\.config\.(js|cjs)$/, ''); const configFilePath = path.join(currentConfigDir, file); try { clearRequireCache(configFilePath); const config = require(configFilePath); configMap[prefix] = config; logSuccess(`[LoadConfig]`, kleur.bold(file), '| prefix:', kleur.yellow(prefix)); if (Array.isArray(config.plugins)) allPlugins.push(...config.plugins); const theme = config.theme?.extend || {}; for (const [section, values] of Object.entries(theme)) { for (const key of Object.keys(values)) { const composite = `${section}:${key}`; if (!usageMap[composite]) usageMap[composite] = []; usageMap[composite].push(prefix); } } } catch (err) { logError(`Error loading ${configFilePath}:`, err.message); return; } } logConfig('[configMap]', configMap); logConfig('[usageMap]', usageMap); // Detect repeated keys for (const [key, usedBy] of Object.entries(usageMap)) { if (usedBy.length > 1) { const [section, field] = key.split(':'); for (const prefix of usedBy) { const value = configMap[prefix]?.theme?.extend?.[section]?.[field]; if (!value) continue; const scopedField = `${prefix}-${field}`; configMap[prefix].theme.extend[section][scopedField] = value; delete configMap[prefix].theme.extend[section][field]; scopedKeys[prefix] ??= {}; scopedKeys[prefix][section] ??= {}; scopedKeys[prefix][section][field] = scopedField; logSuccess(`[scoping]`, key, 'for', kleur.yellow(prefix), '→', kleur.green(scopedField)); } } } logConfig('[scopedKeys]', JSON.stringify(scopedKeys, null, 2)); // Load prefix map let prefixMap = {}; try { await fs.access(currentPrefixMapPath); const raw = await fs.readFile(currentPrefixMapPath, 'utf-8'); prefixMap = JSON.parse(raw); logConfig('[prefixMap]', prefixMap); } catch (err) { logWarn('prefix-map.json not found (JSX files will not be processed):', err.message); } // Process JSX files (glob async) const filesToProcess = await glob(pattern, { cwd: currentPagesDir, absolute: true }); logStep('[JSX Files to process]', filesToProcess); await Promise.all(filesToProcess.map(async filePath => { const pageName = path.basename(path.dirname(filePath)); const prefix = prefixMap[pageName]; if (!prefix) { logWarn('[NoPrefix]', 'page:', pageName, 'file:', filePath); return; } const scoped = scopedKeys[prefix]; if (!scoped) { logWarn('[NoScopedKeys]', prefix); return; } await processJSXFile(filePath, pageName, prefix, { dryRun }); })); // Write virtual debug file (for purge safety in v3/v4) if (virtualClassSet.size > 0) { const virtualContent = `export default () => (<div className="${[...virtualClassSet].join(' ')}" />);\n`; logFile('[virtualClassSet]', Array.from(virtualClassSet)); if (!dryRun) await fs.writeFile(currentScopedDebugPath, virtualContent, 'utf-8'); logSuccess('Debug JSX written →', kleur.underline(currentScopedDebugPath)); } else { try { await fs.unlink(currentScopedDebugPath); logWarn('Unlinked debug file:', currentScopedDebugPath); } catch {} } // مسیر نسبی برای فایل debug let relScopedDebugPath = path.relative(ROOT_DIR, currentScopedDebugPath).replace(/\\/g, '/'); if (!relScopedDebugPath.startsWith('.')) relScopedDebugPath = './' + relScopedDebugPath; logStep('[relScopedDebugPath]', relScopedDebugPath); // --- core config --- let coreConfig = {}; try { clearRequireCache(CORE_CONFIG_PATH); coreConfig = require(CORE_CONFIG_PATH); logConfig('[coreConfig loaded]', CORE_CONFIG_PATH); } catch (err) { logWarn('Core config not found or failed to load:', err.message); } // --- جمع رنگ‌ها و spacingها از همه کانفیگ‌ها --- let colors = {}, spacing = {}; for (const cfg of Object.values(configMap)) { const ext = cfg.theme?.extend || {}; if (ext.colors) colors = { ...colors, ...ext.colors }; if (ext.spacing) spacing = { ...spacing, ...ext.spacing }; } // --- Virtual CSS Injection --- if (!dryRun) { await writeVirtualCSS(virtualClassSet, colors, spacing); } // --- Scoped Plugin Inject with DYNAMIC styles from theme --- const plugin = require('tailwindcss/plugin'); const scopedUtilities = {}; for (const className of virtualClassSet) { if (className.startsWith('bg-') && colors[className.slice(3)]) { scopedUtilities[`.${className}`] = { backgroundColor: colors[className.slice(3)] }; } if (className.startsWith('text-') && colors[className.slice(5)]) { scopedUtilities[`.${className}`] = { color: colors[className.slice(5)] }; } if (className.startsWith('border-') && colors[className.slice(7)]) { scopedUtilities[`.${className}`] = { borderColor: colors[className.slice(7)] }; } if (className.startsWith('p-') && spacing[className.slice(2)]) { scopedUtilities[`.${className}`] = { padding: spacing[className.slice(2)] }; } if (className.startsWith('px-') && spacing[className.slice(3)]) { scopedUtilities[`.${className}`] = { paddingLeft: spacing[className.slice(3)], paddingRight: spacing[className.slice(3)], }; } if (className.startsWith('py-') && spacing[className.slice(3)]) { scopedUtilities[`.${className}`] = { paddingTop: spacing[className.slice(3)], paddingBottom: spacing[className.slice(3)], }; } // ... سایر utilityها به نیاز } const injectScopedPlugin = plugin(function({ addUtilities }) { addUtilities(scopedUtilities, { variants: ['responsive', 'hover'] }); }); // --- Merge Plugins --- const mergedPlugins = [ ...(coreConfig.plugins || []), ...allPlugins, injectScopedPlugin, ]; // --- Merge Final Config --- const baseConfig = { ...(coreConfig || {}), content: [ ...(coreConfig.content || []), './index.html', './src/**/*.{js,ts,jsx,tsx}', relScopedDebugPath, './src/styles/_scoped-virtual.css', // Virtual CSS path ], darkMode: coreConfig.darkMode || 'class', plugins: [...new Set(mergedPlugins)], safelist: [ ...new Set([ ...(coreConfig.safelist || []), ...safelistSet, ...Array.from(virtualClassSet), ]), ], }; logConfig('[baseConfig]', baseConfig); const finalConfig = merge(baseConfig, ...Object.values(configMap)); logConfig('[finalConfig]', finalConfig); if (!dryRun) { const configContent = `/** @type {import('tailwindcss').Config} */\nmodule.exports = ${JSON.stringify(finalConfig, null, 2)};`; await fs.writeFile(TAILWIND_CONFIG_OUTPUT_PATH, configContent, 'utf-8'); logSuccess('Tailwind config written →', kleur.underline(TAILWIND_CONFIG_OUTPUT_PATH)); } // Report if (!dryRun) await writeReport(currentReportPath, changedFiles, scopedClasses, finalConfig, report); const endTime = performance.now(); logBuild( `Build complete! ${kleur.green(changedFiles.size + ' files changed')}, ${kleur.green(scopedClasses.size + ' unique scoped classes')} in ${kleur.bold(((endTime - startTime) / 1000).toFixed(2) + 's')}` ); } async function watchScoper(options = {}) { const { pagesDir = 'src/pages', pattern = '**/*.jsx', verbose = false } = options; const currentPagesDir = path.resolve(process.cwd(), pagesDir); const currentConfigDir = path.resolve(process.cwd(), options.configDir || 'tailwind-configs'); globalVerbose = verbose !== undefined ? verbose : true; await buildScoper(options); logStep(kleur.yellow('👀 Watching for changes...')); chokidar.watch(path.join(currentPagesDir, pattern.replace(/\/\*\*\/.+/, '')), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 50, interval: 10 } }) .on('all', async (event, filePath) => { if (filePath.match(/\.(jsx|tsx)$/)) { logWarn(`[Watcher] File change detected: ${kleur.underline(filePath)} (${event})`); await buildScoper(options); } }); chokidar.watch(currentConfigDir, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 50, interval: 10 } }).on('all', async (event, filePath) => { if (filePath.match(/\.(js|cjs|json)$/)) { logWarn(`[Watcher] Config change detected: ${kleur.underline(filePath)} (${event})`); await buildScoper(options); } }); } module.exports = { build: buildScoper, watch: watchScoper };