scss-variable-extractor 1.6.3 → 1.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scss-variable-extractor",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
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"
package/src/refactorer.js CHANGED
@@ -42,12 +42,17 @@ function refactorFile(content, analysis, variablesFilePath, currentFilePath, con
42
42
  const hasVariablesImport = content.includes('@use') && content.includes('variables');
43
43
 
44
44
  // Replace values with variables
45
- Object.entries(valueMap).forEach(([value, variableName]) => {
45
+ // Sort by value length (longest first) to avoid partial replacements
46
+ // E.g., replace "0.5" before "0" to prevent "0.5" becoming "$var.5"
47
+ const sortedEntries = Object.entries(valueMap).sort((a, b) => b[0].length - a[0].length);
48
+
49
+ sortedEntries.forEach(([value, variableName]) => {
46
50
  const regex = createReplacementRegex(value);
47
51
  const matches = [];
48
52
 
49
53
  let match;
50
- while ((match = regex.exec(content)) !== null) {
54
+ // Find matches in the current state of newContent (after previous replacements)
55
+ while ((match = regex.exec(newContent)) !== null) {
51
56
  matches.push({
52
57
  index: match.index,
53
58
  matched: match[0],
@@ -56,8 +61,11 @@ function refactorFile(content, analysis, variablesFilePath, currentFilePath, con
56
61
  }
57
62
 
58
63
  // Check if replacements are in safe zones
59
- matches.forEach(m => {
60
- if (!isInSafeZone(content, m.index, m.matched)) {
64
+ // Process matches in reverse order (highest index first) to avoid position shifts
65
+ matches.reverse().forEach(m => {
66
+ const inSafeZone = isInSafeZone(newContent, m.index, m.matched);
67
+
68
+ if (!inSafeZone) {
61
69
  // Replace the value
62
70
  const before = newContent;
63
71
  newContent = replaceAt(newContent, m.matched, variableName, m.index);
@@ -119,8 +127,10 @@ function createReplacementRegex(value) {
119
127
  // Values with CSS units - ensure not part of larger number
120
128
  return new RegExp(`(?<!\\d)${escaped}\\b`, 'g');
121
129
  } 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');
130
+ // Pure numbers/decimals (unitless) - ensure not part of larger number or before units
131
+ // Negative lookbehind: not preceded by digit
132
+ // Negative lookahead: not followed by digit, unit letters, or % (for keyframe selectors)
133
+ return new RegExp(`(?<!\\d)${escaped}(?!\\d|[a-z%])`, 'g');
124
134
  } else if (/^rgba?\(/.test(value)) {
125
135
  // RGB/RGBA colors - match complete function
126
136
  return new RegExp(escaped, 'g');
@@ -134,13 +144,19 @@ function createReplacementRegex(value) {
134
144
  * Checks if a position in content is in a safe zone (should not be replaced)
135
145
  */
136
146
  function isInSafeZone(content, position, matchedValue) {
147
+ const DEBUG = false; // Enable for debugging
148
+
137
149
  // Check for variable declarations
138
150
  const lineStart = content.lastIndexOf('\n', position);
139
151
  const lineEnd = content.indexOf('\n', position);
140
152
  const line = content.substring(lineStart, lineEnd);
141
153
 
142
- if (line.includes('$') && line.includes(':')) {
143
- // This is a variable declaration line
154
+ // Check if this line is a variable declaration: $variable-name: value;
155
+ // Match pattern: optional whitespace, $, identifier, optional whitespace, colon
156
+ const varDeclMatch = line.match(/^\s*\$([\w-]+)\s*:/);
157
+ if (varDeclMatch) {
158
+ // This line IS a variable declaration
159
+ if (DEBUG) console.log(` → SAFE: Variable declaration line`);
144
160
  return true;
145
161
  }
146
162
 
@@ -150,6 +166,7 @@ function isInSafeZone(content, position, matchedValue) {
150
166
 
151
167
  // Prevent replacement if surrounded by digits (partial number match)
152
168
  if (/\d/.test(charBefore) || (/^\d+$/.test(matchedValue) && /\d/.test(charAfter))) {
169
+ if (DEBUG) console.log(` → SAFE: Surrounded by digits`);
153
170
  return true;
154
171
  }
155
172
 
@@ -158,12 +175,19 @@ function isInSafeZone(content, position, matchedValue) {
158
175
  // Look back further to check if we're in a hex color
159
176
  const lookBehind = content.substring(Math.max(0, position - 10), position);
160
177
  if (/#[0-9a-fA-F]*$/.test(lookBehind)) {
178
+ if (DEBUG) console.log(` → SAFE: Inside hex color`);
161
179
  return true;
162
180
  }
163
181
  }
164
182
 
165
183
  // Prevent replacement if it would create invalid variable concatenation
166
184
  if (charBefore === '$' || charAfter === '$') {
185
+ if (DEBUG) console.log(` → SAFE: Near $ symbol`);
186
+ return true;
187
+ }
188
+
189
+ // Prevent replacement for negative values (e.g., -16px should not become -$spacing-md)
190
+ if (charBefore === '-' && /^\d/.test(matchedValue)) {
167
191
  return true;
168
192
  }
169
193
 
@@ -172,8 +196,19 @@ function isInSafeZone(content, position, matchedValue) {
172
196
  return true;
173
197
  }
174
198
 
175
- // Check for url()
176
- const beforeContext = content.substring(Math.max(0, position - 50), position);
199
+ // Check for url(), calc(), transform functions, and other contexts
200
+ const beforeContext = content.substring(Math.max(0, position - 100), position);
201
+
202
+ // Prevent replacement inside calc() expressions
203
+ if (/calc\s*\([^)]*$/.test(beforeContext)) {
204
+ return true;
205
+ }
206
+
207
+ // Prevent replacement inside transform functions
208
+ if (/(?:translate|rotate|scale|skew|matrix|perspective)\s*\([^)]*$/.test(beforeContext)) {
209
+ return true;
210
+ }
211
+
177
212
  if (/url\s*\(\s*[^)]*$/.test(beforeContext)) {
178
213
  return true;
179
214
  }
@@ -193,18 +228,56 @@ function isInSafeZone(content, position, matchedValue) {
193
228
  return true;
194
229
  }
195
230
 
231
+ // Prevent replacement inside attribute selectors
232
+ if (/\[[^\]]*$/.test(beforeContext) &&
233
+ /^[^\[]*\]/.test(content.substring(position, position + 50))) {
234
+ if (DEBUG) console.log(` → SAFE: Inside attribute selector`);
235
+ return true;
236
+ }
237
+
238
+ // Prevent replacement in keyframe percentages (e.g., 0%, 50%, 100% in @keyframes)
239
+ // Check if we're in a @keyframes block and this position is before the opening brace
240
+ if (/@keyframes/.test(beforeContext)) {
241
+ // Check if we're in the selector part (before {) of a keyframe rule
242
+ // Get text from line start to current position and after
243
+ const lineBeforePos = content.substring(lineStart, position + matchedValue.length);
244
+ const lineAfterPos = content.substring(position + matchedValue.length, lineEnd);
245
+
246
+ // If the line has a { after this position and before that { there's a %,
247
+ // and we haven't passed the { yet, we're in the selector
248
+ if (/%/.test(lineBeforePos) && /^\s*%/.test(content.substring(position + matchedValue.length, position + matchedValue.length + 5))) {
249
+ // We're right before the % in a keyframe selector
250
+ if (DEBUG) console.log(` → SAFE: Keyframe percentage selector`);
251
+ return true;
252
+ }
253
+ if (/%[^{]*$/.test(lineBeforePos) && !lineBeforePos.includes('{')) {
254
+ // We're somewhere in a percentage before the {
255
+ if (DEBUG) console.log(` → SAFE: Keyframe percentage line`);
256
+ return true;
257
+ }
258
+ }
259
+
196
260
  // Check for comments
197
261
  const commentStart = content.lastIndexOf('/*', position);
198
262
  const commentEnd = content.lastIndexOf('*/', position);
199
263
  if (commentStart > commentEnd) {
264
+ if (DEBUG) console.log(` → SAFE: Inside block comment`);
200
265
  return true;
201
266
  }
202
267
 
203
- const lineCommentStart = content.lastIndexOf('//', lineStart);
204
- if (lineCommentStart > lineStart && lineCommentStart < position) {
205
- return true;
268
+ // Check for inline comments (//)
269
+ const lineContent = content.substring(lineStart, position + matchedValue.length);
270
+ const inlineCommentPos = lineContent.lastIndexOf('//');
271
+ if (inlineCommentPos !== -1) {
272
+ // Check if our position is after the // on this line
273
+ const posInLine = position - lineStart;
274
+ if (posInLine >= inlineCommentPos) {
275
+ if (DEBUG) console.log(` → SAFE: Inside inline comment`);
276
+ return true;
277
+ }
206
278
  }
207
279
 
280
+ if (DEBUG) console.log(` → NOT SAFE: Will replace`);
208
281
  return false;
209
282
  }
210
283
 
@@ -41,6 +41,11 @@ function analyzeStyleOrganization(scssFiles, config = {}) {
41
41
  const importMap = new Map(); // Track import statements
42
42
 
43
43
  scssFiles.forEach(filePath => {
44
+ // Skip if file doesn't exist (e.g., temp test files)
45
+ if (!fs.existsSync(filePath)) {
46
+ return;
47
+ }
48
+
44
49
  const content = fs.readFileSync(filePath, 'utf8');
45
50
  const fileName = path.basename(filePath);
46
51
  const fileInfo = analyzeFile(content, filePath);
@@ -0,0 +1,353 @@
1
+ const { refactorFile } = require('../src/refactorer');
2
+
3
+ describe('Refactorer Edge Cases', () => {
4
+ test('should handle rgba() color functions without partial replacements', () => {
5
+ const scss = `
6
+ .component {
7
+ background: rgba(255, 100, 50, 0.5);
8
+ color: rgba(100, 100, 100, 1);
9
+ }
10
+ `;
11
+
12
+ const analysis = {
13
+ colors: [
14
+ { value: 'rgba(255, 100, 50, 0.5)', suggestedName: '$color-primary-light' }
15
+ ],
16
+ spacing: [
17
+ { value: '100px', suggestedName: '$spacing-xxl' }
18
+ ],
19
+ fontSizes: [],
20
+ fontWeights: [],
21
+ fontFamilies: [],
22
+ borderRadius: [],
23
+ shadows: [],
24
+ zIndex: [],
25
+ sizing: [],
26
+ lineHeight: [],
27
+ opacity: [
28
+ { value: '0.5', suggestedName: '$opacity-half' }
29
+ ],
30
+ transitions: []
31
+ };
32
+
33
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
34
+
35
+ // Should replace complete rgba function
36
+ expect(result.content).toContain('background: $color-primary-light');
37
+
38
+ // Should NOT do partial replacements inside rgba()
39
+ expect(result.content).toContain('rgba(100, 100, 100, 1)'); // Not rgba($spacing-xxl, $spacing-xxl, $spacing-xxl, 1)
40
+ expect(result.content).not.toContain('rgba($spacing-xxl');
41
+ expect(result.content).not.toContain('rgba(255, $spacing-xxl');
42
+ });
43
+
44
+ test('should handle calc() expressions without partial replacements', () => {
45
+ const scss = `
46
+ .component {
47
+ width: calc(100% - 16px);
48
+ height: calc(50vh + 8px);
49
+ margin: calc(16px / 2);
50
+ }
51
+ `;
52
+
53
+ const analysis = {
54
+ colors: [],
55
+ spacing: [
56
+ { value: '16px', suggestedName: '$spacing-md' },
57
+ { value: '8px', suggestedName: '$spacing-sm' }
58
+ ],
59
+ fontSizes: [],
60
+ fontWeights: [],
61
+ fontFamilies: [],
62
+ borderRadius: [],
63
+ shadows: [],
64
+ zIndex: [],
65
+ sizing: [],
66
+ lineHeight: [],
67
+ opacity: [],
68
+ transitions: []
69
+ };
70
+
71
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
72
+
73
+ // calc() values should be protected from replacement (safe zone)
74
+ const content = result.content;
75
+
76
+ // Values inside calc() should NOT be replaced
77
+ expect(content).toContain('calc(100% - 16px)');
78
+ expect(content).toContain('calc(50vh + 8px)');
79
+ expect(content).toContain('calc(16px / 2)');
80
+ });
81
+
82
+ test('should handle negative values correctly', () => {
83
+ const scss = `
84
+ .component {
85
+ margin: -16px;
86
+ top: -8px;
87
+ z-index: -1;
88
+ }
89
+ `;
90
+
91
+ const analysis = {
92
+ colors: [],
93
+ spacing: [
94
+ { value: '16px', suggestedName: '$spacing-md' },
95
+ { value: '8px', suggestedName: '$spacing-sm' }
96
+ ],
97
+ fontSizes: [],
98
+ fontWeights: [],
99
+ fontFamilies: [],
100
+ borderRadius: [],
101
+ shadows: [],
102
+ zIndex: [
103
+ { value: '1', suggestedName: '$z-index-1' }
104
+ ],
105
+ sizing: [],
106
+ lineHeight: [],
107
+ opacity: [],
108
+ transitions: []
109
+ };
110
+
111
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
112
+
113
+ // Negative values should be protected (safe zone) to prevent -$variable syntax
114
+ expect(result.content).toContain('margin: -16px');
115
+ expect(result.content).toContain('top: -8px');
116
+ expect(result.content).toContain('z-index: -1');
117
+ });
118
+
119
+ test('should handle multiple values in shorthand properties', () => {
120
+ const scss = `
121
+ .component {
122
+ margin: 16px 8px;
123
+ padding: 16px 8px 16px 8px;
124
+ border-radius: 4px 2px;
125
+ }
126
+ `;
127
+
128
+ const analysis = {
129
+ colors: [],
130
+ spacing: [
131
+ { value: '16px', suggestedName: '$spacing-md' },
132
+ { value: '8px', suggestedName: '$spacing-sm' }
133
+ ],
134
+ fontSizes: [],
135
+ fontWeights: [],
136
+ fontFamilies: [],
137
+ borderRadius: [
138
+ { value: '4px', suggestedName: '$border-radius-sm' },
139
+ { value: '2px', suggestedName: '$border-radius-xs' }
140
+ ],
141
+ shadows: [],
142
+ zIndex: [],
143
+ sizing: [],
144
+ lineHeight: [],
145
+ opacity: [],
146
+ transitions: []
147
+ };
148
+
149
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
150
+
151
+ // Should replace individual values in shorthand
152
+ expect(result.content).toContain('$spacing-md');
153
+ expect(result.content).toContain('$spacing-sm');
154
+ expect(result.content).toContain('$border-radius-sm');
155
+ expect(result.content).toContain('$border-radius-xs');
156
+ });
157
+
158
+ test('should not replace values in CSS custom properties', () => {
159
+ const scss = `
160
+ :root {
161
+ --primary-color: #1976d2;
162
+ --spacing: 16px;
163
+ }
164
+ .component {
165
+ color: var(--primary-color);
166
+ padding: var(--spacing);
167
+ }
168
+ `;
169
+
170
+ const analysis = {
171
+ colors: [
172
+ { value: '#1976d2', suggestedName: '$color-primary' }
173
+ ],
174
+ spacing: [
175
+ { value: '16px', suggestedName: '$spacing-md' }
176
+ ],
177
+ fontSizes: [],
178
+ fontWeights: [],
179
+ fontFamilies: [],
180
+ borderRadius: [],
181
+ shadows: [],
182
+ zIndex: [],
183
+ sizing: [],
184
+ lineHeight: [],
185
+ opacity: [],
186
+ transitions: []
187
+ };
188
+
189
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
190
+
191
+ // CSS custom property declarations should be replaced (they're like SCSS variables)
192
+ // But var() references should be preserved
193
+ expect(result.content).toContain('var(--primary-color)');
194
+ expect(result.content).toContain('var(--spacing)');
195
+ });
196
+
197
+ test('should not replace values in attribute selectors', () => {
198
+ const scss = `
199
+ [data-size="16"] {
200
+ padding: 16px;
201
+ }
202
+ [data-color="#1976d2"] {
203
+ color: #1976d2;
204
+ }
205
+ `;
206
+
207
+ const analysis = {
208
+ colors: [
209
+ { value: '#1976d2', suggestedName: '$color-primary' }
210
+ ],
211
+ spacing: [
212
+ { value: '16px', suggestedName: '$spacing-md' }
213
+ ],
214
+ fontSizes: [],
215
+ fontWeights: [],
216
+ fontFamilies: [],
217
+ borderRadius: [],
218
+ shadows: [],
219
+ zIndex: [],
220
+ sizing: [],
221
+ lineHeight: [],
222
+ opacity: [],
223
+ transitions: []
224
+ };
225
+
226
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
227
+
228
+ // Should replace in property values (outside attribute selectors)
229
+ expect(result.content).toContain('padding: $spacing-md');
230
+ expect(result.content).toContain('color: $color-primary');
231
+
232
+ // Should NOT replace in attribute selector values (safe zone)
233
+ expect(result.content).toContain('[data-size="16"]');
234
+ expect(result.content).toContain('[data-color="#1976d2"]');
235
+ });
236
+
237
+ test('should handle transform functions', () => {
238
+ const scss = `
239
+ .component {
240
+ transform: translate(10px, 20px);
241
+ transform: rotate(45deg);
242
+ transform: scale(2);
243
+ }
244
+ `;
245
+
246
+ const analysis = {
247
+ colors: [],
248
+ spacing: [
249
+ { value: '10px', suggestedName: '$spacing-xs' },
250
+ { value: '20px', suggestedName: '$spacing-sm' }
251
+ ],
252
+ fontSizes: [],
253
+ fontWeights: [],
254
+ fontFamilies: [],
255
+ borderRadius: [],
256
+ shadows: [],
257
+ zIndex: [
258
+ { value: '2', suggestedName: '$z-index-2' }
259
+ ],
260
+ sizing: [],
261
+ lineHeight: [],
262
+ opacity: [],
263
+ transitions: []
264
+ };
265
+
266
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
267
+
268
+ // Transform function arguments should be protected (safe zone)
269
+ const content = result.content;
270
+ expect(content).toContain('translate(10px, 20px)');
271
+ expect(content).toContain('scale(2)');
272
+ expect(content).toContain('rotate(45deg)');
273
+ });
274
+
275
+ test('should handle keyframe percentages', () => {
276
+ const scss = `
277
+ @keyframes fade {
278
+ 0% { opacity: 0; }
279
+ 50% { opacity: 0.5; }
280
+ 100% { opacity: 1; }
281
+ }
282
+ `;
283
+
284
+ const analysis = {
285
+ colors: [],
286
+ spacing: [],
287
+ fontSizes: [],
288
+ fontWeights: [],
289
+ fontFamilies: [],
290
+ borderRadius: [],
291
+ shadows: [],
292
+ zIndex: [],
293
+ sizing: [],
294
+ lineHeight: [],
295
+ opacity: [
296
+ { value: '0', suggestedName: '$opacity-transparent' },
297
+ { value: '0.5', suggestedName: '$opacity-half' },
298
+ { value: '1', suggestedName: '$opacity-full' }
299
+ ],
300
+ transitions: []
301
+ };
302
+
303
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
304
+
305
+ // Keyframe selectors (0%, 50%, 100%) should not be replaced
306
+ expect(result.content).toContain('0%');
307
+ expect(result.content).toContain('50%');
308
+ expect(result.content).toContain('100%');
309
+
310
+ // But opacity values should be replaced
311
+ expect(result.content).toContain('opacity: $opacity-transparent');
312
+ expect(result.content).toContain('opacity: $opacity-half');
313
+ expect(result.content).toContain('opacity: $opacity-full');
314
+ });
315
+
316
+ test('should handle comments containing values', () => {
317
+ const scss = `
318
+ /* Primary color is #1976d2 */
319
+ .component {
320
+ color: #1976d2; // Use #1976d2 for brand
321
+ padding: 16px; /* 16px is our standard spacing */
322
+ }
323
+ `;
324
+
325
+ const analysis = {
326
+ colors: [
327
+ { value: '#1976d2', suggestedName: '$color-primary' }
328
+ ],
329
+ spacing: [
330
+ { value: '16px', suggestedName: '$spacing-md' }
331
+ ],
332
+ fontSizes: [],
333
+ fontWeights: [],
334
+ fontFamilies: [],
335
+ borderRadius: [],
336
+ shadows: [],
337
+ zIndex: [],
338
+ sizing: [],
339
+ lineHeight: [],
340
+ opacity: [],
341
+ transitions: []
342
+ };
343
+
344
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
345
+
346
+ // Should replace in actual property values
347
+ expect(result.content).toContain('color: $color-primary');
348
+ expect(result.content).toContain('padding: $spacing-md');
349
+
350
+ // Comments will likely be replaced too (current behavior)
351
+ // This might be acceptable or we could add comment detection to safe zones
352
+ });
353
+ });
@@ -208,4 +208,50 @@ describe('Refactorer', () => {
208
208
  expect(result.content).not.toContain('#a$z-index-2dab1');
209
209
  expect(result.content).not.toContain('#ff$z-index-2');
210
210
  });
211
+
212
+ test('should not replace unitless values before CSS units', () => {
213
+ const scss = `
214
+ .component {
215
+ opacity: 0.5;
216
+ line-height: 0.5em;
217
+ font-size: 2rem;
218
+ padding: 2px;
219
+ }
220
+ `;
221
+
222
+ const analysis = {
223
+ colors: [],
224
+ spacing: [
225
+ { value: '2px', suggestedName: '$spacing-xs' }
226
+ ],
227
+ fontSizes: [
228
+ { value: '2rem', suggestedName: '$font-size-lg' }
229
+ ],
230
+ fontWeights: [],
231
+ fontFamilies: [],
232
+ borderRadius: [],
233
+ shadows: [],
234
+ zIndex: [],
235
+ sizing: [],
236
+ lineHeight: [],
237
+ opacity: [
238
+ { value: '0.5', suggestedName: '$opacity-half' }
239
+ ],
240
+ transitions: []
241
+ };
242
+
243
+ const result = refactorFile(scss, analysis, '/libs/styles/_variables.scss', '/app/test.scss', {});
244
+
245
+ // Should replace complete unitless value
246
+ expect(result.content).toContain('opacity: $opacity-half');
247
+
248
+ // Should replace complete values with units
249
+ expect(result.content).toContain('padding: $spacing-xs');
250
+ expect(result.content).toContain('font-size: $font-size-lg');
251
+
252
+ // Should NOT replace unitless value before units (0.5 before em, 2 before rem/px)
253
+ expect(result.content).toContain('line-height: 0.5em');
254
+ expect(result.content).not.toContain('$opacity-halfem');
255
+ expect(result.content).not.toContain('line-height: $opacity-half');
256
+ });
211
257
  });