scss-variable-extractor 1.5.3 ā 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +60 -0
- package/package.json +5 -2
- package/src/index.js +4 -1
- package/src/refactorer.js +35 -4
- package/src/style-organizer.js +499 -0
- package/test/refactorer.test.js +43 -0
- package/test/style-organizer.test.js +106 -0
package/bin/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const { generateVariablesFile, generateReport } = require('../src/generator');
|
|
|
11
11
|
const { refactorScssFiles } = require('../src/refactorer');
|
|
12
12
|
const { analyzeAngularPatterns, refactorAngularPatterns, generateAngularPatternReport } = require('../src/ng-refactorer');
|
|
13
13
|
const { detectBootstrap, migrateBootstrapToMaterial, generateBootstrapReport } = require('../src/bootstrap-migrator');
|
|
14
|
+
const { analyzeStyleOrganization, generateOrganizationReport } = require('../src/style-organizer');
|
|
14
15
|
|
|
15
16
|
const program = new Command();
|
|
16
17
|
|
|
@@ -589,4 +590,63 @@ program
|
|
|
589
590
|
}
|
|
590
591
|
});
|
|
591
592
|
|
|
593
|
+
// Analyze style organization command
|
|
594
|
+
program
|
|
595
|
+
.command('analyze-organization')
|
|
596
|
+
.description('Analyze style organization and get recommendations for best practices')
|
|
597
|
+
.argument('[src]', 'Source directory to scan (optional if using angular.json)')
|
|
598
|
+
.option('--format <format>', 'Report format (table, json, markdown)', 'table')
|
|
599
|
+
.option('--config <path>', 'Path to config file')
|
|
600
|
+
.option('--angular-json <path>', 'Path to angular.json file')
|
|
601
|
+
.option('--project <name>', 'Angular project name (uses default if not specified)')
|
|
602
|
+
.option('--no-angular', 'Disable angular.json integration')
|
|
603
|
+
.action(async (src, options) => {
|
|
604
|
+
try {
|
|
605
|
+
console.log(chalk.cyan.bold('\nš Style Organization Analysis\n'));
|
|
606
|
+
|
|
607
|
+
const config = loadConfig(options.config, {
|
|
608
|
+
useAngularJson: options.angular !== false,
|
|
609
|
+
angularJsonPath: options.angularJson,
|
|
610
|
+
projectName: options.project
|
|
611
|
+
});
|
|
612
|
+
if (src) config.src = src;
|
|
613
|
+
|
|
614
|
+
// Show Angular project info if available
|
|
615
|
+
if (config.angular) {
|
|
616
|
+
console.log(chalk.cyan(`Angular Project: ${config.angular.project}`));
|
|
617
|
+
console.log(chalk.gray(`Prefix: ${config.angular.prefix}`));
|
|
618
|
+
console.log(chalk.gray(`Style: ${config.angular.stylePreprocessor}\n`));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
console.log(chalk.gray(`Scanning: ${config.src}\n`));
|
|
622
|
+
|
|
623
|
+
// Scan files
|
|
624
|
+
const files = await scanScssFiles(config.src, config.ignore);
|
|
625
|
+
console.log(chalk.green(`ā Found ${files.length} SCSS files\n`));
|
|
626
|
+
|
|
627
|
+
// Analyze organization
|
|
628
|
+
const analysis = analyzeStyleOrganization(files, config);
|
|
629
|
+
|
|
630
|
+
// Generate report
|
|
631
|
+
const report = generateOrganizationReport(analysis, options.format);
|
|
632
|
+
console.log(report);
|
|
633
|
+
|
|
634
|
+
// Provide guidance
|
|
635
|
+
if (analysis.recommendations.length > 0) {
|
|
636
|
+
console.log(chalk.yellow.bold('š” Next Steps:\n'));
|
|
637
|
+
console.log(chalk.gray('1. Review recommendations above'));
|
|
638
|
+
console.log(chalk.gray('2. Run "refactor" command to extract variables'));
|
|
639
|
+
console.log(chalk.gray('3. Run "modernize" to remove anti-patterns'));
|
|
640
|
+
console.log(chalk.gray('4. Consider restructuring based on suggested organization'));
|
|
641
|
+
console.log(chalk.gray('5. Use @use instead of @import for better modularity\n'));
|
|
642
|
+
} else {
|
|
643
|
+
console.log(chalk.green('ā Your styles are well organized!\n'));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
} catch (error) {
|
|
647
|
+
console.error(chalk.red('ā Error:'), error.message);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
592
652
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scss-variable-extractor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "Analyzes Angular SCSS files and extracts repeated hardcoded values into reusable variables",
|
|
5
5
|
"bin": {
|
|
6
6
|
"scss-extract": "./bin/cli.js"
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"analyze-patterns": "node bin/cli.js analyze-patterns .",
|
|
15
15
|
"modernize": "node bin/cli.js modernize .",
|
|
16
16
|
"detect-bootstrap": "node bin/cli.js detect-bootstrap .",
|
|
17
|
-
"migrate-bootstrap": "node bin/cli.js migrate-bootstrap ."
|
|
17
|
+
"migrate-bootstrap": "node bin/cli.js migrate-bootstrap .",
|
|
18
|
+
"release": "npm test && npm version patch && npm publish && git push --follow-tags",
|
|
19
|
+
"release:minor": "npm test && npm version minor && npm publish && git push --follow-tags",
|
|
20
|
+
"release:major": "npm test && npm version major && npm publish && git push --follow-tags"
|
|
18
21
|
},
|
|
19
22
|
"keywords": [
|
|
20
23
|
"angular",
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const { refactorScssFiles } = require('./refactorer');
|
|
|
7
7
|
const { analyzeAngularPatterns, refactorAngularPatterns, generateAngularPatternReport } = require('./ng-refactorer');
|
|
8
8
|
const angularParser = require('./angular-parser');
|
|
9
9
|
const { detectBootstrap, migrateBootstrapToMaterial, generateBootstrapReport } = require('./bootstrap-migrator');
|
|
10
|
+
const { analyzeStyleOrganization, generateOrganizationReport } = require('./style-organizer');
|
|
10
11
|
|
|
11
12
|
module.exports = {
|
|
12
13
|
loadConfig,
|
|
@@ -22,5 +23,7 @@ module.exports = {
|
|
|
22
23
|
angularParser,
|
|
23
24
|
detectBootstrap,
|
|
24
25
|
migrateBootstrapToMaterial,
|
|
25
|
-
generateBootstrapReport
|
|
26
|
+
generateBootstrapReport,
|
|
27
|
+
analyzeStyleOrganization,
|
|
28
|
+
generateOrganizationReport
|
|
26
29
|
};
|
package/src/refactorer.js
CHANGED
|
@@ -57,7 +57,7 @@ function refactorFile(content, analysis, variablesFilePath, currentFilePath, con
|
|
|
57
57
|
|
|
58
58
|
// Check if replacements are in safe zones
|
|
59
59
|
matches.forEach(m => {
|
|
60
|
-
if (!isInSafeZone(content, m.index)) {
|
|
60
|
+
if (!isInSafeZone(content, m.index, m.matched)) {
|
|
61
61
|
// Replace the value
|
|
62
62
|
const before = newContent;
|
|
63
63
|
newContent = replaceAt(newContent, m.matched, variableName, m.index);
|
|
@@ -109,14 +109,31 @@ function buildValueMap(analysis) {
|
|
|
109
109
|
function createReplacementRegex(value) {
|
|
110
110
|
// Escape special regex characters
|
|
111
111
|
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
|
|
113
|
+
// Add appropriate boundaries based on value type to prevent partial matches
|
|
114
|
+
|
|
115
|
+
if (/^#[0-9a-fA-F]+$/.test(value)) {
|
|
116
|
+
// Hex colors - no word boundary before #, but check after
|
|
117
|
+
return new RegExp(`${escaped}\\b`, 'g');
|
|
118
|
+
} else if (/\d+(?:px|%|em|rem|vh|vw|pt|ch|ex)$/.test(value)) {
|
|
119
|
+
// Values with CSS units - ensure not part of larger number
|
|
120
|
+
return new RegExp(`(?<!\\d)${escaped}\\b`, 'g');
|
|
121
|
+
} else if (/^-?\d+(?:\.\d+)?$/.test(value)) {
|
|
122
|
+
// Pure numbers (including decimals) - ensure not part of larger number
|
|
123
|
+
return new RegExp(`(?<!\\d)${escaped}(?!\\d)`, 'g');
|
|
124
|
+
} else if (/^rgba?\(/.test(value)) {
|
|
125
|
+
// RGB/RGBA colors - match complete function
|
|
126
|
+
return new RegExp(escaped, 'g');
|
|
127
|
+
} else {
|
|
128
|
+
// Other values (keywords, etc.) - use word boundaries
|
|
129
|
+
return new RegExp(`\\b${escaped}\\b`, 'g');
|
|
130
|
+
}
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/**
|
|
117
134
|
* Checks if a position in content is in a safe zone (should not be replaced)
|
|
118
135
|
*/
|
|
119
|
-
function isInSafeZone(content, position) {
|
|
136
|
+
function isInSafeZone(content, position, matchedValue) {
|
|
120
137
|
// Check for variable declarations
|
|
121
138
|
const lineStart = content.lastIndexOf('\n', position);
|
|
122
139
|
const lineEnd = content.indexOf('\n', position);
|
|
@@ -127,6 +144,20 @@ function isInSafeZone(content, position) {
|
|
|
127
144
|
return true;
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
// Check if we're in the middle of a larger number or value
|
|
148
|
+
const charBefore = content.charAt(position - 1);
|
|
149
|
+
const charAfter = content.charAt(position + matchedValue.length);
|
|
150
|
+
|
|
151
|
+
// Prevent replacement if surrounded by digits (partial number match)
|
|
152
|
+
if (/\d/.test(charBefore) || (/^\d+$/.test(matchedValue) && /\d/.test(charAfter))) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Prevent replacement if it would create invalid variable concatenation
|
|
157
|
+
if (charBefore === '$' || charAfter === '$') {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
130
161
|
// Check for @use, @forward, @import
|
|
131
162
|
if (/@(?:use|forward|import)/.test(line)) {
|
|
132
163
|
return true;
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Analyzes style organization across the project
|
|
6
|
+
* @param {Array<string>} scssFiles - Array of SCSS file paths
|
|
7
|
+
* @param {Object} config - Configuration options
|
|
8
|
+
* @returns {Object} - Organization analysis results
|
|
9
|
+
*/
|
|
10
|
+
function analyzeStyleOrganization(scssFiles, config = {}) {
|
|
11
|
+
const analysis = {
|
|
12
|
+
summary: {
|
|
13
|
+
totalFiles: scssFiles.length,
|
|
14
|
+
componentStyles: 0,
|
|
15
|
+
globalStyles: 0,
|
|
16
|
+
utilityFiles: 0,
|
|
17
|
+
themeFiles: 0,
|
|
18
|
+
mixinFiles: 0
|
|
19
|
+
},
|
|
20
|
+
duplicates: {
|
|
21
|
+
selectors: [],
|
|
22
|
+
rules: [],
|
|
23
|
+
mixins: []
|
|
24
|
+
},
|
|
25
|
+
imports: {
|
|
26
|
+
global: [],
|
|
27
|
+
component: [],
|
|
28
|
+
unused: [],
|
|
29
|
+
circular: []
|
|
30
|
+
},
|
|
31
|
+
recommendations: [],
|
|
32
|
+
structure: {
|
|
33
|
+
current: {},
|
|
34
|
+
suggested: {}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const selectorMap = new Map(); // Track selector usage across files
|
|
39
|
+
const ruleMap = new Map(); // Track identical rule blocks
|
|
40
|
+
const mixinMap = new Map(); // Track mixin definitions
|
|
41
|
+
const importMap = new Map(); // Track import statements
|
|
42
|
+
|
|
43
|
+
scssFiles.forEach(filePath => {
|
|
44
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
45
|
+
const fileName = path.basename(filePath);
|
|
46
|
+
const fileInfo = analyzeFile(content, filePath);
|
|
47
|
+
|
|
48
|
+
// Categorize file type
|
|
49
|
+
if (fileName.includes('theme') || fileName.includes('_variables')) {
|
|
50
|
+
analysis.summary.themeFiles++;
|
|
51
|
+
} else if (fileName.includes('mixin') || fileName.includes('_mixins')) {
|
|
52
|
+
analysis.summary.mixinFiles++;
|
|
53
|
+
} else if (fileName.includes('util') || fileName.includes('helper')) {
|
|
54
|
+
analysis.summary.utilityFiles++;
|
|
55
|
+
} else if (filePath.includes('styles.scss') || filePath.includes('global')) {
|
|
56
|
+
analysis.summary.globalStyles++;
|
|
57
|
+
} else if (fileName.includes('.component.scss')) {
|
|
58
|
+
analysis.summary.componentStyles++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Track selectors
|
|
62
|
+
fileInfo.selectors.forEach(selector => {
|
|
63
|
+
if (!selectorMap.has(selector)) {
|
|
64
|
+
selectorMap.set(selector, []);
|
|
65
|
+
}
|
|
66
|
+
selectorMap.get(selector).push({ file: filePath, content: fileInfo.selectorContent[selector] });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Track rules
|
|
70
|
+
fileInfo.rules.forEach(rule => {
|
|
71
|
+
const ruleKey = rule.replace(/\s+/g, ' ').trim();
|
|
72
|
+
if (!ruleMap.has(ruleKey)) {
|
|
73
|
+
ruleMap.set(ruleKey, []);
|
|
74
|
+
}
|
|
75
|
+
ruleMap.get(ruleKey).push(filePath);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Track mixins
|
|
79
|
+
fileInfo.mixins.forEach(mixin => {
|
|
80
|
+
if (!mixinMap.has(mixin)) {
|
|
81
|
+
mixinMap.set(mixin, []);
|
|
82
|
+
}
|
|
83
|
+
mixinMap.get(mixin).push(filePath);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Track imports
|
|
87
|
+
fileInfo.imports.forEach(importPath => {
|
|
88
|
+
if (!importMap.has(importPath)) {
|
|
89
|
+
importMap.set(importPath, []);
|
|
90
|
+
}
|
|
91
|
+
importMap.get(importPath).push(filePath);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Analyze duplicates
|
|
96
|
+
selectorMap.forEach((files, selector) => {
|
|
97
|
+
if (files.length > 1) {
|
|
98
|
+
analysis.duplicates.selectors.push({
|
|
99
|
+
selector,
|
|
100
|
+
count: files.length,
|
|
101
|
+
files: files.map(f => f.file)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
ruleMap.forEach((files, rule) => {
|
|
107
|
+
if (files.length > 2 && rule.length > 50) { // Only report significant duplicates
|
|
108
|
+
analysis.duplicates.rules.push({
|
|
109
|
+
rule: rule.substring(0, 100) + '...',
|
|
110
|
+
count: files.length,
|
|
111
|
+
files
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
mixinMap.forEach((files, mixin) => {
|
|
117
|
+
if (files.length > 1) {
|
|
118
|
+
analysis.duplicates.mixins.push({
|
|
119
|
+
mixin,
|
|
120
|
+
count: files.length,
|
|
121
|
+
files
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Analyze imports
|
|
127
|
+
analyzeImportPatterns(importMap, analysis, scssFiles);
|
|
128
|
+
|
|
129
|
+
// Generate recommendations
|
|
130
|
+
generateRecommendations(analysis, config);
|
|
131
|
+
|
|
132
|
+
// Suggest structure
|
|
133
|
+
suggestStructure(analysis, config);
|
|
134
|
+
|
|
135
|
+
return analysis;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Analyzes a single SCSS file
|
|
140
|
+
* @param {string} content - File content
|
|
141
|
+
* @param {string} filePath - File path
|
|
142
|
+
* @returns {Object} - File analysis
|
|
143
|
+
*/
|
|
144
|
+
function analyzeFile(content, filePath) {
|
|
145
|
+
const selectors = [];
|
|
146
|
+
const selectorContent = {};
|
|
147
|
+
const rules = [];
|
|
148
|
+
const mixins = [];
|
|
149
|
+
const imports = [];
|
|
150
|
+
|
|
151
|
+
// Extract selectors
|
|
152
|
+
const selectorRegex = /([.#]?[\w-]+(?:\s*[>+~]\s*[\w-]+)*)\s*\{([^}]*)\}/g;
|
|
153
|
+
let match;
|
|
154
|
+
while ((match = selectorRegex.exec(content)) !== null) {
|
|
155
|
+
const selector = match[1].trim();
|
|
156
|
+
const ruleContent = match[2].trim();
|
|
157
|
+
selectors.push(selector);
|
|
158
|
+
selectorContent[selector] = ruleContent;
|
|
159
|
+
if (ruleContent.length > 20) {
|
|
160
|
+
rules.push(match[0]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Extract mixins
|
|
165
|
+
const mixinRegex = /@mixin\s+([\w-]+)/g;
|
|
166
|
+
while ((match = mixinRegex.exec(content)) !== null) {
|
|
167
|
+
mixins.push(match[1]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Extract imports
|
|
171
|
+
const importRegex = /@(?:import|use|forward)\s+['"]([^'"]+)['"]/g;
|
|
172
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
173
|
+
imports.push(match[1]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
selectors,
|
|
178
|
+
selectorContent,
|
|
179
|
+
rules,
|
|
180
|
+
mixins,
|
|
181
|
+
imports
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Analyzes import patterns
|
|
187
|
+
* @param {Map} importMap - Map of imports
|
|
188
|
+
* @param {Object} analysis - Analysis object
|
|
189
|
+
* @param {Array} scssFiles - All SCSS files
|
|
190
|
+
*/
|
|
191
|
+
function analyzeImportPatterns(importMap, analysis, scssFiles) {
|
|
192
|
+
const fileSet = new Set(scssFiles.map(f => path.basename(f, '.scss')));
|
|
193
|
+
|
|
194
|
+
importMap.forEach((importingFiles, importPath) => {
|
|
195
|
+
const isGlobal = importPath.includes('variables') ||
|
|
196
|
+
importPath.includes('theme') ||
|
|
197
|
+
importPath.includes('mixins');
|
|
198
|
+
|
|
199
|
+
if (isGlobal && importingFiles.length > 5) {
|
|
200
|
+
analysis.imports.global.push({
|
|
201
|
+
path: importPath,
|
|
202
|
+
usedIn: importingFiles.length,
|
|
203
|
+
suggestion: 'Consider adding to global styles.scss'
|
|
204
|
+
});
|
|
205
|
+
} else if (importingFiles.length === 1) {
|
|
206
|
+
analysis.imports.unused.push({
|
|
207
|
+
path: importPath,
|
|
208
|
+
file: importingFiles[0],
|
|
209
|
+
suggestion: 'Potentially unused or should be in component file'
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check for component-level imports
|
|
214
|
+
importingFiles.forEach(file => {
|
|
215
|
+
if (file.includes('.component.scss')) {
|
|
216
|
+
analysis.imports.component.push({
|
|
217
|
+
path: importPath,
|
|
218
|
+
component: path.basename(file)
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Generates organization recommendations
|
|
227
|
+
* @param {Object} analysis - Analysis object
|
|
228
|
+
* @param {Object} config - Configuration
|
|
229
|
+
*/
|
|
230
|
+
function generateRecommendations(analysis, config) {
|
|
231
|
+
const recs = analysis.recommendations;
|
|
232
|
+
|
|
233
|
+
// Duplicate selectors
|
|
234
|
+
if (analysis.duplicates.selectors.length > 0) {
|
|
235
|
+
recs.push({
|
|
236
|
+
priority: 'high',
|
|
237
|
+
category: 'duplication',
|
|
238
|
+
title: 'Duplicate selectors detected',
|
|
239
|
+
description: `Found ${analysis.duplicates.selectors.length} selectors used in multiple files`,
|
|
240
|
+
action: 'Extract common selectors to shared utility files or use mixins',
|
|
241
|
+
files: analysis.duplicates.selectors.slice(0, 5)
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Duplicate rules
|
|
246
|
+
if (analysis.duplicates.rules.length > 0) {
|
|
247
|
+
recs.push({
|
|
248
|
+
priority: 'high',
|
|
249
|
+
category: 'duplication',
|
|
250
|
+
title: 'Duplicate CSS rules detected',
|
|
251
|
+
description: `Found ${analysis.duplicates.rules.length} identical rule blocks`,
|
|
252
|
+
action: 'Create reusable mixins or utility classes',
|
|
253
|
+
count: analysis.duplicates.rules.length
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Component vs global styles
|
|
258
|
+
if (analysis.summary.componentStyles > 10 && analysis.summary.globalStyles === 0) {
|
|
259
|
+
recs.push({
|
|
260
|
+
priority: 'medium',
|
|
261
|
+
category: 'organization',
|
|
262
|
+
title: 'No global styles file detected',
|
|
263
|
+
description: 'Consider creating a global styles.scss for shared utilities and resets',
|
|
264
|
+
action: 'Create src/styles.scss with @use imports'
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Import optimization
|
|
269
|
+
if (analysis.imports.global.length > 3) {
|
|
270
|
+
recs.push({
|
|
271
|
+
priority: 'medium',
|
|
272
|
+
category: 'imports',
|
|
273
|
+
title: 'Optimize global imports',
|
|
274
|
+
description: `${analysis.imports.global.length} files are imported globally across many components`,
|
|
275
|
+
action: 'Consolidate into a single theme file or add to styles.scss',
|
|
276
|
+
files: analysis.imports.global.map(i => i.path)
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Theme organization
|
|
281
|
+
if (analysis.summary.themeFiles === 0 && analysis.summary.componentStyles > 5) {
|
|
282
|
+
recs.push({
|
|
283
|
+
priority: 'medium',
|
|
284
|
+
category: 'theming',
|
|
285
|
+
title: 'No theme structure detected',
|
|
286
|
+
description: 'Consider organizing styles following Angular Material theming patterns',
|
|
287
|
+
action: 'Create theme files: _variables.scss, _typography.scss, _theme.scss'
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Mixin organization
|
|
292
|
+
if (analysis.duplicates.mixins.length > 0) {
|
|
293
|
+
recs.push({
|
|
294
|
+
priority: 'low',
|
|
295
|
+
category: 'mixins',
|
|
296
|
+
title: 'Duplicate mixin definitions',
|
|
297
|
+
description: `${analysis.duplicates.mixins.length} mixins defined in multiple files`,
|
|
298
|
+
action: 'Consolidate mixins into a shared _mixins.scss file'
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Suggests ideal folder structure
|
|
305
|
+
* @param {Object} analysis - Analysis object
|
|
306
|
+
* @param {Object} config - Configuration
|
|
307
|
+
*/
|
|
308
|
+
function suggestStructure(analysis, config) {
|
|
309
|
+
const angularConfigured = config.angular !== undefined;
|
|
310
|
+
|
|
311
|
+
analysis.structure.current = {
|
|
312
|
+
componentStyles: analysis.summary.componentStyles,
|
|
313
|
+
globalStyles: analysis.summary.globalStyles,
|
|
314
|
+
themeFiles: analysis.summary.themeFiles,
|
|
315
|
+
utilityFiles: analysis.summary.utilityFiles,
|
|
316
|
+
mixinFiles: analysis.summary.mixinFiles
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
analysis.structure.suggested = {
|
|
320
|
+
description: 'Recommended Angular Material theme structure',
|
|
321
|
+
folders: [
|
|
322
|
+
{
|
|
323
|
+
path: 'src/styles/',
|
|
324
|
+
files: [
|
|
325
|
+
'_variables.scss - Design tokens (colors, spacing, typography)',
|
|
326
|
+
'_mixins.scss - Reusable SCSS mixins',
|
|
327
|
+
'_utilities.scss - Utility classes (flex, spacing, etc.)',
|
|
328
|
+
'_theme.scss - Angular Material theme configuration',
|
|
329
|
+
'_typography.scss - Typography styles',
|
|
330
|
+
'styles.scss - Global entry point'
|
|
331
|
+
]
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
path: 'src/app/components/',
|
|
335
|
+
files: [
|
|
336
|
+
'*.component.scss - Component-scoped styles only',
|
|
337
|
+
'Use @use for variables/mixins instead of @import'
|
|
338
|
+
]
|
|
339
|
+
}
|
|
340
|
+
],
|
|
341
|
+
imports: {
|
|
342
|
+
global: [
|
|
343
|
+
'@use "@angular/material" as mat;',
|
|
344
|
+
'@use "./styles/variables" as vars;',
|
|
345
|
+
'@use "./styles/mixins";',
|
|
346
|
+
'@use "./styles/utilities";'
|
|
347
|
+
],
|
|
348
|
+
component: [
|
|
349
|
+
'@use "../../../styles/variables" as vars;',
|
|
350
|
+
'@use "../../../styles/mixins";'
|
|
351
|
+
]
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Generates organization report
|
|
358
|
+
* @param {Object} analysis - Analysis results
|
|
359
|
+
* @param {string} format - Report format
|
|
360
|
+
* @returns {string} - Formatted report
|
|
361
|
+
*/
|
|
362
|
+
function generateOrganizationReport(analysis, format = 'table') {
|
|
363
|
+
if (format === 'json') {
|
|
364
|
+
return JSON.stringify(analysis, null, 2);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (format === 'markdown') {
|
|
368
|
+
return generateMarkdownOrganizationReport(analysis);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Table format
|
|
372
|
+
const chalk = require('chalk');
|
|
373
|
+
const Table = require('cli-table3');
|
|
374
|
+
|
|
375
|
+
let report = chalk.cyan.bold('\nš Style Organization Analysis\n\n');
|
|
376
|
+
|
|
377
|
+
// Summary
|
|
378
|
+
report += chalk.white.bold('Summary:\n');
|
|
379
|
+
const summaryTable = new Table({
|
|
380
|
+
head: ['Metric', 'Count'],
|
|
381
|
+
style: { head: ['cyan'] }
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
summaryTable.push(
|
|
385
|
+
['Total SCSS Files', analysis.summary.totalFiles],
|
|
386
|
+
['Component Styles', analysis.summary.componentStyles],
|
|
387
|
+
['Global Styles', analysis.summary.globalStyles],
|
|
388
|
+
['Theme Files', analysis.summary.themeFiles],
|
|
389
|
+
['Utility Files', analysis.summary.utilityFiles],
|
|
390
|
+
['Mixin Files', analysis.summary.mixinFiles]
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
report += summaryTable.toString() + '\n\n';
|
|
394
|
+
|
|
395
|
+
// Duplicates
|
|
396
|
+
if (analysis.duplicates.selectors.length > 0 ||
|
|
397
|
+
analysis.duplicates.rules.length > 0) {
|
|
398
|
+
report += chalk.yellow.bold('Duplication Issues:\n');
|
|
399
|
+
const dupTable = new Table({
|
|
400
|
+
head: ['Type', 'Count', 'Example'],
|
|
401
|
+
style: { head: ['yellow'] }
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
if (analysis.duplicates.selectors.length > 0) {
|
|
405
|
+
const example = analysis.duplicates.selectors[0];
|
|
406
|
+
dupTable.push([
|
|
407
|
+
'Duplicate Selectors',
|
|
408
|
+
analysis.duplicates.selectors.length,
|
|
409
|
+
`${example.selector} (${example.count} files)`
|
|
410
|
+
]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (analysis.duplicates.rules.length > 0) {
|
|
414
|
+
dupTable.push([
|
|
415
|
+
'Duplicate Rules',
|
|
416
|
+
analysis.duplicates.rules.length,
|
|
417
|
+
'Identical CSS blocks'
|
|
418
|
+
]);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (analysis.duplicates.mixins.length > 0) {
|
|
422
|
+
dupTable.push([
|
|
423
|
+
'Duplicate Mixins',
|
|
424
|
+
analysis.duplicates.mixins.length,
|
|
425
|
+
analysis.duplicates.mixins[0].mixin
|
|
426
|
+
]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
report += dupTable.toString() + '\n\n';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Recommendations
|
|
433
|
+
if (analysis.recommendations.length > 0) {
|
|
434
|
+
report += chalk.green.bold('Recommendations:\n\n');
|
|
435
|
+
analysis.recommendations.forEach((rec, i) => {
|
|
436
|
+
const priorityColor = rec.priority === 'high' ? 'red' :
|
|
437
|
+
rec.priority === 'medium' ? 'yellow' : 'gray';
|
|
438
|
+
report += chalk[priorityColor](`${i + 1}. [${rec.priority.toUpperCase()}] ${rec.title}\n`);
|
|
439
|
+
report += chalk.gray(` ${rec.description}\n`);
|
|
440
|
+
report += chalk.white(` ā ${rec.action}\n\n`);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Suggested structure
|
|
445
|
+
report += chalk.blue.bold('Suggested Structure:\n\n');
|
|
446
|
+
analysis.structure.suggested.folders.forEach(folder => {
|
|
447
|
+
report += chalk.cyan(`${folder.path}\n`);
|
|
448
|
+
folder.files.forEach(file => {
|
|
449
|
+
report += chalk.gray(` ⢠${file}\n`);
|
|
450
|
+
});
|
|
451
|
+
report += '\n';
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return report;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Generates markdown report
|
|
459
|
+
* @param {Object} analysis - Analysis results
|
|
460
|
+
* @returns {string} - Markdown formatted report
|
|
461
|
+
*/
|
|
462
|
+
function generateMarkdownOrganizationReport(analysis) {
|
|
463
|
+
let md = '# Style Organization Analysis\n\n';
|
|
464
|
+
|
|
465
|
+
md += '## Summary\n\n';
|
|
466
|
+
md += '| Metric | Count |\n';
|
|
467
|
+
md += '|--------|-------|\n';
|
|
468
|
+
md += `| Total SCSS Files | ${analysis.summary.totalFiles} |\n`;
|
|
469
|
+
md += `| Component Styles | ${analysis.summary.componentStyles} |\n`;
|
|
470
|
+
md += `| Global Styles | ${analysis.summary.globalStyles} |\n`;
|
|
471
|
+
md += `| Theme Files | ${analysis.summary.themeFiles} |\n`;
|
|
472
|
+
md += `| Utility Files | ${analysis.summary.utilityFiles} |\n`;
|
|
473
|
+
md += `| Mixin Files | ${analysis.summary.mixinFiles} |\n\n`;
|
|
474
|
+
|
|
475
|
+
if (analysis.recommendations.length > 0) {
|
|
476
|
+
md += '## Recommendations\n\n';
|
|
477
|
+
analysis.recommendations.forEach((rec, i) => {
|
|
478
|
+
md += `### ${i + 1}. ${rec.title} (${rec.priority})\n\n`;
|
|
479
|
+
md += `**Description:** ${rec.description}\n\n`;
|
|
480
|
+
md += `**Action:** ${rec.action}\n\n`;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
md += '## Suggested Structure\n\n';
|
|
485
|
+
analysis.structure.suggested.folders.forEach(folder => {
|
|
486
|
+
md += `### ${folder.path}\n\n`;
|
|
487
|
+
folder.files.forEach(file => {
|
|
488
|
+
md += `- ${file}\n`;
|
|
489
|
+
});
|
|
490
|
+
md += '\n';
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
return md;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
module.exports = {
|
|
497
|
+
analyzeStyleOrganization,
|
|
498
|
+
generateOrganizationReport
|
|
499
|
+
};
|
package/test/refactorer.test.js
CHANGED
|
@@ -124,4 +124,47 @@ describe('Refactorer', () => {
|
|
|
124
124
|
// Should not replace in variable declaration
|
|
125
125
|
expect(result.content).toContain('$my-color: #1976d2');
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
test('should not do partial replacements within larger values', () => {
|
|
129
|
+
const scss = `
|
|
130
|
+
@media (min-width: 768px) {
|
|
131
|
+
.container {
|
|
132
|
+
width: 233px;
|
|
133
|
+
padding: 8px;
|
|
134
|
+
z-index: 100;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
const analysis = {
|
|
140
|
+
colors: [],
|
|
141
|
+
spacing: [
|
|
142
|
+
{ value: '8px', suggestedName: '$spacing-sm' }
|
|
143
|
+
],
|
|
144
|
+
fontSizes: [],
|
|
145
|
+
fontWeights: [],
|
|
146
|
+
fontFamilies: [],
|
|
147
|
+
borderRadius: [],
|
|
148
|
+
shadows: [],
|
|
149
|
+
zIndex: [
|
|
150
|
+
{ value: '100', suggestedName: '$z-index-modal' }
|
|
151
|
+
],
|
|
152
|
+
sizing: [],
|
|
153
|
+
lineHeight: [],
|
|
154
|
+
opacity: [],
|
|
155
|
+
transitions: []
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
|
|
159
|
+
|
|
160
|
+
// Should replace complete values
|
|
161
|
+
expect(result.content).toContain('padding: $spacing-sm');
|
|
162
|
+
expect(result.content).toContain('z-index: $z-index-modal');
|
|
163
|
+
|
|
164
|
+
// Should NOT do partial replacements
|
|
165
|
+
expect(result.content).toContain('768px'); // Not 76$spacing-smpx
|
|
166
|
+
expect(result.content).toContain('233px'); // Not 2$spacing-sm$spacing-smpx
|
|
167
|
+
expect(result.content).not.toContain('76$spacing-sm');
|
|
168
|
+
expect(result.content).not.toContain('$z-index23');
|
|
169
|
+
});
|
|
127
170
|
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const { analyzeStyleOrganization, generateOrganizationReport } = require('../src/style-organizer');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
describe('Style Organizer', () => {
|
|
6
|
+
const fixturesPath = path.join(__dirname, 'fixtures/apps/subapp/src');
|
|
7
|
+
|
|
8
|
+
describe('analyzeStyleOrganization', () => {
|
|
9
|
+
test('should analyze style organization', async () => {
|
|
10
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
11
|
+
const files = await scanScssFiles(fixturesPath);
|
|
12
|
+
|
|
13
|
+
const analysis = analyzeStyleOrganization(files);
|
|
14
|
+
|
|
15
|
+
expect(analysis).toHaveProperty('summary');
|
|
16
|
+
expect(analysis).toHaveProperty('duplicates');
|
|
17
|
+
expect(analysis).toHaveProperty('imports');
|
|
18
|
+
expect(analysis).toHaveProperty('recommendations');
|
|
19
|
+
expect(analysis).toHaveProperty('structure');
|
|
20
|
+
|
|
21
|
+
expect(analysis.summary.totalFiles).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('should categorize file types', async () => {
|
|
25
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
26
|
+
const files = await scanScssFiles(fixturesPath);
|
|
27
|
+
|
|
28
|
+
const analysis = analyzeStyleOrganization(files);
|
|
29
|
+
|
|
30
|
+
expect(analysis.summary).toHaveProperty('componentStyles');
|
|
31
|
+
expect(analysis.summary).toHaveProperty('globalStyles');
|
|
32
|
+
expect(analysis.summary).toHaveProperty('themeFiles');
|
|
33
|
+
expect(analysis.summary).toHaveProperty('utilityFiles');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('should detect duplicate selectors', () => {
|
|
37
|
+
const testFiles = [
|
|
38
|
+
path.join(__dirname, 'fixtures/apps/subapp/src/app/component-a/component-a.component.scss'),
|
|
39
|
+
path.join(__dirname, 'fixtures/apps/subapp/src/app/component-b/component-b.component.scss')
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const analysis = analyzeStyleOrganization(testFiles);
|
|
43
|
+
|
|
44
|
+
expect(analysis.duplicates).toHaveProperty('selectors');
|
|
45
|
+
expect(Array.isArray(analysis.duplicates.selectors)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('should generate recommendations', async () => {
|
|
49
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
50
|
+
const files = await scanScssFiles(fixturesPath);
|
|
51
|
+
|
|
52
|
+
const analysis = analyzeStyleOrganization(files);
|
|
53
|
+
|
|
54
|
+
expect(Array.isArray(analysis.recommendations)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('should suggest structure', async () => {
|
|
58
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
59
|
+
const files = await scanScssFiles(fixturesPath);
|
|
60
|
+
|
|
61
|
+
const analysis = analyzeStyleOrganization(files);
|
|
62
|
+
|
|
63
|
+
expect(analysis.structure).toHaveProperty('current');
|
|
64
|
+
expect(analysis.structure).toHaveProperty('suggested');
|
|
65
|
+
expect(analysis.structure.suggested).toHaveProperty('folders');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('generateOrganizationReport', () => {
|
|
70
|
+
test('should generate table report', async () => {
|
|
71
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
72
|
+
const files = await scanScssFiles(fixturesPath);
|
|
73
|
+
const analysis = analyzeStyleOrganization(files);
|
|
74
|
+
|
|
75
|
+
const report = generateOrganizationReport(analysis, 'table');
|
|
76
|
+
|
|
77
|
+
expect(report).toContain('Style Organization Analysis');
|
|
78
|
+
expect(report).toContain('Summary');
|
|
79
|
+
expect(typeof report).toBe('string');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('should generate JSON report', async () => {
|
|
83
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
84
|
+
const files = await scanScssFiles(fixturesPath);
|
|
85
|
+
const analysis = analyzeStyleOrganization(files);
|
|
86
|
+
|
|
87
|
+
const report = generateOrganizationReport(analysis, 'json');
|
|
88
|
+
|
|
89
|
+
const parsed = JSON.parse(report);
|
|
90
|
+
expect(parsed).toHaveProperty('summary');
|
|
91
|
+
expect(parsed).toHaveProperty('recommendations');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('should generate Markdown report', async () => {
|
|
95
|
+
const { scanScssFiles } = require('../src/scanner');
|
|
96
|
+
const files = await scanScssFiles(fixturesPath);
|
|
97
|
+
const analysis = analyzeStyleOrganization(files);
|
|
98
|
+
|
|
99
|
+
const report = generateOrganizationReport(analysis, 'markdown');
|
|
100
|
+
|
|
101
|
+
expect(report).toContain('# Style Organization Analysis');
|
|
102
|
+
expect(report).toContain('## Summary');
|
|
103
|
+
expect(report).toContain('## Suggested Structure');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|