Remove title slug check and test duplicate titles #116

Merged
eblume merged 1 commit from feature/docs-title-cleanup into main 2026-02-07 21:26:19 -08:00
8 changed files with 34 additions and 180 deletions

View file

@ -92,12 +92,6 @@ repos:
# Documentation validation # Documentation validation
- repo: local - repo: local
hooks: hooks:
- id: docs-check-titles
name: docs-check-titles
entry: mise run docs-check-titles
language: system
files: ^docs/.*\.md$
pass_filenames: false
- id: docs-check-filenames - id: docs-check-filenames
name: docs-check-filenames name: docs-check-filenames
entry: mise run docs-check-filenames entry: mise run docs-check-filenames

View file

@ -0,0 +1 @@
Remove docs-check-titles pre-commit hook, add repo links to homepage, and test duplicate frontmatter titles.

View file

@ -1,5 +1,5 @@
--- ---
title: blumeops-documentation title: BlumeOps
aliases: [] aliases: []
id: index id: index
tags: [] tags: []
@ -14,11 +14,11 @@ infrastructure.
## What is BlumeOps? ## What is BlumeOps?
BlumeOps is my personal homelab infrastructure managed entirely through code. BlumeOps is my personal homelab infrastructure managed entirely through code.
Everything lives in a single git repository, from service configs to deployment Everything lives in a [single git repository](https://github.com/eblume/blumeops), from service configs to
automation. Even the [[forgejo]] instance that hosts this repo is defined deployment automation. Even the [[forgejo]] instance that [hosts this repo](https://forge.ops.eblu.me/eblume/blumeops)
within it, making BlumeOps fully self-hosting. It's a digital life raft I built is defined within it, making BlumeOps fully self-hosting. It's a digital life
for myself as I went, and you can see it all from within your editor of choice. raft I built for myself as I went, and you can see it all from within your
(I recommend vim.) editor of choice. (I recommend vim.)
These services run on my home [[hosts|infrastructure]], primarily an m1 mac These services run on my home [[hosts|infrastructure]], primarily an m1 mac
mini named [[indri]] and a Synology NAS called [[sifaka]]. The infrastructure mini named [[indri]] and a Synology NAS called [[sifaka]]. The infrastructure

View file

@ -0,0 +1,13 @@
---
title: Title Test Card
tags:
- infrastructure
- test
---
# Title Test Card (Alpha)
This card tests that duplicate frontmatter titles don't break wiki-link resolution.
This card and [[title-test-beta]] share the same `title:` frontmatter value.
If you can read both cards and the links work, the test passes.

View file

@ -0,0 +1,13 @@
---
title: Title Test Card
tags:
- infrastructure
- test
---
# Title Test Card (Beta)
This card tests that duplicate frontmatter titles don't break wiki-link resolution.
This card and [[title-test-alpha]] share the same `title:` frontmatter value.
If you can read both cards and the links work, the test passes.

View file

@ -46,6 +46,7 @@ Host inventory and network configuration.
- [[tailscale]] - ACLs, groups, tags - [[tailscale]] - ACLs, groups, tags
- [[gandi]] - DNS hosting for `eblu.me` - [[gandi]] - DNS hosting for `eblu.me`
- [[routing|Routing]] - DNS domains, port mappings - [[routing|Routing]] - DNS domains, port mappings
- [[title-test-alpha]] / [[title-test-beta]] - Duplicate title test (temporary)
## Kubernetes ## Kubernetes

View file

@ -96,7 +96,6 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail
| `tailnet-up` | Apply Tailscale ACL changes via Pulumi | | `tailnet-up` | Apply Tailscale ACL changes via Pulumi |
| `docs-check-links` | Validate wiki-links in documentation (includes orphan detection) | | `docs-check-links` | Validate wiki-links in documentation (includes orphan detection) |
| `docs-check-index` | Check every doc is referenced in its category index | | `docs-check-index` | Check every doc is referenced in its category index |
| `docs-check-titles` | Check for duplicate doc titles |
| `docs-check-filenames` | Check for duplicate doc filenames | | `docs-check-filenames` | Check for duplicate doc filenames |
| `docs-review-stale` | Report docs by last-modified date, highlight stale ones | | `docs-review-stale` | Report docs by last-modified date, highlight stale ones |
| `docs-review-tags` | Print frontmatter tag inventory across all docs | | `docs-review-tags` | Print frontmatter tag inventory across all docs |

View file

@ -1,167 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["pyyaml>=6.0", "rich>=13.0.0"]
# ///
#MISE description="List all doc card titles and detect duplicates or invalid formats"
"""List all documentation card titles and detect duplicates or invalid formats.
This script scans all markdown files in the docs/ directory (excluding
changelog.d/ and zk/), extracts frontmatter titles, and reports any
duplicates or invalid formats that could cause wiki-link resolution issues.
With Quartz, wiki-links like [[title]] resolve by frontmatter title,
so titles must be:
- Unique across the documentation
- Lowercase with hyphens (no spaces or uppercase)
Usage: mise run docs-check-titles
"""
import re
import sys
from collections import defaultdict
from pathlib import Path
import yaml
from rich.console import Console
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
# Valid title pattern: lowercase letters, numbers, hyphens only
VALID_TITLE_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
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 is_valid_slug(title: str) -> bool:
"""Check if title is a valid slug (lowercase, hyphens, no spaces)."""
return bool(VALID_TITLE_PATTERN.match(title))
def main() -> int:
console = Console()
# Collect all titles and their source files
# Key: title, Value: list of file paths
titles: dict[str, list[str]] = defaultdict(list)
invalid_titles: list[tuple[str, str]] = [] # (title, file_path)
# Scan all markdown files (excluding zk/ and changelog.d/)
for md_file in sorted(DOCS_DIR.rglob("*.md")):
# Skip changelog fragments and zk cards
if "changelog.d" in md_file.parts or "zk" in md_file.parts:
continue
rel_path = str(md_file.relative_to(DOCS_DIR))
frontmatter = extract_frontmatter(md_file)
if not frontmatter:
continue
title = frontmatter.get("title")
if title:
titles[title].append(rel_path)
if not is_valid_slug(title):
invalid_titles.append((title, rel_path))
# Find duplicates
duplicates = {title: paths for title, paths in titles.items() if len(paths) > 1}
# Print results
console.print("[bold]Doc Card Title Inventory[/bold]")
console.print()
console.print("Titles must be lowercase slugs (e.g., 'grafana-alloy', not 'Grafana Alloy')")
console.print("and unique across the documentation for wiki-link resolution.")
console.print()
has_errors = False
# Invalid format table (if any)
if invalid_titles:
has_errors = True
console.print("[bold red]Invalid Title Format[/bold red]")
console.print("Titles must be lowercase with hyphens only (no spaces or uppercase).")
inv_table = Table(show_header=True, header_style="bold")
inv_table.add_column("Title")
inv_table.add_column("File")
for title, file_path in invalid_titles:
inv_table.add_row(title, file_path)
console.print(inv_table)
console.print()
# Duplicates table (if any)
if duplicates:
has_errors = True
console.print("[bold red]Duplicate Titles Found[/bold red]")
dup_table = Table(show_header=True, header_style="bold")
dup_table.add_column("Title")
dup_table.add_column("Files")
for title in sorted(duplicates.keys()):
paths = duplicates[title]
dup_table.add_row(title, "\n".join(paths))
console.print(dup_table)
console.print()
# All titles table
console.print("[bold]All Titles[/bold]")
all_table = Table(show_header=True, header_style="bold")
all_table.add_column("Title")
all_table.add_column("File")
all_table.add_column("Status")
for title in sorted(titles.keys()):
paths = titles[title]
is_dup = title in duplicates
is_invalid = not is_valid_slug(title)
if is_dup:
status = "[red]DUPLICATE[/red]"
elif is_invalid:
status = "[red]INVALID[/red]"
else:
status = "[green]OK[/green]"
all_table.add_row(title, paths[0], status)
for extra_path in paths[1:]:
all_table.add_row("", extra_path, "")
console.print(all_table)
# Summary
console.print()
console.print(f"Total files: {sum(len(p) for p in titles.values())}")
console.print(f"Unique titles: {len(titles)}")
console.print(f"Duplicate titles: {len(duplicates)}")
console.print(f"Invalid format: {len(invalid_titles)}")
if has_errors:
console.print()
console.print("[bold red]Action required:[/bold red] Fix title issues above.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())