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,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Specificity Calculator
|
|
3
|
+
* Uses the battle-tested 'specificity' npm library to calculate specificity
|
|
4
|
+
* and predict which CSS rules will actually render
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { calculate } = require('specificity');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate specificity for a CSS selector using the npm library
|
|
11
|
+
* Returns [inline, ids, classes, elements] tuple
|
|
12
|
+
* @param {string} selector - CSS selector
|
|
13
|
+
* @returns {number[]} Specificity tuple [inline, ids, classes, elements]
|
|
14
|
+
*/
|
|
15
|
+
function calculateSpecificity(selector) {
|
|
16
|
+
if (!selector || typeof selector !== 'string') {
|
|
17
|
+
return [0, 0, 0, 0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const result = calculate(selector);
|
|
22
|
+
if (result) {
|
|
23
|
+
// Library returns { A: ids, B: classes, C: elements }
|
|
24
|
+
// We add inline (0) as the first element for consistency
|
|
25
|
+
return [0, result.A || 0, result.B || 0, result.C || 0];
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
// If the library can't parse it, return zeroes
|
|
29
|
+
// console.warn(`Could not calculate specificity for "${selector}": ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [0, 0, 0, 0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compare two specificity tuples
|
|
37
|
+
* @param {number[]} a - First specificity
|
|
38
|
+
* @param {number[]} b - Second specificity
|
|
39
|
+
* @returns {number} -1 if a < b, 0 if equal, 1 if a > b
|
|
40
|
+
*/
|
|
41
|
+
function compareSpecificity(a, b) {
|
|
42
|
+
for (let i = 0; i < 4; i++) {
|
|
43
|
+
if (a[i] > b[i]) return 1;
|
|
44
|
+
if (a[i] < b[i]) return -1;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format specificity as readable string
|
|
51
|
+
* @param {number[]} spec - Specificity tuple
|
|
52
|
+
* @returns {string} Formatted string like "(0,1,2,3)"
|
|
53
|
+
*/
|
|
54
|
+
function formatSpecificity(spec) {
|
|
55
|
+
return `(${spec.join(',')})`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a CSS value has !important
|
|
60
|
+
* @param {string} value - CSS property value
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function hasImportant(value) {
|
|
64
|
+
return typeof value === 'string' && value.includes('!important');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Analyze CSS rules and predict which ones will render
|
|
69
|
+
* @param {Array} cssResults - CSS analysis results from the tool
|
|
70
|
+
* @param {Object} linkedFiles - Information about linked CSS files
|
|
71
|
+
* @returns {Object} Conflict analysis with winners/losers
|
|
72
|
+
*/
|
|
73
|
+
function analyzeConflicts(cssResults, linkedFiles = { css: [] }) {
|
|
74
|
+
// Group rules by property
|
|
75
|
+
const propertyRules = {};
|
|
76
|
+
|
|
77
|
+
// Track file order (later = higher priority for same specificity)
|
|
78
|
+
const fileOrder = {};
|
|
79
|
+
linkedFiles.css.forEach((file, index) => {
|
|
80
|
+
fileOrder[file] = index;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
let globalOrder = 0;
|
|
84
|
+
|
|
85
|
+
for (const result of cssResults) {
|
|
86
|
+
const filePath = result.filePath || result.file;
|
|
87
|
+
const fileOrderValue = fileOrder[filePath] ?? 999;
|
|
88
|
+
|
|
89
|
+
for (const match of result.matches || []) {
|
|
90
|
+
const selector = match.selector || '';
|
|
91
|
+
const specificity = calculateSpecificity(selector);
|
|
92
|
+
const content = match.content || '';
|
|
93
|
+
|
|
94
|
+
// Parse properties from content
|
|
95
|
+
const properties = parseProperties(content);
|
|
96
|
+
|
|
97
|
+
for (const [prop, value] of Object.entries(properties)) {
|
|
98
|
+
if (!propertyRules[prop]) {
|
|
99
|
+
propertyRules[prop] = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
propertyRules[prop].push({
|
|
103
|
+
selector,
|
|
104
|
+
specificity,
|
|
105
|
+
value,
|
|
106
|
+
hasImportant: hasImportant(value),
|
|
107
|
+
file: filePath,
|
|
108
|
+
fileOrder: fileOrderValue,
|
|
109
|
+
globalOrder: globalOrder++,
|
|
110
|
+
startLine: match.startLine,
|
|
111
|
+
endLine: match.endLine
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Determine winner for each property
|
|
118
|
+
const conflicts = {};
|
|
119
|
+
|
|
120
|
+
for (const [prop, rules] of Object.entries(propertyRules)) {
|
|
121
|
+
if (rules.length <= 1) {
|
|
122
|
+
continue; // No conflict
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Sort by priority: !important > specificity > file order > declaration order
|
|
126
|
+
const sorted = [...rules].sort((a, b) => {
|
|
127
|
+
// !important always wins
|
|
128
|
+
if (a.hasImportant && !b.hasImportant) return 1;
|
|
129
|
+
if (!a.hasImportant && b.hasImportant) return -1;
|
|
130
|
+
|
|
131
|
+
// Compare specificity
|
|
132
|
+
const specCompare = compareSpecificity(a.specificity, b.specificity);
|
|
133
|
+
if (specCompare !== 0) return specCompare;
|
|
134
|
+
|
|
135
|
+
// Same specificity: later file wins
|
|
136
|
+
if (a.fileOrder !== b.fileOrder) return a.fileOrder - b.fileOrder;
|
|
137
|
+
|
|
138
|
+
// Same file: later declaration wins
|
|
139
|
+
return a.globalOrder - b.globalOrder;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const winner = sorted[sorted.length - 1];
|
|
143
|
+
const losers = sorted.slice(0, -1);
|
|
144
|
+
|
|
145
|
+
conflicts[prop] = {
|
|
146
|
+
winner,
|
|
147
|
+
losers,
|
|
148
|
+
hasConflict: losers.length > 0
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return conflicts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse CSS properties from a rule content
|
|
157
|
+
* @param {string} content - CSS rule content
|
|
158
|
+
* @returns {Object} Property-value pairs
|
|
159
|
+
*/
|
|
160
|
+
function parseProperties(content) {
|
|
161
|
+
const properties = {};
|
|
162
|
+
|
|
163
|
+
// Remove selector and braces if present
|
|
164
|
+
let body = content;
|
|
165
|
+
const braceMatch = content.match(/\{([^}]+)\}/);
|
|
166
|
+
if (braceMatch) {
|
|
167
|
+
body = braceMatch[1];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Split by semicolons and parse
|
|
171
|
+
const declarations = body.split(';');
|
|
172
|
+
for (const decl of declarations) {
|
|
173
|
+
const colonIndex = decl.indexOf(':');
|
|
174
|
+
if (colonIndex > 0) {
|
|
175
|
+
const prop = decl.substring(0, colonIndex).trim();
|
|
176
|
+
const value = decl.substring(colonIndex + 1).trim();
|
|
177
|
+
if (prop && value) {
|
|
178
|
+
properties[prop] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return properties;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format conflict analysis as markdown
|
|
188
|
+
* @param {Object} conflicts - Conflict analysis from analyzeConflicts
|
|
189
|
+
* @returns {string} Markdown formatted output
|
|
190
|
+
*/
|
|
191
|
+
function formatConflictsMarkdown(conflicts) {
|
|
192
|
+
const conflictingProps = Object.entries(conflicts).filter(([_, data]) => data.hasConflict);
|
|
193
|
+
|
|
194
|
+
if (conflictingProps.length === 0) {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let md = `\n---\n\n## ⚔️ CSS Conflicts Detected\n\n`;
|
|
199
|
+
md += `> Multiple CSS rules are competing for the same properties. Here's what will actually render:\n`;
|
|
200
|
+
md += `> *Note: Rules are sorted by specificity. If equal, the browser uses the rule loaded last.*\n\n`;
|
|
201
|
+
|
|
202
|
+
for (const [prop, data] of conflictingProps) {
|
|
203
|
+
const { winner, losers } = data;
|
|
204
|
+
|
|
205
|
+
md += `### \`${prop}\`\n\n`;
|
|
206
|
+
md += `| Status | File | Selector | Value | Specificity |\n`;
|
|
207
|
+
md += `|--------|------|----------|-------|-------------|\n`;
|
|
208
|
+
|
|
209
|
+
// Show winner first
|
|
210
|
+
md += `| ✅ **Winner** | \`${getFileName(winner.file)}\` | \`${winner.selector}\` | \`${truncateValue(winner.value)}\` | ${formatSpecificity(winner.specificity)}${winner.hasImportant ? ' **!important**' : ''} |\n`;
|
|
211
|
+
|
|
212
|
+
// Show losers
|
|
213
|
+
for (const loser of losers.reverse()) {
|
|
214
|
+
md += `| ❌ Overridden | \`${getFileName(loser.file)}\` | \`${loser.selector}\` | \`${truncateValue(loser.value)}\` | ${formatSpecificity(loser.specificity)}${loser.hasImportant ? ' !important' : ''} |\n`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
md += '\n';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
md += `**${conflictingProps.length}** property conflict(s) detected.\n`;
|
|
221
|
+
|
|
222
|
+
return md;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Truncate long CSS values for display
|
|
227
|
+
*/
|
|
228
|
+
function truncateValue(value) {
|
|
229
|
+
if (!value) return '';
|
|
230
|
+
if (value.length > 40) {
|
|
231
|
+
return value.substring(0, 37) + '...';
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get just the filename from a path
|
|
238
|
+
*/
|
|
239
|
+
function getFileName(filePath) {
|
|
240
|
+
if (!filePath) return 'unknown';
|
|
241
|
+
const parts = filePath.replace(/\\/g, '/').split('/');
|
|
242
|
+
return parts[parts.length - 1];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
calculateSpecificity,
|
|
247
|
+
compareSpecificity,
|
|
248
|
+
formatSpecificity,
|
|
249
|
+
analyzeConflicts,
|
|
250
|
+
formatConflictsMarkdown
|
|
251
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Parser Module
|
|
3
|
+
* Parses PHP, Blade, Twig, and other template files by stripping server-side code
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Supported template types and their patterns
|
|
11
|
+
*/
|
|
12
|
+
const TEMPLATE_PATTERNS = {
|
|
13
|
+
php: {
|
|
14
|
+
extensions: ['.php', '.phtml', '.inc'],
|
|
15
|
+
// Match <?php ... ?> and <?= ... ?> and <? ... ?>
|
|
16
|
+
patterns: [
|
|
17
|
+
/<\?php[\s\S]*?\?>/gi,
|
|
18
|
+
/<\?=[\s\S]*?\?>/gi,
|
|
19
|
+
/<\?(?!xml)[\s\S]*?\?>/gi
|
|
20
|
+
],
|
|
21
|
+
// PHP echo shortcuts to preserve (convert to placeholders)
|
|
22
|
+
echoPatterns: [
|
|
23
|
+
{ regex: /<\?=\s*\$([a-zA-Z_][a-zA-Z0-9_]*)\s*\?>/g, placeholder: '{{$1}}' },
|
|
24
|
+
{ regex: /<\?php\s+echo\s+\$([a-zA-Z_][a-zA-Z0-9_]*)\s*;\s*\?>/g, placeholder: '{{$1}}' }
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
blade: {
|
|
28
|
+
extensions: ['.blade.php'],
|
|
29
|
+
// Blade directives
|
|
30
|
+
patterns: [
|
|
31
|
+
/@(if|else|elseif|endif|foreach|endforeach|for|endfor|while|endwhile|switch|case|break|default|endswitch|unless|endunless|isset|endisset|empty|endempty|auth|endauth|guest|endguest|hasSection|yield|section|endsection|show|parent|include|extends|component|endcomponent|slot|endslot|push|endpush|stack|prepend|endprepend|php|endphp|verbatim|endverbatim|error|enderror|once|endonce|env|endenv|production|endproduction|props|aware|class|disabled|readonly|required|checked|selected)\b[^@]*/gi,
|
|
32
|
+
/\{\{--[\s\S]*?--\}\}/g, // Blade comments
|
|
33
|
+
/\{\!\![\s\S]*?\!\!\}/g, // Unescaped output
|
|
34
|
+
],
|
|
35
|
+
// Preserve Blade echo as placeholders
|
|
36
|
+
echoPatterns: [
|
|
37
|
+
{ regex: /\{\{\s*\$([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, placeholder: '{{$1}}' }
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
twig: {
|
|
41
|
+
extensions: ['.twig', '.html.twig'],
|
|
42
|
+
patterns: [
|
|
43
|
+
/\{%[\s\S]*?%\}/g, // Twig tags
|
|
44
|
+
/\{#[\s\S]*?#\}/g, // Twig comments
|
|
45
|
+
],
|
|
46
|
+
echoPatterns: [
|
|
47
|
+
{ regex: /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g, placeholder: '{{$1}}' }
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
ejs: {
|
|
51
|
+
extensions: ['.ejs'],
|
|
52
|
+
patterns: [
|
|
53
|
+
/<%[\s\S]*?%>/g, // EJS tags
|
|
54
|
+
/<%#[\s\S]*?%>/g, // EJS comments
|
|
55
|
+
],
|
|
56
|
+
echoPatterns: [
|
|
57
|
+
{ regex: /<%=\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*%>/g, placeholder: '{{$1}}' }
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
erb: {
|
|
61
|
+
extensions: ['.erb', '.html.erb'],
|
|
62
|
+
patterns: [
|
|
63
|
+
/<%[\s\S]*?%>/g,
|
|
64
|
+
/<%#[\s\S]*?%>/g,
|
|
65
|
+
],
|
|
66
|
+
echoPatterns: [
|
|
67
|
+
{ regex: /<%=\s*@?([a-zA-Z_][a-zA-Z0-9_.]*)\s*%>/g, placeholder: '{{$1}}' }
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
handlebars: {
|
|
71
|
+
extensions: ['.hbs', '.handlebars'],
|
|
72
|
+
patterns: [
|
|
73
|
+
/\{\{#[\s\S]*?\}\}/g, // Block helpers
|
|
74
|
+
/\{\{\/[\s\S]*?\}\}/g, // Close blocks
|
|
75
|
+
/\{\{!--[\s\S]*?--\}\}/g, // Comments
|
|
76
|
+
/\{\{![\s\S]*?\}\}/g, // Inline comments
|
|
77
|
+
],
|
|
78
|
+
echoPatterns: [
|
|
79
|
+
{ regex: /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g, placeholder: '{{$1}}' }
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
jsp: {
|
|
83
|
+
extensions: ['.jsp', '.jspf'],
|
|
84
|
+
patterns: [
|
|
85
|
+
/<%@[\s\S]*?%>/g, // Directives
|
|
86
|
+
/<%![\s\S]*?%>/g, // Declarations
|
|
87
|
+
/<%[\s\S]*?%>/g, // Scriptlets
|
|
88
|
+
/<%--[\s\S]*?--%>/g, // Comments
|
|
89
|
+
],
|
|
90
|
+
echoPatterns: [
|
|
91
|
+
{ regex: /\$\{([a-zA-Z_][a-zA-Z0-9_.]*)\}/g, placeholder: '{{$1}}' }
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
asp: {
|
|
95
|
+
extensions: ['.asp', '.aspx', '.cshtml', '.vbhtml'],
|
|
96
|
+
patterns: [
|
|
97
|
+
/<%[\s\S]*?%>/g,
|
|
98
|
+
/@\{[\s\S]*?\}/g, // Razor code blocks
|
|
99
|
+
/@[\w]+\([^)]*\)/g, // Razor helpers
|
|
100
|
+
],
|
|
101
|
+
echoPatterns: [
|
|
102
|
+
{ regex: /@([a-zA-Z_][a-zA-Z0-9_.]*)/g, placeholder: '{{$1}}' }
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect template type from file extension
|
|
109
|
+
* @param {string} filePath - Path to template file
|
|
110
|
+
* @returns {string|null} Template type or null
|
|
111
|
+
*/
|
|
112
|
+
function detectTemplateType(filePath) {
|
|
113
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
114
|
+
const basename = path.basename(filePath).toLowerCase();
|
|
115
|
+
|
|
116
|
+
// Check for compound extensions first (like .blade.php)
|
|
117
|
+
for (const [type, config] of Object.entries(TEMPLATE_PATTERNS)) {
|
|
118
|
+
for (const extension of config.extensions) {
|
|
119
|
+
if (basename.endsWith(extension)) {
|
|
120
|
+
return type;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check simple extension
|
|
126
|
+
for (const [type, config] of Object.entries(TEMPLATE_PATTERNS)) {
|
|
127
|
+
if (config.extensions.includes(ext)) {
|
|
128
|
+
return type;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if file is a template file
|
|
137
|
+
* @param {string} filePath - Path to check
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
function isTemplateFile(filePath) {
|
|
141
|
+
return detectTemplateType(filePath) !== null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse template file and extract HTML
|
|
146
|
+
* @param {string} filePath - Path to template file
|
|
147
|
+
* @param {Object} options - Parse options
|
|
148
|
+
* @returns {Object} Parsed result with HTML and metadata
|
|
149
|
+
*/
|
|
150
|
+
function parseTemplateFile(filePath, options = {}) {
|
|
151
|
+
const {
|
|
152
|
+
preserveEchos = true,
|
|
153
|
+
preserveComments = false
|
|
154
|
+
} = options;
|
|
155
|
+
|
|
156
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
157
|
+
return parseTemplateContent(content, filePath, { preserveEchos, preserveComments });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse template content and extract HTML
|
|
162
|
+
* @param {string} content - Template content
|
|
163
|
+
* @param {string} filePath - Original file path (for type detection)
|
|
164
|
+
* @param {Object} options - Parse options
|
|
165
|
+
* @returns {Object} Parsed result
|
|
166
|
+
*/
|
|
167
|
+
function parseTemplateContent(content, filePath, options = {}) {
|
|
168
|
+
const {
|
|
169
|
+
preserveEchos = true,
|
|
170
|
+
preserveComments = false
|
|
171
|
+
} = options;
|
|
172
|
+
|
|
173
|
+
const templateType = detectTemplateType(filePath);
|
|
174
|
+
if (!templateType) {
|
|
175
|
+
// Not a template, return as-is
|
|
176
|
+
return {
|
|
177
|
+
html: content,
|
|
178
|
+
templateType: null,
|
|
179
|
+
dynamicClasses: [],
|
|
180
|
+
warnings: []
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const config = TEMPLATE_PATTERNS[templateType];
|
|
185
|
+
let html = content;
|
|
186
|
+
const dynamicClasses = [];
|
|
187
|
+
const warnings = [];
|
|
188
|
+
|
|
189
|
+
// First, extract potential dynamic class patterns
|
|
190
|
+
const classPatterns = [
|
|
191
|
+
/class\s*=\s*["'][^"']*<\?[\s\S]*?\?>[^"']*["']/g,
|
|
192
|
+
/class\s*=\s*["'][^"']*\{\{[\s\S]*?\}\}[^"']*["']/g,
|
|
193
|
+
/class\s*=\s*["'][^"']*<%[\s\S]*?%>[^"']*["']/g,
|
|
194
|
+
/:class\s*=\s*["']\{[\s\S]*?\}["']/g, // Vue-like
|
|
195
|
+
/className\s*=\s*\{[\s\S]*?\}/g, // React-like in templates
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const pattern of classPatterns) {
|
|
199
|
+
const matches = content.match(pattern);
|
|
200
|
+
if (matches) {
|
|
201
|
+
matches.forEach(m => {
|
|
202
|
+
dynamicClasses.push({
|
|
203
|
+
original: m,
|
|
204
|
+
warning: 'Dynamic class detected - may not be fully extracted'
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (dynamicClasses.length > 0) {
|
|
211
|
+
warnings.push(`Found ${dynamicClasses.length} dynamic class attribute(s) - these may contain additional classes at runtime`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Preserve echo patterns as placeholders if requested
|
|
215
|
+
if (preserveEchos && config.echoPatterns) {
|
|
216
|
+
for (const echo of config.echoPatterns) {
|
|
217
|
+
html = html.replace(echo.regex, echo.placeholder);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Remove server-side code patterns
|
|
222
|
+
for (const pattern of config.patterns) {
|
|
223
|
+
html = html.replace(pattern, '');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Clean up excess whitespace but preserve structure
|
|
227
|
+
html = html
|
|
228
|
+
.replace(/^\s*[\r\n]/gm, '\n') // Remove blank lines
|
|
229
|
+
.replace(/\n{3,}/g, '\n\n') // Max 2 newlines
|
|
230
|
+
.trim();
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
html,
|
|
234
|
+
templateType,
|
|
235
|
+
originalContent: content,
|
|
236
|
+
dynamicClasses,
|
|
237
|
+
warnings
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get list of supported template extensions
|
|
243
|
+
* @returns {string[]}
|
|
244
|
+
*/
|
|
245
|
+
function getSupportedExtensions() {
|
|
246
|
+
const extensions = [];
|
|
247
|
+
for (const config of Object.values(TEMPLATE_PATTERNS)) {
|
|
248
|
+
extensions.push(...config.extensions);
|
|
249
|
+
}
|
|
250
|
+
return [...new Set(extensions)];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
TEMPLATE_PATTERNS,
|
|
255
|
+
detectTemplateType,
|
|
256
|
+
isTemplateFile,
|
|
257
|
+
parseTemplateFile,
|
|
258
|
+
parseTemplateContent,
|
|
259
|
+
getSupportedExtensions
|
|
260
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Fetcher Module
|
|
3
|
+
* Fetches rendered HTML from live URLs for WordPress/Magento/dynamic sites
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch HTML content from a URL
|
|
11
|
+
* @param {string} url - URL to fetch
|
|
12
|
+
* @param {Object} options - Fetch options
|
|
13
|
+
* @returns {Promise<{html: string, url: string, statusCode: number}>}
|
|
14
|
+
*/
|
|
15
|
+
async function fetchURL(url, options = {}) {
|
|
16
|
+
const {
|
|
17
|
+
timeout = 30000,
|
|
18
|
+
userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
19
|
+
followRedirects = true,
|
|
20
|
+
maxRedirects = 5
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const parsedUrl = new URL(url);
|
|
25
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
26
|
+
|
|
27
|
+
const requestOptions = {
|
|
28
|
+
hostname: parsedUrl.hostname,
|
|
29
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
30
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
31
|
+
method: 'GET',
|
|
32
|
+
headers: {
|
|
33
|
+
'User-Agent': userAgent,
|
|
34
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
35
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
36
|
+
'Accept-Encoding': 'identity',
|
|
37
|
+
'Connection': 'keep-alive'
|
|
38
|
+
},
|
|
39
|
+
timeout
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const req = protocol.request(requestOptions, (res) => {
|
|
43
|
+
// Handle redirects
|
|
44
|
+
if (followRedirects && [301, 302, 303, 307, 308].includes(res.statusCode)) {
|
|
45
|
+
if (maxRedirects <= 0) {
|
|
46
|
+
reject(new Error('Too many redirects'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const redirectUrl = res.headers.location;
|
|
50
|
+
if (!redirectUrl) {
|
|
51
|
+
reject(new Error('Redirect without location header'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Resolve relative URLs
|
|
55
|
+
const absoluteUrl = redirectUrl.startsWith('http')
|
|
56
|
+
? redirectUrl
|
|
57
|
+
: new URL(redirectUrl, url).toString();
|
|
58
|
+
|
|
59
|
+
fetchURL(absoluteUrl, { ...options, maxRedirects: maxRedirects - 1 })
|
|
60
|
+
.then(resolve)
|
|
61
|
+
.catch(reject);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let data = '';
|
|
66
|
+
res.setEncoding('utf8');
|
|
67
|
+
|
|
68
|
+
res.on('data', (chunk) => {
|
|
69
|
+
data += chunk;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
res.on('end', () => {
|
|
73
|
+
resolve({
|
|
74
|
+
html: data,
|
|
75
|
+
url: url,
|
|
76
|
+
statusCode: res.statusCode,
|
|
77
|
+
contentType: res.headers['content-type'] || ''
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
req.on('error', (error) => {
|
|
83
|
+
reject(new Error(`Failed to fetch URL: ${error.message}`));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
req.on('timeout', () => {
|
|
87
|
+
req.destroy();
|
|
88
|
+
reject(new Error(`Request timeout after ${timeout}ms`));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
req.end();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if input is a URL
|
|
97
|
+
* @param {string} input - Input string
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function isURL(input) {
|
|
101
|
+
try {
|
|
102
|
+
const url = new URL(input);
|
|
103
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract base URL for relative path resolution
|
|
111
|
+
* @param {string} url - Full URL
|
|
112
|
+
* @returns {string} Base URL
|
|
113
|
+
*/
|
|
114
|
+
function getBaseURL(url) {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
fetchURL,
|
|
121
|
+
isURL,
|
|
122
|
+
getBaseURL
|
|
123
|
+
};
|