Switch to filename-based wiki-links (Quartz resolves by filename)

- Convert all wiki-links from title-based to filename-based
- Update doc-links to validate against filenames
- Add doc-filenames task for duplicate filename detection
- Consolidate doc hooks into single local block in pre-commit config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-02-03 16:29:31 -08:00
commit 9630655cc9
28 changed files with 166 additions and 149 deletions

View file

@ -89,7 +89,7 @@ repos:
args: ['-config-file', '.github/actionlint.yaml'] args: ['-config-file', '.github/actionlint.yaml']
files: ^\.forgejo/workflows/ files: ^\.forgejo/workflows/
# Documentation - check for duplicate titles (required for Quartz wiki-link resolution) # Documentation validation
- repo: local - repo: local
hooks: hooks:
- id: doc-titles - id: doc-titles
@ -98,10 +98,12 @@ repos:
language: system language: system
files: ^docs/.*\.md$ files: ^docs/.*\.md$
pass_filenames: false pass_filenames: false
- id: doc-filenames
# Documentation - validate wiki-links point to existing titles name: doc-filenames
- repo: local entry: mise run doc-filenames
hooks: language: system
files: ^docs/.*\.md$
pass_filenames: false
- id: doc-links - id: doc-links
name: doc-links name: doc-links
entry: mise run doc-links entry: mise run doc-links

View file

