UNPKG

vue-cssgen

Version:

Инструмент для автоматизации работы со стилями в Vue 3-проектах

280 lines (248 loc) 11.9 kB
// main.js import fs from 'fs'; import path from 'path'; import { globSync } from 'glob'; import crypto from 'crypto'; import defaultRules from './rules/rules.js'; // --- Конфиг --- const currentDir = process.cwd(); const toFileUrl = filePath => `file://${path.resolve(filePath).replace(/\\/g, '/')}`; const configPath = path.join(currentDir, 'vue-cssgen.conf.js'); if (!fs.existsSync(configPath)) throw new Error('Не найден vue-cssgen.conf.js в корне проекта!'); const config = (await import(toFileUrl(configPath))).default; if (!Array.isArray(config.componentsDir) || !config.componentsDir.length) throw new Error('В config.componentsDir должен быть массив файлов/папок.'); const globalCssFile = path.resolve(config.globalCssFile || 'global.css'); // --- Кастомные правила --- let customRules = []; const customRulesPath = path.join(currentDir, 'vue-cssgen-rules', 'project-rules.js'); if (fs.existsSync(customRulesPath)) { customRules = (await import(toFileUrl(customRulesPath))).default || []; } const allRules = [...defaultRules, ...customRules]; // --- Утилиты --- const collectVueFiles = dirs => Array.from(new Set(dirs.flatMap(entry => { const abs = path.resolve(entry); if (!fs.existsSync(abs)) return []; const stat = fs.statSync(abs); if (stat.isFile() && abs.endsWith('.vue')) return [abs]; if (stat.isDirectory()) return globSync(path.join(abs, '**/*.vue').replace(/\\/g, '/'), { nocase: true }); return []; }))); const extractClasses = tpl => { const cleaned = tpl .replace(/<!--[\s\S]*?-->/g, '') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/.*$/gm, ''); const out = []; for (const m of cleaned.matchAll(/class\s*=\s*["']([^"']+)["']/g)) { out.push(...m[1].split(/\s+/).filter(Boolean)); } return out; }; const readCssFile = file => { if (!fs.existsSync(file)) return {}; return Object.fromEntries( Array.from(fs.readFileSync(file, 'utf8').matchAll(/\.([a-zA-Z0-9\-_]+)\s*\{([\s\S]*?)\}/g)) .map(m => [m[1], m[2].trim()]) ); }; // --- Для учёта реализации класса по хешу --- function hashCss(css) { return crypto.createHash('md5').update(css.trim()).digest('hex'); } // --- Сбор данных по всем компонентам --- const vueFiles = collectVueFiles(config.componentsDir); const globalCssMap = readCssFile(globalCssFile); const classUsage = {}; // { cls: Set([файлы]) } const classCssMap = {}; // { cls: { [файл]: css } } const classCssSource = {}; // { cls: { hash: { css, files: [] } } } const usedInComponent = {}; // { файл: Set([классы]) } const deadClasses = {}; // { файл: [классы] } for (const file of vueFiles) { const code = fs.readFileSync(file, 'utf8'); const tMatch = code.match(/<template>([\s\S]*?)<\/template>/); if (!tMatch) continue; const usedClasses = new Set(extractClasses(tMatch[1])); usedInComponent[file] = usedClasses; // Сбор <style> классов const styleBlocks = Array.from(code.matchAll(/<style([\s\S]*?)>([\s\S]*?)<\/style>/gi)).map(m => ({ attrs: m[1].trim(), content: m[2] })); const styleContent = styleBlocks.map(b => b.content).join('\n'); const styleClasses = Object.fromEntries( Array.from(styleContent.matchAll(/\.([a-zA-Z0-9\-_]+)\s*\{([\s\S]*?)\}/g)).map(m => [m[1], m[2].trim()]) ); for (const cls of usedClasses) { if (!(cls in classUsage)) classUsage[cls] = new Set(); classUsage[cls].add(file); if (!classCssMap[cls]) classCssMap[cls] = {}; if (styleClasses[cls]) classCssMap[cls][file] = styleClasses[cls]; // --- Главное: учитываем реализацию по хешу! --- if (!classCssSource[cls]) classCssSource[cls] = {}; const cssVal = styleClasses[cls] || ''; if (cssVal) { const cssHash = hashCss(cssVal); if (!classCssSource[cls][cssHash]) classCssSource[cls][cssHash] = { css: cssVal, files: [] }; classCssSource[cls][cssHash].files.push(file); } } // Мертвые классы for (const cls in styleClasses) { if (!usedClasses.has(cls)) { if (!deadClasses[file]) deadClasses[file] = []; deadClasses[file].push(cls); } } } // Добавить из глобального css, если не хватает реализаций (локализация) for (const cls in globalCssMap) { if (!(cls in classCssSource)) classCssSource[cls] = {}; const cssVal = globalCssMap[cls]; if (cssVal) { const cssHash = hashCss(cssVal); if (!classCssSource[cls][cssHash]) classCssSource[cls][cssHash] = { css: cssVal, files: [] }; classCssSource[cls][cssHash].files.push('__global__'); } } // --- Анализ и запись итоговых стилей --- // 1. Глобальные классы const globalClasses = new Set(); const globalCssFinal = {}; // Тут теперь работает учёт по хешу: for (const cls in classUsage) { if (classUsage[cls].size > 1) { globalClasses.add(cls); // Получаем все реализации (по хешу) const hashes = Object.keys(classCssSource[cls]); if (hashes.length > 1) { // Несколько реализаций — считаем "вариациями", не теряем ни одну! globalCssFinal[cls] = `/* несколько реализаций */\n` + hashes.map((h, i) => `.${cls} {${classCssSource[cls][h].css}} /* использовано в: ${classCssSource[cls][h].files.join(', ')} */` ).join('\n'); } else if (hashes.length === 1) { // Только одна реализация, классический вариант globalCssFinal[cls] = classCssSource[cls][hashes[0]].css; } else { // Если реализации нет — по дефолтному правилу или пусто const rule = allRules.find(r => r.match.test(cls)); globalCssFinal[cls] = rule ? rule.css(cls).match(/\{([\s\S]*?)\}/)[1].trim() : '/*пусто*/'; } } } // 2. Локальные классы const localClasses = {}; for (const cls in classUsage) { if (classUsage[cls].size === 1) { const file = Array.from(classUsage[cls])[0]; if (!localClasses[file]) localClasses[file] = new Set(); localClasses[file].add(cls); } } // 3. Перепроверка на локализацию из глобального (de-globalize) const classesNowLocal = []; for (const cls in globalCssMap) { if (!(cls in classUsage)) continue; // полностью не используется if (classUsage[cls].size === 1) { classesNowLocal.push(cls); } } // 4. Запись глобальных стилей const globalCss = Array.from(globalClasses) .map(cls => { let css = globalCssFinal[cls]; if (typeof css === 'string' && css.startsWith('/* несколько реализаций */')) { // оставить как есть, уже с комментами return css; } return `.${cls} {${css}}`; }).join('\n'); fs.writeFileSync(globalCssFile, globalCss); // 5. Обновление компонентов for (const file of vueFiles) { const code = fs.readFileSync(file, 'utf8'); const tMatch = code.match(/<template>([\s\S]*?)<\/template>/); if (!tMatch) continue; const styleBlocks = Array.from(code.matchAll(/<style([\s\S]*?)>([\s\S]*?)<\/style>/gi)).map(m => ({ attrs: m[1].trim(), content: m[2] })); const styleContent = styleBlocks.map(b => b.content).join('\n'); const existingClasses = Object.fromEntries( Array.from(styleContent.matchAll(/\.([a-zA-Z0-9\-_]+)\s*\{([\s\S]*?)\}/g)).map(m => [m[1], m[2].trim()]) ); const usedClasses = usedInComponent[file]; const newStyles = []; const processed = new Set(); // --- Локальные классы --- if (localClasses[file]) { for (const cls of localClasses[file]) { // --- Получаем реализацию по хешу --- let css = ''; let found = false; // Сначала ищем реализацию этого класса для данного файла по хешу if (classCssMap[cls] && classCssMap[cls][file]) { css = classCssMap[cls][file]; found = true; } else if (classCssSource[cls]) { // Берём первую реализацию, где есть текущий файл for (const hash in classCssSource[cls]) { if (classCssSource[cls][hash].files.includes(file)) { css = classCssSource[cls][hash].css; found = true; break; } } } // Если реализация не найдена — ищем любую (например, раньше был глобальным) if (!found && classCssSource[cls]) { const anyHash = Object.keys(classCssSource[cls])[0]; if (anyHash) css = classCssSource[cls][anyHash].css; } // Если нет — дефолтное правило или пусто if (!css) { const rule = allRules.find(r => r.match.test(cls)); css = rule ? rule.css(cls).match(/\{([\s\S]*?)\}/)[1].trim() : '/*пусто*/'; } newStyles.push(`.${cls} {${css}}`); processed.add(cls); } } // --- "Пустые" классы (нет нигде реализации) --- for (const cls of usedClasses) { if (!processed.has(cls) && !globalClasses.has(cls)) { if (!existingClasses[cls]) { newStyles.push(`.${cls} {/*пусто*/}`); processed.add(cls); } } } // --- Мертвые классы --- if (deadClasses[file]) { for (const cls of deadClasses[file]) { newStyles.push(`// #мертвый-класс-${cls}\n.${cls} {${existingClasses[cls]}}`); } } // --- Обновление style scoped --- let updatedCode = code; const scopedBlock = styleBlocks.find(b => /\bscoped\b/.test(b.attrs)); const styleAttrs = scopedBlock ? scopedBlock.attrs : 'scoped'; const styleRegexScoped = scopedBlock ? new RegExp(`<style${styleAttrs ? '\\s*' + styleAttrs : ''}>[\\s\\S]*?<\\/style>`) : /<style scoped>[\s\S]*?<\/style>/; if (newStyles.length) { const styleBlock = `<style ${styleAttrs}>\n${newStyles.join('\n')}\n</style>`; updatedCode = scopedBlock ? updatedCode.replace(styleRegexScoped, styleBlock) : updatedCode.replace(/<\/template>/, '</template>\n\n' + styleBlock); } else if (scopedBlock) { updatedCode = updatedCode.replace(styleRegexScoped, ''); } if (code !== updatedCode) { fs.writeFileSync(file, updatedCode, 'utf8'); console.log('Обновлен:', file); } } console.log('Глобальные стили обновлены:', globalCssFile);