scss-variable-extractor 1.0.0

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/jest.config.js ADDED
@@ -0,0 +1,12 @@
1
+ module.exports = {
2
+ testEnvironment: 'node',
3
+ collectCoverageFrom: [
4
+ 'src/**/*.js',
5
+ '!src/**/*.test.js'
6
+ ],
7
+ testMatch: [
8
+ '**/test/**/*.test.js'
9
+ ],
10
+ coverageDirectory: 'coverage',
11
+ verbose: true
12
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "scss-variable-extractor",
3
+ "version": "1.0.0",
4
+ "description": "Analyzes Angular SCSS files and extracts repeated hardcoded values into reusable variables",
5
+ "bin": {
6
+ "scss-extract": "./bin/cli.js"
7
+ },
8
+ "main": "src/index.js",
9
+ "scripts": {
10
+ "test": "jest",
11
+ "analyze": "node bin/cli.js analyze",
12
+ "generate": "node bin/cli.js generate",
13
+ "refactor": "node bin/cli.js refactor"
14
+ },
15
+ "keywords": [
16
+ "angular",
17
+ "scss",
18
+ "sass",
19
+ "css",
20
+ "variables",
21
+ "refactoring",
22
+ "cli",
23
+ "angular-material"
24
+ ],
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=14.0.0"
28
+ },
29
+ "dependencies": {
30
+ "commander": "^9.0.0",
31
+ "glob": "^8.0.0",
32
+ "chalk": "^4.1.2",
33
+ "cli-table3": "^0.6.3"
34
+ },
35
+ "devDependencies": {
36
+ "jest": "^29.0.0"
37
+ }
38
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Analyzes extracted values and identifies repeated ones
3
+ */
4
+
5
+ /**
6
+ * Analyzes all extracted values from multiple files
7
+ * @param {Array<Object>} allExtracted - Array of extracted values from all files
8
+ * @param {Object} config - Configuration object
9
+ * @returns {Object} - Analysis results with repeated values
10
+ */
11
+ function analyzeValues(allExtracted, config) {
12
+ const analysis = {
13
+ colors: {},
14
+ spacing: {},
15
+ fontSizes: {},
16
+ fontWeights: {},
17
+ fontFamilies: {},
18
+ borderRadius: {},
19
+ shadows: {},
20
+ zIndex: {},
21
+ sizing: {},
22
+ lineHeight: {},
23
+ opacity: {},
24
+ transitions: {}
25
+ };
26
+
27
+ // Count occurrences of each value
28
+ Object.keys(allExtracted).forEach(category => {
29
+ if (!config.categories[category]) return;
30
+
31
+ allExtracted[category].forEach(item => {
32
+ const normalizedValue = normalizeValue(item.value, category);
33
+
34
+ if (!analysis[category][normalizedValue]) {
35
+ analysis[category][normalizedValue] = {
36
+ value: item.value,
37
+ count: 0,
38
+ files: new Set(),
39
+ occurrences: []
40
+ };
41
+ }
42
+
43
+ analysis[category][normalizedValue].count++;
44
+ analysis[category][normalizedValue].files.add(item.file);
45
+ analysis[category][normalizedValue].occurrences.push({
46
+ file: item.file,
47
+ line: item.line,
48
+ context: item.context,
49
+ property: item.property
50
+ });
51
+ });
52
+ });
53
+
54
+ // Filter by threshold and generate variable names
55
+ const results = {};
56
+
57
+ Object.keys(analysis).forEach(category => {
58
+ results[category] = [];
59
+
60
+ Object.keys(analysis[category]).forEach(normalizedValue => {
61
+ const data = analysis[category][normalizedValue];
62
+
63
+ if (data.count >= config.threshold) {
64
+ results[category].push({
65
+ value: data.value,
66
+ normalizedValue,
67
+ count: data.count,
68
+ fileCount: data.files.size,
69
+ files: Array.from(data.files),
70
+ occurrences: data.occurrences,
71
+ suggestedName: generateVariableName(category, data.value, data.occurrences, config)
72
+ });
73
+ }
74
+ });
75
+
76
+ // Sort by count (descending)
77
+ results[category].sort((a, b) => b.count - a.count);
78
+ });
79
+
80
+ return results;
81
+ }
82
+
83
+ /**
84
+ * Normalizes value for comparison
85
+ */
86
+ function normalizeValue(value, category) {
87
+ if (category === 'colors') {
88
+ return normalizeColor(value);
89
+ }
90
+ return value.toLowerCase().trim();
91
+ }
92
+
93
+ /**
94
+ * Normalizes color values for comparison
95
+ */
96
+ function normalizeColor(color) {
97
+ color = color.toLowerCase().trim();
98
+
99
+ // Convert 3-digit hex to 6-digit
100
+ if (/^#[0-9a-f]{3}$/i.test(color)) {
101
+ color = '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3];
102
+ }
103
+
104
+ // Normalize rgb/rgba spacing
105
+ color = color.replace(/\s+/g, '');
106
+
107
+ return color;
108
+ }
109
+
110
+ /**
111
+ * Generates a variable name based on category and value
112
+ */
113
+ function generateVariableName(category, value, occurrences, config) {
114
+ const prefix = getCategoryPrefix(category);
115
+
116
+ if (category === 'spacing') {
117
+ return generateSpacingVariableName(value, config);
118
+ } else if (category === 'colors') {
119
+ return generateColorVariableName(value, occurrences);
120
+ } else if (category === 'fontSizes') {
121
+ return generateFontSizeVariableName(value);
122
+ } else if (category === 'fontWeights') {
123
+ return generateFontWeightVariableName(value);
124
+ } else if (category === 'fontFamilies') {
125
+ return prefix + sanitizeName(value);
126
+ } else if (category === 'borderRadius') {
127
+ return generateBorderRadiusVariableName(value);
128
+ } else if (category === 'shadows') {
129
+ return prefix + 'default';
130
+ } else if (category === 'zIndex') {
131
+ return prefix + value;
132
+ } else if (category === 'sizing') {
133
+ return prefix + value.replace('px', '');
134
+ } else if (category === 'lineHeight') {
135
+ return prefix + sanitizeName(value);
136
+ } else if (category === 'opacity') {
137
+ return prefix + sanitizeName(value);
138
+ } else if (category === 'transitions') {
139
+ return prefix + 'default';
140
+ }
141
+
142
+ return prefix + 'value';
143
+ }
144
+
145
+ /**
146
+ * Gets prefix for a category
147
+ */
148
+ function getCategoryPrefix(category) {
149
+ const prefixes = {
150
+ colors: '$color-',
151
+ spacing: '$spacing-',
152
+ fontSizes: '$font-size-',
153
+ fontWeights: '$font-weight-',
154
+ fontFamilies: '$font-family-',
155
+ borderRadius: '$border-radius-',
156
+ shadows: '$shadow-',
157
+ zIndex: '$z-index-',
158
+ sizing: '$size-',
159
+ lineHeight: '$line-height-',
160
+ opacity: '$opacity-',
161
+ transitions: '$transition-'
162
+ };
163
+
164
+ return prefixes[category] || '$';
165
+ }
166
+
167
+ /**
168
+ * Generates spacing variable name using t-shirt sizing
169
+ */
170
+ function generateSpacingVariableName(value, config) {
171
+ const spacingScale = config.spacingScale;
172
+
173
+ // Try to match to spacing scale
174
+ for (const [size, scaleValue] of Object.entries(spacingScale)) {
175
+ if (scaleValue === value) {
176
+ return `$spacing-${size}`;
177
+ }
178
+ }
179
+
180
+ // Fallback to pixel value
181
+ return `$spacing-${value.replace('px', '')}`;
182
+ }
183
+
184
+ /**
185
+ * Generates color variable name
186
+ */
187
+ function generateColorVariableName(value, occurrences) {
188
+ // Try to detect common color patterns
189
+ const normalizedColor = normalizeColor(value);
190
+
191
+ // Check for common Material colors
192
+ if (normalizedColor === '#1976d2' || normalizedColor === 'rgb(25,118,210)') {
193
+ return '$color-brand-primary';
194
+ }
195
+ if (normalizedColor === 'rgba(0,0,0,0.87)') {
196
+ return '$color-text-primary';
197
+ }
198
+ if (normalizedColor === 'rgba(0,0,0,0.54)') {
199
+ return '$color-text-secondary';
200
+ }
201
+ if (normalizedColor === '#ffffff' || normalizedColor === 'white') {
202
+ return '$color-white';
203
+ }
204
+ if (normalizedColor === '#000000' || normalizedColor === 'black') {
205
+ return '$color-black';
206
+ }
207
+
208
+ // Try to derive from context
209
+ if (occurrences && occurrences.length > 0) {
210
+ const context = occurrences[0].context || '';
211
+ if (context.includes('background')) return '$color-background';
212
+ if (context.includes('border')) return '$color-border';
213
+ if (context.includes('text') || context.includes('color:')) return '$color-text';
214
+ }
215
+
216
+ // Fallback to hex value
217
+ return '$color-' + sanitizeName(value);
218
+ }
219
+
220
+ /**
221
+ * Generates font size variable name
222
+ */
223
+ function generateFontSizeVariableName(value) {
224
+ const sizeMap = {
225
+ '12px': 'xs',
226
+ '14px': 'sm',
227
+ '16px': 'md',
228
+ '18px': 'lg',
229
+ '20px': 'xl',
230
+ '24px': 'xxl'
231
+ };
232
+
233
+ return `$font-size-${sizeMap[value] || value.replace('px', '')}`;
234
+ }
235
+
236
+ /**
237
+ * Generates font weight variable name
238
+ */
239
+ function generateFontWeightVariableName(value) {
240
+ const weightMap = {
241
+ '300': 'light',
242
+ '400': 'normal',
243
+ '500': 'medium',
244
+ '600': 'semibold',
245
+ '700': 'bold',
246
+ 'bold': 'bold',
247
+ 'normal': 'normal'
248
+ };
249
+
250
+ return `$font-weight-${weightMap[value] || value}`;
251
+ }
252
+
253
+ /**
254
+ * Generates border radius variable name
255
+ */
256
+ function generateBorderRadiusVariableName(value) {
257
+ if (value === '50%') return '$border-radius-circle';
258
+
259
+ const radiusMap = {
260
+ '2px': 'xs',
261
+ '4px': 'sm',
262
+ '8px': 'md',
263
+ '12px': 'lg',
264
+ '16px': 'xl'
265
+ };
266
+
267
+ return `$border-radius-${radiusMap[value] || value.replace(/px|%/, '')}`;
268
+ }
269
+
270
+ /**
271
+ * Sanitizes a value to be a valid variable name
272
+ */
273
+ function sanitizeName(value) {
274
+ return value
275
+ .replace(/[^a-zA-Z0-9-]/g, '-')
276
+ .replace(/-+/g, '-')
277
+ .replace(/^-|-$/g, '')
278
+ .toLowerCase();
279
+ }
280
+
281
+ module.exports = {
282
+ analyzeValues,
283
+ normalizeValue,
284
+ generateVariableName
285
+ };
package/src/config.js ADDED
@@ -0,0 +1,82 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULT_CONFIG = {
5
+ src: './apps/subapp/src',
6
+ output: './libs/styles/_variables.scss',
7
+ threshold: 2,
8
+ categories: {
9
+ colors: true,
10
+ spacing: true,
11
+ fontSizes: true,
12
+ fontWeights: true,
13
+ fontFamilies: true,
14
+ borderRadius: true,
15
+ shadows: true,
16
+ zIndex: true,
17
+ sizing: true,
18
+ lineHeight: true,
19
+ opacity: true,
20
+ transitions: true
21
+ },
22
+ spacingScale: {
23
+ xxs: '2px',
24
+ xs: '4px',
25
+ sm: '8px',
26
+ md: '16px',
27
+ lg: '24px',
28
+ xl: '32px',
29
+ xxl: '48px'
30
+ },
31
+ ignore: [
32
+ '**/node_modules/**',
33
+ '**/dist/**',
34
+ '**/_variables.scss'
35
+ ],
36
+ importStyle: 'use',
37
+ reportFormat: 'table'
38
+ };
39
+
40
+ function loadConfig(configPath = null) {
41
+ let userConfig = {};
42
+
43
+ // Try to find config file
44
+ const possiblePaths = [
45
+ configPath,
46
+ '.scssextractrc.json',
47
+ path.join(process.cwd(), '.scssextractrc.json')
48
+ ].filter(Boolean);
49
+
50
+ for (const configFile of possiblePaths) {
51
+ if (fs.existsSync(configFile)) {
52
+ try {
53
+ const content = fs.readFileSync(configFile, 'utf8');
54
+ userConfig = JSON.parse(content);
55
+ console.log(`Loaded config from: ${configFile}`);
56
+ break;
57
+ } catch (error) {
58
+ console.warn(`Warning: Could not parse config file ${configFile}:`, error.message);
59
+ }
60
+ }
61
+ }
62
+
63
+ // Merge with defaults
64
+ return {
65
+ ...DEFAULT_CONFIG,
66
+ ...userConfig,
67
+ categories: {
68
+ ...DEFAULT_CONFIG.categories,
69
+ ...(userConfig.categories || {})
70
+ },
71
+ spacingScale: {
72
+ ...DEFAULT_CONFIG.spacingScale,
73
+ ...(userConfig.spacingScale || {})
74
+ },
75
+ ignore: userConfig.ignore || DEFAULT_CONFIG.ignore
76
+ };
77
+ }
78
+
79
+ module.exports = {
80
+ DEFAULT_CONFIG,
81
+ loadConfig
82
+ };
@@ -0,0 +1,219 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Generates a _variables.scss file from analyzed values
6
+ * @param {Object} analysis - Analysis results
7
+ * @param {string} outputPath - Output file path
8
+ * @param {Object} config - Configuration
9
+ */
10
+ function generateVariablesFile(analysis, outputPath, config) {
11
+ const version = require('../package.json').version;
12
+ const timestamp = new Date().toISOString();
13
+
14
+ let content = '';
15
+
16
+ // Header
17
+ content += `//\n`;
18
+ content += `// Auto-generated SCSS Variables\n`;
19
+ content += `// Generated by scss-variable-extractor v${version}\n`;
20
+ content += `// Generated at: ${timestamp}\n`;
21
+ content += `//\n`;
22
+ content += `// DO NOT EDIT THIS FILE MANUALLY\n`;
23
+ content += `// This file is auto-generated. Your changes will be overwritten.\n`;
24
+ content += `//\n\n`;
25
+
26
+ // Categories
27
+ const categories = [
28
+ { key: 'colors', title: 'Colors' },
29
+ { key: 'spacing', title: 'Spacing' },
30
+ { key: 'fontSizes', title: 'Font Sizes' },
31
+ { key: 'fontWeights', title: 'Font Weights' },
32
+ { key: 'fontFamilies', title: 'Font Families' },
33
+ { key: 'borderRadius', title: 'Border Radius' },
34
+ { key: 'shadows', title: 'Shadows' },
35
+ { key: 'zIndex', title: 'Z-Index' },
36
+ { key: 'sizing', title: 'Sizing' },
37
+ { key: 'lineHeight', title: 'Line Heights' },
38
+ { key: 'opacity', title: 'Opacity' },
39
+ { key: 'transitions', title: 'Transitions' }
40
+ ];
41
+
42
+ categories.forEach(({ key, title }) => {
43
+ if (analysis[key] && analysis[key].length > 0) {
44
+ content += `// ${title}\n`;
45
+ content += `// ────────────────────────────────────────\n`;
46
+
47
+ // Group variables with same name
48
+ const variableGroups = {};
49
+ analysis[key].forEach(item => {
50
+ const baseName = item.suggestedName;
51
+ if (!variableGroups[baseName]) {
52
+ variableGroups[baseName] = [];
53
+ }
54
+ variableGroups[baseName].push(item);
55
+ });
56
+
57
+ // Generate unique variable names
58
+ Object.keys(variableGroups).forEach(baseName => {
59
+ const items = variableGroups[baseName];
60
+
61
+ if (items.length === 1) {
62
+ const item = items[0];
63
+ content += `${item.suggestedName}: ${item.value};\n`;
64
+ } else {
65
+ // Multiple values with same suggested name, add suffixes
66
+ items.forEach((item, index) => {
67
+ const uniqueName = index === 0 ? item.suggestedName : `${item.suggestedName}-${index + 1}`;
68
+ content += `${uniqueName}: ${item.value};\n`;
69
+ });
70
+ }
71
+ });
72
+
73
+ content += '\n';
74
+ }
75
+ });
76
+
77
+ // Ensure output directory exists
78
+ const outputDir = path.dirname(outputPath);
79
+ if (!fs.existsSync(outputDir)) {
80
+ fs.mkdirSync(outputDir, { recursive: true });
81
+ }
82
+
83
+ // Write file
84
+ fs.writeFileSync(outputPath, content, 'utf8');
85
+
86
+ return content;
87
+ }
88
+
89
+ /**
90
+ * Generates a report in the specified format
91
+ * @param {Object} analysis - Analysis results
92
+ * @param {string} format - Report format (table, json, markdown)
93
+ * @param {Object} config - Configuration
94
+ * @returns {string} - Formatted report
95
+ */
96
+ function generateReport(analysis, format = 'table', config) {
97
+ if (format === 'json') {
98
+ return generateJsonReport(analysis);
99
+ } else if (format === 'markdown') {
100
+ return generateMarkdownReport(analysis);
101
+ } else {
102
+ return generateTableReport(analysis);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Generates a console table report
108
+ */
109
+ function generateTableReport(analysis) {
110
+ const Table = require('cli-table3');
111
+ const chalk = require('chalk');
112
+
113
+ let output = '\n';
114
+ output += chalk.bold.cyan('SCSS Variable Extraction Analysis\n');
115
+ output += chalk.gray('═'.repeat(80)) + '\n\n';
116
+
117
+ const categories = [
118
+ { key: 'colors', title: 'Colors' },
119
+ { key: 'spacing', title: 'Spacing' },
120
+ { key: 'fontSizes', title: 'Font Sizes' },
121
+ { key: 'fontWeights', title: 'Font Weights' },
122
+ { key: 'fontFamilies', title: 'Font Families' },
123
+ { key: 'borderRadius', title: 'Border Radius' },
124
+ { key: 'shadows', title: 'Shadows' },
125
+ { key: 'zIndex', title: 'Z-Index' },
126
+ { key: 'sizing', title: 'Sizing' },
127
+ { key: 'lineHeight', title: 'Line Heights' },
128
+ { key: 'opacity', title: 'Opacity' },
129
+ { key: 'transitions', title: 'Transitions' }
130
+ ];
131
+
132
+ categories.forEach(({ key, title }) => {
133
+ if (analysis[key] && analysis[key].length > 0) {
134
+ output += chalk.bold.yellow(`${title}\n`);
135
+
136
+ const table = new Table({
137
+ head: [
138
+ chalk.white('Value'),
139
+ chalk.white('Count'),
140
+ chalk.white('Files'),
141
+ chalk.white('Suggested Variable')
142
+ ],
143
+ colWidths: [30, 10, 10, 40]
144
+ });
145
+
146
+ analysis[key].forEach(item => {
147
+ table.push([
148
+ item.value,
149
+ chalk.green(item.count),
150
+ chalk.blue(item.fileCount),
151
+ chalk.cyan(item.suggestedName)
152
+ ]);
153
+ });
154
+
155
+ output += table.toString() + '\n\n';
156
+ }
157
+ });
158
+
159
+ // Summary
160
+ const totalExtracted = Object.values(analysis).reduce((sum, arr) => sum + arr.length, 0);
161
+ output += chalk.gray('─'.repeat(80)) + '\n';
162
+ output += chalk.bold(`Total extractable values: ${totalExtracted}\n`);
163
+
164
+ return output;
165
+ }
166
+
167
+ /**
168
+ * Generates a JSON report
169
+ */
170
+ function generateJsonReport(analysis) {
171
+ return JSON.stringify(analysis, null, 2);
172
+ }
173
+
174
+ /**
175
+ * Generates a Markdown report
176
+ */
177
+ function generateMarkdownReport(analysis) {
178
+ let output = '# SCSS Variable Extraction Analysis\n\n';
179
+
180
+ const categories = [
181
+ { key: 'colors', title: 'Colors' },
182
+ { key: 'spacing', title: 'Spacing' },
183
+ { key: 'fontSizes', title: 'Font Sizes' },
184
+ { key: 'fontWeights', title: 'Font Weights' },
185
+ { key: 'fontFamilies', title: 'Font Families' },
186
+ { key: 'borderRadius', title: 'Border Radius' },
187
+ { key: 'shadows', title: 'Shadows' },
188
+ { key: 'zIndex', title: 'Z-Index' },
189
+ { key: 'sizing', title: 'Sizing' },
190
+ { key: 'lineHeight', title: 'Line Heights' },
191
+ { key: 'opacity', title: 'Opacity' },
192
+ { key: 'transitions', title: 'Transitions' }
193
+ ];
194
+
195
+ categories.forEach(({ key, title }) => {
196
+ if (analysis[key] && analysis[key].length > 0) {
197
+ output += `## ${title}\n\n`;
198
+ output += '| Value | Count | Files | Suggested Variable |\n';
199
+ output += '|-------|-------|-------|-------------------|\n';
200
+
201
+ analysis[key].forEach(item => {
202
+ output += `| ${item.value} | ${item.count} | ${item.fileCount} | \`${item.suggestedName}\` |\n`;
203
+ });
204
+
205
+ output += '\n';
206
+ }
207
+ });
208
+
209
+ // Summary
210
+ const totalExtracted = Object.values(analysis).reduce((sum, arr) => sum + arr.length, 0);
211
+ output += `---\n\n**Total extractable values:** ${totalExtracted}\n`;
212
+
213
+ return output;
214
+ }
215
+
216
+ module.exports = {
217
+ generateVariablesFile,
218
+ generateReport
219
+ };
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ const { loadConfig } = require('./config');
2
+ const { scanScssFiles } = require('./scanner');
3
+ const { parseScss } = require('./parser');
4
+ const { analyzeValues } = require('./analyzer');
5
+ const { generateVariablesFile, generateReport } = require('./generator');
6
+ const { refactorScssFiles } = require('./refactorer');
7
+
8
+ module.exports = {
9
+ loadConfig,
10
+ scanScssFiles,
11
+ parseScss,
12
+ analyzeValues,
13
+ generateVariablesFile,
14
+ generateReport,
15
+ refactorScssFiles
16
+ };