mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-08-04 01:58:18 +00:00
410 lines
12 KiB
Python
410 lines
12 KiB
Python
# /// 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()
|