add basic documentation (#42)

* add basic documentation

* remove leading slash
This commit is contained in:
Josh Thomas 2024-12-16 16:47:58 -06:00 committed by GitHub
parent 757a400a8a
commit 5c821d8591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1435 additions and 71 deletions

410
docs/processor.py Normal file
View file

@ -0,0 +1,410 @@
# /// script
# dependencies = [
# "rich>=13.9.4",
# ]
# ///
"""
README.md processor using functional callbacks for processing steps.
Uses rich for beautiful logging and progress display.
"""
from __future__ import annotations
import logging
import re
from difflib import Differ
from functools import reduce
from itertools import islice
from pathlib import Path
from typing import Callable
from rich.console import Console
from rich.logging import RichHandler
from rich.panel import Panel
from rich.progress import track
console = Console()
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[RichHandler(rich_tracebacks=True, show_time=False)],
)
logger = logging.getLogger(__name__)
ProcessingFunc = Callable[[str], str]
def compose(*functions: ProcessingFunc) -> ProcessingFunc:
"""Compose multiple processing functions into a single function."""
return reduce(lambda f, g: lambda x: g(f(x)), functions)
def read_file(path: Path) -> str | None:
"""Read content from a file."""
try:
content = path.read_text(encoding="utf-8")
console.print(f"[green]✓[/green] Read {len(content)} bytes from {path}")
return content
except FileNotFoundError:
console.print(f"[red]✗[/red] Input file not found: {path}")
return None
except Exception as e:
console.print(f"[red]✗[/red] Error reading input file: {e}")
return None
def write_file(path: Path, content: str) -> bool:
"""Write content to a file."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
console.print(f"[green]✓[/green] Wrote {len(content)} bytes to {path}")
return True
except Exception as e:
console.print(f"[red]✗[/red] Error writing output file: {e}")
return False
def preview_changes(original: str, processed: str, context_lines: int = 2) -> None:
"""Show a preview of the changes made."""
console.print("\n[yellow]Preview of changes:[/yellow]")
# Basic statistics
orig_lines = original.count("\n")
proc_lines = processed.count("\n")
diff_lines = proc_lines - orig_lines
stats_panel = Panel(
f"Original lines: {orig_lines}\n"
f"Processed lines: {proc_lines}\n"
f"Difference: {diff_lines:+d} lines",
title="Statistics",
border_style="blue",
)
console.print(stats_panel)
# Create diff
differ = Differ()
diff = list(differ.compare(original.splitlines(), processed.splitlines()))
# Find changed line groups with context
changes = []
current_group = []
in_change = False
last_change_line = -1
for i, line in enumerate(diff):
if line.startswith("? "): # Skip hint lines
continue
is_change = line.startswith(("- ", "+ "))
if is_change:
if not in_change: # Start of a new change group
start = max(0, i - context_lines)
# If we're close to previous group, connect them
if start <= last_change_line + context_lines:
start = last_change_line + 1
else:
if current_group:
changes.append(current_group)
current_group = []
# Add previous context
current_group.extend(
l for l in diff[start:i] if not l.startswith("? ")
)
current_group.append(line)
in_change = True
last_change_line = i
else:
if in_change:
# Add following context
following_context = list(
islice(
(l for l in diff[i:] if not l.startswith("? ")), context_lines
)
)
if following_context: # Only extend if we have context to add
current_group.extend(following_context)
in_change = False
if current_group:
changes.append(current_group)
# Format and display the changes
formatted_output = []
for i, group in enumerate(changes):
if i > 0:
formatted_output.append(
"[bright_black]⋮ skipped unchanged content ⋮[/bright_black]"
)
# Track the last line to avoid duplicates
last_line = None
for line in group:
# Skip if this line is the same as the last one
if line == last_line:
continue
if line.startswith(" "): # unchanged
formatted_output.append(f"[white]{line[2:]}[/white]")
elif line.startswith("- "): # removed
formatted_output.append(f"[red]━ {line[2:]}[/red]")
elif line.startswith("+ "): # added
formatted_output.append(f"[green]+ {line[2:]}[/green]")
last_line = line
console.print(
Panel(
"\n".join(formatted_output),
title="Changes with Context",
border_style="yellow",
)
)
def process_readme(
input: str = "README.md",
output: str = "docs/index.md",
processors: list[ProcessingFunc] | None = None,
preview: bool = True,
) -> bool:
"""
Process README.md with given processing functions.
Args:
input_path: Path to the input README.md file
output_path: Path where the processed file will be saved
processors: List of processing functions to apply
preview: Whether to show a preview of changes
Returns:
bool: True if processing was successful, False otherwise
"""
with console.status("[bold green]Processing README...") as status:
input_path = Path(input)
output_path = Path(output)
content = read_file(input_path)
if content is None:
return False
original_content = content
try:
for proc in track(processors, description="Applying processors"):
status.update(f"[bold green]Running {proc.__name__}...")
content = proc(content)
if preview:
preview_changes(original_content, content)
return write_file(output_path, content)
except Exception as e:
console.print(f"[red]Error during processing:[/red] {e}")
return False
def add_frontmatter(
metadata: dict[str, str | int | float | bool | list | None],
) -> ProcessingFunc:
"""
Add or update frontmatter from a dictionary of metadata.
Args:
metadata: Dictionary of metadata to add to frontmatter
Returns:
A processor function that adds/updates frontmatter
Example:
Input:
# Title
Content here
Output:
---
title: My Page
weight: 10
hide:
- navigation
---
# Title
Content here
"""
def processor(content: str) -> str:
# Remove existing frontmatter if present
content_without_frontmatter = re.sub(
r"^---\n.*?\n---\n", "", content, flags=re.DOTALL
)
# Build the new frontmatter
frontmatter_lines = ["---"]
for key, value in metadata.items():
if isinstance(value, (str, int, float, bool)) or value is None:
frontmatter_lines.append(f"{key}: {value}")
elif isinstance(value, list):
frontmatter_lines.append(f"{key}:")
for item in value:
frontmatter_lines.append(f" - {item}")
# Could add more types (dict, etc.) as needed
frontmatter_lines.append("---\n\n")
return "\n".join(frontmatter_lines) + content_without_frontmatter
processor.__name__ = "add_frontmatter"
return processor
def convert_admonitions(content: str) -> str:
"""
Convert GitHub-style admonitions to Material for MkDocs-style admonitions.
Args:
content: The markdown content to process
Returns:
Processed content with converted admonitions
Example:
Input:
> [!NOTE]
> Content here
> More content
Output:
!!! note
Content here
More content
"""
# Mapping from GitHub admonition types to Material for MkDocs types
ADMONITION_MAP = {
"NOTE": "note",
"TIP": "tip",
"IMPORTANT": "important",
"WARNING": "warning",
"CAUTION": "warning",
"ALERT": "danger",
"DANGER": "danger",
"INFO": "info",
"TODO": "todo",
"HINT": "tip",
}
def process_match(match: re.Match[str]) -> str:
# Get admonition type and map it, defaulting to note if unknown
admonition_type = ADMONITION_MAP.get(match.group(1).upper(), "note")
content_lines = match.group(2).rstrip().split("\n")
# Remove the leading '> ' from each line
cleaned_lines = [line.lstrip("> ") for line in content_lines]
# Indent the content (4 spaces)
indented_content = "\n".join(
f" {line}" if line.strip() else "" for line in cleaned_lines
)
# Preserve the exact number of trailing newlines from the original match
trailing_newlines = len(match.group(2)) - len(match.group(2).rstrip("\n"))
return f"!!! {admonition_type}\n\n{indented_content}" + "\n" * trailing_newlines
# Match GitHub-style admonitions
pattern = r"(?m)^>\s*\[!(.*?)\]\s*\n((?:>.*(?:\n|$))+)"
return re.sub(pattern, process_match, content)
def convert_repo_links(repo_url: str) -> ProcessingFunc:
"""
Convert relative repository links to absolute URLs.
Args:
repo_url: The base repository URL (e.g., 'https://github.com/username/repo')
Returns:
A processor function that converts relative links to absolute URLs
Example:
Input:
See the [`LICENSE`](LICENSE) file for more information.
Check the [Neovim](/docs/editors/neovim.md) guide.
Output:
See the [`LICENSE`](https://github.com/username/repo/blob/main/LICENSE) file for more information.
Check the [Neovim](editors/neovim.md) guide.
"""
def processor(content: str) -> str:
def replace_link(match: re.Match[str]) -> str:
text = match.group(1)
path = match.group(2)
# Skip anchor links
if path.startswith("#"):
return match.group(0)
# Skip already absolute URLs
if path.startswith(("http://", "https://")):
return match.group(0)
# Handle docs directory links
if path.startswith(("/docs/", "docs/")):
# Remove /docs/ or docs/ prefix and .md extension
clean_path = path.removeprefix("/docs/").removeprefix("docs/")
return f"[{text}]({clean_path})"
# Handle root-relative paths
if path.startswith("/"):
path = path.removeprefix("/")
# Remove ./ if present
path = path.removeprefix("./")
# Construct the full URL for repository files
full_url = f"{repo_url.rstrip('/')}/blob/main/{path}"
return f"[{text}]({full_url})"
# Match markdown links: [text](url)
pattern = r"\[((?:[^][]|\[[^]]*\])*)\]\(([^)]+)\)"
return re.sub(pattern, replace_link, content)
processor.__name__ = "convert_repo_links"
return processor
def main():
"""Example usage of the readme processor."""
console.print("[bold blue]README Processor[/bold blue]")
processors = [
add_frontmatter({"title": "Home"}),
convert_admonitions,
convert_repo_links(
"https://github.com/joshuadavidthomas/django-language-server"
),
]
success = process_readme(
input="README.md",
output="docs/index.md",
processors=processors,
preview=True,
)
if success:
console.print("\n[green]✨ Processing completed successfully![/green]")
else:
console.print("\n[red]Processing failed![/red]")
if __name__ == "__main__":
main()