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.
@@ -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
+ };