@ -4,8 +4,8 @@ title: blumeops-documentation
Welcome to the BlumeOps documentation. Welcome to the BlumeOps documentation.
[[readme | Documentation Home]] - Temporary home while docs are being restructured (see [Diataxis](https://diataxis.fr/) restructuring plan) [[README | Documentation Home]] - Temporary home while docs are being restructured (see [Diataxis](https://diataxis.fr/) restructuring plan)
## Sections ## Sections
- [[reference]] - Technical reference cards for services, infrastructure, and operations - [[index | reference]] - Technical reference cards for services, infrastructure, and operations

View file

@ -14,7 +14,7 @@ Individual service reference cards with URLs and configuration details.
| Service | Description | Location | | Service | Description | Location |
|---------|-------------|----------| |---------|-------------|----------|
| [[grafana-alloy | Alloy]] | Observability collector (metrics & logs) | indri + k8s | | [[alloy | Alloy]] | Observability collector (metrics & logs) | indri + k8s |
| [[argocd]] | GitOps continuous delivery | k8s | | [[argocd]] | GitOps continuous delivery | k8s |
| [[borgmatic]] | Backup system | indri | | [[borgmatic]] | Backup system | indri |
| [[1password]] | Secrets management | cloud + k8s | | [[1password]] | Secrets management | cloud + k8s |
@ -36,27 +36,27 @@ Individual service reference cards with URLs and configuration details.
Host inventory and network configuration. Host inventory and network configuration.
- [[host-inventory | Hosts]] - Device inventory - [[hosts | Hosts]] - Device inventory
- [[indri]] - Primary server - [[indri]] - Primary server
- [[gilbert]] - Development workstation - [[gilbert]] - Development workstation
- [[tailscale]] - ACLs, groups, tags - [[tailscale]] - ACLs, groups, tags
- [[service-routing | Routing]] - DNS domains, port mappings - [[routing | Routing]] - DNS domains, port mappings
## Kubernetes ## Kubernetes
Cluster configuration and application registry. Cluster configuration and application registry.
- [[kubernetes-cluster | Cluster]] - Minikube specs, storage, networking - [[cluster | Cluster]] - Minikube specs, storage, networking
- [[argocd-applications | Apps]] - ArgoCD application registry - [[apps | Apps]] - ArgoCD application registry
- [[external-secrets]] - Secrets management - [[external-secrets]] - Secrets management
## Storage ## Storage
Network storage and backup configuration. Network storage and backup configuration.
- [[sifaka-nas | Sifaka]] - Synology NAS configuration - [[sifaka | Sifaka]] - Synology NAS configuration
- [[postgresql-storage]] - Database cluster - [[postgresql-storage]] - Database cluster
- [[backup-policy | Backups]] - Backup policy and schedule - [[backups | Backups]] - Backup policy and schedule
## Operations ## Operations

View file

@ -24,4 +24,4 @@ Managed via `Brewfile` and `mise.toml` in the blumeops repo.
## Related ## Related
- [[indri]] - Server accessed from gilbert - [[indri]] - Server accessed from gilbert
- [[kubernetes-cluster | Cluster]] - Remote k8s access - [[cluster | Cluster]] - Remote k8s access

View file

@ -14,7 +14,7 @@ All devices connected via [Tailscale](https://login.tailscale.com/) tailnet `tai
|------|-------------|------| |------|-------------|------|
| **Indri** | Mac Mini M1, 2020 - Primary server | [[indri | Details]] | | **Indri** | Mac Mini M1, 2020 - Primary server | [[indri | Details]] |
| **Gilbert** | MacBook Air M4, 2025 - Workstation | [[gilbert | Details]] | | **Gilbert** | MacBook Air M4, 2025 - Workstation | [[gilbert | Details]] |
| **[[sifaka-nas | Sifaka]]** | Synology NAS - Storage & backups | [[sifaka-nas | Details]] | | **[[sifaka | Sifaka]]** | Synology NAS - Storage & backups | [[sifaka | Details]] |
| **Mouse** | MacBook Air M2 - Allison's laptop | - | | **Mouse** | MacBook Air M2 - Allison's laptop | - |
| **UniFi** | UniFi Express 7 - Home WiFi | - | | **UniFi** | UniFi Express 7 - Home WiFi | - |
| **Dwarf** | iPad Air - Employer-provided, off tailnet | - | | **Dwarf** | iPad Air - Employer-provided, off tailnet | - |
@ -22,4 +22,4 @@ All devices connected via [Tailscale](https://login.tailscale.com/) tailnet `tai
## Related ## Related
- [[tailscale]] - Network configuration - [[tailscale]] - Network configuration
- [[service-routing | Routing]] - Service URLs - [[routing | Routing]] - Service URLs

View file

@ -26,13 +26,13 @@ Primary BlumeOps server. Mac Mini M1 (2020).
- [[zot]] - Container registry - [[zot]] - Container registry
- [[jellyfin]] - Media server - [[jellyfin]] - Media server
- [[borgmatic]] - Backup system - [[borgmatic]] - Backup system
- [[grafana-alloy | Alloy]] - Metrics/logs collector - [[alloy | Alloy]] - Metrics/logs collector
- Caddy - Reverse proxy for `*.ops.eblu.me` - Caddy - Reverse proxy for `*.ops.eblu.me`
**Kubernetes (via minikube):** **Kubernetes (via minikube):**
- [[argocd-applications | All k8s applications]] - [[apps | All k8s applications]]
## Related ## Related
- [[service-routing | Routing]] - Port mappings - [[routing | Routing]] - Port mappings
- [[kubernetes-cluster | Cluster]] - Minikube details - [[cluster | Cluster]] - Minikube details

View file

@ -38,7 +38,7 @@ DNS points to indri's Tailscale IP (100.98.163.89). TLS via Let's Encrypt (ACME
| [[navidrome]] | https://dj.ops.eblu.me | Music streaming | | [[navidrome]] | https://dj.ops.eblu.me | Music streaming |
| [[jellyfin]] | https://jellyfin.ops.eblu.me | Media server | | [[jellyfin]] | https://jellyfin.ops.eblu.me | Media server |
| [[postgresql]] | pg.ops.eblu.me:5432 | Database | | [[postgresql]] | pg.ops.eblu.me:5432 | Database |
| [[sifaka-nas | Sifaka]] | https://nas.ops.eblu.me | NAS dashboard | | [[sifaka | Sifaka]] | https://nas.ops.eblu.me | NAS dashboard |
## Tailscale-Only Services ## Tailscale-Only Services

View file

@ -58,5 +58,5 @@ Pulumi uses OAuth client from 1Password (blumeops vault):
## Related ## Related
- [[service-routing | Routing]] - Service URLs - [[routing | Routing]] - Service URLs
- [[host-inventory | Hosts]] - Device inventory - [[hosts | Hosts]] - Device inventory

View file

@ -26,7 +26,7 @@ Registry of all applications deployed via [[argocd]].
| `grafana` | monitoring | Helm chart (forge mirror) | [[grafana]] | | `grafana` | monitoring | Helm chart (forge mirror) | [[grafana]] |
| `grafana-config` | monitoring | `argocd/manifests/grafana-config/` | [[grafana]] | | `grafana-config` | monitoring | `argocd/manifests/grafana-config/` | [[grafana]] |
| `immich` | immich | Helm chart | [[immich]] | | `immich` | immich | Helm chart | [[immich]] |
| `alloy-k8s` | alloy | `argocd/manifests/alloy-k8s/` | [[grafana-alloy | Alloy]] | | `alloy-k8s` | alloy | `argocd/manifests/alloy-k8s/` | [[alloy | Alloy]] |
| `kube-state-metrics` | monitoring | `argocd/manifests/kube-state-metrics/` | K8s metrics | | `kube-state-metrics` | monitoring | `argocd/manifests/kube-state-metrics/` | K8s metrics |
| `miniflux` | miniflux | `argocd/manifests/miniflux/` | [[miniflux]] | | `miniflux` | miniflux | `argocd/manifests/miniflux/` | [[miniflux]] |
| `kiwix` | kiwix | `argocd/manifests/kiwix/` | [[kiwix]] | | `kiwix` | kiwix | `argocd/manifests/kiwix/` | [[kiwix]] |
@ -45,4 +45,4 @@ Registry of all applications deployed via [[argocd]].
## Related ## Related
- [[argocd]] - GitOps platform details - [[argocd]] - GitOps platform details
- [[kubernetes-cluster | Cluster]] - Kubernetes infrastructure - [[cluster | Cluster]] - Kubernetes infrastructure

View file

@ -24,7 +24,7 @@ Single-node Minikube cluster running on [[indri]].
## Volume Mounting ## Volume Mounting
Pods mount NFS directly from [[sifaka-nas | Sifaka]]. Docker NATs outbound traffic through indri's LAN IP (192.168.1.50), allowing access to Sifaka's NFS exports. Pods mount NFS directly from [[sifaka | Sifaka]]. Docker NATs outbound traffic through indri's LAN IP (192.168.1.50), allowing access to Sifaka's NFS exports.
## Registry Mirror ## Registry Mirror
@ -34,6 +34,6 @@ Mirrors configured: `registry.ops.eblu.me`, `docker.io`, `ghcr.io`, `quay.io`
## Related ## Related
- [[argocd-applications | Apps]] - ArgoCD applications - [[apps | Apps]] - ArgoCD applications
- [[argocd]] - GitOps deployment - [[argocd]] - GitOps deployment
- [[zot]] - Registry mirror - [[zot]] - Registry mirror

View file

@ -11,5 +11,5 @@ Daily automated backups of BlumeOps data.
## Components ## Components
- [[borgmatic]] - Backup orchestration - [[borgmatic]] - Backup orchestration
- [[sifaka-nas | Sifaka]] - Backup target (NAS) - [[sifaka | Sifaka]] - Backup target (NAS)
- [[backup-policy]] - What gets backed up and retention - [[backups | backup-policy]] - What gets backed up and retention

View file

@ -8,7 +8,7 @@ tags:
TBD. Current state: TBD. Current state:
- [[borgmatic]] provides daily backups to [[sifaka-nas | Sifaka]] - [[borgmatic]] provides daily backups to [[sifaka | Sifaka]]
- Infrastructure can be rebootstrapped using the blumeops repo - Infrastructure can be rebootstrapped using the blumeops repo
- Detailed DR procedures not yet documented - Detailed DR procedures not yet documented

View file

@ -12,5 +12,5 @@ Metrics, logs, and dashboards for BlumeOps infrastructure.
- [[prometheus]] - Metrics storage and querying - [[prometheus]] - Metrics storage and querying
- [[loki]] - Log aggregation - [[loki]] - Log aggregation
- [[grafana-alloy | Alloy]] - Metrics and log collection - [[alloy | Alloy]] - Metrics and log collection
- [[grafana]] - Dashboards and visualization - [[grafana]] - Dashboards and visualization

View file

@ -7,7 +7,7 @@ tags:
# ArgoCD # ArgoCD
GitOps continuous delivery platform for the [[kubernetes-cluster | Kubernetes cluster]]. GitOps continuous delivery platform for the [[cluster | Kubernetes cluster]].
## Quick Reference ## Quick Reference
@ -33,5 +33,5 @@ GitOps continuous delivery platform for the [[kubernetes-cluster | Kubernetes cl
## Related ## Related
- [[argocd-applications | Apps]] - Full application registry - [[apps | Apps]] - Full application registry
- [[forgejo]] - Git source - [[forgejo]] - Git source

View file

@ -16,7 +16,7 @@ Daily backup system using Borg backup, running on indri.
| **Install** | mise (pipx) | | **Install** | mise (pipx) |
| **Config** | `~/.config/borgmatic/config.yaml` | | **Config** | `~/.config/borgmatic/config.yaml` |
| **Schedule** | Daily at 2:00 AM | | **Schedule** | Daily at 2:00 AM |
| **Repository** | `/Volumes/backups/borg/` on [[sifaka-nas | Sifaka]] | | **Repository** | `/Volumes/backups/borg/` on [[sifaka | Sifaka]] |
## What Gets Backed Up ## What Gets Backed Up
@ -55,6 +55,6 @@ Dashboard: "Borgmatic Backups" in [[grafana]]
## Related ## Related
- [[backup-policy | Backups]] - Full backup policy - [[backups | Backups]] - Full backup policy
- [[sifaka-nas | Sifaka]] - Backup target - [[sifaka | Sifaka]] - Backup target
- [[postgresql]] - Database backups - [[postgresql]] - Database backups

View file

@ -47,4 +47,4 @@ Optional annotation: `grafana_folder: "FolderName"`
- [[prometheus]] - Metrics datasource - [[prometheus]] - Metrics datasource
- [[loki]] - Logs datasource - [[loki]] - Logs datasource
- [[grafana-alloy | Alloy]] - Data collector - [[alloy | Alloy]] - Data collector

View file

@ -17,10 +17,10 @@ Self-hosted photo and video management.
| **Namespace** | `immich` | | **Namespace** | `immich` |
| **Deployment** | Helm chart (k8s) | | **Deployment** | Helm chart (k8s) |
| **Database** | [[postgresql]] (CNPG) | | **Database** | [[postgresql]] (CNPG) |
| **Storage** | [[sifaka-nas | Sifaka]] photos volume | | **Storage** | [[sifaka | Sifaka]] photos volume |
## Related ## Related
- [[postgresql]] - Database backend - [[postgresql]] - Database backend
- [[sifaka-nas | Sifaka]] - Photo storage - [[sifaka | Sifaka]] - Photo storage
- [[jellyfin]] - Video streaming (separate service) - [[jellyfin]] - Video streaming (separate service)

View file

@ -42,10 +42,10 @@ Dashboard > Playback:
## Observability ## Observability
- Metrics: `jellyfin_metrics` ansible role - Metrics: `jellyfin_metrics` ansible role
- Logs: Forwarded via [[grafana-alloy | Alloy]] - Logs: Forwarded via [[alloy | Alloy]]
- Dashboard: "Jellyfin Media Server" in [[grafana]] - Dashboard: "Jellyfin Media Server" in [[grafana]]
## Related ## Related
- [[navidrome]] - Music streaming - [[navidrome]] - Music streaming
- [[sifaka-nas | Sifaka]] - Media storage - [[sifaka | Sifaka]] - Media storage

View file

@ -17,7 +17,7 @@ Offline Wikipedia and ZIM archive server.
| **Tailscale URL** | https://kiwix.tail8d86e.ts.net | | **Tailscale URL** | https://kiwix.tail8d86e.ts.net |
| **Namespace** | `kiwix` | | **Namespace** | `kiwix` |
| **Image** | `ghcr.io/kiwix/kiwix-serve:3.8.1` | | **Image** | `ghcr.io/kiwix/kiwix-serve:3.8.1` |
| **Storage** | NFS from [[sifaka-nas | Sifaka]] (`/volume1/torrents`) | | **Storage** | NFS from [[sifaka | Sifaka]] (`/volume1/torrents`) |
## Architecture ## Architecture
@ -49,4 +49,4 @@ Full list: `argocd/manifests/kiwix/configmap-zim-torrents.yaml`
## Related ## Related
- [[transmission]] - Downloads ZIM files - [[transmission]] - Downloads ZIM files
- [[sifaka-nas | Sifaka]] - ZIM storage - [[sifaka | Sifaka]] - ZIM storage

View file

@ -24,7 +24,7 @@ Log aggregation system for BlumeOps infrastructure.
- Single-node deployment with filesystem storage - Single-node deployment with filesystem storage
- TSDB index with 24h period - TSDB index with 24h period
- Logs collected by [[grafana-alloy | Alloy]] and pushed via Loki API - Logs collected by [[alloy | Alloy]] and pushed via Loki API
- Queried via [[grafana]] - Queried via [[grafana]]
## Log Sources ## Log Sources
@ -46,6 +46,6 @@ Log aggregation system for BlumeOps infrastructure.
## Related ## Related
- [[grafana-alloy | Alloy]] - Log collector - [[alloy | Alloy]] - Log collector
- [[grafana]] - Log visualization - [[grafana]] - Log visualization
- [[prometheus]] - Metrics counterpart - [[prometheus]] - Metrics counterpart

View file

@ -39,4 +39,4 @@ The `/data` directory contains SQLite database, configuration, and cache.
## Related ## Related
- [[jellyfin]] - Video streaming - [[jellyfin]] - Video streaming
- [[sifaka-nas | Sifaka]] - Music storage - [[sifaka | Sifaka]] - Music storage

View file

@ -23,19 +23,19 @@ Metrics storage and querying for BlumeOps infrastructure.
## Data Sources ## Data Sources
### Remote Write (from Alloy) ### Remote Write (from Alloy)
- Indri system metrics via [[grafana-alloy | Alloy]] remote_write - Indri system metrics via [[alloy | Alloy]] remote_write
- Textfile metrics: minikube, borgmatic, zot, jellyfin - Textfile metrics: minikube, borgmatic, zot, jellyfin
### Scrape Targets ### Scrape Targets
| Target | Metrics | | Target | Metrics |
|--------|---------| |--------|---------|
| `sifaka:9100` | [[sifaka-nas | Sifaka]] NAS (node_exporter) | | `sifaka:9100` | [[sifaka | Sifaka]] NAS (node_exporter) |
| `cnpg-metrics.tail8d86e.ts.net:9187` | [[postgresql | CloudNativePG]] metrics | | `cnpg-metrics.tail8d86e.ts.net:9187` | [[postgresql | CloudNativePG]] metrics |
| `kube-state-metrics.monitoring.svc:8080` | Kubernetes resource metrics | | `kube-state-metrics.monitoring.svc:8080` | Kubernetes resource metrics |
## Related ## Related
- [[grafana-alloy | Alloy]] - Metrics collector - [[alloy | Alloy]] - Metrics collector
- [[grafana]] - Visualization - [[grafana]] - Visualization
- [[loki]] - Logs counterpart - [[loki]] - Logs counterpart

View file

@ -17,7 +17,7 @@ BitTorrent daemon, primarily for downloading ZIM archives for [[kiwix]].
| **Tailscale URL** | https://torrent.tail8d86e.ts.net | | **Tailscale URL** | https://torrent.tail8d86e.ts.net |
| **Namespace** | `torrent` | | **Namespace** | `torrent` |
| **Image** | `lscr.io/linuxserver/transmission:latest` | | **Image** | `lscr.io/linuxserver/transmission:latest` |
| **Storage** | NFS PVC from [[sifaka-nas | Sifaka]] | | **Storage** | NFS PVC from [[sifaka | Sifaka]] |
## Storage Layout ## Storage Layout
@ -43,11 +43,11 @@ When downloads complete, the zim-watcher CronJob detects new ZIMs and restarts K
## Monitoring ## Monitoring
Basic uptime via blackbox probe in [[grafana-alloy | Alloy]] k8s (Services Health dashboard). Basic uptime via blackbox probe in [[alloy | Alloy]] k8s (Services Health dashboard).
Web UI shows: active/seeding/paused counts, speeds, disk usage. Web UI shows: active/seeding/paused counts, speeds, disk usage.
## Related ## Related
- [[kiwix]] - ZIM archive consumer - [[kiwix]] - ZIM archive consumer
- [[sifaka-nas | Sifaka]] - Download storage - [[sifaka | Sifaka]] - Download storage

View file

@ -30,7 +30,7 @@ OCI-native container registry providing pull-through cache and private image sto
## Pull-Through Cache ## Pull-Through Cache
When [[kubernetes-cluster | minikube]] pulls an image, containerd checks zot first. If cached, returns immediately. If not, zot fetches from upstream, caches it, then returns. When [[cluster | minikube]] pulls an image, containerd checks zot first. If cached, returns immediately. If not, zot fetches from upstream, caches it, then returns.
## Security Model ## Security Model
@ -39,4 +39,4 @@ Network access only (no authentication). Defense is the Tailscale ACL boundary.
## Related ## Related
- [[forgejo]] - Container build CI - [[forgejo]] - Container build CI
- [[kubernetes-cluster | Cluster]] - Registry consumer - [[cluster | Cluster]] - Registry consumer

View file

@ -7,7 +7,7 @@ tags:
# Backup Policy # Backup Policy
Daily automated backups from [[indri]] to [[sifaka-nas | Sifaka]] NAS. Daily automated backups from [[indri]] to [[sifaka | Sifaka]] NAS.
## Schedule ## Schedule
@ -53,7 +53,7 @@ Daily automated backups from [[indri]] to [[sifaka-nas | Sifaka]] NAS.
## Backup Target ## Backup Target
Repository: `/Volumes/backups/borg/` on [[sifaka-nas | Sifaka]] Repository: `/Volumes/backups/borg/` on [[sifaka | Sifaka]]
## Monitoring ## Monitoring
@ -67,5 +67,5 @@ Dashboard: "Borgmatic Backups" in [[grafana]]
## Related ## Related
- [[borgmatic]] - Backup system details - [[borgmatic]] - Backup system details
- [[sifaka-nas | Sifaka]] - Backup storage - [[sifaka | Sifaka]] - Backup storage
- [[postgresql]] - Database backups - [[postgresql]] - Database backups

View file

@ -52,7 +52,7 @@ Data protection for sifaka itself currently relies on the Synology RAID 5 config
## Related ## Related
- [[backup-policy | Backups]] - Backup policy - [[backups | Backups]] - Backup policy
- [[borgmatic]] - Backup system - [[borgmatic]] - Backup system
- [[immich]] - Photo consumer - [[immich]] - Photo consumer
- [[jellyfin]] - Media consumer - [[jellyfin]] - Media consumer

88
mise-tasks/doc-filenames Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich>=13.0.0"]
# ///
#MISE description="Detect duplicate filenames in documentation"
"""Detect duplicate filenames in documentation.
This script scans all markdown files in the docs/ directory (excluding
changelog.d/ and zk/) and reports any duplicate filenames that could
cause wiki-link resolution issues.
With Quartz, wiki-links like [[filename]] resolve by filename,
so filenames must be unique across the documentation.
Usage: mise run doc-filenames
"""
import sys
from collections import defaultdict
from pathlib import Path
from rich.console import Console
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
def main() -> int:
console = Console()
# Collect all filenames and their paths
# Key: filename (without .md), Value: list of file paths
filenames: dict[str, list[str]] = defaultdict(list)
# Scan all markdown files (excluding zk/, changelog.d/, and index.md files)
for md_file in sorted(DOCS_DIR.rglob("*.md")):
if "changelog.d" in md_file.parts or "zk" in md_file.parts:
continue
# Skip index.md files - they're expected to exist in multiple directories
if md_file.name == "index.md":
continue
rel_path = str(md_file.relative_to(DOCS_DIR))
filename = md_file.stem # filename without .md
filenames[filename].append(rel_path)
# Find duplicates
duplicates = {name: paths for name, paths in filenames.items() if len(paths) > 1}
# Print results
console.print("[bold]Doc Filename Inventory[/bold]")
console.print()
console.print("With Quartz, wiki-links like [[filename]] resolve by filename,")
console.print("so filenames must be unique across the documentation.")
console.print()
# Duplicates table (if any)
if duplicates:
console.print("[bold red]Duplicate Filenames Found[/bold red]")
dup_table = Table(show_header=True, header_style="bold")
dup_table.add_column("Filename")
dup_table.add_column("Paths")
for name in sorted(duplicates.keys()):
paths = duplicates[name]
dup_table.add_row(name, "\n".join(paths))
console.print(dup_table)
console.print()
# Summary
console.print(f"Total files: {sum(len(p) for p in filenames.values())}")
console.print(f"Unique filenames: {len(filenames)}")
console.print(f"Duplicate filenames: {len(duplicates)}")
if duplicates:
console.print()
console.print("[bold red]Action required:[/bold red] Rename files to ensure unique wiki-link resolution.")
return 1
console.print()
console.print("[bold green]All filenames are unique![/bold green]")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,29 +1,26 @@
#!/usr/bin/env -S uv run --script #!/usr/bin/env -S uv run --script
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = ["pyyaml>=6.0", "rich>=13.0.0"] # dependencies = ["rich>=13.0.0"]
# /// # ///
#MISE description="Validate all wiki-links point to existing doc titles" #MISE description="Validate all wiki-links point to existing doc filenames"
"""Validate that all wiki-links in documentation point to existing titles. """Validate that all wiki-links in documentation point to existing filenames.
This script scans all markdown files in the docs/ directory (excluding This script scans all markdown files in the docs/ directory (excluding
changelog.d/ and zk/), extracts wiki-links, and verifies each link target changelog.d/ and zk/), extracts wiki-links, and verifies each link target
exists as a frontmatter title in the documentation. exists as a filename in the documentation.
Wiki-link formats supported: Wiki-link formats supported:
- [[target]] - links to a doc with frontmatter title "target" - [[filename]] - links to filename.md
- [[target | Display Text]] - links to "target", displays "Display Text" - [[filename | Display Text]] - links to filename.md, displays "Display Text"
(spaces around the pipe are REQUIRED)
Usage: mise run doc-links Usage: mise run doc-links
""" """
import re import re
import sys import sys
from collections import defaultdict
from pathlib import Path from pathlib import Path
import yaml
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
from rich.table import Table from rich.table import Table
@ -31,32 +28,12 @@ from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs" DOCS_DIR = Path(__file__).parent.parent / "docs"
# Regex to match wiki-links: [[Target]] or [[Target | Display]] # Regex to match wiki-links: [[Target]] or [[Target | Display]]
WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(?:\s+\|\s+[^\]]+)?\]\]") WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(?:\s*\|\s*[^\]]+)?\]\]")
# Regex to detect any wiki-link with a pipe (for format checking)
WIKILINK_WITH_PIPE_PATTERN = re.compile(r"\[\[([^\]]+)\]\]")
# Regex to match inline code (backticks) # Regex to match inline code (backticks)
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
def extract_frontmatter(file_path: Path) -> dict | None:
"""Extract YAML frontmatter from a markdown file."""
content = file_path.read_text()
if not content.startswith("---"):
return None
end_idx = content.find("---", 3)
if end_idx == -1:
return None
frontmatter_text = content[3:end_idx].strip()
try:
return yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return None
def extract_wikilinks(file_path: Path) -> list[tuple[str, int]]: def extract_wikilinks(file_path: Path) -> list[tuple[str, int]]:
"""Extract all wiki-link targets from a markdown file with line numbers. """Extract all wiki-link targets from a markdown file with line numbers.
@ -75,41 +52,20 @@ def extract_wikilinks(file_path: Path) -> list[tuple[str, int]]:
return links return links
def find_unspaced_pipes(file_path: Path) -> list[tuple[str, int]]:
"""Find wiki-links with unspaced pipes (e.g., [[target|display]] instead of [[target | display]])."""
content = file_path.read_text()
issues = []
for line_num, line in enumerate(content.splitlines(), start=1):
# Remove inline code before searching
line_without_code = INLINE_CODE_PATTERN.sub("", line)
for match in WIKILINK_WITH_PIPE_PATTERN.finditer(line_without_code):
inner = match.group(1)
# Check if there's a pipe without proper spacing (space on both sides)
if "|" in inner and " | " not in inner:
issues.append((match.group(0), line_num))
return issues
def main() -> int: def main() -> int:
console = Console() console = Console()
# Collect all valid titles from frontmatter # Collect all valid filenames
valid_titles: set[str] = set() valid_filenames: set[str] = set()
# Scan all markdown files for titles (excluding zk/ and changelog.d/) # Scan all markdown files for filenames (excluding zk/ and changelog.d/)
for md_file in DOCS_DIR.rglob("*.md"): for md_file in DOCS_DIR.rglob("*.md"):
if "changelog.d" in md_file.parts or "zk" in md_file.parts: if "changelog.d" in md_file.parts or "zk" in md_file.parts:
continue continue
valid_filenames.add(md_file.stem)
frontmatter = extract_frontmatter(md_file) # Collect all broken links
if frontmatter and frontmatter.get("title"):
valid_titles.add(frontmatter["title"])
# Collect all broken links and format issues
broken_links: list[tuple[str, int, str]] = [] broken_links: list[tuple[str, int, str]] = []
unspaced_pipes: list[tuple[str, int, str]] = []
# Scan all markdown files for wiki-links (excluding zk/ and changelog.d/) # Scan all markdown files for wiki-links (excluding zk/ and changelog.d/)
for md_file in sorted(DOCS_DIR.rglob("*.md")): for md_file in sorted(DOCS_DIR.rglob("*.md")):
@ -117,45 +73,19 @@ def main() -> int:
continue continue
rel_path = str(md_file.relative_to(DOCS_DIR)) rel_path = str(md_file.relative_to(DOCS_DIR))
# Check for unspaced pipes
for link_text, line_num in find_unspaced_pipes(md_file):
unspaced_pipes.append((rel_path, line_num, link_text))
# Check for broken links
links = extract_wikilinks(md_file) links = extract_wikilinks(md_file)
for target, line_num in links: for target, line_num in links:
if target not in valid_titles: if target not in valid_filenames:
broken_links.append((rel_path, line_num, target)) broken_links.append((rel_path, line_num, target))
# Print results # Print results
console.print("[bold]Wiki-Link Validation[/bold]") console.print("[bold]Wiki-Link Validation[/bold]")
console.print() console.print()
console.print(f"Found {len(valid_titles)} valid titles in documentation.") console.print(f"Found {len(valid_filenames)} valid filenames in documentation.")
console.print() console.print()
has_errors = False
# Report unspaced pipes
if unspaced_pipes:
has_errors = True
console.print("[bold red]Unspaced Pipe in Wiki-Links[/bold red]")
console.print("Wiki-links with display text must use spaces: [[target | display]]")
console.print()
table = Table(show_header=True, header_style="bold")
table.add_column("File")
table.add_column("Line", justify="right")
table.add_column("Link")
for file_path, line_num, link_text in unspaced_pipes:
table.add_row(file_path, str(line_num), escape(link_text))
console.print(table)
console.print()
# Report broken links
if broken_links: if broken_links:
has_errors = True
console.print("[bold red]Broken Wiki-Links Found[/bold red]") console.print("[bold red]Broken Wiki-Links Found[/bold red]")
table = Table(show_header=True, header_style="bold") table = Table(show_header=True, header_style="bold")
table.add_column("File") table.add_column("File")
@ -167,12 +97,9 @@ def main() -> int:
console.print(table) console.print(table)
console.print() console.print()
console.print("Each wiki-link target must match a frontmatter title in docs/.") console.print(f"[bold red]{len(broken_links)} broken link(s) found.[/bold red]")
console.print() console.print()
console.print("Each wiki-link target must match a filename in docs/.")
if has_errors:
error_count = len(broken_links) + len(unspaced_pipes)
console.print(f"[bold red]{error_count} issue(s) found.[/bold red]")
return 1 return 1
console.print("[bold green]All wiki-links are valid![/bold green]") console.print("[bold green]All wiki-links are valid![/bold green]")