dotfiles

My PC Dotfiles & Website

site/git/assemble.py (6850B)


  1"""Apply website theme to gitsite"""
  2
  3import argparse
  4from copy import deepcopy
  5from pathlib import Path
  6import posixpath
  7import shutil
  8import subprocess
  9from urllib.parse import quote, unquote, urlsplit
 10
 11from lxml import html
 12
 13
 14def serialize(node):
 15    return html.tostring(node, encoding="unicode", method="html")
 16
 17
 18def export(source, output, base_url):
 19    output.mkdir(parents=True, exist_ok=True)
 20    shell = html.parse(str(source / "git-shell/index.html"))
 21    prefix = base_url.rstrip("/") + "/"
 22
 23    # Local Assets
 24    for node in shell.xpath("//*[@src or @srcset] | //link[@href]"):
 25        for attr in ("src", "href", "srcset"):
 26            if node.get(attr):
 27                node.set(attr, node.get(attr).replace(prefix, "/"))
 28
 29    shell.find("body").set("class", "git-site")
 30    (output / "shell.html").write_text(serialize(shell), encoding="utf-8")
 31
 32    for asset in (
 33        "style.css",
 34        "git.css",
 35        "fonts.css",
 36        "color.css",
 37        "fonts",
 38        "website.ico",
 39        "math",
 40        "js",
 41        "processed_images",
 42    ):
 43        src, dst = source / asset, output / asset
 44        if src.is_dir():
 45            shutil.copytree(src, dst)
 46        else:
 47            shutil.copyfile(src, dst)
 48
 49
 50def rewrite_markdown(article, page, output, repo):
 51    """Resolve Markdown links and images"""
 52    relative = page.relative_to(output)
 53    if len(relative.parts) < 3 or relative.parts[1] != "file":
 54        return
 55    repo_name = relative.parts[0]
 56    filename = "/".join(relative.parts[2:])[:-5]  # strip stagit's .html suffix
 57    for node in article.xpath(".//*[@href or @src]"):
 58        attr = "src" if node.tag == "img" else "href"
 59        value = node.get(attr)
 60        if not value:
 61            continue
 62
 63        url = urlsplit(value)
 64        if url.scheme or url.netloc or not url.path:
 65            continue
 66
 67        decoded = unquote(url.path).replace("\\", "/")
 68        target = posixpath.normpath(
 69            decoded.lstrip("/")
 70            if decoded.startswith("/")
 71            else posixpath.join(posixpath.dirname(filename), decoded)
 72        )
 73        if target == ".." or target.startswith(("../", "/")) or "\0" in target:
 74            node.attrib.pop(attr, None)
 75            continue
 76
 77        suffix = ("?" + url.query if url.query else "") + (
 78            "#" + url.fragment if url.fragment else ""
 79        )
 80        if attr == "src":
 81            if Path(target).suffix.lower() not in {
 82                ".png",
 83                ".jpg",
 84                ".jpeg",
 85                ".gif",
 86                ".webp",
 87                ".avif",
 88                ".ico",
 89                ".svg",
 90            }:
 91                node.attrib.pop(attr, None)
 92                continue
 93            dest = output / repo_name / "raw" / target
 94            if not dest.is_file():
 95                blob = subprocess.run(
 96                    ["git", "--git-dir", str(repo), "show", f"HEAD:{target}"],
 97                    capture_output=True,
 98                )
 99                if blob.returncode:
100                    node.attrib.pop(attr, None)
101                    continue
102                dest.parent.mkdir(parents=True, exist_ok=True)
103                dest.write_bytes(blob.stdout)
104            node.set(attr, f"/{quote(repo_name)}/raw/{quote(target)}{suffix}")
105
106        elif attr == "href":
107            dest = output / repo_name / "file" / (target + ".html")
108            if dest.is_file():
109                node.set(attr, f"/{quote(repo_name)}/file/{quote(target)}.html{suffix}")
110            else:
111                # Directory Links
112                directory = output / repo_name / "file" / target
113                if target == "." or directory.is_dir():
114                    node.set(attr, f"/{quote(repo_name)}/files.html")
115
116
117def assemble(output, assets, repositories):
118    shell = html.parse(str(assets / "shell.html"))
119    for path in sorted(output.rglob("*.html")):
120        original = html.parse(str(path))
121        main = original.find("body/main")
122        if main is None:
123            raise ValueError(f"Missing <main> in {path}")
124
125        document = deepcopy(shell)
126        document.find("head/title").text = original.findtext("head/title")
127        for feed in original.xpath("//head/link[@rel='alternate']"):
128            document.find("head").append(deepcopy(feed))
129        placeholder = document.find(".//main")
130        if placeholder is None:
131            raise ValueError("Missing <main> in git-shell.html")
132        placeholder.getparent().replace(placeholder, main)
133
134        for article in main.iter("article"):
135            name = path.relative_to(output).parts[0]
136            rewrite_markdown(article, path, output, repositories / f"{name}.git")
137
138        for alert in main.xpath(
139            ".//*[contains(concat(' ', @class, ' '), ' admonition ')]"
140        ):
141            kind = next(
142                (
143                    value
144                    for value in alert.get("class").split()
145                    if value != "admonition"
146                ),
147                "note",
148            )
149            alert.tag = "blockquote"
150            alert.set(
151                "class", "markdown-alert-" + {"danger": "caution"}.get(kind, kind)
152            )
153
154        for table in main.xpath(".//table[not(ancestor::table)]"):
155            for cell in table.xpath("./thead/tr/td"):
156                cell.tag = "th"
157                cell.set("scope", "col")
158            wrapper = html.Element(
159                "div",
160                {
161                    "class": "table-scroll",
162                    "tabindex": "0",
163                    "role": "region",
164                    "aria-label": "Repository table",
165                },
166            )
167            table.addprevious(wrapper)
168            wrapper.append(table)
169
170        for pre in main.xpath(".//pre"):
171            pre.set("tabindex", "0")
172
173        # Math
174        if main.xpath(".//*[contains(concat(' ', @class, ' '), ' arithmatex ')]"):
175            document.find("body").append(
176                html.Element("script", defer="", src="/js/git-math.js")
177            )
178
179        path.write_text("<!doctype html>\n" + serialize(document), encoding="utf-8")
180
181
182if __name__ == "__main__":
183    parser = argparse.ArgumentParser(description=__doc__)
184    parser.add_argument("command", choices=["export", "assemble"])
185    parser.add_argument("source", type=Path)
186    parser.add_argument("destination", type=Path)
187    parser.add_argument("--repositories", type=Path)
188    parser.add_argument("--base-url")
189
190    args = parser.parse_args()
191    if args.command == "export":
192        if not args.base_url:
193            parser.error("export requires --base-url")
194        export(args.source, args.destination, args.base_url)
195    else:
196        if not args.repositories:
197            parser.error("assemble requires --repositories")
198        assemble(args.source, args.destination, args.repositories)