239 lines
7.4 KiB
JavaScript
239 lines
7.4 KiB
JavaScript
/**
|
||
* 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}`;
|
||
} |