blumeops/mise-tasks/doc-titles
Erich Blume 3e4b5c2dd3 Convert wiki-link titles to lowercase slugs (#92)
## Summary
- Convert all frontmatter titles to lowercase-hyphenated format (e.g., `grafana-alloy` instead of `Grafana Alloy`)
- Update all wiki-links to use the new slug format
- Update `doc-titles` task to validate slug format (lowercase, hyphens only)

Quartz appears to require titles without spaces for wiki-link resolution.

## Deployment and Testing
- [x] Pre-commit hooks pass (`doc-titles` and `doc-links`)
- [ ] Build docs v1.0.8 and deploy
- [ ] Verify wiki-links resolve correctly (e.g., `[[grafana-alloy]]`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/92
2026-02-03 16:06:35 -08:00

167 lines
5.1 KiB
Text
Executable file

#!/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 doc-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())