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
JavaScript
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 };