import { copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const siteRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = path.resolve(siteRoot, '..'); const sourceDir = path.join(repoRoot, 'docs'); const outputDir = path.join(siteRoot, 'src', 'content', 'docs'); const descriptions = { 'getting-started/demo.md': 'Как устроен статический демо-режим Remnawave Minishop и почему он собирается только вместе с документацией.', 'index.md': 'Документация по запуску, настройке и сопровождению Telegram Mini App для Remnawave.', 'getting-started/overview.md': 'Что входит в Remnawave Minishop и как связаны бот, Mini App, backend, worker и Remnawave Panel.', 'getting-started/setup.md': 'Минимальный путь запуска Remnawave Minishop через Docker Compose.', 'getting-started/configuration.md': 'Минимальный .env, bootstrap-секреты и настройка через Web App админку.', 'getting-started/deployment.md': 'Docker Compose, обратный прокси, TLS, образы, обновления и резервные копии.', 'configuration/security.md': 'Секреты, публичные URL, доступ администраторов и базовые меры защиты Minishop.', 'configuration/env-vars.md': 'Полный справочник переменных окружения Remnawave Minishop.', 'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.', 'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.', 'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.', 'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.', 'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.', 'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.', 'features/email-login.md': 'SMTP, одноразовые коды, magic link, парольный вход и привязка email-аккаунтов.', 'features/webapp-themes.md': 'Кастомные темы, CSS-токены, ассеты и пайплайн создания темы.', 'features/admin-panel.md': 'Возможности админ-панели, управление пользователями, настройками, тарифами и поддержкой.', 'features/backups.md': 'Автоматические бэкапы, отправка архивов в Telegram, локальное хранение и восстановление БД/compose-папки из админки.', 'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.', 'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.', 'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.', 'troubleshooting/issues.md': 'Короткие чеклисты для частых проблем запуска, вебхуков, Mini App и платежей.', 'troubleshooting/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.', 'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.', 'architecture.md': 'Краткая архитектура backend, frontend, worker и инфраструктурных сервисов.', }; const imageExtensions = new Set(['.avif', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']); function yamlString(value) { return JSON.stringify(value); } function toPosix(relativePath) { return relativePath.split(path.sep).join('/'); } function outputRelativePath(sourceRelativePath) { if (sourceRelativePath === 'index.md') { return 'index.md'; } if (!sourceRelativePath.includes('/')) { return `reference/${sourceRelativePath}`; } return sourceRelativePath; } function pagePathForSource(sourceRelativePath, hash = '') { const output = outputRelativePath(sourceRelativePath).replace(/\.md$/i, ''); const route = output === 'index' ? '/' : `/${output.replace(/\/index$/u, '')}/`; return `${route}${hash}`; } function titleForRelativePath(relativePath) { const baseName = path.posix.basename(relativePath, '.md'); return baseName; } function extractTitle(relativePath, content) { const match = content.match(/^#\s+(.+?)\s*$/m); return match?.[1] ?? titleForRelativePath(relativePath); } function stripFirstHeading(content) { return content.replace(/^#\s+.+?\s*\r?\n+/, ''); } function rewriteMarkdownLinks(markdown, sourceRelativePath) { const sourceDirectory = path.posix.dirname(sourceRelativePath); return markdown.replace(/\]\((?!https?:\/\/|mailto:|tel:|\/|#)([^)\s]+\.md)(#[^)]+)?\)/g, (match, target, hash = '') => { const resolvedTarget = path.posix.normalize(path.posix.join(sourceDirectory, target)); return `](${pagePathForSource(resolvedTarget, hash)})`; }); } function normalizeCodeFences(markdown) { return markdown .replace(/^```env\s*$/gim, '```ini') .replace(/^```caddyfile\s*$/gim, '```txt'); } function extraFrontmatter(sourceRelativePath) { if (sourceRelativePath !== 'index.md') { return []; } return [ 'template: splash', 'hero:', ' tagline: "Telegram-бот и Mini App для продажи подписок Remnawave: платежи, тарифы, админка, поддержка и инструкции подключения."', ' image:', ' html: \'Интерфейс Remnawave Minishop\'', ' actions:', ' - text: "Демо"', ' link: /demo/', ' icon: right-arrow', ' - text: "Установка"', ' link: /getting-started/setup/', ' icon: setting', ' variant: minimal', ]; } function frontmatter({ title, description, sourceRelativePath }) { const editPath = sourceRelativePath .split('/') .map((segment) => encodeURIComponent(segment)) .join('/'); const editUrl = `https://github.com/3252a8/remnawave-minishop/edit/main/docs/${editPath}`; return [ '---', `title: ${yamlString(title)}`, `description: ${yamlString(description)}`, `editUrl: ${yamlString(editUrl)}`, ...extraFrontmatter(sourceRelativePath), '---', '', ].join('\n'); } async function walk(directory) { const entries = await readdir(directory, { withFileTypes: true }); const files = []; for (const entry of entries) { const absolutePath = path.join(directory, entry.name); if (entry.isDirectory()) { files.push(...(await walk(absolutePath))); continue; } if (entry.isFile()) { files.push(absolutePath); } } return files; } async function syncMarkdown(files) { for (const sourcePath of files.filter((file) => file.endsWith('.md'))) { const sourceRelativePath = toPosix(path.relative(sourceDir, sourcePath)); const outputRelative = outputRelativePath(sourceRelativePath); const outputPath = path.join(outputDir, ...outputRelative.split('/')); const content = await readFile(sourcePath, 'utf8'); const title = extractTitle(sourceRelativePath, content); const body = normalizeCodeFences( rewriteMarkdownLinks(stripFirstHeading(content).trimStart(), sourceRelativePath), ); const output = frontmatter({ title, description: descriptions[sourceRelativePath] ?? title, sourceRelativePath, }); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, `${output}${body}\n`, 'utf8'); } } async function syncAssets(files) { for (const sourcePath of files.filter((file) => imageExtensions.has(path.extname(file).toLowerCase()))) { const sourceRelativePath = toPosix(path.relative(sourceDir, sourcePath)); const outputRelative = !sourceRelativePath.includes('/') ? sourceRelativePath : sourceRelativePath; const outputPath = path.join(outputDir, ...outputRelative.split('/')); await mkdir(path.dirname(outputPath), { recursive: true }); await copyFile(sourcePath, outputPath); if (!sourceRelativePath.includes('/')) { const referenceOutputPath = path.join(outputDir, 'reference', sourceRelativePath); await mkdir(path.dirname(referenceOutputPath), { recursive: true }); await copyFile(sourcePath, referenceOutputPath); } } } await rm(outputDir, { recursive: true, force: true }); await mkdir(outputDir, { recursive: true }); const files = await walk(sourceDir); await syncMarkdown(files); await syncAssets(files); console.log(`Synced documentation from ${path.relative(repoRoot, sourceDir)} to ${path.relative(repoRoot, outputDir)}`);