/** * SUB STORE YAML ASSEMBLER (Content-Tag Aware) * * Требование: Каждый файл должен начинаться с комментария: * # @file: filename.yaml */ // --- UTILS --- // Функция нормализации теперь парсит содержимое, чтобы найти имя файла function normalizeFiles(rawFiles) { const map = new Map(); if (!rawFiles || !Array.isArray(rawFiles)) return map; rawFiles.forEach((content, index) => { if (typeof content !== 'string') return; // Ищем магический тег: # @file: filename.yaml const match = content.match(/^#\s*@file:\s*(.+?)(\s|$)/m); if (match) { const filename = match[1].trim(); map.set(filename, content); } else { // Если тега нет, файл остается "анонимным" и недоступным через манифест, // но мы можем логировать это. // console.log(`File at index ${index} has no @file tag`); } }); return map; } function cleanText(text) { return String(text || "").replace(/\r\n/g, "\n"); } // Извлечение name из элемента списка function extractName(block) { // Ищем name: value с учетом отступов (4 пробела) const match = block.match(/^ {4}name:\s*(?:["']?)(.*?)(?:["']?)\s*(?:#.*)?$/m); return match ? match[1].trim() : null; } // Извлечение ключа из map (0 или 2 пробела) function extractKey(block, indentLevel) { const spaceStr = " ".repeat(indentLevel); const re = new RegExp(`^${spaceStr}([^#\\s][^:]+):`); const match = block.match(re); return match ? match[1].trim().replace(/['"]/g, "") : null; } // Разбивка текста на логические блоки function splitBlocks(text, type) { const lines = cleanText(text).split("\n"); const blocks = []; let currentBuf = []; // Детекторы начала блока const isListStart = (l) => l.match(/^ {2}-\s/); // " - " const isMapStart2 = (l) => l.match(/^ {2}[^ \-#][^:]*:/); // " key:" const isMapStart0 = (l) => l.match(/^[^ \-#][^:]*:/); // "key:" (root) const isStart = (l) => { if (type === 'list') return isListStart(l); if (type === 'map2') return isMapStart2(l); if (type === 'map0') return isMapStart0(l); return false; }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Если строка начинается с # @file:, пропускаем ее, чтобы не мусорить в конфиге if (line.match(/^#\s*@file:/)) continue; if (isStart(line)) { if (currentBuf.length > 0) { blocks.push(currentBuf.join("\n")); currentBuf = []; } } // Пропуск пустых строк в начале, если буфер пуст if (currentBuf.length === 0 && !line.trim()) continue; currentBuf.push(line); } if (currentBuf.length > 0) { blocks.push(currentBuf.join("\n")); } return blocks; } // --- MERGE LOGIC --- function processSection(sectionName, manifestEntries, fileMap) { let sectionOutput = []; const seenKeys = new Set(); // Добавляем заголовок секции (кроме root, x-substore и rules) if (!['root', 'x-substore', 'rules'].includes(sectionName)) { sectionOutput.push(`${sectionName}:`); } if (sectionName === 'x-substore') { sectionOutput.push(`x-substore:`); } for (const entry of manifestEntries) { const content = fileMap.get(entry.file); if (!content) { // FAIL-FAST: Если файл из манифеста не найден (нет тега или не загружен) throw new Error(`CRITICAL: File "${entry.file}" not found inside input bundle. Did you add '# @file: ${entry.file}' header?`); } const mode = entry.mode || "concat"; sectionOutput.push(`\n# --- source: ${entry.file} | mode: ${mode} ---`); if (mode === 'concat') { // Просто чистим от тега @file при вставке const lines = cleanText(content).split('\n').filter(l => !l.match(/^#\s*@file:/)); sectionOutput.push(lines.join('\n')); continue; } if (mode === 'first_wins') { let blockType = 'map2'; if (sectionName === 'root') blockType = 'map0'; if (['proxies', 'proxy-groups'].includes(sectionName)) blockType = 'list'; const blocks = splitBlocks(content, blockType); if (blocks.length === 0) { sectionOutput.push(`# WARNING: No valid blocks found in ${entry.file}`); } for (const block of blocks) { let id = null; if (blockType === 'list') { id = extractName(block); } else if (blockType === 'map0') { id = extractKey(block, 0); } else { id = extractKey(block, 2); } if (id) { if (seenKeys.has(id)) { sectionOutput.push(`# [SKIP] Duplicate "${id}" ignored`); continue; } seenKeys.add(id); } sectionOutput.push(block); } } } return sectionOutput.join("\n"); } // --- MAIN EXECUTION --- try { // 1. Создаем карту файлов на основе тегов # @file: const fileMap = normalizeFiles($files); // 2. Ищем Манифест let manifestKey = null; // Ищем файл, чье имя (из тега) начинается с 00-manifest for (const k of fileMap.keys()) { if (k.startsWith("00-manifest")) { manifestKey = k; break; } } if (!manifestKey) { const foundFiles = Array.from(fileMap.keys()).join(", "); throw new Error(`Manifest file (00-manifest-...) not found in headers. Found files: [${foundFiles}]`); } // 3. Парсим Манифест const manifestRaw = fileMap.get(manifestKey); const manifestObj = ProxyUtils.yaml.safeLoad(manifestRaw); if (!manifestObj?.['x-substore']?.manifest) { throw new Error("Invalid Manifest: missing x-substore.manifest structure."); } const manifestList = manifestObj['x-substore'].manifest; const replacements = manifestObj['x-substore'].replacements || []; // 4. Планирование порядка секций const sectionOrder = [ "x-substore", "root", "hosts", "sniffer", "tun", "dns", "proxies", "proxy-providers", "proxy-groups", "rule-providers", "rules" ]; const plan = {}; sectionOrder.forEach(s => plan[s] = []); manifestList.forEach(entry => { // Если секция в манифесте есть, а в нашем плане нет - добавляем динамически (на всякий случай) if (!plan[entry.section]) plan[entry.section] = []; plan[entry.section].push(entry); }); // 5. Сборка const finalChunks = []; for (const sec of sectionOrder) { if (!plan[sec] || plan[sec].length === 0) continue; // Обработка секции const secStr = processSection(sec, plan[sec], fileMap); finalChunks.push(secStr); } let result = finalChunks.join("\n\n"); // 6. Replacements (Literal) if (Array.isArray(replacements)) { replacements.forEach(rep => { if (rep.from && rep.to) { result = result.split(rep.from).join(rep.to); } }); } $content = result; } catch (err) { $content = `# CRITICAL ERROR in Assembler:\n# ${err.message}\n\n# Stack:\n${err.stack}`; }