vue-cssgen
Version:
Инструмент для автоматизации работы со стилями в Vue 3-проектах
280 lines (248 loc) • 11.9 kB
JavaScript
// 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);