diff --git a/.gitignore b/.gitignore index 8403ece01..ac18461a9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ node_modules _scripts/script-wizard /gradio/gradio/chatbot/__pycache__/ app-router-bootstrap.sh +/_scripts/__pycache__/ +/gradio/target/ diff --git a/Makefile b/Makefile index 6869f9931..97d51ff16 100644 --- a/Makefile +++ b/Makefile @@ -96,7 +96,7 @@ userns-remap-check: .PHONY: readme # Open the README.md in your web browser readme: - xdg-open "https://github.com/EnigmaCurry/d.rymcg.tech/tree/master#readme" + @${BIN}/d.rymcg.tech readme readme .PHONY: install-cli # Install CLI install-cli: diff --git a/_scripts/Makefile.readme b/_scripts/Makefile.readme index c242b19f9..5d5a2138c 100644 --- a/_scripts/Makefile.readme +++ b/_scripts/Makefile.readme @@ -1,4 +1,3 @@ .PHONY: readme # Open the README.md in your web browser readme: -# @URL="https://github.com/EnigmaCurry/d.rymcg.tech/tree/master/$$(pwd | grep -Po \"$$(realpath ${ROOT_DIR})\K.*\")"; set -x; xdg-open "$${URL}" - @PROJECT_DIR=$$(realpath $$(pwd) | grep -Po "$$(realpath ${ROOT_DIR})\K.*"); URL="https://github.com/EnigmaCurry/d.rymcg.tech/tree/master$${PROJECT_DIR}#readme"; set -x; xdg-open "$${URL}" + @PROJECT_DIR=$$(realpath $$(pwd) | grep -Po "$$(realpath ${ROOT_DIR})\K.*"); URL="https://github.com/EnigmaCurry/d.rymcg.tech/tree/master$${PROJECT_DIR}#readme"; OPENER=$$(${BIN}/d.rymcg.tech info README_OPENER); set -x; $${OPENER} "$${URL}" diff --git a/_scripts/d.rymcg.tech b/_scripts/d.rymcg.tech index 9343b934e..55e11122f 100755 --- a/_scripts/d.rymcg.tech +++ b/_scripts/d.rymcg.tech @@ -21,7 +21,7 @@ set -eo pipefail BIN=$(dirname $(realpath ${BASH_SOURCE})) ## INFO_WORDS are keys to information that the completion script can ask for at runtime: -INFO_WORDS="INFO_WORDS ROOT_DIR" +INFO_WORDS="INFO_WORDS ROOT_DIR README_OPENER" ## ROOT_DIR is the root path of the d.rymcg.tech project ## Validate that this script is running from the canonical location @@ -146,7 +146,65 @@ __list_projects() { ) } +__xdg_https_exec() { + # Prints the executable xdg-open would use for https:// (best-effort) + local desktop exec line + + desktop="$(xdg-mime query default x-scheme-handler/https 2>/dev/null || true)" + [[ -z "$desktop" ]] && return 1 + + # Find the desktop file + local -a candidates=( + "$XDG_DATA_HOME/applications/$desktop" + "$HOME/.local/share/applications/$desktop" + "/usr/local/share/applications/$desktop" + "/usr/share/applications/$desktop" + ) + + for f in "${candidates[@]}"; do + if [[ -r "$f" ]]; then + line="$(grep -m1 '^Exec=' "$f" || true)" + break + fi + done + + [[ -z "$line" ]] && return 1 + + exec="${line#Exec=}" + # Strip field codes and args; keep the program token + exec="${exec%% *}" + exec="${exec//%u/}" + exec="${exec//%U/}" + exec="${exec//%f/}" + exec="${exec//%F/}" + exec="${exec//%i/}" + exec="${exec//%c/}" + exec="${exec//%k/}" + exec="${exec//\"/}" + + [[ -z "$exec" ]] && return 1 + printf '%s\n' "$exec" +} + +__readme_opener() { + # Decide whether to use xdg-open or mdview.py + local https_exec base + https_exec="$(__xdg_https_exec)" || https_exec="" + + base="$(basename -- "${https_exec:-}" 2>/dev/null || true)" + + # If we can't detect, or it looks like w3m, use mdview.py + if [[ -z "$https_exec" || "$base" == "w3m" ]]; then + printf '%s\n' "${BIN}/mdview.py" + else + printf '%s\n' "xdg-open" + fi +} + __readme() { + local opener + opener="$(__readme_opener)" + if [[ $# -gt 0 ]]; then NAME="${1}"; shift NAME_UPPERCASE="$(echo "${NAME}" | tr '[:lower:]' '[:upper:]')" @@ -155,12 +213,13 @@ __readme() { for doc in "${DOCS_ARRAY[@]}"; do DOCS_INDEX["${doc}"]="${doc}" done + if [[ "${NAME_UPPERCASE}" == "LICENSE" ]]; then - exe xdg-open "https://github.com/EnigmaCurry/d.rymcg.tech/blob/master/LICENSE.txt" + exe "$opener" "https://github.com/EnigmaCurry/d.rymcg.tech/blob/master/LICENSE.txt" elif [[ -v DOCS_INDEX["${NAME_UPPERCASE}"] ]]; then - exe xdg-open "https://github.com/EnigmaCurry/d.rymcg.tech/blob/master/${NAME_UPPERCASE}.md#readme" + exe "$opener" "https://github.com/EnigmaCurry/d.rymcg.tech/blob/master/${NAME_UPPERCASE}.md#readme" else - echo ${DOCS_INDEX["${NAME_UPPERCASE}"]} + echo "${DOCS_INDEX["${NAME_UPPERCASE}"]}" __make "${NAME}" readme "$@" fi else @@ -184,6 +243,8 @@ __info() { echo "${ROOT_DIR}" elif [[ "$1" == "INFO_WORDS" ]]; then echo "${INFO_WORDS}" + elif [[ "$1" == "README_OPENER" ]]; then + __readme_opener fi } diff --git a/_scripts/mdview.py b/_scripts/mdview.py new file mode 100755 index 000000000..6085db034 --- /dev/null +++ b/_scripts/mdview.py @@ -0,0 +1,154 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["rich", "certifi"] +# /// +""" +mdview.py - lightweight terminal Markdown viewer (URL/file/stdin) using Rich. + +Features: +- Fetch URLs with a portable TLS trust store (certifi). +- Render Markdown in a Rich pager. +- Auto-rewrite GitHub URLs to fetch raw Markdown: + 1) https://github.com/OWNER/REPO/blob/BRANCH/path.md + -> https://github.com/OWNER/REPO/raw/BRANCH/path.md + 2) https://github.com/OWNER/REPO/tree/BRANCH[/subpath]#readme + -> https://raw.githubusercontent.com/OWNER/REPO/refs/heads/BRANCH/[subpath/]README.md +""" + +from __future__ import annotations + +import argparse +import ssl +import sys +import urllib.request +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse, urlunparse + +import certifi +from rich.console import Console +from rich.markdown import Markdown + + +def parse_headers(items: list[str]) -> dict[str, str]: + headers: dict[str, str] = {} + for item in items: + if ":" not in item: + raise ValueError(f"Bad header (expected 'Name: value'): {item!r}") + name, value = item.split(":", 1) + headers[name.strip()] = value.lstrip() + return headers + + +def rewrite_github_url(url: str) -> str: + """ + Convert GitHub HTML URLs into raw markdown fetch URLs. + """ + p = urlparse(url) + if p.scheme not in ("http", "https") or p.netloc.lower() != "github.com": + return url + + parts = [seg for seg in p.path.split("/") if seg] + # Expect: OWNER / REPO / / / + if len(parts) >= 4: + owner, repo, mode, ref = parts[0], parts[1], parts[2], parts[3] + + # 1) blob -> raw (same host) + if mode == "blob": + parts[2] = "raw" + new_path = "/" + "/".join(parts) + p2 = p._replace(path=new_path) + return urlunparse(p2) + + # 2) tree/[/subpath]#readme -> raw.githubusercontent.com/.../refs/heads//[subpath/]README.md + if mode == "tree" and (p.fragment or "").lower() == "readme": + branch = ref + subpath = "/".join(parts[4:]) + prefix = f"{subpath}/" if subpath else "" + raw = f"https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{prefix}README.md" + return raw + + return url + + +def read_url(url: str, headers: dict[str, str], insecure: bool) -> str: + url = rewrite_github_url(url) + req = urllib.request.Request(url, headers=headers) + + if insecure: + ctx = ssl._create_unverified_context() + else: + ctx = ssl.create_default_context(cafile=certifi.where()) + + try: + with urllib.request.urlopen(req, context=ctx) as resp: + charset = resp.headers.get_content_charset() or "utf-8" + return resp.read().decode(charset, errors="replace") + except HTTPError as e: + body = "" + try: + body = e.read().decode("utf-8", errors="replace") + except Exception: + pass + raise RuntimeError( + f"HTTP error {e.code} {e.reason} for {url}\n{body}".rstrip() + ) from e + except URLError as e: + raise RuntimeError(f"URL error for {url}: {e.reason}") from e + + +def read_source(source: str, headers: dict[str, str], insecure: bool) -> str: + if source == "-" or source == "": + return sys.stdin.read() + + if source.startswith(("http://", "https://")): + return read_url(source, headers, insecure) + + with open(source, "r", encoding="utf-8", errors="replace") as f: + return f.read() + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(prog="mdview.py") + ap.add_argument( + "source", + nargs="?", + default="-", + help="URL, file path, or '-' to read from stdin (default: '-')", + ) + ap.add_argument( + "-H", + "--header", + action="append", + default=[], + help="HTTP header, repeatable. Example: -H 'Authorization: token XYZ'", + ) + ap.add_argument( + "--no-wrap", + action="store_true", + help="Disable soft-wrapping inside the pager.", + ) + ap.add_argument( + "-k", + "--insecure", + action="store_true", + help="Disable TLS certificate verification (NOT recommended).", + ) + args = ap.parse_args(argv) + + try: + headers = parse_headers(args.header) + text = read_source(args.source, headers=headers, insecure=args.insecure) + except Exception as e: + print(f"mdview: {e}", file=sys.stderr) + return 2 + + console = Console() + # Use Rich's built-in pager; user can quit with q. + with console.pager(styles=True): + console.print(Markdown(text), soft_wrap=not args.no_wrap) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))