Remove docs-check-titles and test duplicate frontmatter titles

Title slug format was enforced by convention but wiki-links resolve by
filename stem, not frontmatter title. Remove the check and add two test
cards with duplicate titles to verify. Also add GitHub/Forgejo repo
links to homepage and retitle index to "BlumeOps".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-02-07 21:24:50 -08:00
commit 5105353cdb
8 changed files with 34 additions and 180 deletions

View file

@ -92,12 +92,6 @@ repos:
# Documentation validation
- repo: local
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
name: 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: []
id: index
tags: []
@ -14,11 +14,11 @@ infrastructure.
## What is BlumeOps?
BlumeOps is my personal homelab infrastructure managed entirely through code.
Everything lives in a single git repository, from service configs to deployment
automation. Even the [[forgejo]] instance that hosts this repo is defined
within it, making BlumeOps fully self-hosting. It's a digital life raft I built
for myself as I went, and you can see it all from within your editor of choice.
(I recommend vim.)
Everything lives in a [single git repository](https://github.com/eblume/blumeops), from service configs to
deployment automation. Even the [[forgejo]] instance that [hosts this repo](https://forge.ops.eblu.me/eblume/blumeops)
is defined within it, making BlumeOps fully self-hosting. It's a digital life
raft I built for myself as I went, and you can see it all from within your
editor of choice. (I recommend vim.)
These services run on my home [[hosts|infrastructure]], primarily an m1 mac
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
- [[gandi]] - DNS hosting for `eblu.me`
- [[routing|Routing]] - DNS domains, port mappings
- [[title-test-alpha]] / [[title-test-beta]] - Duplicate title test (temporary)
## 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 |
| `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-titles` | Check for duplicate doc titles |
| `docs-check-filenames` | Check for duplicate doc filenames |
| `docs-review-stale` | Report docs by last-modified date, highlight stale ones |
| `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())