@sitrozyi/repomix-semantic-compressor 0.1.2

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/src/core.mjs ADDED
@@ -0,0 +1,502 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { Worker } from 'node:worker_threads';
5
+ import { parseArgs } from 'node:util';
6
+ import { execSync } from 'node:child_process';
7
+ import { getEncoding } from 'js-tiktoken';
8
+ import {
9
+ optimizeHTML,
10
+ summarizeCSS,
11
+ processJSON,
12
+ optimizeSQL,
13
+ optimizeYAML,
14
+ optimizeDockerfile,
15
+ optimizeMarkdown,
16
+ skeletonizePython,
17
+ skeletonizeGo
18
+ } from './optimizers.mjs';
19
+ import { skeletonizeWithAST } from './ast.mjs';
20
+ import { extractFiles } from './extractor.mjs';
21
+ import { extractImports, resolveLocalImportPath, summarizeExports } from './imports.mjs';
22
+
23
+ export {
24
+ optimizeHTML,
25
+ summarizeCSS,
26
+ processJSON,
27
+ optimizeSQL,
28
+ optimizeYAML,
29
+ optimizeDockerfile,
30
+ optimizeMarkdown,
31
+ skeletonizePython,
32
+ skeletonizeGo
33
+ } from './optimizers.mjs';
34
+ export { skeletonizeWithAST, extractProtocolsFromAST } from './ast.mjs';
35
+ export { extractFiles } from './extractor.mjs';
36
+ export { extractImports, resolveLocalImportPath, summarizeExports } from './imports.mjs';
37
+
38
+ // --- CLI Configuration Specifications ---
39
+ const CLI_OPTIONS = {
40
+ input: { type: 'string', short: 'i' },
41
+ output: { type: 'string', short: 'o', default: 'repomix-optimized.md' },
42
+ 'max-preserve-lines': { type: 'string', short: 'm', default: '8' },
43
+ focus: { type: 'string', short: 'f' },
44
+ 'exact-tokens': { type: 'boolean', short: 'e', default: false },
45
+ 'auto-pack': { type: 'boolean', default: true },
46
+ 'no-auto-pack': { type: 'boolean', default: false },
47
+ help: { type: 'boolean', short: 'h' }
48
+ };
49
+
50
+ // --- Terminal Formatting Utilities ---
51
+ const styles = {
52
+ green: (t) => `\x1b[32m${t}\x1b[0m`,
53
+ cyan: (t) => `\x1b[36m${t}\x1b[0m`,
54
+ bold: (t) => `\x1b[1m${t}\x1b[0m`,
55
+ gray: (t) => `\x1b[90m${t}\x1b[0m`,
56
+ yellow: (t) => `\x1b[33m${t}\x1b[0m`,
57
+ magenta: (t) => `\x1b[35m${t}\x1b[0m`
58
+ };
59
+
60
+ /**
61
+ * Converts raw byte size to a human-readable string (kB, MB, etc.).
62
+ * @param {number} bytes
63
+ * @returns {string} Formatted size string
64
+ */
65
+ function formatBytes(bytes) {
66
+ if (!bytes || bytes === 0) return '0 B';
67
+ const k = 1024;
68
+ const units = ['B', 'kB', 'MB', 'GB'];
69
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
70
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${units[i]}`;
71
+ }
72
+
73
+ /**
74
+ * Formats LLM token count with thousands separator.
75
+ * @param {number} tokens
76
+ * @returns {string} Formatted token count
77
+ */
78
+ export function formatTokens(tokens) {
79
+ return `${(tokens || 0).toLocaleString()}`;
80
+ }
81
+
82
+ let tokenizerInstance = null;
83
+ /**
84
+ * Counts LLM tokens. Uses fast byte-length approximation by default (~0ms),
85
+ * or exact cl100k_base BPE tokenization when exact === true.
86
+ * @param {string} text
87
+ * @param {boolean} [exact=false]
88
+ * @returns {number} Token count
89
+ */
90
+ export function countTokens(text, exact = false) {
91
+ if (!text || typeof text !== 'string') return 0;
92
+ if (!exact) {
93
+ return Math.round(Buffer.byteLength(text, 'utf-8') / 3.8);
94
+ }
95
+ if (!tokenizerInstance) {
96
+ tokenizerInstance = getEncoding('cl100k_base');
97
+ }
98
+ try {
99
+ return tokenizerInstance.encode(text).length;
100
+ } catch {
101
+ return Math.round(Buffer.byteLength(text, 'utf-8') / 3.8);
102
+ }
103
+ }
104
+ /**
105
+ * Resolves CLI arguments and determines input/output paths safely.
106
+ * @returns {{ inputFile: string, outputFile: string, maxPreserveLines: number }}
107
+ */
108
+ export function resolveConfig() {
109
+ let parsed;
110
+ try {
111
+ parsed = parseArgs({ options: CLI_OPTIONS, allowPositionals: true }).values;
112
+ } catch (err) {
113
+ console.error(`error: ${err.message}`);
114
+ process.exit(1);
115
+ }
116
+
117
+ if (parsed.help) {
118
+ const bold = '\x1b[1m';
119
+ const cyan = '\x1b[36m';
120
+ const underline = '\x1b[4m';
121
+ const dim = '\x1b[2m';
122
+ const reset = '\x1b[0m';
123
+
124
+ console.log(`
125
+ ${bold}Repomix Semantic Compressor (AST-Powered)${reset}
126
+ A semantic post-processor for Repomix (${cyan}${underline}https://repomix.com${reset})
127
+
128
+ ${bold}Usage:${reset}
129
+ npx repomix-compress [options]
130
+ node bin/cli.mjs [options]
131
+
132
+ ${bold}Options:${reset}
133
+ -i, --input <path> Input Repomix file (auto-detects .xml or .json)
134
+ -o, --output <path> Output optimized file (default: repomix-optimized.md)
135
+ -f, --focus <pattern> Retain full implementation for matched path/module
136
+ -m, --max-preserve-lines <num> Max lines to preserve full function body (default: 8)
137
+ -e, --exact-tokens Use exact BPE tokenizer (slower, default: false)
138
+ --no-auto-pack Disable automatic repomix execution if artifact is missing
139
+ -h, --help Show CLI help and exit
140
+
141
+ ${bold}Examples:${reset}
142
+ ${dim}$${reset} npx repomix-compress
143
+ ${dim}$${reset} npx repomix-compress -f src/auth -o auth-context.md
144
+ ${dim}$${reset} npx repomix-compress -i repomix-output.xml -o context.md
145
+ `);
146
+ process.exit(0);
147
+ }
148
+
149
+ const autoPack = parsed['no-auto-pack'] ? false : (parsed['auto-pack'] ?? true);
150
+ let inputFile = parsed.input;
151
+ if (!inputFile) {
152
+ inputFile = findDefaultInputFile(autoPack);
153
+ }
154
+
155
+ const maxPreserveLines = Number.parseInt(parsed['max-preserve-lines'], 10);
156
+ if (Number.isNaN(maxPreserveLines) || maxPreserveLines < 0) {
157
+ console.error(`error: --max-preserve-lines must be a non-negative integer.`);
158
+ process.exit(1);
159
+ }
160
+
161
+ return {
162
+ inputFile,
163
+ outputFile: parsed.output,
164
+ maxPreserveLines,
165
+ focus: parsed.focus || null,
166
+ exactTokens: Boolean(parsed['exact-tokens']),
167
+ autoPack
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Finds default repomix output file or automatically runs repomix by default.
173
+ * @param {boolean} [autoPack=true]
174
+ * @param {string} [baseDir='.']
175
+ * @returns {string} Path to input file
176
+ */
177
+ export function findDefaultInputFile(autoPack = true, baseDir = '.', silent = false) {
178
+ const xmlPath = path.normalize(path.join(baseDir, 'repomix-output.xml')).replace(/\\/g, '/');
179
+ const jsonPath = path.normalize(path.join(baseDir, 'repomix-output.json')).replace(/\\/g, '/');
180
+
181
+ if (fs.existsSync(xmlPath)) return xmlPath;
182
+ if (fs.existsSync(jsonPath)) return jsonPath;
183
+
184
+ if (!autoPack) {
185
+ throw new Error(
186
+ 'Repomix output file not found (repomix-output.xml or repomix-output.json).\n' +
187
+ 'Please run "npx repomix" first, or omit --no-auto-pack to pack automatically.'
188
+ );
189
+ }
190
+
191
+ const packStart = performance.now();
192
+ // Route logs to stderr to avoid corrupting MCP JSON-RPC stdio protocol
193
+ if (!silent) {
194
+ process.stderr.write(`${styles.cyan('ℹ')} repomix-output not found. Running "npx repomix" automatically... `);
195
+ }
196
+
197
+ try {
198
+ execSync('npx repomix', {
199
+ stdio: 'pipe',
200
+ timeout: 30000,
201
+ maxBuffer: 10 * 1024 * 1024
202
+ });
203
+ const packDuration = ((performance.now() - packStart) / 1000).toFixed(1);
204
+ if (!silent) {
205
+ process.stderr.write(`${styles.gray(`(done in ${packDuration}s)\n`)}`);
206
+ }
207
+
208
+ if (fs.existsSync(xmlPath)) return xmlPath;
209
+ if (fs.existsSync(jsonPath)) return jsonPath;
210
+ if (fs.existsSync('repomix-output.xml')) return 'repomix-output.xml';
211
+ if (fs.existsSync('repomix-output.json')) return 'repomix-output.json';
212
+ } catch (err) {
213
+ if (!silent) {
214
+ process.stderr.write('\n');
215
+ }
216
+ const errMsg = err.stderr ? err.stderr.toString().trim() : err.message;
217
+ throw new Error(`Auto-running "npx repomix" failed: ${errMsg}`);
218
+ }
219
+ throw new Error('Repomix completed but neither repomix-output.xml nor repomix-output.json was found.');
220
+ }
221
+ export function writeToStream(stream, chunk) {
222
+ if (!stream.write(chunk)) {
223
+ return new Promise((resolve) => stream.once('drain', resolve));
224
+ }
225
+ return Promise.resolve();
226
+ }
227
+
228
+ export function transformFileContent(filePath, originalCode, maxPreserveLines) {
229
+ const ext = path.extname(filePath).toLowerCase();
230
+ const baseName = path.basename(filePath).toLowerCase();
231
+ // Normalize Windows CRLF to standard LF
232
+ const normalizedCode = originalCode.replace(/\r\n/g, '\n');
233
+ let processedCode = '';
234
+
235
+ const isDockerfile = baseName === 'dockerfile' || baseName.startsWith('dockerfile.');
236
+
237
+ if (isDockerfile) {
238
+ processedCode = optimizeDockerfile(normalizedCode);
239
+ } else if (['.css', '.scss', '.less'].includes(ext)) {
240
+ processedCode = summarizeCSS(normalizedCode);
241
+ } else if (ext === '.html' || ext === '.htm') {
242
+ processedCode = optimizeHTML(normalizedCode);
243
+ } else if (ext === '.json') {
244
+ processedCode = processJSON(normalizedCode);
245
+ } else if (ext === '.sql') {
246
+ processedCode = optimizeSQL(normalizedCode);
247
+ } else if (ext === '.yml' || ext === '.yaml') {
248
+ processedCode = optimizeYAML(normalizedCode);
249
+ } else if (ext === '.md' || ext === '.mdx') {
250
+ processedCode = optimizeMarkdown(normalizedCode);
251
+ } else if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx'].includes(ext)) {
252
+ const isTS = ['.ts', '.mts', '.cts', '.tsx'].includes(ext);
253
+ const isJSX = ['.jsx', '.tsx'].includes(ext);
254
+ processedCode = skeletonizeWithAST(normalizedCode, isTS, maxPreserveLines, isJSX);
255
+ } else if (ext === '.py') {
256
+ processedCode = skeletonizePython(normalizedCode, maxPreserveLines);
257
+ } else if (ext === '.go') {
258
+ processedCode = skeletonizeGo(normalizedCode, maxPreserveLines);
259
+ } else {
260
+ processedCode = normalizedCode;
261
+ }
262
+
263
+ return {
264
+ ext,
265
+ code: processedCode.replace(/(?:\r?\n){3,}/g, '\n\n').trim()
266
+ };
267
+ }
268
+
269
+
270
+ /**
271
+ * Transforms repository files in parallel using worker threads if file count exceeds threshold.
272
+ * Preserves original index order deterministically.
273
+ * @param {{ path: string, content: string }[]} files
274
+ * @param {number} maxPreserveLines
275
+ * @returns {Promise<{ path: string, ext: string, code: string }[]>}
276
+ */
277
+ export async function transformFilesParallel(files, maxPreserveLines = 8) {
278
+ if (!files || files.length === 0) return [];
279
+
280
+ // Fallback to single thread for small batches to avoid worker initialization overhead
281
+ if (files.length <= 20) {
282
+ return files.map((f) => {
283
+ const transformed = transformFileContent(f.path, f.content, maxPreserveLines);
284
+ return {
285
+ path: f.path,
286
+ ext: transformed.ext,
287
+ code: transformed.code
288
+ };
289
+ });
290
+ }
291
+
292
+ const numCPUs = os.cpus()?.length || 4;
293
+ const workerCount = Math.min(4, Math.min(numCPUs, Math.ceil(files.length / 10)));
294
+ const chunkSize = Math.ceil(files.length / workerCount);
295
+
296
+ const chunks = [];
297
+ for (let i = 0; i < workerCount; i++) {
298
+ const start = i * chunkSize;
299
+ const end = Math.min(start + chunkSize, files.length);
300
+ if (start < end) {
301
+ const items = files.slice(start, end).map((file, localIdx) => ({
302
+ index: start + localIdx,
303
+ path: file.path,
304
+ content: file.content
305
+ }));
306
+ chunks.push(items);
307
+ }
308
+ }
309
+
310
+ const workerUrl = new URL('./worker.mjs', import.meta.url);
311
+ const activeWorkers = [];
312
+
313
+ try {
314
+ // Fall back to sequential execution on the main thread if any worker fails
315
+ const workerPromises = chunks.map((chunk) => {
316
+ return new Promise((resolve) => {
317
+ const worker = new Worker(workerUrl, {
318
+ workerData: { items: chunk, maxPreserveLines }
319
+ });
320
+ activeWorkers.push(worker);
321
+ worker.on('message', (results) => resolve(results));
322
+ worker.on('error', () => {
323
+ // Fallback chunk execution on main thread
324
+ const fallback = chunk.map((item) => {
325
+ const transformed = transformFileContent(item.path, item.content, maxPreserveLines);
326
+ return { index: item.index, path: item.path, ext: transformed.ext, code: transformed.code };
327
+ });
328
+ resolve(fallback);
329
+ });
330
+ worker.on('exit', (code) => {
331
+ if (code !== 0) {
332
+ const fallback = chunk.map((item) => {
333
+ const transformed = transformFileContent(item.path, item.content, maxPreserveLines);
334
+ return { index: item.index, path: item.path, ext: transformed.ext, code: transformed.code };
335
+ });
336
+ resolve(fallback);
337
+ }
338
+ });
339
+ });
340
+ });
341
+
342
+ const chunkResults = await Promise.all(workerPromises);
343
+ const allResults = new Array(files.length);
344
+ for (const resList of chunkResults) {
345
+ for (const res of resList) {
346
+ allResults[res.index] = {
347
+ path: res.path,
348
+ ext: res.ext,
349
+ code: res.code
350
+ };
351
+ }
352
+ }
353
+
354
+ return allResults;
355
+ } finally {
356
+ await Promise.allSettled(activeWorkers.map((w) => w.terminate()));
357
+ }
358
+ }
359
+
360
+ const TYPE_CONTRACT_REGEX = /(?:^|[\\/])(?:types?|interfaces?|models?|schemas?|constants?|contracts?|entities?)(?:[\\/.]|\.d\.ts$)/i;
361
+
362
+ function isTypeOrContractDefinition(filePath) {
363
+ return TYPE_CONTRACT_REGEX.test(filePath) || filePath.endsWith('.d.ts');
364
+ }
365
+
366
+ /**
367
+ * Compresses an array of repository files with transitive dependency resolution.
368
+ * @param {{ path: string, content: string }[]} files
369
+ * @param {{ focus?: string|null, maxPreserveLines?: number }} options
370
+ * @returns {Promise<string>}
371
+ */
372
+ export async function compressRepository(files, options = {}) {
373
+ const { focus = null, maxPreserveLines = 8 } = options;
374
+ const sections = [
375
+ `[SEMANTIC REPOSITORY SKELETON CONTEXT]\n Optimized for LLM reasoning & architectural analysis.\n========================================\n\n`
376
+ ];
377
+
378
+ if (focus) {
379
+ const fileContentMap = new Map(files.map((f) => [f.path, f.content]));
380
+ const focusFiles = new Set();
381
+ const dependencyFiles = new Set();
382
+ const visited = new Set();
383
+ const traversalQueue = [];
384
+
385
+ for (const f of files) {
386
+ if (f.path.includes(focus)) {
387
+ focusFiles.add(f.path);
388
+ visited.add(f.path);
389
+ traversalQueue.push({ path: f.path, depth: 0 });
390
+ }
391
+ }
392
+
393
+ while (traversalQueue.length > 0) {
394
+ const current = traversalQueue.shift();
395
+ const content = fileContentMap.get(current.path);
396
+ if (!content) continue;
397
+
398
+ const imports = extractImports(content);
399
+ for (const imp of imports) {
400
+ const resolved = resolveLocalImportPath(current.path, imp, files);
401
+ if (!resolved || visited.has(resolved)) continue;
402
+
403
+ const isDirect = current.depth === 0;
404
+ const isTypeContract = isTypeOrContractDefinition(resolved);
405
+
406
+ if (isDirect || isTypeContract) {
407
+ visited.add(resolved);
408
+ if (!focusFiles.has(resolved)) {
409
+ dependencyFiles.add(resolved);
410
+ }
411
+ traversalQueue.push({ path: resolved, depth: current.depth + 1 });
412
+ }
413
+ }
414
+ }
415
+
416
+ const dependencyFilesList = files.filter((f) => dependencyFiles.has(f.path));
417
+ const transformedDependencies = await transformFilesParallel(dependencyFilesList, maxPreserveLines);
418
+ const transformedMap = new Map();
419
+ for (const item of transformedDependencies) {
420
+ transformedMap.set(item.path, item);
421
+ }
422
+
423
+ for (const file of files) {
424
+ const ext = path.extname(file.path).toLowerCase();
425
+ const lang = ext ? ext.replace(/^\./, '') : '';
426
+
427
+ if (focusFiles.has(file.path)) {
428
+ sections.push(`### File: ${file.path} [FOCUS - FULL IMPLEMENTATION]\n\`\`\`\`${lang}\n${file.content.trim()}\n\`\`\`\`\n\n`);
429
+ } else if (dependencyFiles.has(file.path)) {
430
+ const transformed = transformedMap.get(file.path) || transformFileContent(file.path, file.content, maxPreserveLines);
431
+ sections.push(`### File: ${file.path} [DEPENDENCY - SKELETON]\n\`\`\`\`${lang}\n${transformed.code}\n\`\`\`\`\n\n`);
432
+ } else {
433
+ const summary = summarizeExports(file.content);
434
+ sections.push(`### File: ${file.path} [OUT OF SCOPE - SUMMARY]\n\`\`\`\`${lang}\n${summary}\n\`\`\`\`\n\n`);
435
+ }
436
+ }
437
+ } else {
438
+ const transformedList = await transformFilesParallel(files, maxPreserveLines);
439
+ for (const item of transformedList) {
440
+ const lang = item.ext ? item.ext.replace(/^\./, '') : '';
441
+ sections.push(`### File: ${item.path}\n\`\`\`\`${lang}\n${item.code}\n\`\`\`\`\n\n`);
442
+ }
443
+ }
444
+
445
+ return sections.join('');
446
+ }
447
+
448
+ const ARTIFACT_IGNORE_REGEX = /repomix-(?:optimized|output)/i;
449
+
450
+ export async function main() {
451
+ const { inputFile, outputFile, maxPreserveLines, focus, exactTokens } = resolveConfig();
452
+ const startTime = performance.now();
453
+
454
+ if (!fs.existsSync(inputFile)) {
455
+ throw new Error(`Input artifact not found: ${inputFile}`);
456
+ }
457
+
458
+ const rawContent = fs.readFileSync(inputFile, 'utf-8');
459
+ const inputBytes = Buffer.byteLength(rawContent, 'utf-8');
460
+ const inputTokens = countTokens(rawContent, exactTokens);
461
+
462
+ const rawFiles = extractFiles(rawContent, inputFile);
463
+ const files = rawFiles.filter((f) => !ARTIFACT_IGNORE_REGEX.test(f.path));
464
+ const optimizedContent = await compressRepository(files, { focus, maxPreserveLines });
465
+
466
+ // Stream output to disk with backpressure handling to prevent memory spikes
467
+ const writeStream = fs.createWriteStream(outputFile, { encoding: 'utf-8' });
468
+ await writeToStream(writeStream, optimizedContent);
469
+ await new Promise((resolve) => writeStream.end(resolve));
470
+
471
+ const outputRaw = fs.readFileSync(outputFile, 'utf-8');
472
+ const outputBytes = Buffer.byteLength(outputRaw, 'utf-8');
473
+ const outputTokens = countTokens(outputRaw, exactTokens);
474
+ const duration = Math.round(performance.now() - startTime);
475
+
476
+ const byteReduction = (((inputBytes - outputBytes) / inputBytes) * 100).toFixed(1);
477
+ const tokenReduction = (((inputTokens - outputTokens) / inputTokens) * 100).toFixed(1);
478
+
479
+ const savedTokens = Math.max(0, inputTokens - outputTokens);
480
+ const estimatedSavings = ((savedTokens * 3.0) / 1_000_000).toFixed(4);
481
+
482
+ const tokenModeLabel = exactTokens ? 'LLM Tokens:' : 'LLM Tokens ~:';
483
+ const labelTokens = tokenModeLabel.padEnd(14);
484
+ const labelSize = 'File Size:'.padEnd(14);
485
+ const labelSavings = 'Est. Savings:'.padEnd(14);
486
+ const labelFocus = 'Focus Filter:'.padEnd(14);
487
+ const labelOutput = 'Output File:'.padEnd(14);
488
+
489
+ const inTokStr = `${formatTokens(inputTokens)}`.padStart(9);
490
+ const outTokStr = `${formatTokens(outputTokens)}`.padStart(8);
491
+ const inByteStr = formatBytes(inputBytes).padStart(9);
492
+ const outByteStr = formatBytes(outputBytes).padStart(8);
493
+
494
+ console.log(`\n${styles.green('✔')} ${styles.bold(`Optimized ${files.length} files in ${duration}ms`)}\n`);
495
+ console.log(` ${styles.gray(labelTokens)} ${inTokStr} ${styles.gray('→')} ${styles.cyan(outTokStr)} ${styles.green(`(-${tokenReduction}%)`)}`);
496
+ console.log(` ${styles.gray(labelSize)} ${inByteStr} ${styles.gray('→')} ${styles.cyan(outByteStr)} ${styles.green(`(-${byteReduction}%)`)}`);
497
+ console.log(` ${styles.gray(labelSavings)} ${styles.yellow(`~$${estimatedSavings} / prompt`)} ${styles.gray('(Claude 3.5 Sonnet / GPT-4o input rate)')}`);
498
+ if (focus) {
499
+ console.log(` ${styles.gray(labelFocus)} ${styles.magenta(focus)}`);
500
+ }
501
+ console.log(` ${styles.gray(labelOutput)} ${styles.bold(outputFile)}\n`);
502
+ }
@@ -0,0 +1,89 @@
1
+ import { XMLParser } from 'fast-xml-parser';
2
+
3
+ /**
4
+ * Safely decodes XML entities in raw extracted file contents.
5
+ * @param {string} str
6
+ * @returns {string} Decoded string
7
+ */
8
+ function decodeXMLEntities(str) {
9
+ if (typeof str !== 'string') return '';
10
+ return str
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&apos;|&#39;/g, "'")
15
+ .replace(/&amp;/g, '&');
16
+ }
17
+
18
+ export function extractFiles(rawContent, filePath) {
19
+ if (filePath.endsWith('.json') || rawContent.trim().startsWith('{')) {
20
+ try {
21
+ const data = JSON.parse(rawContent);
22
+ const results = [];
23
+ if (data.files) {
24
+ if (Array.isArray(data.files)) {
25
+ for (const f of data.files) {
26
+ results.push({ path: f.path || 'unknown', content: f.content || '' });
27
+ }
28
+ } else if (typeof data.files === 'object') {
29
+ for (const [p, val] of Object.entries(data.files)) {
30
+ results.push({ path: p, content: typeof val === 'string' ? val : val.content || '' });
31
+ }
32
+ }
33
+ }
34
+ if (results.length > 0) return results;
35
+ } catch {}
36
+ }
37
+
38
+ try {
39
+ const parser = new XMLParser({
40
+ ignoreAttributes: false,
41
+ attributeNamePrefix: '@_',
42
+ stopNodes: ['*.file', 'file', 'files.file', 'root.files.file', 'root.file'],
43
+ processEntities: true,
44
+ htmlEntities: true,
45
+ trimValues: false
46
+ });
47
+
48
+ const sanitizedXml = rawContent.replace(/<\?xml[\s\S]*?\?>/gi, '').trim();
49
+ const wrappedXml = `<root>${sanitizedXml}</root>`;
50
+ const parsed = parser.parse(wrappedXml);
51
+ const results = [];
52
+
53
+ const collectFiles = (node) => {
54
+ if (!node) return;
55
+ if (Array.isArray(node)) {
56
+ for (const item of node) collectFiles(item);
57
+ return;
58
+ }
59
+ if (typeof node === 'object') {
60
+ if (node.file) {
61
+ const files = Array.isArray(node.file) ? node.file : [node.file];
62
+ for (const f of files) {
63
+ if (typeof f === 'object') {
64
+ const filePathAttr = f['@_path'] || f.path || 'unknown';
65
+ let fileContent = typeof f['#text'] === 'string' ? f['#text'] : (typeof f === 'string' ? f : '');
66
+ results.push({ path: filePathAttr, content: decodeXMLEntities(fileContent) });
67
+ }
68
+ }
69
+ }
70
+ for (const key of Object.keys(node)) {
71
+ if (key !== 'file' && typeof node[key] === 'object') {
72
+ collectFiles(node[key]);
73
+ }
74
+ }
75
+ }
76
+ };
77
+
78
+ collectFiles(parsed.root || parsed);
79
+ if (results.length > 0) return results;
80
+ } catch {}
81
+
82
+ const fileRegex = /<file\s+path="([^"]+)">([\s\S]*?)<\/file>/g;
83
+ const results = [];
84
+ let m;
85
+ while ((m = fileRegex.exec(rawContent)) !== null) {
86
+ results.push({ path: m[1], content: decodeXMLEntities(m[2]) });
87
+ }
88
+ return results;
89
+ }