Wiki Index¶
Build a Markdown or AsciiDoc index of questions in a Markdown wiki.
Expected entry format:
# Entry title
> Question text (possibly spanning multiple lines)
---
Answer text
from __future__ import annotations
import argparse
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote
TITLE_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
SEPARATOR_RE = re.compile(r"^\s*-{3,}\s*$")
BLOCKQUOTE_RE = re.compile(r"^\s*> ?(.*)$")
@dataclass(frozen=True)
class Entry:
title: str
question: str
line_number: int
- extract_entries(markdown: str) list[Entry]¶
def extract_entries(markdown: str) -> list[Entry]:
"""Extract valid title/question pairs from Markdown text."""
lines = markdown.splitlines()
entries: list[Entry] = []
for index, line in enumerate(lines):
title_match = TITLE_RE.match(line)
if not title_match:
continue
question_start = index + 1
while question_start < len(lines) and not lines[question_start].strip():
question_start += 1
# A wiki entry is recognized only when its first content line is quoted.
if question_start >= len(lines) or not BLOCKQUOTE_RE.match(lines[question_start]):
continue
separator = question_start + 1
while separator < len(lines) and not SEPARATOR_RE.match(lines[separator]):
separator += 1
if separator >= len(lines):
continue
question_lines = lines[question_start:separator]
while question_lines and not question_lines[-1].strip():
question_lines.pop()
# Remove Markdown's blockquote marker. Lines without a marker are kept:
# Markdown permits them as lazy continuation lines in a blockquote.
cleaned_lines = []
for question_line in question_lines:
match = BLOCKQUOTE_RE.match(question_line)
cleaned_lines.append(match.group(1) if match else question_line)
question = "\n".join(cleaned_lines).strip()
if question:
entries.append(
Entry(
title=title_match.group(1).strip(),
question=question,
line_number=index + 1,
)
)
return entries
- asciidoc_text(text: str) str¶
def asciidoc_text(text: str) -> str:
"""Prevent accidental AsciiDoc substitutions in displayed prose."""
return text.replace("\\", "\\\\").replace("{", "\\{")
- link_target(source: Path, output: Path) str¶
def link_target(source: Path, anchor: str, output: Path) -> str:
relative = os.path.relpath(source, output.parent).replace(os.sep, "/")
return quote(relative, safe="/._-~") + "#" + anchor
- header_to_anchor(header: str) str¶
def header_to_anchor(header: str) -> str:
"""
Convert a header into a URL-friendly HTML anchor ID.
Example:
"GitLab Pipeline 2nd Error After Updating `.gitlab-ci.yml`"
-> "gitlab-pipeline-2nd-error-after-updating-gitlab-ciyml"
"""
# Convert to lowercase
anchor = header.lower()
# Remove punctuation, while preserving letters, numbers, spaces, and hyphens
anchor = re.sub(r"[^a-z0-9\s-]", "", anchor)
# Replace whitespace with a single hyphen
anchor = re.sub(r"\s+", "-", anchor)
# Collapse repeated hyphens and remove leading/trailing ones
return re.sub(r"-+", "-", anchor).strip("-")
- render_asciidoc_index(files_and_entries: list[tuple[Path, list[Entry]]], output: Path, root: Path) str¶
def truncate(line, maxlen):
return line if len(line) <= maxlen else line[:maxlen] + "..."
def render_asciidoc_index(
files_and_entries: list[tuple[Path, list[Entry]]], output: Path, root: Path
) -> str:
total = sum(len(entries) for _, entries in files_and_entries)
lines = [
f"= {root.name}",
":icons: font",
":toc: left",
":toclevels: 2",
"",
f"Indexed {total} question{'s' if total != 1 else ''} "
f"from {len(files_and_entries)} file{'s' if len(files_and_entries) != 1 else ''}.",
"",
]
for source, entries in files_and_entries:
relative_name = source.relative_to(root).as_posix()
lines.extend([f"== {asciidoc_text(relative_name)}", ""])
for entry in reversed(entries):
label = asciidoc_text(f"{relative_name[0:-3]} ({entry.line_number})")
anchor = header_to_anchor(entry.title)
target = link_target(source, anchor, output)
lines.extend(
[
f"=== {asciidoc_text(entry.title)}",
"",
"[CAUTION]",
"====",
]
)
for question_line in asciidoc_text(truncate(entry.question, 200)).splitlines():
# Avoid prematurely closing the quote block.
#lines.append("\\____" if question_line == "____" else question_line)
lines.append(question_line)
#lines.extend(["====", ""])
#lines.append("")
lines.extend(
[
"",
f"link:{target}[{label}]",
"",
"====",
"",
]
)
return "\n".join(lines).rstrip() + "\n"
- markdown_label(text: str) str¶
def markdown_label(text: str) -> str:
"""Escape text used as a Markdown link label."""
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
- render_markdown_index(files_and_entries: list[tuple[Path, list[Entry]]], output: Path, root: Path) str¶
def render_markdown_index(
files_and_entries: list[tuple[Path, list[Entry]]], output: Path, root: Path
) -> str:
total = sum(len(entries) for _, entries in files_and_entries)
lines = [
f"# {root.name}",
"",
f"Indexed {total} question{'s' if total != 1 else ''} "
f"from {len(files_and_entries)} file{'s' if len(files_and_entries) != 1 else ''}.",
"",
]
for source, entries in files_and_entries:
relative_name = source.relative_to(root).as_posix()
display_name = relative_name.replace("`", "\\`")
lines.extend([f"## `{display_name}`", ""])
for entry in entries:
label = markdown_label(f"{relative_name}:{entry.line_number}")
anchor = header_to_anchor(entry.title)
target = link_target(source, anchor, output)
lines.extend(
[
f"### {entry.title}",
"",
f"Source: [{label}]({target}#L{entry.line_number})",
"",
]
)
for question_line in entry.question.splitlines():
lines.append(f"> {question_line}" if question_line else ">")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
- markdown_files(root: Path) list[Path]¶
def markdown_files(root: Path) -> list[Path]:
return sorted(
(path for path in root.rglob("*.md") if path.is_file()),
key=lambda path: path.relative_to(root).as_posix().casefold(),
reverse=True,
)
- parse_args() argparse.Namespace¶
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Recursively create a Markdown or AsciiDoc wiki question index."
)
parser.add_argument(
"root",
nargs="?",
type=Path,
default=Path.cwd(),
help="wiki directory to scan (default: current directory)",
)
parser.add_argument(
"-f",
"--format",
choices=("markdown", "asciidoc"),
default="markdown",
help="index format (default: markdown)",
)
parser.add_argument(
"-o",
"--output",
type=Path,
help="output file (default: wiki_index.md or wiki_index.adoc)",
)
return parser.parse_args()
- main() int¶
def main() -> int:
args = parse_args()
root = args.root.expanduser().resolve()
default_name = "wiki_index.md" if args.format == "markdown" else "wiki_index.adoc"
output = (args.output or Path(default_name)).expanduser().resolve()
if not root.is_dir():
print(f"error: wiki root is not a directory: {root}", file=sys.stderr)
return 2
indexed: list[tuple[Path, list[Entry]]] = []
for source in markdown_files(root):
try:
entries = extract_entries(source.read_text(encoding="utf-8"))
except (OSError, UnicodeError) as error:
print(f"warning: cannot read {source}: {error}", file=sys.stderr)
continue
if entries:
indexed.append((source, entries))
output.parent.mkdir(parents=True, exist_ok=True)
renderer = (
render_markdown_index if args.format == "markdown" else render_asciidoc_index
)
output.write_text(renderer(indexed, output, root), encoding="utf-8")
count = sum(len(entries) for _, entries in indexed)
print(f"Wrote {count} question(s) from {len(indexed)} file(s) to {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())