133 lines
5.5 KiB
Python
133 lines
5.5 KiB
Python
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
|
||
from termcolor import colored
|
||
from pygments import highlight
|
||
from pygments.lexers import get_lexer_by_name, TextLexer
|
||
from pygments.formatters import Terminal256Formatter
|
||
|
||
def render(text):
|
||
lines = text.splitlines()
|
||
formatted_text = ""
|
||
in_code_block = False
|
||
|
||
# Track code block state
|
||
code_block_lang = None
|
||
code_block_lines: list[str] = []
|
||
for line in lines:
|
||
# Check for code blocks (opening/closing)
|
||
if line.lstrip().startswith("```"):
|
||
# Opening or closing
|
||
if not in_code_block: # opening
|
||
formatted_text += colored(line.strip(), "light_grey") + "\n"
|
||
# Extract optional language
|
||
match = re.match(r"^\s*```\s*(\w+)?", line)
|
||
if match:
|
||
code_block_lang = match.group(1)
|
||
else:
|
||
code_block_lang = None
|
||
in_code_block = True
|
||
code_block_lines = []
|
||
continue # Skip fence line
|
||
else: # closing
|
||
# Highlight accumulated code
|
||
code_source = "\n".join(code_block_lines)
|
||
if code_source:
|
||
lexer = get_lexer_by_name(code_block_lang, stripall=True) if code_block_lang else TextLexer()
|
||
highlighted = highlight(code_source, lexer, Terminal256Formatter())
|
||
formatted_text += highlighted
|
||
formatted_text += colored(line.strip(), "light_grey") + "\n"
|
||
in_code_block = False
|
||
code_block_lang = None
|
||
code_block_lines = []
|
||
continue
|
||
# Inside code block: accumulate lines
|
||
if in_code_block:
|
||
code_block_lines.append(line)
|
||
continue
|
||
|
||
# Check for headers
|
||
if line.startswith("# "):
|
||
# level = len(line) - len(line.lstrip("#"))
|
||
header_text = line.strip() #.lstrip("#").strip()
|
||
formatted_text += colored(header_text, "blue", attrs=["bold", "underline"]) + "\n"
|
||
continue
|
||
|
||
if line.startswith("## "):
|
||
# level = len(line) - len(line.lstrip("#"))
|
||
header_text = line.strip() #.lstrip("#").strip()
|
||
formatted_text += colored(header_text, "blue", attrs=["bold"]) + "\n"
|
||
continue
|
||
|
||
if line.startswith("### "):
|
||
# level = len(line) - len(line.lstrip("#"))
|
||
header_text = line.strip() #.lstrip("#").strip()
|
||
formatted_text += colored(header_text, "cyan", attrs=["bold"]) + "\n"
|
||
continue
|
||
|
||
# Check for blockquotes
|
||
if line.startswith(">"):
|
||
quote_text = line.strip() #.lstrip(">").strip()
|
||
formatted_text += colored(quote_text, "yellow") + "\n"
|
||
continue
|
||
|
||
# Check for tables (rows separated by "|")
|
||
if "|" in line:
|
||
table_row = "\t| ".join(line.split("|")).strip()
|
||
formatted_text += table_row + "\n"
|
||
continue
|
||
|
||
# Inline formatting for bold, italic, and code (keeping the symbols)
|
||
# Bold (**text** or __text__)
|
||
line = re.sub(r"[^\*_](\*\*|__)(.+?)(\*\*|__)[^\*_]", lambda m: colored(m.group(), attrs=["bold"]), line)
|
||
# Italic (*text* or _text_)
|
||
line = re.sub(r"[^\*_](\*|_)([^\*_].+?[^\*_])(\*|_)[^\*_]", lambda m: colored(m.group(), attrs=["underline"]), line)
|
||
# Inline code (`code`)
|
||
line = re.sub(r"[^\*_](`)(.+?)`[^\*_]", lambda m: colored(m.group() + "`", "green"), line)
|
||
|
||
# List items (bullets and numbers)
|
||
# Bulleted list
|
||
line = re.sub(r"^(\s*[-*])\s", lambda m: colored(m.group(1), "cyan") + " ", line)
|
||
# Numbered list
|
||
line = re.sub(r"^(\s*\d+\.)\s", lambda m: colored(m.group(1), "cyan") + " ", line)
|
||
|
||
# Add processed line to formatted text
|
||
formatted_text += line + "\n"
|
||
|
||
return formatted_text
|
||
|
||
|
||
def main() -> None:
|
||
"""Command‑line entry point.
|
||
|
||
The CLI accepts one or more file paths, reads each file, renders it via
|
||
:func:`render`, and prints the result to stdout. Files that cannot be
|
||
opened will emit an error on ``stderr`` but the program continues
|
||
processing subsequent files.
|
||
"""
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="Render markdown files to the terminal with ANSI color." # pragma: no cover - trivial description
|
||
)
|
||
parser.add_argument("files", nargs="+", help="Markdown files to render")
|
||
args = parser.parse_args()
|
||
|
||
for path in args.files:
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
# Render and print
|
||
sys.stdout.write(render(content))
|
||
except Exception as exc: # pragma: no cover - error handling
|
||
sys.stderr.write(f"Error reading {path}: {exc}\n")
|
||
|
||
if __name__ == "__main__": # pragma: no cover - used only when executed as script
|
||
main()
|