codescoop 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/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/codescoop.js +276 -0
- package/package.json +75 -0
- package/src/cli/interactive.js +153 -0
- package/src/index.js +303 -0
- package/src/output/conversion-generator.js +501 -0
- package/src/output/markdown.js +562 -0
- package/src/parsers/css-analyzer.js +488 -0
- package/src/parsers/html-parser.js +455 -0
- package/src/parsers/js-analyzer.js +413 -0
- package/src/utils/file-scanner.js +191 -0
- package/src/utils/ghost-detector.js +174 -0
- package/src/utils/library-detector.js +335 -0
- package/src/utils/specificity-calculator.js +251 -0
- package/src/utils/template-parser.js +260 -0
- package/src/utils/url-fetcher.js +123 -0
- package/src/utils/validation.js +278 -0
- package/src/utils/variable-extractor.js +271 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Analyzer Module
|
|
3
|
+
* Finds CSS rules that affect the target element
|
|
4
|
+
* Supports: .css, .scss, .sass, .min.css
|
|
5
|
+
*
|
|
6
|
+
* UPDATES v2.2:
|
|
7
|
+
* - Modern pseudo-class expansion (:is, :where, :not, :has)
|
|
8
|
+
* - @layer, @container, @supports tracking
|
|
9
|
+
* - Improved attribute selector matching
|
|
10
|
+
* - Shadow DOM support (::part, ::slotted)
|
|
11
|
+
* - CSS Houdini (@property)
|
|
12
|
+
* - Performance optimizations for large projects
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const postcss = require('postcss');
|
|
18
|
+
const postcssScss = require('postcss-scss');
|
|
19
|
+
const sass = require('sass');
|
|
20
|
+
|
|
21
|
+
// Performance: Cache for parsed CSS to avoid re-parsing
|
|
22
|
+
const parseCache = new Map();
|
|
23
|
+
const MAX_CACHE_SIZE = 50; // Limit cache size to prevent memory bloat
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Analyze a CSS file for rules matching the target element
|
|
27
|
+
* @param {string} filePath - Path to CSS/SCSS file
|
|
28
|
+
* @param {Object} targetInfo - Target element information
|
|
29
|
+
* @param {Object} options - Options
|
|
30
|
+
* @returns {Object} Analysis result
|
|
31
|
+
*/
|
|
32
|
+
async function analyzeCSS(filePath, targetInfo, options = {}) {
|
|
33
|
+
const { verbose = false, useCache = true } = options;
|
|
34
|
+
const log = verbose ? console.log : () => { };
|
|
35
|
+
|
|
36
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
37
|
+
let cssContent;
|
|
38
|
+
let originalContent;
|
|
39
|
+
let isScss = false;
|
|
40
|
+
|
|
41
|
+
// Performance: Check cache first
|
|
42
|
+
const cacheKey = `${filePath}:${JSON.stringify(targetInfo)}`;
|
|
43
|
+
if (useCache && parseCache.has(cacheKey)) {
|
|
44
|
+
log(` Using cached result for ${path.basename(filePath)}`);
|
|
45
|
+
return parseCache.get(cacheKey);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
originalContent = fs.readFileSync(filePath, 'utf-8');
|
|
50
|
+
|
|
51
|
+
// Performance: Skip empty files immediately
|
|
52
|
+
if (!originalContent || originalContent.trim().length === 0) {
|
|
53
|
+
return { filePath, matches: [], error: 'Empty file' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle different file types
|
|
57
|
+
if (ext === '.scss') {
|
|
58
|
+
isScss = true;
|
|
59
|
+
// Parse SCSS directly without compiling (to preserve source lines)
|
|
60
|
+
cssContent = originalContent;
|
|
61
|
+
} else if (ext === '.sass') {
|
|
62
|
+
// Compile SASS (indented syntax) to CSS
|
|
63
|
+
const result = sass.compileString(originalContent, { syntax: 'indented' });
|
|
64
|
+
cssContent = result.css;
|
|
65
|
+
} else {
|
|
66
|
+
// Regular CSS or minified CSS
|
|
67
|
+
cssContent = originalContent;
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
log(` Warning: Could not read ${filePath}: ${error.message}`);
|
|
71
|
+
return { filePath, matches: [], error: error.message };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse the CSS/SCSS
|
|
75
|
+
const matches = [];
|
|
76
|
+
const shadowDOMRules = [];
|
|
77
|
+
const houdiniProperties = [];
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const parseOptions = isScss ? { syntax: postcssScss } : {};
|
|
81
|
+
const root = postcss.parse(cssContent, parseOptions);
|
|
82
|
+
|
|
83
|
+
// Build list of selectors to match against
|
|
84
|
+
const targetSelectors = buildTargetSelectors(targetInfo);
|
|
85
|
+
|
|
86
|
+
root.walkRules(rule => {
|
|
87
|
+
// Resolve nested selectors (SCSS & and standard nesting)
|
|
88
|
+
const resolvedSelector = resolveNestedSelector(rule);
|
|
89
|
+
|
|
90
|
+
// Check for Shadow DOM selectors (::part, ::slotted)
|
|
91
|
+
const shadowDOMMatch = checkShadowDOMMatch(resolvedSelector, targetInfo);
|
|
92
|
+
if (shadowDOMMatch.matches) {
|
|
93
|
+
const ruleContent = rule.toString();
|
|
94
|
+
|
|
95
|
+
// Check context
|
|
96
|
+
let atRuleContext = null;
|
|
97
|
+
if (rule.parent && rule.parent.type === 'atrule') {
|
|
98
|
+
atRuleContext = formatAtRuleContext(rule.parent);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
shadowDOMRules.push({
|
|
102
|
+
selector: resolvedSelector,
|
|
103
|
+
originalSelector: rule.selector,
|
|
104
|
+
content: ruleContent,
|
|
105
|
+
startLine: rule.source?.start?.line || 0,
|
|
106
|
+
endLine: rule.source?.end?.line || 0,
|
|
107
|
+
matchedOn: shadowDOMMatch.matchedOn,
|
|
108
|
+
shadowDOMType: shadowDOMMatch.type,
|
|
109
|
+
atRuleContext,
|
|
110
|
+
isShadowDOM: true
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if this rule matches any of our target selectors
|
|
115
|
+
const matchInfo = checkRuleMatch(resolvedSelector, targetSelectors, targetInfo);
|
|
116
|
+
|
|
117
|
+
if (matchInfo.matches) {
|
|
118
|
+
// Get the full rule with its content
|
|
119
|
+
const ruleContent = rule.toString();
|
|
120
|
+
|
|
121
|
+
// Calculate line numbers
|
|
122
|
+
const startLine = rule.source?.start?.line || 0;
|
|
123
|
+
const endLine = rule.source?.end?.line || startLine;
|
|
124
|
+
|
|
125
|
+
// Check if this rule is inside ANY at-rule (media, layer, container, supports)
|
|
126
|
+
let atRuleContext = null;
|
|
127
|
+
if (rule.parent && rule.parent.type === 'atrule') {
|
|
128
|
+
atRuleContext = formatAtRuleContext(rule.parent);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
matches.push({
|
|
132
|
+
selector: resolvedSelector, // Use resolved selector for reporting
|
|
133
|
+
originalSelector: rule.selector,
|
|
134
|
+
content: ruleContent,
|
|
135
|
+
startLine,
|
|
136
|
+
endLine,
|
|
137
|
+
matchedOn: matchInfo.matchedOn,
|
|
138
|
+
atRuleContext,
|
|
139
|
+
isNested: isScss && rule.selector.includes('&')
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Find CSS Houdini @property definitions
|
|
145
|
+
root.walkAtRules(atRule => {
|
|
146
|
+
if (atRule.name === 'property') {
|
|
147
|
+
// @property --my-color { ... }
|
|
148
|
+
const propertyName = atRule.params.trim();
|
|
149
|
+
|
|
150
|
+
// Check if this property is used in matched rules (standard or shadow DOM)
|
|
151
|
+
const isUsed = matches.some(m => m.content.includes(propertyName)) ||
|
|
152
|
+
shadowDOMRules.some(m => m.content.includes(propertyName));
|
|
153
|
+
|
|
154
|
+
if (isUsed) {
|
|
155
|
+
// Check context
|
|
156
|
+
let atRuleContext = null;
|
|
157
|
+
if (atRule.parent && atRule.parent.type === 'atrule') {
|
|
158
|
+
atRuleContext = formatAtRuleContext(atRule.parent);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
houdiniProperties.push({
|
|
162
|
+
propertyName,
|
|
163
|
+
content: atRule.toString(),
|
|
164
|
+
startLine: atRule.source?.start?.line || 0,
|
|
165
|
+
endLine: atRule.source?.end?.line || 0,
|
|
166
|
+
atRuleContext,
|
|
167
|
+
isHoudini: true
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} else if (atRule.name === 'keyframes') {
|
|
171
|
+
// Check if any animation name is used on a matched class
|
|
172
|
+
// For now, include all keyframes (could be more selective)
|
|
173
|
+
const animationName = atRule.params;
|
|
174
|
+
|
|
175
|
+
// Check if this animation is referenced in matched rules
|
|
176
|
+
const isUsed = matches.some(m =>
|
|
177
|
+
m.content.includes(animationName) ||
|
|
178
|
+
m.content.includes(`animation-name: ${animationName}`) ||
|
|
179
|
+
m.content.includes(`animation: ${animationName}`)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (isUsed) {
|
|
183
|
+
matches.push({
|
|
184
|
+
selector: `@keyframes ${animationName}`,
|
|
185
|
+
content: atRule.toString(),
|
|
186
|
+
startLine: atRule.source?.start?.line || 0,
|
|
187
|
+
endLine: atRule.source?.end?.line || 0,
|
|
188
|
+
matchedOn: ['animation'],
|
|
189
|
+
isKeyframes: true
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
} catch (error) {
|
|
196
|
+
log(` Warning: Could not parse ${filePath}: ${error.message}`);
|
|
197
|
+
return { filePath, matches: [], error: error.message };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Detect if file is minified
|
|
201
|
+
const isMinified = detectMinified(originalContent);
|
|
202
|
+
|
|
203
|
+
const result = {
|
|
204
|
+
filePath,
|
|
205
|
+
relativePath: path.relative(process.cwd(), filePath),
|
|
206
|
+
matches,
|
|
207
|
+
shadowDOMRules,
|
|
208
|
+
houdiniProperties,
|
|
209
|
+
isScss,
|
|
210
|
+
isMinified,
|
|
211
|
+
fileType: ext.replace('.', '')
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Performance: Cache the result
|
|
215
|
+
if (useCache) {
|
|
216
|
+
if (parseCache.size >= MAX_CACHE_SIZE) {
|
|
217
|
+
// Remove oldest entry
|
|
218
|
+
const firstKey = parseCache.keys().next().value;
|
|
219
|
+
parseCache.delete(firstKey);
|
|
220
|
+
}
|
|
221
|
+
parseCache.set(cacheKey, result);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Format at-rule context for display
|
|
229
|
+
* Supports @media, @layer, @container, @supports
|
|
230
|
+
*/
|
|
231
|
+
function formatAtRuleContext(atRuleNode) {
|
|
232
|
+
const name = atRuleNode.name;
|
|
233
|
+
const params = atRuleNode.params;
|
|
234
|
+
|
|
235
|
+
switch (name) {
|
|
236
|
+
case 'layer':
|
|
237
|
+
return `@layer ${params}`;
|
|
238
|
+
case 'container':
|
|
239
|
+
return `@container ${params}`;
|
|
240
|
+
case 'supports':
|
|
241
|
+
return `@supports ${params}`;
|
|
242
|
+
case 'media':
|
|
243
|
+
return `@media ${params}`;
|
|
244
|
+
default:
|
|
245
|
+
return `@${name} ${params}`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build a list of selector patterns to match against
|
|
251
|
+
*/
|
|
252
|
+
function buildTargetSelectors(targetInfo) {
|
|
253
|
+
const selectors = [];
|
|
254
|
+
|
|
255
|
+
// Add class selectors
|
|
256
|
+
targetInfo.classes.forEach(cls => {
|
|
257
|
+
selectors.push({
|
|
258
|
+
type: 'class',
|
|
259
|
+
value: cls,
|
|
260
|
+
pattern: new RegExp(`\\.${escapeRegex(cls)}(?=[\\s,:.\\[#]|$)`)
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Add ID selectors
|
|
265
|
+
targetInfo.ids.forEach(id => {
|
|
266
|
+
selectors.push({
|
|
267
|
+
type: 'id',
|
|
268
|
+
value: id,
|
|
269
|
+
pattern: new RegExp(`#${escapeRegex(id)}(?=[\\s,:.\\[#]|$)`)
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Add tag name (be careful with this - only for specific compound selectors)
|
|
274
|
+
if (targetInfo.classes.length > 0 || targetInfo.ids.length > 0) {
|
|
275
|
+
selectors.push({
|
|
276
|
+
type: 'tag',
|
|
277
|
+
value: targetInfo.tagName,
|
|
278
|
+
pattern: new RegExp(`(?:^|[\\s,>+~])${escapeRegex(targetInfo.tagName)}(?=[\\s,:.\\[#>+~]|$)`)
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Add data attribute selectors (IMPROVED - matches both [attr] and [attr="value"])
|
|
283
|
+
targetInfo.dataAttributes.forEach(attr => {
|
|
284
|
+
selectors.push({
|
|
285
|
+
type: 'data-attr',
|
|
286
|
+
value: attr,
|
|
287
|
+
pattern: new RegExp(`\\[${escapeRegex(attr)}(?:[~|^$*]?=|\\])`)
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return selectors;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Check for Shadow DOM pseudo-elements (::part, ::slotted)
|
|
296
|
+
* These target elements inside Web Components' shadow DOM
|
|
297
|
+
*/
|
|
298
|
+
function checkShadowDOMMatch(selector, targetInfo) {
|
|
299
|
+
const matchedOn = [];
|
|
300
|
+
let type = null;
|
|
301
|
+
|
|
302
|
+
// Check for ::part() - styles parts of shadow DOM from outside
|
|
303
|
+
if (selector.includes('::part(')) {
|
|
304
|
+
const partMatch = selector.match(/::part\(([^)]+)\)/);
|
|
305
|
+
if (partMatch) {
|
|
306
|
+
const partName = partMatch[1].trim();
|
|
307
|
+
|
|
308
|
+
// Check if target has this part attribute
|
|
309
|
+
if (targetInfo.shadowParts?.includes(partName)) {
|
|
310
|
+
matchedOn.push(`part: ${partName}`);
|
|
311
|
+
type = '::part';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Also check if any of the target's classes/ids/tagName are mentioned
|
|
315
|
+
const allIdentifiers = [...targetInfo.classes, ...targetInfo.ids, targetInfo.tagName];
|
|
316
|
+
if (allIdentifiers.some(id => id && selector.includes(id))) {
|
|
317
|
+
matchedOn.push(`shadow-host with part`);
|
|
318
|
+
type = '::part';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Check for ::slotted() - styles slotted content from inside shadow DOM
|
|
324
|
+
if (selector.includes('::slotted(')) {
|
|
325
|
+
const slottedMatch = selector.match(/::slotted\(([^)]+)\)/);
|
|
326
|
+
if (slottedMatch) {
|
|
327
|
+
const slottedSelector = slottedMatch[1].trim();
|
|
328
|
+
|
|
329
|
+
// Check if target matches the slotted selector
|
|
330
|
+
const allIdentifiers = [...targetInfo.classes, ...targetInfo.ids];
|
|
331
|
+
if (allIdentifiers.some(id => slottedSelector.includes(id))) {
|
|
332
|
+
matchedOn.push(`slotted: ${slottedSelector}`);
|
|
333
|
+
type = '::slotted';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
matches: matchedOn.length > 0,
|
|
340
|
+
matchedOn,
|
|
341
|
+
type
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Expand modern pseudo-class functions to extract inner selectors
|
|
347
|
+
* Handles :is(), :where(), :not(), :has()
|
|
348
|
+
*/
|
|
349
|
+
function expandModernPseudoClasses(selector) {
|
|
350
|
+
// Extract selectors from :is(), :where(), :not(), :has()
|
|
351
|
+
// We create a space-separated list that includes both original and inner selectors
|
|
352
|
+
let expanded = selector;
|
|
353
|
+
|
|
354
|
+
const pseudoFunctions = ['is', 'where', 'not', 'has'];
|
|
355
|
+
|
|
356
|
+
for (const func of pseudoFunctions) {
|
|
357
|
+
const regex = new RegExp(`:${func}\\(([^)]+)\\)`, 'g');
|
|
358
|
+
let match;
|
|
359
|
+
|
|
360
|
+
while ((match = regex.exec(selector)) !== null) {
|
|
361
|
+
const innerSelectors = match[1];
|
|
362
|
+
// Append inner selectors to make them matchable
|
|
363
|
+
// This allows our pattern matching to find classes/ids inside these functions
|
|
364
|
+
expanded += ` ${innerSelectors}`;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return expanded;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Check if a CSS rule selector matches our target
|
|
373
|
+
*/
|
|
374
|
+
function checkRuleMatch(ruleSelector, targetSelectors, targetInfo) {
|
|
375
|
+
const matchedOn = [];
|
|
376
|
+
|
|
377
|
+
// Expand modern pseudo-classes before matching
|
|
378
|
+
const expandedSelector = expandModernPseudoClasses(ruleSelector);
|
|
379
|
+
|
|
380
|
+
for (const target of targetSelectors) {
|
|
381
|
+
// Test against both original and expanded selector
|
|
382
|
+
if (target.pattern.test(ruleSelector) || target.pattern.test(expandedSelector)) {
|
|
383
|
+
matchedOn.push(`${target.type}: ${target.value}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
matches: matchedOn.length > 0,
|
|
389
|
+
matchedOn
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Detect if CSS content is minified
|
|
395
|
+
*/
|
|
396
|
+
function detectMinified(content) {
|
|
397
|
+
const lines = content.split('\n');
|
|
398
|
+
if (lines.length === 0) return false;
|
|
399
|
+
|
|
400
|
+
// If average line length is very high, it's likely minified
|
|
401
|
+
const avgLineLength = content.length / lines.length;
|
|
402
|
+
|
|
403
|
+
// Also check if there are very few newlines relative to content
|
|
404
|
+
const newlineRatio = lines.length / content.length;
|
|
405
|
+
|
|
406
|
+
return avgLineLength > 200 || newlineRatio < 0.002;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Escape special regex characters
|
|
411
|
+
*/
|
|
412
|
+
function escapeRegex(string) {
|
|
413
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Format CSS for output (beautify if minified)
|
|
418
|
+
*/
|
|
419
|
+
function formatCSS(content, isMinified) {
|
|
420
|
+
if (!isMinified) return content;
|
|
421
|
+
|
|
422
|
+
// Simple CSS beautification
|
|
423
|
+
return content
|
|
424
|
+
.replace(/\{/g, ' {\n ')
|
|
425
|
+
.replace(/;/g, ';\n ')
|
|
426
|
+
.replace(/\}/g, '\n}\n')
|
|
427
|
+
.replace(/,\s*/g, ',\n')
|
|
428
|
+
.replace(/\n\s*\n/g, '\n')
|
|
429
|
+
.trim();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Resolve nested selectors by unwrapping parent rules
|
|
434
|
+
* Supports SCSS (&) and standard CSS nesting
|
|
435
|
+
*/
|
|
436
|
+
function resolveNestedSelector(rule) {
|
|
437
|
+
if (!rule.parent || rule.parent.type !== 'rule') {
|
|
438
|
+
return rule.selector;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const parentSelector = resolveNestedSelector(rule.parent);
|
|
442
|
+
const selfSelector = rule.selector;
|
|
443
|
+
|
|
444
|
+
// SCSS-style nesting with &
|
|
445
|
+
if (selfSelector.includes('&')) {
|
|
446
|
+
// Handle comma-separated parent selectors (basic support)
|
|
447
|
+
// .a, .b { &--mod } -> .a--mod, .b--mod
|
|
448
|
+
if (parentSelector.includes(',')) {
|
|
449
|
+
const parents = parentSelector.split(',').map(s => s.trim());
|
|
450
|
+
return parents.map(p => selfSelector.replace(/&/g, p)).join(', ');
|
|
451
|
+
}
|
|
452
|
+
return selfSelector.replace(/&/g, parentSelector);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Standard CSS nesting (descendant)
|
|
456
|
+
// .a, .b { .c } -> .a .c, .b .c
|
|
457
|
+
if (parentSelector.includes(',')) {
|
|
458
|
+
const parents = parentSelector.split(',').map(s => s.trim());
|
|
459
|
+
return parents.map(p => `${p} ${selfSelector}`).join(', ');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return `${parentSelector} ${selfSelector}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Clear the parse cache (useful for long-running processes)
|
|
467
|
+
*/
|
|
468
|
+
function clearCache() {
|
|
469
|
+
parseCache.clear();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get cache statistics
|
|
474
|
+
*/
|
|
475
|
+
function getCacheStats() {
|
|
476
|
+
return {
|
|
477
|
+
size: parseCache.size,
|
|
478
|
+
maxSize: MAX_CACHE_SIZE,
|
|
479
|
+
hitRate: parseCache.size > 0 ? 'Cache enabled' : 'Cache empty'
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = {
|
|
484
|
+
analyzeCSS,
|
|
485
|
+
formatCSS,
|
|
486
|
+
clearCache,
|
|
487
|
+
getCacheStats
|
|
488
|
+
};
|