dotfiles
My PC Dotfiles & Websitegit clone https://github.com/maydayv7/dotfiles
commit cd74933572fadd4fe31ab2284025366f3eaa91db parent 476157b64f06cb38f12d29caf34732afa3f8f16e Author: maydayv7 <maydayv7@gmail.com> Date: Wed, 23 Sep 2026 18:58:49 +0530 feat(site): Move stagit in-tree and share assets
Diffstat:
| M | .github/workflows/website-git.yaml | | | 13 | ++++++++++--- |
| M | .github/workflows/website.yml | | | 4 | ++++ |
| M | flake.lock | | | 24 | ------------------------ |
| M | flake.nix | | | 9 | --------- |
| M | modules/core/checks.nix | | | 2 | ++ |
| M | modules/hosts/valkyrie/_dev.nix | | | 1 | + |
| M | modules/hosts/valkyrie/default.nix | | | 1 | - |
| M | packages/_module.nix | | | 1 | - |
| M | site/README.md | | | 21 | +++++++++++++++++---- |
| M | site/_module.nix | | | 3 | ++- |
| M | site/default.nix | | | 2 | +- |
| A | site/git/_headers | | | 9 | +++++++++ |
| A | site/git/assemble.py | | | 198 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/assets.nix | | | 33 | +++++++++++++++++++++++++++++++++ |
| M | site/git/default.nix | | | 160 | ++++++++++++++++++++++++++++++++----------------------------------------------- |
| D | site/git/index.js | | | 50 | -------------------------------------------------- |
| M | site/git/repos.nix | | | 6 | ------ |
| A | site/git/stagit/Makefile | | | 98 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/README.md | | | 57 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/compat.h | | | 6 | ++++++ |
| A | site/git/stagit/default.nix | | | 37 | +++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/favicon.png | | | 0 | |
| A | site/git/stagit/reallocarray.c | | | 37 | +++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/render.py | | | 126 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/stagit-index.1 | | | 47 | +++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/stagit-index.c | | | 328 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/stagit.1 | | | 126 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/stagit.c | | | 1484 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/strlcat.c | | | 55 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/git/stagit/strlcpy.c | | | 50 | ++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | site/git/style.css | | | 707 | ------------------------------------------------------------------------------- |
| D | site/git/theme.nix | | | 72 | ------------------------------------------------------------------------ |
| M | site/git/wrangler.toml | | | 3 | --- |
| M | site/sass/_syntax.scss | | | 115 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
| A | site/sass/color.scss | | | 3 | +++ |
| M | site/sass/color/black.scss | | | 2 | +- |
| M | site/sass/color/blue.scss | | | 2 | +- |
| M | site/sass/color/red.scss | | | 3 | ++- |
| A | site/sass/git.scss | | | 103 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | site/static/js/git-math.js | | | 24 | ++++++++++++++++++++++++ |
| M | site/static/js/load.js | | | 2 | +- |
| M | site/static/js/theme.js | | | 58 | ++++++++++++++++++++++------------------------------------ |
| M | site/templates/base.html | | | 6 | ++++-- |
| A | site/templates/git-shell.html | | | 14 | ++++++++++++++ |
| M | site/templates/macros/footer.html | | | 13 | ++++++++----- |
| M | site/templates/macros/head.html | | | 22 | +++++++++------------- |
| M | site/templates/macros/header.html | | | 8 | +++++--- |
| M | site/templates/macros/social.html | | | 4 | +++- |
| M | site/zola.toml | | | 1 | + |
49 files changed, 3101 insertions(+), 1049 deletions(-)
diff --git a/.github/workflows/website-git.yaml b/.github/workflows/website-git.yaml @@ -7,7 +7,12 @@ on: branches: - stable paths: - - "site/git/**" + - "site/**" + - "flake.lock" + pull_request: + paths: + - "site/**" + - "flake.lock" jobs: deploy: runs-on: ubuntu-latest @@ -27,11 +32,12 @@ jobs: experimental-features = nix-command flakes accept-flake-config = true access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} - - name: Build Stagit Site - run: nix run .#build-stagit + - name: Build Git Site + run: nix run .#gitsite - name: Prepare for Deployment run: mv public site/git/public - name: Deploy to Cloudflare + if: github.event_name != 'pull_request' id: deploy uses: cloudflare/wrangler-action@v4 with: @@ -41,5 +47,6 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Print Deployment URL + if: github.event_name != 'pull_request' run: | echo "Site deployed to: ${{ steps.deploy.outputs.deployment-url }}" diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml @@ -6,9 +6,11 @@ on: - stable paths: - "site/**" + - "flake.lock" pull_request: paths: - "site/**" + - "flake.lock" jobs: deploy: runs-on: ubuntu-latest @@ -35,6 +37,7 @@ jobs: mkdir -p site/public cp -rL result/* site/public/ - name: Deploy to Cloudflare + if: github.event_name != 'pull_request' id: deploy uses: cloudflare/wrangler-action@v4 with: @@ -44,5 +47,6 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Print Deployment URL + if: github.event_name != 'pull_request' run: | echo "Site deployed to: ${{ steps.deploy.outputs.deployment-url }}" diff --git a/flake.lock b/flake.lock @@ -863,7 +863,6 @@ "sops": "sops", "spicetify": "spicetify", "stable": "stable", - "stagit": "stagit", "stylix": "stylix", "systems": "systems_3", "unstable": "unstable", @@ -951,29 +950,6 @@ "type": "github" } }, - "stagit": { - "inputs": { - "nixpkgs": [ - "stable" - ], - "utils": [ - "utils" - ] - }, - "locked": { - "lastModified": 1767557231, - "narHash": "sha256-cXLrl1XCf3Mhz9UhCQxt0eZNcAOtejReiMAi/fTWn6s=", - "owner": "maydayv7", - "repo": "stagit", - "rev": "62010429c33b33ef1239638b202a745ea29e43a1", - "type": "github" - }, - "original": { - "owner": "maydayv7", - "repo": "stagit", - "type": "github" - } - }, "stylix": { "inputs": { "base16": "base16", diff --git a/flake.nix b/flake.nix @@ -47,15 +47,6 @@ inputs.nixpkgs.follows = "nixpkgs"; }; - # Personal Stagit Fork - stagit = { - url = "github:maydayv7/stagit"; - inputs = { - nixpkgs.follows = "stable"; - utils.follows = "utils"; - }; - }; - # VS Code Extensions vscode = { url = "github:nix-community/nix-vscode-extensions"; diff --git a/modules/core/checks.nix b/modules/core/checks.nix @@ -25,6 +25,8 @@ enable = true; settings.bracketSameLine = true; }; + clang-format.enable = true; + ruff-format.enable = true; }; }; diff --git a/modules/hosts/valkyrie/_dev.nix b/modules/hosts/valkyrie/_dev.nix @@ -27,6 +27,7 @@ _: { # Tools systemPackages = with pkgs; [ cloudflared + dig gcc repomix mongodb-compass diff --git a/modules/hosts/valkyrie/default.nix b/modules/hosts/valkyrie/default.nix @@ -31,7 +31,6 @@ "zed" "antigravity" "codex" - "claude" #"stream" #"minecraft" #"osu" diff --git a/packages/_module.nix b/packages/_module.nix @@ -30,7 +30,6 @@ in { ++ [ (final: _: { custom = config.flake.packages."${system}" or {}; - stagit-fork = stagit.packages."${system}".default; unstable = import unstable { inherit system; config = nixpkgsConfig; diff --git a/site/README.md b/site/README.md @@ -55,9 +55,22 @@ To override the URL, run: <pre><code>nix build --impure --expr 'with (builtins.g - [x] GitHub : Star Count - <code>{{ github(repo="<b>USER</b>/<b>NAME</b>") }}</code> - [x] YouTube : Embed Video - <code>{{ youtube(id="<b>ID</b>") }}</code> -## `git` frontend +## `git` Site The [`git`](./git) directory contains the configuration for my static `git` frontend, hosted at [git.maydayv7.dev](https://git.maydayv7.dev) -It is built using my `stagit` [fork](https://github.com/maydayv7/stagit) to generate static HTML pages for my repositories -To build it, run `nix run .#build-stagit` -[`GitHub Actions`](../.github/workflows/website-git.yaml) is used to automatically build the site and deploy it to Cloudflare every week +It is built using [`stagit`](./git/stagit) to generate static HTML pages for my repositories + +### Build + +To build it, run `nix run .#gitsite` +[`GitHub Actions`](../.github/workflows/website-git.yaml) is used to automatically build the site and deploy it every week + +For testing, run the following commands: + +```sh +PREVIEW=$(mktemp -d) +nix run .#gitsite -- "$PREVIEW/site" . +python3 -m http.server 1112 --bind 127.0.0.1 --directory "$PREVIEW/site" +``` + +Then click [here](http://localhost:1112) diff --git a/site/_module.nix b/site/_module.nix @@ -9,7 +9,8 @@ _: { packages.website = pkgs.callPackage ./. {inherit lib pkgs;}; # 'git' Frontend - apps.build-stagit = { + packages.stagit = pkgs.callPackage ./git/stagit {}; + apps.gitsite = { type = "app"; program = lib.getExe (pkgs.callPackage ./git {inherit lib pkgs;}); }; diff --git a/site/default.nix b/site/default.nix @@ -16,7 +16,7 @@ pkgs.stdenvNoCC.mkDerivation { buildPhase = '' zola build ${ if (site != null) - then "--base-url " + site + then "--base-url " + lib.escapeShellArg site else "" } python3 scripts/optimize_fonts.py public diff --git a/site/git/_headers b/site/git/_headers @@ -0,0 +1,9 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + +/processed_images/* + Cache-Control: public, max-age=31536000, immutable + +/:repository/raw/* + Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox diff --git a/site/git/assemble.py b/site/git/assemble.py @@ -0,0 +1,198 @@ +"""Apply website theme to gitsite""" + +import argparse +from copy import deepcopy +from pathlib import Path +import posixpath +import shutil +import subprocess +from urllib.parse import quote, unquote, urlsplit + +from lxml import html + + +def serialize(node): + return html.tostring(node, encoding="unicode", method="html") + + +def export(source, output, base_url): + output.mkdir(parents=True, exist_ok=True) + shell = html.parse(str(source / "git-shell/index.html")) + prefix = base_url.rstrip("/") + "/" + + # Local Assets + for node in shell.xpath("//*[@src or @srcset] | //link[@href]"): + for attr in ("src", "href", "srcset"): + if node.get(attr): + node.set(attr, node.get(attr).replace(prefix, "/")) + + shell.find("body").set("class", "git-site") + (output / "shell.html").write_text(serialize(shell), encoding="utf-8") + + for asset in ( + "style.css", + "git.css", + "fonts.css", + "color.css", + "fonts", + "website.ico", + "math", + "js", + "processed_images", + ): + src, dst = source / asset, output / asset + if src.is_dir(): + shutil.copytree(src, dst) + else: + shutil.copyfile(src, dst) + + +def rewrite_markdown(article, page, output, repo): + """Resolve Markdown links and images""" + relative = page.relative_to(output) + if len(relative.parts) < 3 or relative.parts[1] != "file": + return + repo_name = relative.parts[0] + filename = "/".join(relative.parts[2:])[:-5] # strip stagit's .html suffix + for node in article.xpath(".//*[@href or @src]"): + attr = "src" if node.tag == "img" else "href" + value = node.get(attr) + if not value: + continue + + url = urlsplit(value) + if url.scheme or url.netloc or not url.path: + continue + + decoded = unquote(url.path).replace("\\", "/") + target = posixpath.normpath( + decoded.lstrip("/") + if decoded.startswith("/") + else posixpath.join(posixpath.dirname(filename), decoded) + ) + if target == ".." or target.startswith(("../", "/")) or "\0" in target: + node.attrib.pop(attr, None) + continue + + suffix = ("?" + url.query if url.query else "") + ( + "#" + url.fragment if url.fragment else "" + ) + if attr == "src": + if Path(target).suffix.lower() not in { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".avif", + ".ico", + ".svg", + }: + node.attrib.pop(attr, None) + continue + dest = output / repo_name / "raw" / target + if not dest.is_file(): + blob = subprocess.run( + ["git", "--git-dir", str(repo), "show", f"HEAD:{target}"], + capture_output=True, + ) + if blob.returncode: + node.attrib.pop(attr, None) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(blob.stdout) + node.set(attr, f"/{quote(repo_name)}/raw/{quote(target)}{suffix}") + + elif attr == "href": + dest = output / repo_name / "file" / (target + ".html") + if dest.is_file(): + node.set(attr, f"/{quote(repo_name)}/file/{quote(target)}.html{suffix}") + else: + # Directory Links + directory = output / repo_name / "file" / target + if target == "." or directory.is_dir(): + node.set(attr, f"/{quote(repo_name)}/files.html") + + +def assemble(output, assets, repositories): + shell = html.parse(str(assets / "shell.html")) + for path in sorted(output.rglob("*.html")): + original = html.parse(str(path)) + main = original.find("body/main") + if main is None: + raise ValueError(f"Missing <main> in {path}") + + document = deepcopy(shell) + document.find("head/title").text = original.findtext("head/title") + for feed in original.xpath("//head/link[@rel='alternate']"): + document.find("head").append(deepcopy(feed)) + placeholder = document.find(".//main") + if placeholder is None: + raise ValueError("Missing <main> in git-shell.html") + placeholder.getparent().replace(placeholder, main) + + for article in main.iter("article"): + name = path.relative_to(output).parts[0] + rewrite_markdown(article, path, output, repositories / f"{name}.git") + + for alert in main.xpath( + ".//*[contains(concat(' ', @class, ' '), ' admonition ')]" + ): + kind = next( + ( + value + for value in alert.get("class").split() + if value != "admonition" + ), + "note", + ) + alert.tag = "blockquote" + alert.set( + "class", "markdown-alert-" + {"danger": "caution"}.get(kind, kind) + ) + + for table in main.xpath(".//table[not(ancestor::table)]"): + for cell in table.xpath("./thead/tr/td"): + cell.tag = "th" + cell.set("scope", "col") + wrapper = html.Element( + "div", + { + "class": "table-scroll", + "tabindex": "0", + "role": "region", + "aria-label": "Repository table", + }, + ) + table.addprevious(wrapper) + wrapper.append(table) + + for pre in main.xpath(".//pre"): + pre.set("tabindex", "0") + + # Math + if main.xpath(".//*[contains(concat(' ', @class, ' '), ' arithmatex ')]"): + document.find("body").append( + html.Element("script", defer="", src="/js/git-math.js") + ) + + path.write_text("<!doctype html>\n" + serialize(document), encoding="utf-8") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["export", "assemble"]) + parser.add_argument("source", type=Path) + parser.add_argument("destination", type=Path) + parser.add_argument("--repositories", type=Path) + parser.add_argument("--base-url") + + args = parser.parse_args() + if args.command == "export": + if not args.base_url: + parser.error("export requires --base-url") + export(args.source, args.destination, args.base_url) + else: + if not args.repositories: + parser.error("assemble requires --repositories") + assemble(args.source, args.destination, args.repositories) diff --git a/site/git/assets.nix b/site/git/assets.nix @@ -0,0 +1,33 @@ +{ + lib, + pkgs, + gitSite ? null, +}: let + mainSite = (fromTOML (builtins.readFile ../zola.toml)).base_url; +in + pkgs.stdenvNoCC.mkDerivation { + pname = "gitsite-assets"; + version = "1"; + src = ../.; + nativeBuildInputs = [ + pkgs.zola + (pkgs.python3.withPackages (ps: [ps.lxml])) + ]; + buildPhase = '' + runHook preBuild + cat > content/git-shell.md <<'EOF' + +++ + template = "git-shell.html" + in_search_index = false + +++ + EOF + ${lib.optionalString (gitSite != null) '' + substituteInPlace zola.toml --replace-fail 'https://git.maydayv7.dev' ${lib.escapeShellArg gitSite} + ''} + zola build + runHook postBuild + ''; + installPhase = '' + python3 git/assemble.py export public "$out" --base-url ${lib.escapeShellArg mainSite} + ''; + } diff --git a/site/git/default.nix b/site/git/default.nix @@ -5,109 +5,77 @@ sitename ? "maydayv7", test ? false, }: let - inherit (lib) concatMapStrings licenses recursiveUpdate; - theme = import ./theme.nix pkgs; - + stagit = pkgs.callPackage ./stagit {}; + assets = pkgs.callPackage ./assets.nix {gitSite = site;}; buildCommands = if test then ''build_repo test "A long description just for testing" Me "$(pwd)"'' else - concatMapStrings (repo: '' - build_repo "${repo.name}" "${repo.description}" "${repo.owner}" "${repo.url}" + lib.concatMapStrings (repo: '' + build_repo ${lib.escapeShellArgs [repo.name repo.description repo.owner repo.url]} '') (import ./repos.nix); in - recursiveUpdate - { + pkgs.writeShellApplication { + name = "gitsite"; + runtimeInputs = with pkgs; [ + coreutils + git + stagit + (python3.withPackages (ps: [ps.fonttools ps.brotli ps.lxml])) + ]; + text = '' + # Build Directory + OUTPUT_DIR=$(realpath -m "''${1:-./public}") + if [ -e "$OUTPUT_DIR" ] + then + echo "Output already exists: $OUTPUT_DIR. Choose an empty destination." >&2 + exit 1 + fi + mkdir -p "$(dirname "$OUTPUT_DIR")" + BUILD_ROOT=$(mktemp -d "$(dirname "$OUTPUT_DIR")/.stagit-build.XXXXXXXX") + trap 'rm -rf "$BUILD_ROOT"' EXIT + STAGING_DIR="$BUILD_ROOT/public" + export TZ=UTC + mkdir -p "$STAGING_DIR" "$BUILD_ROOT/repos" + + build_repo() { + local name=$1 description=$2 owner=$3 url=$4 + local repository="$BUILD_ROOT/repos/$name.git" + echo "Building $name..." + + # Repository + git -c safe.directory="$url" -c safe.directory="$url/.git" \ + -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=60 clone --bare "$url" "$repository" + printf '%s\n' "$description" > "$repository/description" + printf '%s\n' "$owner" > "$repository/owner" + printf '%s\n' "$url" > "$repository/url" + mkdir -p "$STAGING_DIR/$name" + (cd "$STAGING_DIR/$name" && stagit -l 50 -n ${lib.escapeShellArg sitename} \ + -u ${lib.escapeShellArg site}/"$name" "$repository") + } + + if [ "$#" -gt 1 ] + then + LOCAL_REPO=$(realpath "$2") + build_repo "$(basename "$LOCAL_REPO" .git)" "Local preview" ${lib.escapeShellArg sitename} "$LOCAL_REPO" + else + ${buildCommands} + fi + + stagit-index -n ${lib.escapeShellArg sitename} "$BUILD_ROOT/repos/"*.git > "$STAGING_DIR/index.html" + python3 ${./assemble.py} assemble "$STAGING_DIR" ${assets} --repositories "$BUILD_ROOT/repos" + cp -r ${assets}/. "$STAGING_DIR/" + chmod -R u+w "$STAGING_DIR" + rm "$STAGING_DIR/shell.html" + cp ${./_headers} "$STAGING_DIR/_headers" + python3 ${../scripts/optimize_fonts.py} "$STAGING_DIR" --source ${../static/fonts} + mv "$STAGING_DIR" "$OUTPUT_DIR" + echo "Built gitsite at $OUTPUT_DIR" + ''; meta = { - mainProgram = "build-stagit"; - description = "Build 'git' frontend site"; - license = licenses.gpl3Only; + mainProgram = "gitsite"; + description = "Build the gitsite"; + license = lib.licenses.gpl3Only; maintainers = ["maydayv7"]; }; } - ( - pkgs.writeShellApplication { - name = "build-stagit"; - runtimeInputs = with pkgs; [ - coreutils - git - stagit-fork - (python3.withPackages (python: [python.fonttools python.brotli])) - ]; - - text = '' - set -e - echo "Building Site..." - - BASE_URL=${site} - OUTPUT_DIR="./public" - FONTS_SRC="./site/static/fonts" - FAVICON_SRC="./site/static/website.ico" - - mkdir -p "$OUTPUT_DIR" - mkdir -p "$OUTPUT_DIR/fonts" - - # 1. Global Assets - echo "Copying Global Assets..." - - # Fonts - if [ -d "$FONTS_SRC" ] - then - cp "$FONTS_SRC"/* "$OUTPUT_DIR/fonts/" - else - echo "Warning: Fonts directory $FONTS_SRC not found." - fi - - # Theme - cp "${theme.red}" "$OUTPUT_DIR/style-red.css" - cp "${theme.blue}" "$OUTPUT_DIR/style-blue.css" - cp "${theme.black}" "$OUTPUT_DIR/style-black.css" - ln -sf style-red.css "$OUTPUT_DIR/style.css" - - # Favicon - if [ -f "$FAVICON_SRC" ] - then - cp "$FAVICON_SRC" "$OUTPUT_DIR/favicon.png" - else - echo "Warning: Global favicon not found at $FAVICON_SRC" - fi - - # 2. Build Setup - BUILD_ROOT=$(mktemp -d) - trap 'rm -rf "$BUILD_ROOT"' EXIT - build_repo() { - NAME=$1 - DESC=$2 - OWNER=$3 - URL=$4 - - echo "---------------------------------------" - echo "Processing $NAME..." - REPO_DIR="$BUILD_ROOT/$NAME.git" - git clone --bare --filter=blob:limit=2m "$URL" "$REPO_DIR" - - # Metadata - echo "$DESC" > "$REPO_DIR/description" - echo "$OWNER" > "$REPO_DIR/owner" - echo "$URL" > "$REPO_DIR/url" - mkdir -p "$OUTPUT_DIR/$NAME" - ln -sf ../favicon.png "$OUTPUT_DIR/$NAME/favicon.png" - - # Run Stagit - (cd "$OUTPUT_DIR/$NAME" && stagit -l 50 -n ${sitename} -u "$BASE_URL/$NAME" "$REPO_DIR") - echo "---------------------------------------" - } - - # 3. Execute Builds - ${buildCommands} - - # 4. Generate Index - echo "Generating Index..." - stagit-index -n ${sitename} "$BUILD_ROOT"/*.git > "$OUTPUT_DIR/index.html" - - python3 ${../scripts/optimize_fonts.py} "$OUTPUT_DIR" --source "$FONTS_SRC" - - echo "Successfully built to $OUTPUT_DIR!" - ''; - } - ) diff --git a/site/git/index.js b/site/git/index.js @@ -1,50 +0,0 @@ -function getCookie(cookieHeader, name) { - if (!cookieHeader) return null; - const cookies = cookieHeader.split(";"); - - for (const cookie of cookies) { - const [k, ...v] = cookie.trim().split("="); - if (k === name) return v.join("="); - } - return null; -} - -export default { - async fetch(request, env) { - const url = new URL(request.url); - - // Theme Switching - if (url.pathname === "/style.css") { - const theme = getCookie(request.headers.get("Cookie"), "theme_color"); - if (["black", "blue"].includes(theme)) { - const themeUrl = new URL(url); - themeUrl.pathname = themeUrl.pathname.replace( - "style.css", - `style-${theme}.css`, - ); - - const themed = await env.ASSETS.fetch(new Request(themeUrl, request)); - - if (themed.ok) { - const headers = new Headers(themed.headers); - headers.set("Vary", "Cookie"); - headers.set("Cache-Control", "private, no-cache"); - return new Response(themed.body, { - status: themed.status, - headers, - }); - } - } - } - - // Serve assets - const response = await env.ASSETS.fetch(request); - if (url.pathname === "/style.css") { - const headers = new Headers(response.headers); - headers.set("Vary", "Cookie"); - headers.set("Cache-Control", "private, no-cache"); - return new Response(response.body, { status: response.status, headers }); - } - return response; - }, -}; diff --git a/site/git/repos.nix b/site/git/repos.nix @@ -6,12 +6,6 @@ owner = "V7"; } { - name = "stagit"; - url = "https://github.com/maydayv7/stagit"; - description = "Personal Stagit Fork"; - owner = "V7"; - } - { name = "creek"; url = "https://github.com/maydayv7/creek"; description = "The AI Image Editor of 2030"; diff --git a/site/git/stagit/Makefile b/site/git/stagit/Makefile @@ -0,0 +1,98 @@ +.POSIX: + +NAME = stagit +VERSION = 1.2 + +# paths +PREFIX = /usr/local +MANPREFIX = ${PREFIX}/man +DOCPREFIX = ${PREFIX}/share/doc/${NAME} + +LIBGIT_INC = -I/usr/local/include +LIBGIT_LIB = -L/usr/local/lib -lgit2 + +# use system flags. +STAGIT_CFLAGS = ${LIBGIT_INC} ${CFLAGS} +STAGIT_LDFLAGS = ${LIBGIT_LIB} ${LDFLAGS} +STAGIT_CPPFLAGS = -D_XOPEN_SOURCE=700 -D_DEFAULT_SOURCE -D_BSD_SOURCE + +# Uncomment to enable workaround for older libgit2 which don't support this +# option. This workaround will be removed in the future *pinky promise*. +#STAGIT_CFLAGS += -DGIT_OPT_SET_OWNER_VALIDATION=-1 + +SRC = \ + stagit.c\ + stagit-index.c +COMPATSRC = \ + reallocarray.c\ + strlcat.c\ + strlcpy.c +BIN = \ + stagit\ + stagit-index +DOC = \ + README.md +HDR = compat.h + +COMPATOBJ = \ + reallocarray.o\ + strlcat.o\ + strlcpy.o + +OBJ = ${SRC:.c=.o} ${COMPATOBJ} + +all: ${BIN} + +.o: + ${CC} -o $@ ${LDFLAGS} + +.c.o: + ${CC} -o $@ -c $< ${STAGIT_CFLAGS} ${STAGIT_CPPFLAGS} + +dist: + rm -rf ${NAME}-${VERSION} + mkdir -p ${NAME}-${VERSION} + cp -f ${HDR} ${SRC} ${COMPATSRC} ${DOC} \ + Makefile favicon.png render.py stagit.1 stagit-index.1 \ + ${NAME}-${VERSION} + # make tarball + tar -cf - ${NAME}-${VERSION} | \ + gzip -c > ${NAME}-${VERSION}.tar.gz + rm -rf ${NAME}-${VERSION} + +${OBJ}: ${HDR} + +stagit: stagit.o ${COMPATOBJ} + ${CC} -o $@ stagit.o ${COMPATOBJ} ${STAGIT_LDFLAGS} + +stagit-index: stagit-index.o ${COMPATOBJ} + ${CC} -o $@ stagit-index.o ${COMPATOBJ} ${STAGIT_LDFLAGS} + +clean: + rm -f ${BIN} ${OBJ} ${NAME}-${VERSION}.tar.gz + +install: all + # installing executable files. + mkdir -p ${DESTDIR}${PREFIX}/bin + cp -f ${BIN} ${DESTDIR}${PREFIX}/bin + cp -f render.py ${DESTDIR}${PREFIX}/bin/render + chmod 755 ${DESTDIR}${PREFIX}/bin/render + for f in ${BIN}; do chmod 755 ${DESTDIR}${PREFIX}/bin/$$f; done + # installing example files. + mkdir -p ${DESTDIR}${DOCPREFIX} + cp -f favicon.png README.md ${DESTDIR}${DOCPREFIX} + mkdir -p ${DESTDIR}${MANPREFIX}/man1 + cp -f stagit.1 stagit-index.1 ${DESTDIR}${MANPREFIX}/man1 + +uninstall: + # removing executable files. + for f in ${BIN}; do rm -f ${DESTDIR}${PREFIX}/bin/$$f; done + rm -f ${DESTDIR}${PREFIX}/bin/render + rm -f ${DESTDIR}${MANPREFIX}/man1/stagit.1 ${DESTDIR}${MANPREFIX}/man1/stagit-index.1 + # removing example files. + rm -f \ + ${DESTDIR}${DOCPREFIX}/favicon.png\ + ${DESTDIR}${DOCPREFIX}/README.md + -rmdir ${DESTDIR}${DOCPREFIX} + +.PHONY: all clean dist install uninstall diff --git a/site/git/stagit/README.md b/site/git/stagit/README.md @@ -0,0 +1,57 @@ +# stagit + +Static git page generator. + +## Changes + +This is my personal fork of [stagit](https://git.codemadness.org/stagit/). + +- Added `-n` to set the site name +- Pages use `/style.css` from the site root +- Sort repositories by last commit time on the index page +- Use local timezone for short dates +- Custom rendering with [`render.py`](./render.py): + - Code syntax highlighting with [Pygments](https://pygments.org/) + - Markdown rendering with [Python-Markdown](https://github.com/Python-Markdown/markdown) + - Tables, task lists, GitHub callouts and math markup +- Modified markup and shared elements through the website's [build](../default.nix) and [assembly](../assemble.py) + +## Usage + +Build the complete Git site from the repo root: + +```sh +nix run .#gitsite +``` + +To generate standalone pages directly: + +```sh +mkdir -p htmldir && cd htmldir +stagit -n maydayv7 /path/to/repo +``` + +Make the repository index: + +```sh +stagit-index -n maydayv7 repodir1 repodir2 > index.html +``` + +## Build and Install + +From the repo root: + +```sh +nix build .#stagit +``` + +Or build manually from this directory: + +```sh +make +make install +``` + +## Documentation + +See [stagit(1)](./stagit.1) and [stagit-index(1)](./stagit-index.1). diff --git a/site/git/stagit/compat.h b/site/git/stagit/compat.h @@ -0,0 +1,6 @@ +#undef strlcat +size_t strlcat(char *, const char *, size_t); +#undef strlcpy +size_t strlcpy(char *, const char *, size_t); +#undef reallocarray +void *reallocarray(void *, size_t, size_t); diff --git a/site/git/stagit/default.nix b/site/git/stagit/default.nix @@ -0,0 +1,37 @@ +{pkgs}: let + python = pkgs.python3.withPackages (ps: + with ps; [ + pygments + nh3 + markdown + pymdown-extensions + (buildPythonPackage rec { + pname = "markdown_callouts"; + version = "0.4.0"; + pyproject = true; + build-system = [hatchling]; + dependencies = [markdown]; + src = fetchPypi { + inherit pname version; + hash = "sha256-ftLJBIaWcFinOlR3gRIZg4OVItZwQa5SxJeWFvGyt0Y="; + }; + }) + ]); +in + pkgs.stdenv.mkDerivation { + pname = "stagit"; + version = "1.2-custom"; + src = ./.; + buildInputs = [pkgs.libgit2]; + nativeBuildInputs = [pkgs.makeWrapper]; + makeFlags = [ + "PREFIX=$(out)" + "STAGIT_CFLAGS=-I${pkgs.libgit2.dev}/include" + "STAGIT_LDFLAGS=-lgit2" + ]; + postInstall = '' + sed -i '1c #!${python}/bin/python3' "$out/bin/render" + wrapProgram "$out/bin/stagit" --prefix PATH : "$out/bin" + ''; + meta.mainProgram = "stagit"; + } diff --git a/site/git/stagit/favicon.png b/site/git/stagit/favicon.png Binary files differ. diff --git a/site/git/stagit/reallocarray.c b/site/git/stagit/reallocarray.c @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2008 Otto Moerbeek <otto@drijf.net> + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include <errno.h> +#include <stdint.h> +#include <stdlib.h> +#include <sys/types.h> + +#include "compat.h" + +/* + * This is sqrt(SIZE_MAX+1), as s1*s2 <= SIZE_MAX + * if both s1 < MUL_NO_OVERFLOW and s2 < MUL_NO_OVERFLOW + */ +#define MUL_NO_OVERFLOW (1UL << (sizeof(size_t) * 4)) + +void *reallocarray(void *optr, size_t nmemb, size_t size) { + if ((nmemb >= MUL_NO_OVERFLOW || size >= MUL_NO_OVERFLOW) && nmemb > 0 && + SIZE_MAX / nmemb < size) { + errno = ENOMEM; + return NULL; + } + return realloc(optr, size * nmemb); +} diff --git a/site/git/stagit/render.py b/site/git/stagit/render.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Render file contents""" + +import html +import sys + +import nh3 +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_for_filename, TextLexer +from pygments.util import ClassNotFound + +# Highlighting Limit +MAX_HIGHLIGHT = 512 * 1024 +FORMAT = HtmlFormatter( + cssclass="highlight", + linenos="inline", + lineanchors="loc", + anchorlinenos=True, + linespans="line", +) + + +def lexer_for(filename): + try: + return get_lexer_for_filename(filename) + except ClassNotFound: + return TextLexer() + + +def markdown_parser(): + from markdown import Markdown + from markdown_callouts.github_callouts import GitHubCalloutsExtension + + return Markdown( + extensions=[ + "markdown.extensions.tables", + "markdown.extensions.toc", + "pymdownx.arithmatex", + "pymdownx.betterem", + "pymdownx.magiclink", + "pymdownx.superfences", + "pymdownx.tasklist", + "pymdownx.tilde", + GitHubCalloutsExtension(), + ], + extension_configs={ + "pymdownx.arithmatex": {"generic": True}, + "pymdownx.magiclink": { + "repo_url_shortener": True, + "repo_url_shorthand": True, + }, + }, + ) + + +def safe_attribute(tag, attr, value): + # Keep Markdown separated + if attr == "id" and value in { + "main-content", + "theme-select", + "mobile-menu", + "more-menu", + }: + return None + if attr == "class": + return " ".join( + name + for name in value.split() + if name + not in { + "header", + "header-logo", + "logo", + "loading", + "footer", + "container", + "hidden", + "js-only", + } + ) + return value + + +def render(filename, contents): + rendered = "" + if len(contents) <= MAX_HIGHLIGHT and filename.lower().endswith( + (".md", ".markdown", ".mdown") + ): + markup = markdown_parser().reset().convert(contents) + attrs = {tag: set(values) for tag, values in nh3.ALLOWED_ATTRIBUTES.items()} + attrs["*"] = {"class", "id", "title"} + attrs["input"] = {"type", "checked", "disabled"} + markup = nh3.clean( + markup, + tags=nh3.ALLOWED_TAGS | {"input"}, + attributes=attrs, + attribute_filter=safe_attribute, + url_schemes={"http", "https", "mailto"}, + set_tag_attribute_values={"input": {"type": "checkbox", "disabled": ""}}, + ) + rendered = ( + '<h3>Rendered</h3><article class="post-content markdown">' + + markup + + "</article><h3>Code</h3>" + ) + if len(contents) > MAX_HIGHLIGHT: + # Plain Text + code = ( + "<pre>" + + "".join( + f'<span id="loc-{number}"><a class="linenos" href="#loc-{number}">{number}</a> {html.escape(line)}</span>' + for number, line in enumerate(contents.splitlines(keepends=True), 1) + ) + + "</pre>" + ) + else: + code = highlight(contents, lexer_for(filename), FORMAT) + return rendered + '<div id="blob">' + code + "</div>" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit("usage: render filename") + contents = sys.stdin.buffer.read().decode("utf-8", errors="replace") + sys.stdout.write(render(sys.argv[1], contents)) diff --git a/site/git/stagit/stagit-index.1 b/site/git/stagit/stagit-index.1 @@ -0,0 +1,47 @@ +.Dd August 2, 2021 +.Dt STAGIT-INDEX 1 +.Os +.Sh NAME +.Nm stagit-index +.Nd static git index page generator +.Sh SYNOPSIS +.Nm +.Op Fl n Ar sitename +.Op Ar repodir... +.Sh DESCRIPTION +.Nm +will create an index HTML page for the repositories specified and writes +the HTML data to stdout. +The repos in the index are sorted by last commit time, newest first. +Repositories with the same commit time are sorted by name. +.Pp +The basename of the directory is used as the repository name. +The suffix ".git" is removed from the basename, this suffix is commonly used +for "bare" repos. +.Pp +The content of the follow files specifies the meta data for each repository: +.Bl -tag -width Ds +.It .git/description or description (bare repos). +description +.It .git/owner or owner (bare repo). +owner of repository +.It Fl n Ar sitename +Set the site name used in the header link and page title. +.El +.Pp +For changing the style of the page you can use the following files: +.Bl -tag -width Ds +.It favicon.png +favicon image. +.It style.css +Global CSS stylesheet (must be present at root). +.El +.Sh EXAMPLES +.Bd -literal +cd htmlroot +stagit-index path/to/gitrepo1 path/to/gitrepo2 > index.html +.Ed +.Sh SEE ALSO +.Xr stagit 1 +.Sh AUTHORS +.An V7 diff --git a/site/git/stagit/stagit-index.c b/site/git/stagit/stagit-index.c @@ -0,0 +1,328 @@ +#include <err.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include <git2.h> + +static char *sitename = "Git"; +static const char *relpath = ""; + +struct repoinfo { + char *name; + char *strippedname; + char *description; + char *owner; + git_time time; +}; + +/* Handle read or write errors for a FILE * stream */ +void checkfileerror(FILE *fp, const char *name, int mode) { + if (mode == 'r' && ferror(fp)) + errx(1, "read error: %s", name); + else if (mode == 'w' && (fflush(fp) || ferror(fp))) + errx(1, "write error: %s", name); +} + +void joinpath(char *buf, size_t bufsiz, const char *path, const char *path2) { + int r; + + r = snprintf(buf, bufsiz, "%s%s%s", path, + path[0] && path[strlen(path) - 1] != '/' ? "/" : "", path2); + if (r < 0 || (size_t)r >= bufsiz) + errx(1, "path truncated: '%s%s%s'", path, + path[0] && path[strlen(path) - 1] != '/' ? "/" : "", path2); +} + +/* Percent-encode, see RFC3986 section 2.1. */ +void percentencode(FILE *fp, const char *s, size_t len) { + static char tab[] = "0123456789ABCDEF"; + unsigned char uc; + size_t i; + + for (i = 0; *s && i < len; s++, i++) { + uc = *s; + /* NOTE: do not encode '/' for paths or ",-." */ + if (uc < ',' || uc >= 127 || (uc >= ':' && uc <= '@') || uc == '[' || + uc == ']') { + putc('%', fp); + putc(tab[(uc >> 4) & 0x0f], fp); + putc(tab[uc & 0x0f], fp); + } else { + putc(uc, fp); + } + } +} + +/* Escape characters below as HTML 2.0 / XML 1.0. */ +void xmlencode(FILE *fp, const char *s, size_t len) { + size_t i; + + for (i = 0; *s && i < len; s++, i++) { + switch (*s) { + case '<': + fputs("<", fp); + break; + case '>': + fputs(">", fp); + break; + case '\'': + fputs("'", fp); + break; + case '&': + fputs("&", fp); + break; + case '"': + fputs(""", fp); + break; + default: + putc(*s, fp); + } + } +} + +void printtimeshort(FILE *fp, const git_time *intime) { + struct tm *intm; + time_t t; + char out[32]; + + t = (time_t)intime->time; + if (!(intm = localtime(&t))) + return; + strftime(out, sizeof(out), "%Y-%m-%d %H:%M", intm); + fputs(out, fp); +} + +void writeheader(FILE *fp) { + fputs("<!DOCTYPE html>\n" + "<html lang=\"en\">\n<head>\n" + "<meta http-equiv=\"Content-Type\" content=\"text/html; " + "charset=UTF-8\" />\n" + "<meta name=\"viewport\" content=\"width=device-width, " + "initial-scale=1\" />\n" + "<title>", + fp); + xmlencode(fp, sitename, strlen(sitename)); + fprintf(fp, + "</title>\n<link rel=\"icon\" type=\"image/png\" " + "href=\"%sfavicon.png\" />\n", + relpath); + fputs("<link rel=\"stylesheet\" type=\"text/css\" href=\"/style.css\" />\n", + fp); + fputs("</head>\n<body>\n<header><a href=\"./\">", fp); + xmlencode(fp, sitename, strlen(sitename)); + fputs( + "</a></header>\n" + "<main id=\"main-content\" class=\"post git-content\" tabindex=\"-1\">\n" + "<h1 class=\"post-title\">Git repositories</h1>\n" + "<div id=\"content\">\n", + fp); + + fputs( + "<table id=\"index\"><thead>\n" + "<tr><td><b>Name</b></td><td><b>Description</b></td><td><b>Owner</b></td>" + "<td><b>Last commit</b></td></tr>" + "</thead><tbody>\n", + fp); +} + +void writefooter(FILE *fp) { + fputs("</tbody>\n</table>\n</div>\n</main>\n</body>\n</html>\n", fp); +} + +int getlastcommit(git_repository *repo, git_time *t) { + git_revwalk *w = NULL; + git_oid id; + git_commit *c = NULL; + const git_signature *author; + int ret = -1; + + if (git_revwalk_new(&w, repo)) + return -1; + if (git_revwalk_push_head(w)) + goto err; + if (git_revwalk_next(&id, w)) + goto err; + if (git_commit_lookup(&c, repo, &id)) + goto err; + + author = git_commit_author(c); + *t = author->when; + ret = 0; + +err: + git_commit_free(c); + git_revwalk_free(w); + return ret; +} + +int repocompare(const void *a, const void *b) { + const struct repoinfo *ra = a; + const struct repoinfo *rb = b; + + /* sort by time descending (newest first) */ + if (ra->time.time > rb->time.time) + return -1; + if (ra->time.time < rb->time.time) + return 1; + /* fallback to name */ + return strcmp(ra->name, rb->name); +} + +void printrepo(FILE *fp, struct repoinfo *ri) { + fputs("<tr><td><a href=\"", fp); + percentencode(fp, ri->strippedname, strlen(ri->strippedname)); + fputs("/log.html\">", fp); + xmlencode(fp, ri->strippedname, strlen(ri->strippedname)); + fputs("</a></td><td>", fp); + xmlencode(fp, ri->description, strlen(ri->description)); + fputs("</td><td>", fp); + xmlencode(fp, ri->owner, strlen(ri->owner)); + fputs("</td><td>", fp); + if (ri->time.time) + printtimeshort(fp, &(ri->time)); + fputs("</td></tr>\n", fp); +} + +void usage(char *argv0) { + fprintf(stderr, "usage: %s [-n sitename] [repodir...]\n", argv0); + exit(1); +} + +int main(int argc, char *argv[]) { + git_repository *repo = NULL; + struct repoinfo *repos = NULL; + size_t nrepos = 0; + char path[PATH_MAX], repodirabs[PATH_MAX + 1]; + char description[255], owner[255]; + char *name, *p; + FILE *fp; + int i, ret = 0; + + for (i = 1; i < argc; i++) { + if (argv[i][0] == '-') { + if (!strcmp(argv[i], "-n")) { + if (i + 1 >= argc) + usage(argv[0]); + sitename = argv[++i]; + } else { + usage(argv[0]); + } + } + } + + if (argc < 2) + usage(argv[0]); + + /* do not search outside the git repository: + GIT_CONFIG_LEVEL_APP is the highest level currently */ + git_libgit2_init(); + for (i = 1; i <= GIT_CONFIG_LEVEL_APP; i++) + git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, i, ""); + /* do not require the git repository to be owned by the current user */ + git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); + +#ifdef __OpenBSD__ + if (pledge("stdio rpath", NULL) == -1) + err(1, "pledge"); +#endif + + for (i = 1; i < argc; i++) { + if (argv[i][0] == '-') { + if (argv[i][1] == 'n') + i++; + continue; + } + + if (!realpath(argv[i], repodirabs)) { + fprintf(stderr, "realpath error for %s\n", argv[i]); + ret = 1; + continue; + } + + if (git_repository_open_ext(&repo, argv[i], GIT_REPOSITORY_OPEN_NO_SEARCH, + NULL)) { + fprintf(stderr, "%s: cannot open repository\n", argv[i]); + ret = 1; + continue; + } + + repos = realloc(repos, (nrepos + 1) * sizeof(*repos)); + if (!repos) + err(1, "realloc"); + + /* set defaults */ + memset(&repos[nrepos], 0, sizeof(struct repoinfo)); + repos[nrepos].name = strdup(argv[i]); + description[0] = '\0'; + owner[0] = '\0'; + + /* calculate stripped name */ + if ((name = strrchr(repodirabs, '/'))) + name++; + else + name = ""; + if (!(repos[nrepos].strippedname = strdup(name))) + err(1, "strdup"); + if ((p = strrchr(repos[nrepos].strippedname, '.'))) + if (!strcmp(p, ".git")) + *p = '\0'; + + /* description */ + joinpath(path, sizeof(path), argv[i], "description"); + if (!(fp = fopen(path, "r"))) { + joinpath(path, sizeof(path), argv[i], ".git/description"); + fp = fopen(path, "r"); + } + if (fp) { + if (!fgets(description, sizeof(description), fp)) + description[0] = '\0'; + fclose(fp); + } + repos[nrepos].description = strdup(description); + + /* owner */ + joinpath(path, sizeof(path), argv[i], "owner"); + if (!(fp = fopen(path, "r"))) { + joinpath(path, sizeof(path), argv[i], ".git/owner"); + fp = fopen(path, "r"); + } + if (fp) { + if (!fgets(owner, sizeof(owner), fp)) + owner[0] = '\0'; + owner[strcspn(owner, "\n")] = '\0'; + fclose(fp); + } + repos[nrepos].owner = strdup(owner); + + /* last commit */ + getlastcommit(repo, &repos[nrepos].time); + + git_repository_free(repo); + repo = NULL; + nrepos++; + } + + /* sort by last commit time */ + qsort(repos, nrepos, sizeof(struct repoinfo), repocompare); + + /* output */ + writeheader(stdout); + for (i = 0; i < nrepos; i++) { + printrepo(stdout, &repos[i]); + free(repos[i].name); + free(repos[i].strippedname); + free(repos[i].description); + free(repos[i].owner); + } + writefooter(stdout); + free(repos); + + checkfileerror(stdout, "stdout", 'w'); + git_libgit2_shutdown(); + + return ret; +} diff --git a/site/git/stagit/stagit.1 b/site/git/stagit/stagit.1 @@ -0,0 +1,126 @@ +.Dd August 2, 2021 +.Dt STAGIT 1 +.Os +.Sh NAME +.Nm stagit +.Nd static git page generator +.Sh SYNOPSIS +.Nm +.Op Fl c Ar cachefile +.Op Fl l Ar commits +.Op Fl u Ar baseurl +.Op Fl n Ar sitename +.Ar repodir +.Sh DESCRIPTION +.Nm +writes HTML pages for the repository +.Ar repodir +to the current directory. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl c Ar cachefile +Cache the entries of the log page up to the point of +the last commit. +The +.Ar cachefile +will store the last commit id and the entries in the HTML table. +It is up to the user to make sure the state of the +.Ar cachefile +is in sync with the history of the repository. +.It Fl l Ar commits +Write a maximum number of +.Ar commits +to the log.html file only. +However the commit files are written as usual. +.It Fl u Ar baseurl +Base URL to make links in the Atom feeds absolute. +For example: "https://github.com/maydayv7/stagit/". +.It Fl n Ar sitename +Set the site name used in the header link. +.El +.Pp +The options +.Fl c +and +.Fl l +cannot be used at the same time. +.Pp +The following files will be written: +.Bl -tag -width Ds +.It atom.xml +Atom XML feed of the last 100 commits. +.It tags.xml +Atom XML feed of the tags. +.It files.html +List of files in the latest tree, linking to the file. +.It log.html +List of commits in reverse chronological applied commit order, each commit +links to a page with a diffstat and diff of the commit. +.It refs.html +Lists references of the repository such as branches and tags. +.El +.Pp +For each entry in HEAD a file will be written in the format: +file/filepath.html. +This file will contain the textual data of the file prefixed by line numbers. +The file will have the string "Binary file" if the data is considered to be +non-textual. +.Pp +For each commit a file will be written in the format: +commit/commitid.html. +This file will contain the diffstat and diff of the commit. +It will write the string "Binary files differ" if the data is considered to +be non-textual. +Too large diffs will be suppressed and a string +"Diff is too large, output suppressed" will be written. +.Pp +When a commit HTML file exists it won't be overwritten again, note that if +you've changed +.Nm +or changed one of the metadata files of the repository it is recommended to +recreate all the output files because it will contain old data. +To do this remove the output directory and +.Ar cachefile , +then recreate the files. +.Pp +The basename of the directory is used as the repository name. +The suffix ".git" is removed from the basename, this suffix is commonly used +for "bare" repos. +.Pp +The content of the follow files specifies the metadata for each repository: +.Bl -tag -width Ds +.It .git/description or description (bare repo). +description +.It .git/owner or owner (bare repo). +owner of repository +.It .git/url or url (bare repo). +primary clone URL of the repository, for example: +https://github.com/maydayv7/stagit +.El +.Pp +When a README or LICENSE file exists in HEAD or a .gitmodules submodules file +exists in HEAD a direct link in the menu is made. +.Pp +For changing the style of the page you can use the following files: +.Bl -tag -width Ds +.It favicon.png +favicon image. +.It /style.css +Global CSS stylesheet (must be present at root). +.El +.Sh EXIT STATUS +.Ex -std +.Sh EXAMPLES +.Bd -literal +mkdir -p htmlroot/htmlrepo1 && cd htmlroot/htmlrepo1 +stagit path/to/gitrepo1 +# repeat for other repositories. +.Ed +.Pp +To update the HTML files when the repository is changed a git post-receive hook +can be used, see the file example_post-receive.sh for an example. +.Sh SEE ALSO +.Xr stagit-index 1 +.Sh AUTHORS +.An V7 diff --git a/site/git/stagit/stagit.c b/site/git/stagit/stagit.c @@ -0,0 +1,1484 @@ +#include <sys/stat.h> +#include <sys/types.h> +#include <sys/wait.h> + +#include <err.h> +#include <errno.h> +#include <libgen.h> +#include <limits.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include <git2.h> + +#include "compat.h" + +#define CMD_BUFSIZE 255 + +#define LEN(s) (sizeof(s) / sizeof(*s)) + +struct deltainfo { + git_patch *patch; + + size_t addcount; + size_t delcount; +}; + +struct commitinfo { + const git_oid *id; + + char oid[GIT_OID_HEXSZ + 1]; + char parentoid[GIT_OID_HEXSZ + 1]; + + const git_signature *author; + const git_signature *committer; + const char *summary; + const char *msg; + + git_diff *diff; + git_commit *commit; + git_commit *parent; + git_tree *commit_tree; + git_tree *parent_tree; + + size_t addcount; + size_t delcount; + size_t filecount; + + struct deltainfo **deltas; + size_t ndeltas; +}; + +/* reference and associated data for sorting */ +struct referenceinfo { + struct git_reference *ref; + struct commitinfo *ci; +}; + +static git_repository *repo; + +static const char *baseurl = ""; /* base URL to make absolute RSS/Atom URI */ +static const char *relpath = ""; +static const char *repodir; +static char *sitename = "Git"; + +static char *name = ""; +static char *strippedname = ""; +static char description[255]; +static char cloneurl[1024]; +static char *submodules; +static char *licensefiles[] = {"HEAD:LICENSE", "HEAD:LICENSE.md", + "HEAD:COPYING"}; +static char *license; +static char *readmefiles[] = {"HEAD:README", "HEAD:README.md"}; +static char *readme; +static long long nlogcommits = -1; /* -1 indicates not used */ + +/* cache */ +static git_oid lastoid; +static char lastoidstr[GIT_OID_HEXSZ + 2]; /* id + newline + NUL byte */ +static FILE *rcachefp, *wcachefp; +static const char *cachefile; + +/* Handle read or write errors for a FILE * stream */ +void checkfileerror(FILE *fp, const char *name, int mode) { + if (mode == 'r' && ferror(fp)) + errx(1, "read error: %s", name); + else if (mode == 'w' && (fflush(fp) || ferror(fp))) + errx(1, "write error: %s", name); +} + +void joinpath(char *buf, size_t bufsiz, const char *path, const char *path2) { + int r; + + r = snprintf(buf, bufsiz, "%s%s%s", path, + path[0] && path[strlen(path) - 1] != '/' ? "/" : "", path2); + if (r < 0 || (size_t)r >= bufsiz) + errx(1, "path truncated: '%s%s%s'", path, + path[0] && path[strlen(path) - 1] != '/' ? "/" : "", path2); +} + +void deltainfo_free(struct deltainfo *di) { + if (!di) + return; + git_patch_free(di->patch); + memset(di, 0, sizeof(*di)); + free(di); +} + +int commitinfo_getstats(struct commitinfo *ci) { + struct deltainfo *di; + git_diff_options opts; + git_diff_find_options fopts; + const git_diff_delta *delta; + const git_diff_hunk *hunk; + const git_diff_line *line; + git_patch *patch = NULL; + size_t ndeltas, nhunks, nhunklines; + size_t i, j, k; + + if (git_tree_lookup(&(ci->commit_tree), repo, git_commit_tree_id(ci->commit))) + goto err; + if (!git_commit_parent(&(ci->parent), ci->commit, 0)) { + if (git_tree_lookup(&(ci->parent_tree), repo, + git_commit_tree_id(ci->parent))) { + ci->parent = NULL; + ci->parent_tree = NULL; + } + } + + git_diff_init_options(&opts, GIT_DIFF_OPTIONS_VERSION); + opts.flags |= GIT_DIFF_DISABLE_PATHSPEC_MATCH | GIT_DIFF_IGNORE_SUBMODULES | + GIT_DIFF_INCLUDE_TYPECHANGE; + if (git_diff_tree_to_tree(&(ci->diff), repo, ci->parent_tree, ci->commit_tree, + &opts)) + goto err; + + if (git_diff_find_init_options(&fopts, GIT_DIFF_FIND_OPTIONS_VERSION)) + goto err; + /* find renames and copies, exact matches (no heuristic) for renames. */ + fopts.flags |= GIT_DIFF_FIND_RENAMES | GIT_DIFF_FIND_COPIES | + GIT_DIFF_FIND_EXACT_MATCH_ONLY; + if (git_diff_find_similar(ci->diff, &fopts)) + goto err; + + ndeltas = git_diff_num_deltas(ci->diff); + if (ndeltas && !(ci->deltas = calloc(ndeltas, sizeof(struct deltainfo *)))) + err(1, "calloc"); + + for (i = 0; i < ndeltas; i++) { + if (git_patch_from_diff(&patch, ci->diff, i)) + goto err; + + if (!(di = calloc(1, sizeof(struct deltainfo)))) + err(1, "calloc"); + di->patch = patch; + ci->deltas[i] = di; + + delta = git_patch_get_delta(patch); + + /* skip stats for binary data */ + if (delta->flags & GIT_DIFF_FLAG_BINARY) + continue; + + nhunks = git_patch_num_hunks(patch); + for (j = 0; j < nhunks; j++) { + if (git_patch_get_hunk(&hunk, &nhunklines, patch, j)) + break; + for (k = 0;; k++) { + if (git_patch_get_line_in_hunk(&line, patch, j, k)) + break; + if (line->old_lineno == -1) { + di->addcount++; + ci->addcount++; + } else if (line->new_lineno == -1) { + di->delcount++; + ci->delcount++; + } + } + } + } + ci->ndeltas = i; + ci->filecount = i; + + return 0; + +err: + git_diff_free(ci->diff); + ci->diff = NULL; + git_tree_free(ci->commit_tree); + ci->commit_tree = NULL; + git_tree_free(ci->parent_tree); + ci->parent_tree = NULL; + git_commit_free(ci->parent); + ci->parent = NULL; + + if (ci->deltas) + for (i = 0; i < ci->ndeltas; i++) + deltainfo_free(ci->deltas[i]); + free(ci->deltas); + ci->deltas = NULL; + ci->ndeltas = 0; + ci->addcount = 0; + ci->delcount = 0; + ci->filecount = 0; + + return -1; +} + +void commitinfo_free(struct commitinfo *ci) { + size_t i; + + if (!ci) + return; + if (ci->deltas) + for (i = 0; i < ci->ndeltas; i++) + deltainfo_free(ci->deltas[i]); + + free(ci->deltas); + git_diff_free(ci->diff); + git_tree_free(ci->commit_tree); + git_tree_free(ci->parent_tree); + git_commit_free(ci->commit); + git_commit_free(ci->parent); + memset(ci, 0, sizeof(*ci)); + free(ci); +} + +struct commitinfo *commitinfo_getbyoid(const git_oid *id) { + struct commitinfo *ci; + + if (!(ci = calloc(1, sizeof(struct commitinfo)))) + err(1, "calloc"); + + if (git_commit_lookup(&(ci->commit), repo, id)) + goto err; + ci->id = id; + + git_oid_tostr(ci->oid, sizeof(ci->oid), git_commit_id(ci->commit)); + git_oid_tostr(ci->parentoid, sizeof(ci->parentoid), + git_commit_parent_id(ci->commit, 0)); + + ci->author = git_commit_author(ci->commit); + ci->committer = git_commit_committer(ci->commit); + ci->summary = git_commit_summary(ci->commit); + ci->msg = git_commit_message(ci->commit); + + return ci; + +err: + commitinfo_free(ci); + + return NULL; +} + +int refs_cmp(const void *v1, const void *v2) { + const struct referenceinfo *r1 = v1, *r2 = v2; + time_t t1, t2; + int r; + + if ((r = git_reference_is_tag(r1->ref) - git_reference_is_tag(r2->ref))) + return r; + + t1 = r1->ci->author ? r1->ci->author->when.time : 0; + t2 = r2->ci->author ? r2->ci->author->when.time : 0; + if ((r = t1 > t2 ? -1 : (t1 == t2 ? 0 : 1))) + return r; + + return strcmp(git_reference_shorthand(r1->ref), + git_reference_shorthand(r2->ref)); +} + +int getrefs(struct referenceinfo **pris, size_t *prefcount) { + struct referenceinfo *ris = NULL; + struct commitinfo *ci = NULL; + git_reference_iterator *it = NULL; + const git_oid *id = NULL; + git_object *obj = NULL; + git_reference *dref = NULL, *r, *ref = NULL; + size_t i, refcount; + + *pris = NULL; + *prefcount = 0; + + if (git_reference_iterator_new(&it, repo)) + return -1; + + for (refcount = 0; !git_reference_next(&ref, it);) { + if (!git_reference_is_branch(ref) && !git_reference_is_tag(ref)) { + git_reference_free(ref); + ref = NULL; + continue; + } + + switch (git_reference_type(ref)) { + case GIT_REF_SYMBOLIC: + if (git_reference_resolve(&dref, ref)) + goto err; + r = dref; + break; + case GIT_REF_OID: + r = ref; + break; + default: + continue; + } + if (!git_reference_target(r) || git_reference_peel(&obj, r, GIT_OBJ_ANY)) + goto err; + if (!(id = git_object_id(obj))) + goto err; + if (!(ci = commitinfo_getbyoid(id))) + break; + + if (!(ris = reallocarray(ris, refcount + 1, sizeof(*ris)))) + err(1, "realloc"); + ris[refcount].ci = ci; + ris[refcount].ref = r; + refcount++; + + git_object_free(obj); + obj = NULL; + git_reference_free(dref); + dref = NULL; + } + git_reference_iterator_free(it); + + /* sort by type, date then shorthand name */ + qsort(ris, refcount, sizeof(*ris), refs_cmp); + + *pris = ris; + *prefcount = refcount; + + return 0; + +err: + git_object_free(obj); + git_reference_free(dref); + commitinfo_free(ci); + for (i = 0; i < refcount; i++) { + commitinfo_free(ris[i].ci); + git_reference_free(ris[i].ref); + } + free(ris); + + return -1; +} + +FILE *efopen(const char *filename, const char *flags) { + FILE *fp; + + if (!(fp = fopen(filename, flags))) + err(1, "fopen: '%s'", filename); + + return fp; +} + +/* Percent-encode, see RFC3986 section 2.1. */ +void percentencode(FILE *fp, const char *s, size_t len) { + static char tab[] = "0123456789ABCDEF"; + unsigned char uc; + size_t i; + + for (i = 0; *s && i < len; s++, i++) { + uc = *s; + /* NOTE: do not encode '/' for paths or ",-." */ + if (uc < ',' || uc >= 127 || (uc >= ':' && uc <= '@') || uc == '[' || + uc == ']') { + putc('%', fp); + putc(tab[(uc >> 4) & 0x0f], fp); + putc(tab[uc & 0x0f], fp); + } else { + putc(uc, fp); + } + } +} + +/* Escape characters below as HTML 2.0 / XML 1.0. */ +void xmlencode(FILE *fp, const char *s, size_t len) { + size_t i; + + for (i = 0; *s && i < len; s++, i++) { + switch (*s) { + case '<': + fputs("<", fp); + break; + case '>': + fputs(">", fp); + break; + case '\'': + fputs("'", fp); + break; + case '&': + fputs("&", fp); + break; + case '"': + fputs(""", fp); + break; + default: + putc(*s, fp); + } + } +} + +/* Escape characters below as HTML 2.0 / XML 1.0, ignore printing '\r', '\n' */ +void xmlencodeline(FILE *fp, const char *s, size_t len) { + size_t i; + + for (i = 0; *s && i < len; s++, i++) { + switch (*s) { + case '<': + fputs("<", fp); + break; + case '>': + fputs(">", fp); + break; + case '\'': + fputs("'", fp); + break; + case '&': + fputs("&", fp); + break; + case '"': + fputs(""", fp); + break; + case '\r': + break; /* ignore CR */ + case '\n': + break; /* ignore LF */ + default: + putc(*s, fp); + } + } +} + +int mkdirp(const char *path) { + char tmp[PATH_MAX], *p; + + if (strlcpy(tmp, path, sizeof(tmp)) >= sizeof(tmp)) + errx(1, "path truncated: '%s'", path); + for (p = tmp + (tmp[0] == '/'); *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + if (mkdir(tmp, S_IRWXU | S_IRWXG | S_IRWXO) < 0 && errno != EEXIST) + return -1; + *p = '/'; + } + if (mkdir(tmp, S_IRWXU | S_IRWXG | S_IRWXO) < 0 && errno != EEXIST) + return -1; + return 0; +} + +void printtimez(FILE *fp, const git_time *intime) { + struct tm *intm; + time_t t; + char out[32]; + + t = (time_t)intime->time; + if (!(intm = gmtime(&t))) + return; + strftime(out, sizeof(out), "%Y-%m-%dT%H:%M:%SZ", intm); + fputs(out, fp); +} + +void printtime(FILE *fp, const git_time *intime) { + struct tm *intm; + time_t t; + char out[32]; + + t = (time_t)intime->time + (intime->offset * 60); + if (!(intm = gmtime(&t))) + return; + strftime(out, sizeof(out), "%a, %e %b %Y %H:%M:%S", intm); + if (intime->offset < 0) + fprintf(fp, "%s -%02d%02d", out, -(intime->offset) / 60, + -(intime->offset) % 60); + else + fprintf(fp, "%s +%02d%02d", out, intime->offset / 60, intime->offset % 60); +} + +void printtimeshort(FILE *fp, const git_time *intime) { + struct tm *intm; + time_t t; + char out[32]; + + t = (time_t)intime->time; + if (!(intm = localtime(&t))) + return; + strftime(out, sizeof(out), "%Y-%m-%d %H:%M", intm); + fputs(out, fp); +} + +void writeheader(FILE *fp, const char *title) { + fputs("<!DOCTYPE html>\n" + "<html lang=\"en\">\n<head>\n" + "<meta http-equiv=\"Content-Type\" content=\"text/html; " + "charset=UTF-8\" />\n" + "<meta name=\"viewport\" content=\"width=device-width, " + "initial-scale=1\" />\n" + "<title>", + fp); + xmlencode(fp, title, strlen(title)); + if (title[0] && strippedname[0]) + fputs(" - ", fp); + xmlencode(fp, strippedname, strlen(strippedname)); + if (description[0]) + fputs(" - ", fp); + xmlencode(fp, description, strlen(description)); + fprintf(fp, + "</title>\n<link rel=\"icon\" type=\"image/png\" " + "href=\"%sfavicon.png\" />\n", + relpath); + fputs("<link rel=\"alternate\" type=\"application/atom+xml\" title=\"", fp); + xmlencode(fp, name, strlen(name)); + fprintf(fp, " Atom Feed\" href=\"%satom.xml\" />\n", relpath); + fputs("<link rel=\"alternate\" type=\"application/atom+xml\" title=\"", fp); + xmlencode(fp, name, strlen(name)); + fprintf(fp, " Atom Feed (tags)\" href=\"%stags.xml\" />\n", relpath); + fputs("<link rel=\"stylesheet\" type=\"text/css\" href=\"/style.css\" />\n", + fp); + + fputs("</head>\n<body>\n<header>\n", fp); + fprintf(fp, "<a href=\"../%s\">", relpath); + xmlencode(fp, sitename, strlen(sitename)); + fputs( + "</a>\n</header>\n" + "<main id=\"main-content\" class=\"post git-content\" tabindex=\"-1\">\n" + "<nav class=\"repo-menu\" aria-label=\"Repository navigation\">\n<ul>\n", + fp); + fprintf(fp, "<li><a href=\"../%s\">Repositories</a></li>", relpath); + + fprintf(fp, "<li><a href=\"%slog.html\">Log</a></li>", relpath); + fprintf(fp, "<li><a href=\"%sfiles.html\">Files</a></li>", relpath); + fprintf(fp, "<li><a href=\"%srefs.html\">Refs</a></li>", relpath); + + if (submodules) + fprintf(fp, "<li><a href=\"%sfile/%s.html\">Submodules</a></li>", relpath, + submodules); + if (readme) + fprintf(fp, "<li><a href=\"%sfile/%s.html\">README</a></li>", relpath, + readme); + if (license) + fprintf(fp, "<li><a href=\"%sfile/%s.html\">LICENSE</a></li>", relpath, + license); + + fputs("</ul>\n</nav>\n", fp); + fputs("<div id=\"repo-header\">\n", fp); + fputs("<div class=\"repo-title\">\n", fp); + fputs("<h1 class=\"post-title\">", fp); + xmlencode(fp, strippedname, strlen(strippedname)); + fputs("</h1>\n", fp); + fputs("<span class=\"desc\">", fp); + xmlencode(fp, description, strlen(description)); + fputs("</span>\n", fp); + fputs("</div>\n", fp); + if (cloneurl[0]) { + fputs("<div class=\"url\">git clone <a href=\"", fp); + xmlencode(fp, cloneurl, strlen(cloneurl)); + fputs("\">", fp); + xmlencode(fp, cloneurl, strlen(cloneurl)); + fputs("</a></div>\n", fp); + } + fputs("</div>\n", fp); + + fputs("<div id=\"content\">\n", fp); +} + +void writefooter(FILE *fp) { fputs("</div>\n</main>\n</body>\n</html>\n", fp); } + +/* Render file contents. */ +int custom_render(const char *filename, FILE *fp, const char *s, size_t len) { + FILE *input; + pid_t pid; + int status, lc = 0; + size_t i; + + if (!(input = tmpfile())) + err(1, "render input"); + if (fwrite(s, 1, len, input) != len || fflush(input) || fflush(fp)) + err(1, "render write"); + rewind(input); + if ((pid = fork()) == -1) + err(1, "render fork"); + if (!pid) { + if (dup2(fileno(input), STDIN_FILENO) == -1 || + dup2(fileno(fp), STDOUT_FILENO) == -1) + _exit(127); + execlp("render", "render", filename, (char *)NULL); + _exit(127); + } + while (waitpid(pid, &status, 0) == -1) + if (errno != EINTR) + err(1, "render waitpid"); + fclose(input); + if (!WIFEXITED(status) || WEXITSTATUS(status)) + errx(1, "render failed: %s", filename); + if (fseek(fp, 0, SEEK_END)) + err(1, "render output"); + for (i = 0; i < len; i++) + if (s[i] == '\n') + lc++; + if (len && s[len - 1] != '\n') + lc++; + return lc; +} + +int writeblobhtml(const char *filename, FILE *fp, const git_blob *blob) { + int lc = 0; + const char *s = git_blob_rawcontent(blob); + git_off_t len = git_blob_rawsize(blob); + + if (len > 0) + lc = custom_render(filename, fp, s, len); + + return lc; +} + +void printcommit(FILE *fp, struct commitinfo *ci) { + fprintf(fp, "<b>commit</b> <a href=\"%scommit/%s.html\">%s</a>\n", relpath, + ci->oid, ci->oid); + + if (ci->parentoid[0]) + fprintf(fp, "<b>parent</b> <a href=\"%scommit/%s.html\">%s</a>\n", relpath, + ci->parentoid, ci->parentoid); + + if (ci->author) { + fputs("<b>Author:</b> ", fp); + xmlencode(fp, ci->author->name, strlen(ci->author->name)); + fputs(" <<a href=\"mailto:", fp); + xmlencode(fp, ci->author->email, + strlen(ci->author->email)); /* not percent-encoded */ + fputs("\">", fp); + xmlencode(fp, ci->author->email, strlen(ci->author->email)); + fputs("</a>>\n<b>Date:</b> ", fp); + printtime(fp, &(ci->author->when)); + putc('\n', fp); + } + if (ci->msg) { + putc('\n', fp); + xmlencode(fp, ci->msg, strlen(ci->msg)); + putc('\n', fp); + } +} + +void printshowfile(FILE *fp, struct commitinfo *ci) { + const git_diff_delta *delta; + const git_diff_hunk *hunk; + const git_diff_line *line; + git_patch *patch; + size_t nhunks, nhunklines, changed, add, del, total, i, j, k; + char linestr[80]; + int c; + + printcommit(fp, ci); + + if (!ci->deltas) + return; + + if (ci->filecount > 1000 || ci->ndeltas > 1000 || ci->addcount > 100000 || + ci->delcount > 100000) { + fputs("Diff is too large, output suppressed.\n", fp); + return; + } + + /* diff stat */ + fputs("</pre><p><b>Diffstat:</b></p>\n<table id=\"diffstat\">", fp); + for (i = 0; i < ci->ndeltas; i++) { + delta = git_patch_get_delta(ci->deltas[i]->patch); + + switch (delta->status) { + case GIT_DELTA_ADDED: + c = 'A'; + break; + case GIT_DELTA_COPIED: + c = 'C'; + break; + case GIT_DELTA_DELETED: + c = 'D'; + break; + case GIT_DELTA_MODIFIED: + c = 'M'; + break; + case GIT_DELTA_RENAMED: + c = 'R'; + break; + case GIT_DELTA_TYPECHANGE: + c = 'T'; + break; + default: + c = ' '; + break; + } + if (c == ' ') + fprintf(fp, "<tr><td>%c", c); + else + fprintf(fp, "<tr><td class=\"%c\">%c", c, c); + + fprintf(fp, "</td><td><a href=\"#h%zu\">", i); + xmlencode(fp, delta->old_file.path, strlen(delta->old_file.path)); + if (strcmp(delta->old_file.path, delta->new_file.path)) { + fputs(" -> ", fp); + xmlencode(fp, delta->new_file.path, strlen(delta->new_file.path)); + } + + add = ci->deltas[i]->addcount; + del = ci->deltas[i]->delcount; + changed = add + del; + total = sizeof(linestr) - 2; + if (changed > total) { + if (add) + add = ((float)total / changed * add) + 1; + if (del) + del = ((float)total / changed * del) + 1; + } + memset(&linestr, '+', add); + memset(&linestr[add], '-', del); + + fprintf( + fp, + "</a></td><td> | </td><td class=\"num\">%zu</td><td><span class=\"i\">", + ci->deltas[i]->addcount + ci->deltas[i]->delcount); + fwrite(&linestr, 1, add, fp); + fputs("</span><span class=\"d\">", fp); + fwrite(&linestr[add], 1, del, fp); + fputs("</span></td></tr>\n", fp); + } + fprintf(fp, + "</table><pre>%zu file%s changed, %zu insertion%s(+), %zu " + "deletion%s(-)\n", + ci->filecount, ci->filecount == 1 ? "" : "s", ci->addcount, + ci->addcount == 1 ? "" : "s", ci->delcount, + ci->delcount == 1 ? "" : "s"); + + fputs("<hr/>", fp); + + for (i = 0; i < ci->ndeltas; i++) { + patch = ci->deltas[i]->patch; + delta = git_patch_get_delta(patch); + fprintf(fp, "<b>diff --git a/<a id=\"h%zu\" href=\"%sfile/", i, relpath); + percentencode(fp, delta->old_file.path, strlen(delta->old_file.path)); + fputs(".html\">", fp); + xmlencode(fp, delta->old_file.path, strlen(delta->old_file.path)); + fprintf(fp, "</a> b/<a href=\"%sfile/", relpath); + percentencode(fp, delta->new_file.path, strlen(delta->new_file.path)); + fprintf(fp, ".html\">"); + xmlencode(fp, delta->new_file.path, strlen(delta->new_file.path)); + fprintf(fp, "</a></b>\n"); + + /* check binary data */ + if (delta->flags & GIT_DIFF_FLAG_BINARY) { + fputs("Binary files differ.\n", fp); + continue; + } + + nhunks = git_patch_num_hunks(patch); + for (j = 0; j < nhunks; j++) { + if (git_patch_get_hunk(&hunk, &nhunklines, patch, j)) + break; + + fprintf(fp, "<a href=\"#h%zu-%zu\" id=\"h%zu-%zu\" class=\"h\">", i, j, i, + j); + xmlencode(fp, hunk->header, hunk->header_len); + fputs("</a>", fp); + + for (k = 0;; k++) { + if (git_patch_get_line_in_hunk(&line, patch, j, k)) + break; + if (line->old_lineno == -1) + fprintf(fp, + "<a href=\"#h%zu-%zu-%zu\" id=\"h%zu-%zu-%zu\" class=\"i\">+", + i, j, k, i, j, k); + else if (line->new_lineno == -1) + fprintf(fp, + "<a href=\"#h%zu-%zu-%zu\" id=\"h%zu-%zu-%zu\" class=\"d\">-", + i, j, k, i, j, k); + else + putc(' ', fp); + xmlencodeline(fp, line->content, line->content_len); + putc('\n', fp); + if (line->old_lineno == -1 || line->new_lineno == -1) + fputs("</a>", fp); + } + } + } +} + +void writelogline(FILE *fp, struct commitinfo *ci) { + fputs("<tr><td>", fp); + if (ci->author) + printtimeshort(fp, &(ci->author->when)); + fputs("</td><td>", fp); + if (ci->summary) { + fprintf(fp, "<a href=\"%scommit/%s.html\">", relpath, ci->oid); + xmlencode(fp, ci->summary, strlen(ci->summary)); + fputs("</a>", fp); + } + fputs("</td><td>", fp); + if (ci->author) + xmlencode(fp, ci->author->name, strlen(ci->author->name)); + fputs("</td><td class=\"num\" align=\"right\">", fp); + fprintf(fp, "%zu", ci->filecount); + fputs("</td><td class=\"num\" align=\"right\">", fp); + fprintf(fp, "+%zu", ci->addcount); + fputs("</td><td class=\"num\" align=\"right\">", fp); + fprintf(fp, "-%zu", ci->delcount); + fputs("</td></tr>\n", fp); +} + +size_t writelog(FILE *fp, const git_oid *oid) { + struct commitinfo *ci; + git_revwalk *w = NULL; + git_oid id; + char path[PATH_MAX], oidstr[GIT_OID_HEXSZ + 1]; + FILE *fpfile; + size_t remcommits = 0; + int r; + + git_revwalk_new(&w, repo); + git_revwalk_push(w, oid); + + while (!git_revwalk_next(&id, w)) { + relpath = ""; + + if (cachefile && !memcmp(&id, &lastoid, sizeof(id))) + break; + + git_oid_tostr(oidstr, sizeof(oidstr), &id); + r = snprintf(path, sizeof(path), "commit/%s.html", oidstr); + if (r < 0 || (size_t)r >= sizeof(path)) + errx(1, "path truncated: 'commit/%s.html'", oidstr); + r = access(path, F_OK); + + /* optimization: if there are no log lines to write and + the commit file already exists: skip the diffstat */ + if (!nlogcommits) { + remcommits++; + if (!r) + continue; + } + + if (!(ci = commitinfo_getbyoid(&id))) + break; + /* diffstat: for stagit HTML required for the log.html line */ + if (commitinfo_getstats(ci) == -1) + goto err; + + if (nlogcommits != 0) { + writelogline(fp, ci); + if (nlogcommits > 0) + nlogcommits--; + } + + if (cachefile) + writelogline(wcachefp, ci); + + /* check if file exists if so skip it */ + if (r) { + relpath = "../"; + fpfile = efopen(path, "w"); + writeheader(fpfile, ci->summary); + fputs("<pre>", fpfile); + printshowfile(fpfile, ci); + fputs("</pre>\n", fpfile); + writefooter(fpfile); + checkfileerror(fpfile, path, 'w'); + fclose(fpfile); + } + err: + commitinfo_free(ci); + } + git_revwalk_free(w); + + relpath = ""; + + return remcommits; +} + +void printcommitatom(FILE *fp, struct commitinfo *ci, const char *tag) { + fputs("<entry>\n", fp); + + fprintf(fp, "<id>%s</id>\n", ci->oid); + if (ci->author) { + fputs("<published>", fp); + printtimez(fp, &(ci->author->when)); + fputs("</published>\n", fp); + } + if (ci->committer) { + fputs("<updated>", fp); + printtimez(fp, &(ci->committer->when)); + fputs("</updated>\n", fp); + } + if (ci->summary) { + fputs("<title>", fp); + if (tag && tag[0]) { + fputs("[", fp); + xmlencode(fp, tag, strlen(tag)); + fputs("] ", fp); + } + xmlencode(fp, ci->summary, strlen(ci->summary)); + fputs("</title>\n", fp); + } + fprintf(fp, + "<link rel=\"alternate\" type=\"text/html\" " + "href=\"%scommit/%s.html\" />\n", + baseurl, ci->oid); + + if (ci->author) { + fputs("<author>\n<name>", fp); + xmlencode(fp, ci->author->name, strlen(ci->author->name)); + fputs("</name>\n<email>", fp); + xmlencode(fp, ci->author->email, strlen(ci->author->email)); + fputs("</email>\n</author>\n", fp); + } + + fputs("<content>", fp); + fprintf(fp, "commit %s\n", ci->oid); + if (ci->parentoid[0]) + fprintf(fp, "parent %s\n", ci->parentoid); + if (ci->author) { + fputs("Author: ", fp); + xmlencode(fp, ci->author->name, strlen(ci->author->name)); + fputs(" <", fp); + xmlencode(fp, ci->author->email, strlen(ci->author->email)); + fputs(">\nDate: ", fp); + printtime(fp, &(ci->author->when)); + putc('\n', fp); + } + if (ci->msg) { + putc('\n', fp); + xmlencode(fp, ci->msg, strlen(ci->msg)); + } + fputs("\n</content>\n</entry>\n", fp); +} + +int writeatom(FILE *fp, int all) { + struct referenceinfo *ris = NULL; + size_t refcount = 0; + struct commitinfo *ci; + git_revwalk *w = NULL; + git_oid id; + size_t i, m = 100; /* last 'm' commits */ + + fputs("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<feed xmlns=\"http://www.w3.org/2005/Atom\">\n<title>", + fp); + xmlencode(fp, strippedname, strlen(strippedname)); + fputs(", branch HEAD</title>\n<subtitle>", fp); + xmlencode(fp, description, strlen(description)); + fputs("</subtitle>\n", fp); + + /* all commits or only tags? */ + if (all) { + git_revwalk_new(&w, repo); + git_revwalk_push_head(w); + for (i = 0; i < m && !git_revwalk_next(&id, w); i++) { + if (!(ci = commitinfo_getbyoid(&id))) + break; + printcommitatom(fp, ci, ""); + commitinfo_free(ci); + } + git_revwalk_free(w); + } else if (getrefs(&ris, &refcount) != -1) { + /* references: tags */ + for (i = 0; i < refcount; i++) { + if (git_reference_is_tag(ris[i].ref)) + printcommitatom(fp, ris[i].ci, git_reference_shorthand(ris[i].ref)); + + commitinfo_free(ris[i].ci); + git_reference_free(ris[i].ref); + } + free(ris); + } + + fputs("</feed>\n", fp); + + return 0; +} + +size_t writeblob(git_object *obj, const char *fpath, const char *filename, + size_t filesize) { + char tmp[PATH_MAX] = "", *d; + const char *p; + size_t lc = 0; + FILE *fp; + + if (strlcpy(tmp, fpath, sizeof(tmp)) >= sizeof(tmp)) + errx(1, "path truncated: '%s'", fpath); + if (!(d = dirname(tmp))) + err(1, "dirname"); + if (mkdirp(d)) + return -1; + + for (p = fpath, tmp[0] = '\0'; *p; p++) { + if (*p == '/' && strlcat(tmp, "../", sizeof(tmp)) >= sizeof(tmp)) + errx(1, "path truncated: '../%s'", tmp); + } + relpath = tmp; + + fp = efopen(fpath, "w"); + writeheader(fp, filename); + fputs("<p> ", fp); + xmlencode(fp, filename, strlen(filename)); + fprintf(fp, " (%zuB)", filesize); + fputs("</p><hr/>", fp); + + if (git_blob_is_binary((git_blob *)obj)) + fputs("<p>Binary file.</p>\n", fp); + else + lc = writeblobhtml(filename, fp, (git_blob *)obj); + + writefooter(fp); + checkfileerror(fp, fpath, 'w'); + fclose(fp); + + relpath = ""; + + return lc; +} + +const char *filemode(git_filemode_t m) { + static char mode[11]; + + memset(mode, '-', sizeof(mode) - 1); + mode[10] = '\0'; + + if (S_ISREG(m)) + mode[0] = '-'; + else if (S_ISBLK(m)) + mode[0] = 'b'; + else if (S_ISCHR(m)) + mode[0] = 'c'; + else if (S_ISDIR(m)) + mode[0] = 'd'; + else if (S_ISFIFO(m)) + mode[0] = 'p'; + else if (S_ISLNK(m)) + mode[0] = 'l'; + else if (S_ISSOCK(m)) + mode[0] = 's'; + else + mode[0] = '?'; + + if (m & S_IRUSR) + mode[1] = 'r'; + if (m & S_IWUSR) + mode[2] = 'w'; + if (m & S_IXUSR) + mode[3] = 'x'; + if (m & S_IRGRP) + mode[4] = 'r'; + if (m & S_IWGRP) + mode[5] = 'w'; + if (m & S_IXGRP) + mode[6] = 'x'; + if (m & S_IROTH) + mode[7] = 'r'; + if (m & S_IWOTH) + mode[8] = 'w'; + if (m & S_IXOTH) + mode[9] = 'x'; + + if (m & S_ISUID) + mode[3] = (mode[3] == 'x') ? 's' : 'S'; + if (m & S_ISGID) + mode[6] = (mode[6] == 'x') ? 's' : 'S'; + if (m & S_ISVTX) + mode[9] = (mode[9] == 'x') ? 't' : 'T'; + + return mode; +} + +int writefilestree(FILE *fp, git_tree *tree, const char *path) { + const git_tree_entry *entry = NULL; + git_object *obj = NULL; + const char *entryname; + char filepath[PATH_MAX], entrypath[PATH_MAX], oid[8]; + size_t count, i, lc, filesize; + int r, ret; + + count = git_tree_entrycount(tree); + for (i = 0; i < count; i++) { + if (!(entry = git_tree_entry_byindex(tree, i)) || + !(entryname = git_tree_entry_name(entry))) + return -1; + joinpath(entrypath, sizeof(entrypath), path, entryname); + + r = snprintf(filepath, sizeof(filepath), "file/%s.html", entrypath); + if (r < 0 || (size_t)r >= sizeof(filepath)) + errx(1, "path truncated: 'file/%s.html'", entrypath); + + if (!git_tree_entry_to_object(&obj, repo, entry)) { + switch (git_object_type(obj)) { + case GIT_OBJ_BLOB: + break; + case GIT_OBJ_TREE: + /* NOTE: recurses */ + ret = writefilestree(fp, (git_tree *)obj, entrypath); + git_object_free(obj); + if (ret) + return ret; + continue; + default: + git_object_free(obj); + continue; + } + + filesize = git_blob_rawsize((git_blob *)obj); + lc = writeblob(obj, filepath, entryname, filesize); + + fputs("<tr><td>", fp); + fputs(filemode(git_tree_entry_filemode(entry)), fp); + fprintf(fp, "</td><td><a href=\"%s", relpath); + percentencode(fp, filepath, strlen(filepath)); + fputs("\">", fp); + xmlencode(fp, entrypath, strlen(entrypath)); + fputs("</a></td><td class=\"num\" align=\"right\">", fp); + if (lc > 0) + fprintf(fp, "%zuL", lc); + else + fprintf(fp, "%zuB", filesize); + fputs("</td></tr>\n", fp); + git_object_free(obj); + } else if (git_tree_entry_type(entry) == GIT_OBJ_COMMIT) { + /* commit object in tree is a submodule */ + fprintf(fp, + "<tr><td>m---------</td><td><a href=\"%sfile/.gitmodules.html\">", + relpath); + xmlencode(fp, entrypath, strlen(entrypath)); + fputs("</a> @ ", fp); + git_oid_tostr(oid, sizeof(oid), git_tree_entry_id(entry)); + xmlencode(fp, oid, strlen(oid)); + fputs("</td><td class=\"num\" align=\"right\"></td></tr>\n", fp); + } + } + + return 0; +} + +int writefiles(FILE *fp, const git_oid *id) { + git_tree *tree = NULL; + git_commit *commit = NULL; + int ret = -1; + + fputs("<table id=\"files\"><thead>\n<tr>" + "<td><b>Mode</b></td><td><b>Name</b></td>" + "<td class=\"num\" align=\"right\"><b>Size</b></td>" + "</tr>\n</thead><tbody>\n", + fp); + + if (!git_commit_lookup(&commit, repo, id) && !git_commit_tree(&tree, commit)) + ret = writefilestree(fp, tree, ""); + + fputs("</tbody></table>", fp); + + git_commit_free(commit); + git_tree_free(tree); + + return ret; +} + +int writerefs(FILE *fp) { + struct referenceinfo *ris = NULL; + struct commitinfo *ci; + size_t count, i, j, refcount; + const char *titles[] = {"Branches", "Tags"}; + const char *ids[] = {"branches", "tags"}; + const char *s; + + if (getrefs(&ris, &refcount) == -1) + return -1; + + for (i = 0, j = 0, count = 0; i < refcount; i++) { + if (j == 0 && git_reference_is_tag(ris[i].ref)) { + if (count) + fputs("</tbody></table><br/>\n", fp); + count = 0; + j = 1; + } + + /* print header if it has an entry (first). */ + if (++count == 1) { + fprintf(fp, + "<h2>%s</h2><table id=\"%s\">" + "<thead>\n<tr><td><b>Name</b></td>" + "<td><b>Last commit date</b></td>" + "<td><b>Author</b></td>\n</tr>\n" + "</thead><tbody>\n", + titles[j], ids[j]); + } + + ci = ris[i].ci; + s = git_reference_shorthand(ris[i].ref); + + fputs("<tr><td>", fp); + xmlencode(fp, s, strlen(s)); + fputs("</td><td>", fp); + if (ci->author) + printtimeshort(fp, &(ci->author->when)); + fputs("</td><td>", fp); + if (ci->author) + xmlencode(fp, ci->author->name, strlen(ci->author->name)); + fputs("</td></tr>\n", fp); + } + /* table footer */ + if (count) + fputs("</tbody></table><br/>\n", fp); + + for (i = 0; i < refcount; i++) { + commitinfo_free(ris[i].ci); + git_reference_free(ris[i].ref); + } + free(ris); + + return 0; +} + +void usage(char *argv0) { + fprintf(stderr, + "usage: %s [-c cachefile | -l commits] " + "[-u baseurl] [-n sitename] repodir\n", + argv0); + exit(1); +} + +int main(int argc, char *argv[]) { + git_object *obj = NULL; + const git_oid *head = NULL; + mode_t mask; + FILE *fp, *fpread; + char path[PATH_MAX], repodirabs[PATH_MAX + 1], *p; + char tmppath[64] = "cache.XXXXXXXXXXXX", buf[BUFSIZ]; + size_t n; + int i, fd; + size_t remcommits = 0; + + for (i = 1; i < argc; i++) { + if (argv[i][0] == '-' && strlen(argv[i]) != 2) + usage(argv[0]); + if (argv[i][0] != '-') { + if (repodir) + usage(argv[0]); + repodir = argv[i]; + } else if (argv[i][1] == 'c') { + if (nlogcommits > 0 || i + 1 >= argc) + usage(argv[0]); + cachefile = argv[++i]; + } else if (argv[i][1] == 'l') { + if (cachefile || i + 1 >= argc) + usage(argv[0]); + errno = 0; + nlogcommits = strtoll(argv[++i], &p, 10); + if (argv[i][0] == '\0' || *p != '\0' || nlogcommits <= 0 || errno) + usage(argv[0]); + } else if (argv[i][1] == 'u') { + if (i + 1 >= argc) + usage(argv[0]); + baseurl = argv[++i]; + } else if (argv[i][1] == 'n') { + if (i + 1 >= argc) + usage(argv[0]); + sitename = argv[++i]; + } else { + usage(argv[0]); + } + } + if (!repodir) + usage(argv[0]); + + if (!realpath(repodir, repodirabs)) + err(1, "realpath"); + + /* do not search outside the git repository: + GIT_CONFIG_LEVEL_APP is the highest level currently */ + git_libgit2_init(); + for (i = 1; i <= GIT_CONFIG_LEVEL_APP; i++) + git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, i, ""); + /* do not require the git repository to be owned by the current user */ + git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); + +#ifdef __OpenBSD__ + if (unveil(repodir, "r") == -1) + err(1, "unveil: %s", repodir); + if (unveil(".", "rwc") == -1) + err(1, "unveil: ."); + if (cachefile && unveil(cachefile, "rwc") == -1) + err(1, "unveil: %s", cachefile); + + if (cachefile) { + if (pledge("stdio rpath wpath cpath fattr", NULL) == -1) + err(1, "pledge"); + } else { + if (pledge("stdio rpath wpath cpath", NULL) == -1) + err(1, "pledge"); + } +#endif + + if (git_repository_open_ext(&repo, repodir, GIT_REPOSITORY_OPEN_NO_SEARCH, + NULL) < 0) { + fprintf(stderr, "%s: cannot open repository\n", argv[0]); + return 1; + } + + /* find HEAD */ + if (!git_revparse_single(&obj, repo, "HEAD")) + head = git_object_id(obj); + git_object_free(obj); + + /* use directory name as name */ + if ((name = strrchr(repodirabs, '/'))) + name++; + else + name = ""; + + /* strip .git suffix */ + if (!(strippedname = strdup(name))) + err(1, "strdup"); + if ((p = strrchr(strippedname, '.'))) + if (!strcmp(p, ".git")) + *p = '\0'; + + /* read description or .git/description */ + joinpath(path, sizeof(path), repodir, "description"); + if (!(fpread = fopen(path, "r"))) { + joinpath(path, sizeof(path), repodir, ".git/description"); + fpread = fopen(path, "r"); + } + if (fpread) { + if (!fgets(description, sizeof(description), fpread)) + description[0] = '\0'; + checkfileerror(fpread, path, 'r'); + fclose(fpread); + } + + /* read url or .git/url */ + joinpath(path, sizeof(path), repodir, "url"); + if (!(fpread = fopen(path, "r"))) { + joinpath(path, sizeof(path), repodir, ".git/url"); + fpread = fopen(path, "r"); + } + if (fpread) { + if (!fgets(cloneurl, sizeof(cloneurl), fpread)) + cloneurl[0] = '\0'; + checkfileerror(fpread, path, 'r'); + fclose(fpread); + cloneurl[strcspn(cloneurl, "\n")] = '\0'; + } + + /* check LICENSE */ + for (i = 0; i < LEN(licensefiles) && !license; i++) { + if (!git_revparse_single(&obj, repo, licensefiles[i]) && + git_object_type(obj) == GIT_OBJ_BLOB) + license = licensefiles[i] + strlen("HEAD:"); + git_object_free(obj); + } + + /* check README */ + for (i = 0; i < LEN(readmefiles) && !readme; i++) { + if (!git_revparse_single(&obj, repo, readmefiles[i]) && + git_object_type(obj) == GIT_OBJ_BLOB) + readme = readmefiles[i] + strlen("HEAD:"); + git_object_free(obj); + } + + if (!git_revparse_single(&obj, repo, "HEAD:.gitmodules") && + git_object_type(obj) == GIT_OBJ_BLOB) + submodules = ".gitmodules"; + git_object_free(obj); + + /* log for HEAD */ + fp = efopen("log.html", "w"); + relpath = ""; + mkdir("commit", S_IRWXU | S_IRWXG | S_IRWXO); + writeheader(fp, "Log"); + fputs( + "<table id=\"log\"><thead>\n<tr><td><b>Date</b></td>" + "<td><b>Commit message</b></td>" + "<td><b>Author</b></td><td class=\"num\" " + "align=\"right\"><b>Files</b></td>" + "<td class=\"num\" align=\"right\"><b>+</b></td>" + "<td class=\"num\" align=\"right\"><b>-</b></td></tr>\n</thead><tbody>\n", + fp); + + if (cachefile && head) { + /* read from cache file (does not need to exist) */ + if ((rcachefp = fopen(cachefile, "r"))) { + if (!fgets(lastoidstr, sizeof(lastoidstr), rcachefp)) + errx(1, "%s: no object id", cachefile); + if (git_oid_fromstr(&lastoid, lastoidstr)) + errx(1, "%s: invalid object id", cachefile); + } + + /* write log to (temporary) cache */ + if ((fd = mkstemp(tmppath)) == -1) + err(1, "mkstemp"); + if (!(wcachefp = fdopen(fd, "w"))) + err(1, "fdopen: '%s'", tmppath); + /* write last commit id (HEAD) */ + git_oid_tostr(buf, sizeof(buf), head); + fprintf(wcachefp, "%s\n", buf); + + remcommits = writelog(fp, head); + + if (rcachefp) { + /* append previous log to log.html and the new cache */ + while (!feof(rcachefp)) { + n = fread(buf, 1, sizeof(buf), rcachefp); + if (ferror(rcachefp)) + break; + if (fwrite(buf, 1, n, fp) != n || fwrite(buf, 1, n, wcachefp) != n) + break; + } + checkfileerror(rcachefp, cachefile, 'r'); + fclose(rcachefp); + } + checkfileerror(wcachefp, tmppath, 'w'); + fclose(wcachefp); + } else { + if (head) + remcommits = writelog(fp, head); + } + + fputs("</tbody></table>", fp); + + if (remcommits > 0) { + fprintf(fp, + "<div id=\"log-msg\">" + "%zu more commits remaining, fetch the repository" + "</div>\n", + remcommits); + } + + writefooter(fp); + checkfileerror(fp, "log.html", 'w'); + fclose(fp); + + /* files for HEAD */ + fp = efopen("files.html", "w"); + writeheader(fp, "Files"); + if (head) + writefiles(fp, head); + writefooter(fp); + checkfileerror(fp, "files.html", 'w'); + fclose(fp); + + /* summary page with branches and tags */ + fp = efopen("refs.html", "w"); + writeheader(fp, "Refs"); + writerefs(fp); + writefooter(fp); + checkfileerror(fp, "refs.html", 'w'); + fclose(fp); + + /* Atom feed */ + fp = efopen("atom.xml", "w"); + writeatom(fp, 1); + checkfileerror(fp, "atom.xml", 'w'); + fclose(fp); + + /* Atom feed for tags / releases */ + fp = efopen("tags.xml", "w"); + writeatom(fp, 0); + checkfileerror(fp, "tags.xml", 'w'); + fclose(fp); + + /* rename new cache file on success */ + if (cachefile && head) { + if (rename(tmppath, cachefile)) + err(1, "rename: '%s' to '%s'", tmppath, cachefile); + umask((mask = umask(0))); + if (chmod(cachefile, + (S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH) & + ~mask)) + err(1, "chmod: '%s'", cachefile); + } + + /* cleanup */ + git_repository_free(repo); + git_libgit2_shutdown(); + + return 0; +} diff --git a/site/git/stagit/strlcat.c b/site/git/stagit/strlcat.c @@ -0,0 +1,55 @@ +/* $OpenBSD: strlcat.c,v 1.15 2015/03/02 21:41:08 millert Exp $ */ + +/* + * Copyright (c) 1998, 2015 Todd C. Miller <Todd.Miller@courtesan.com> + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include <string.h> +#include <sys/types.h> + +#include "compat.h" + +/* + * Appends src to string dst of size dsize (unlike strncat, dsize is the + * full size of dst, not space left). At most dsize-1 characters + * will be copied. Always NUL terminates (unless dsize <= strlen(dst)). + * Returns strlen(src) + MIN(dsize, strlen(initial dst)). + * If retval >= dsize, truncation occurred. + */ +size_t strlcat(char *dst, const char *src, size_t dsize) { + const char *odst = dst; + const char *osrc = src; + size_t n = dsize; + size_t dlen; + + /* Find the end of dst and adjust bytes left but don't go past end. */ + while (n-- != 0 && *dst != '\0') + dst++; + dlen = dst - odst; + n = dsize - dlen; + + if (n-- == 0) + return (dlen + strlen(src)); + while (*src != '\0') { + if (n != 0) { + *dst++ = *src; + n--; + } + src++; + } + *dst = '\0'; + + return (dlen + (src - osrc)); /* count does not include NUL */ +} diff --git a/site/git/stagit/strlcpy.c b/site/git/stagit/strlcpy.c @@ -0,0 +1,50 @@ +/* $OpenBSD: strlcpy.c,v 1.12 2015/01/15 03:54:12 millert Exp $ */ + +/* + * Copyright (c) 1998, 2015 Todd C. Miller <Todd.Miller@courtesan.com> + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include <string.h> +#include <sys/types.h> + +#include "compat.h" + +/* + * Copy string src to buffer dst of size dsize. At most dsize-1 + * chars will be copied. Always NUL terminates (unless dsize == 0). + * Returns strlen(src); if retval >= dsize, truncation occurred. + */ +size_t strlcpy(char *dst, const char *src, size_t dsize) { + const char *osrc = src; + size_t nleft = dsize; + + /* Copy as many bytes as will fit. */ + if (nleft != 0) { + while (--nleft != 0) { + if ((*dst++ = *src++) == '\0') + break; + } + } + + /* Not enough room in dst, add NUL and traverse rest of src. */ + if (nleft == 0) { + if (dsize != 0) + *dst = '\0'; /* NUL-terminate dst */ + while (*src++) + ; + } + + return (src - osrc - 1); /* count does not include NUL */ +} diff --git a/site/git/style.css b/site/git/style.css @@ -1,707 +0,0 @@ -/* Fonts */ -@font-face { - font-family: "JetBrainsMono Nerd Font"; - font-display: swap; - src: url("/fonts/JetBrainsMono-Nerd-Regular.woff2") format("woff2"); - font-weight: normal; - font-style: normal; -} -@font-face { - font-family: "JetBrainsMono Nerd Font"; - font-display: swap; - src: url("/fonts/JetBrainsMono-Nerd-Bold.woff2") format("woff2"); - font-weight: bold; - font-style: normal; -} -@font-face { - font-family: "JetBrainsMono Nerd Font"; - font-display: swap; - src: url("/fonts/JetBrainsMono-Nerd-Italic.woff2") format("woff2"); - font-weight: normal; - font-style: italic; -} - -:root { - --font-main: "JetBrainsMono Nerd Font", Menlo, DejaVu Sans Mono, monospace; -} - -html { - box-sizing: border-box; - scroll-behavior: smooth; -} - -*, -*:after, -*:before { - box-sizing: inherit; -} - -body { - font-family: var(--font-main); - color: var(--fg); - background-color: var(--bg); - font-size: 1rem; - line-height: 1.54; - margin: 0; - padding: 0; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -webkit-text-size-adjust: 100%; -} - -a { - text-decoration: none; - color: var(--accent); - transition: color 0.2s; - &:hover { - text-decoration: underline; - color: var(--secondary); - } -} - -.container { - display: flex; - flex-direction: column; - max-width: 900px; - margin: 0 auto; - padding: 30px; - border-left: 1px solid var(--border); - border-right: 1px solid var(--border); - min-height: 100vh; -} - -@media (max-width: 900px) { - .container { - border: none; - max-width: 100%; - } -} - -@media (max-width: 684px) { - .container { - padding: 20px; - } - - body { - font-size: 1rem; - } -} - -/* Navigation Styling */ -.menu { - margin-top: 15px; -} - -.menu-inner { - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0; - padding: 0; -} - -.menu-inner li { - flex: 0 0 auto; -} - -.menu-inner li:not(:last-child) { - margin-right: 20px; -} - -.menu-inner li a { - font-weight: bold; - color: var(--fg); - text-decoration: underline; - text-underline-offset: 4px; - &:hover { - color: var(--secondary); - } -} - -/* Header */ -.header { - display: flex; - flex-direction: column; - position: relative; - margin-bottom: 2rem; - width: 100%; -} - -.header-inner { - align-items: center; - display: flex; - justify-content: space-between; - width: 100%; -} - -/* Bars */ -.header-logo { - display: flex; - flex: 1; - align-items: center; -} - -.header-logo::after { - content: ""; - display: block; - width: 100%; - height: 40px; - margin: 0px 0px 0px 10px; - background: linear-gradient( - 90deg, - var(--accent) 0% 12%, - transparent 12% 50%, - var(--secondary) 50% 62%, - transparent 62% - ); - background-position: left center; - background-repeat: repeat-x; - background-size: 40px 100%; -} - -/* Button */ -.header-logo a { - flex: 0 0 auto; - text-decoration: none; - font-weight: bold; - font-size: 1.2em; - color: var(--bg); - background-color: var(--accent); - padding: 5px 10px; - border-radius: 3px; - - &:hover { - color: var(--secondary); - text-decoration: none; - } - - &:focus { - outline: dotted 2px var(--fg); - } -} - -@media (max-width: 684px) { - .header-inner { - flex-direction: column; - align-items: flex-start; - } - - .header-logo { - width: 100%; - margin-bottom: 15px; - } - - .header-logo::after { - margin: 0px 15px 0px 10px; - } - - .menu-inner { - flex-direction: column; - width: 100%; - } - - .menu-inner li { - margin-right: 0; - margin-bottom: 10px; - padding: 5px 0; - border-bottom: 1px solid var(--bg-hover); - } -} - -/* Repo Header */ -#repo-header { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-subtle); -} - -.repo-title h1 { - margin: 0; - font-size: 1.5em; - line-height: 1.2; -} - -.repo-title .desc { - font-size: 0.9em; - margin-top: 0.2rem; - color: var(--fg-dim); -} - -#repo-header .url { - font-family: var(--font-main); - background-color: var(--bg-code); - padding: 0.5em 0.8em; - border-radius: 3px; - font-size: 0.9em; - white-space: nowrap; - border: 1px solid var(--border-subtle); -} - -@media (max-width: 684px) { - #repo-header .url { - overflow-x: auto; - } -} - -/* Tables */ -table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; - min-width: 800px; -} - -@media (max-width: 684px) { - table { - display: block; - overflow-x: auto; - width: 100%; - min-width: 0; - } -} - -td, -th { - padding: 0.25em 0.5em; - text-align: left; - border: 1px solid var(--bg); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -td:empty { - border: none; - background: transparent !important; -} - -tbody:not(#blob tbody) tr:hover td { - background-color: var(--bg-hover); -} - -th { - font-weight: bold; - border-bottom: 1px solid var(--border); - color: var(--accent); -} - -/* --- Page-specific sizing --- */ - -/* Index Page */ -#index td:nth-child(1) { - /* Name */ - width: 7em; - font-weight: bold; -} -#index td:nth-child(2) { - /* Description */ - width: auto; -} -#index td:nth-child(3) { - /* Owner */ - width: 5em; - text-align: center; -} -#index td:nth-child(4) { - /* Last Commit */ - width: 11em; -} - -/* Log Page */ -#log td:nth-child(1) { - /* Date */ - width: 11em; -} -#log td:nth-child(2) { - /* Message */ - width: auto; -} -#log td:nth-child(3) { - /* Author */ - width: 6em; -} -#log td:nth-child(4) { - /* Files */ - width: 5em; - text-align: right; -} -#log td:nth-child(5), -#log td:nth-child(6) { - /* +/- */ - width: 4em; - text-align: right; - text-overflow: clip; -} - -/* Files Page */ -#files td:nth-child(1) { - /* Mode */ - width: 8em; -} -#files td:nth-child(2) { - /* Name */ - width: auto; -} -#files td:nth-child(3) { - /* Size */ - width: 6em; - text-align: right; -} - -/* Refs Page */ -#branches td:nth-child(1), -#tags td:nth-child(1) { - /* Name */ - width: auto; -} -#branches td:nth-child(2), -#tags td:nth-child(2) { - /* Date */ - width: 11em; -} -#branches td:nth-child(3), -#tags td:nth-child(3) { - /* Author */ - width: 10em; -} - -/* Diffstat Table */ -#diffstat td:nth-child(1) { - /* Status */ - width: 2em; - text-align: center; - padding: 0.25em 0; -} -#diffstat td:nth-child(2) { - /* Filename */ - width: auto; -} -#diffstat td:nth-child(3) { - /* Separator */ - width: 2em; - text-align: center; - color: var(--fg-dim); -} -#diffstat td:nth-child(4) { - /* Changes */ - width: 3em; - text-align: right; - font-variant-numeric: tabular-nums; -} -#diffstat td:nth-child(5) { - /* +/- */ - width: auto; - color: var(--fg-dim); -} - -/* Code Blocks */ -pre { - font-family: var(--font-main); - padding: 0.8em; - overflow: auto; - background-color: var(--bg-code); - border: 1px solid var(--border-subtle); - border-radius: 2px; -} - -/* Diff (File View) */ -.i { - color: var(--diff-add); - &:hover { - color: var(--diff-add); - } -} -.d { - color: var(--diff-del); - &:hover { - color: var(--diff-del); - } -} -.h { - color: var(--diff-mod); - &:hover { - color: var(--diff-mod); - } -} -pre span.i, -pre span.d, -pre span.h { - font-weight: bold; -} - -/* Status (Log View) */ -.A { - color: var(--diff-add); - font-weight: bold; -} -.D { - color: var(--diff-del); - font-weight: bold; -} -.M { - color: var(--diff-mod); - font-weight: bold; -} -.R { - font-weight: bold; -} - -/* Blob View */ -#blob a { - color: var(--fg-dim); - &:hover { - color: var(--fg); - text-decoration: none; - } -} - -/* Metadata */ -.desc { - color: var(--fg-dim); - display: block; - margin-top: 0.2rem; -} - -/* Commits Remaining */ -#log-msg { - text-align: center; - padding: 1em 0.5em; - font-weight: bold; - color: var(--fg-dim); - border-top: 1px solid var(--border-subtle); -} - -/* --- Syntax Highlighting --- */ - -#blob table { - width: 100%; - border-collapse: collapse; - font-family: var(--font-main); - background-color: var(--bg-code); - border: 1px solid var(--border-subtle); - border-radius: 2px; - margin-top: 0; - display: table; - table-layout: fixed; -} - -@media (max-width: 684px) { - #blob table { - display: block; - overflow-x: auto; - } -} - -#blob td { - padding: 0 0.5em; - vertical-align: top; - line-height: 1.5; - border: none; -} - -/* Line Numbers */ -#blob td.linenos { - width: 4em; - text-align: right; - border-right: 1px solid var(--border-subtle); - background-color: var(--bg-hover); - color: var(--fg-dim); - user-select: none; - white-space: nowrap; -} - -#blob .highlight span[id^="line-"] { - display: block; - width: 100%; - &:hover { - background-color: var(--bg-hover); - } -} - -/* Code Content */ -#blob td.code { - width: 100%; - white-space: pre-wrap; - word-break: break-all; - padding-left: 1em; -} - -#blob td.linenos pre, -#blob td.code pre { - margin: 0; - padding: 0; - border: none; - background: transparent; - overflow: visible; - font-family: inherit; -} - -/* --- Syntax Colors --- */ -.highlight { - color: var(--fg); -} - -/* Comments */ -.highlight .c, -.highlight .ch, -.highlight .cm, -.highlight .cp, -.highlight .cpf, -.highlight .c1, -.highlight .cs { - color: var(--fg-dim); - font-style: italic; -} - -/* Keywords */ -.highlight .k, -.highlight .kd, -.highlight .kn, -.highlight .kp, -.highlight .kr, -.highlight .kt { - color: var(--accent-alpha-70); - font-weight: bold; -} - -/* Strings */ -.highlight .s, -.highlight .sa, -.highlight .sb, -.highlight .sc, -.highlight .dl, -.highlight .sd, -.highlight .s2, -.highlight .se, -.highlight .sh, -.highlight .si, -.highlight .sx, -.highlight .sr, -.highlight .s1, -.highlight .ss { - color: var(--fg); -} - -/* Numbers */ -.highlight .m, -.highlight .mb, -.highlight .mf, -.highlight .mh, -.highlight .mi, -.highlight .il, -.highlight .mo { - color: var(--accent-alpha-70); -} - -/* Types / Classes / Namespaces */ -.highlight .nc, -.highlight .no, -.highlight .nd, -.highlight .ne, -.highlight .nl { - color: var(--secondary); - font-weight: bold; -} - -/* Variables */ -.highlight .nv, -.highlight .vc, -.highlight .vg, -.highlight .vi { - color: var(--secondary); -} - -/* Functions */ -.highlight .nf, -.highlight .fm { - color: var(--secondary); -} - -/* Tags */ -.highlight .nt { - color: var(--accent-alpha-70); -} - -/* Attributes */ -.highlight .na { - color: var(--secondary); -} - -/* Operators */ -.highlight .o, -.highlight .ow { - color: var(--accent-alpha-70); -} - -/* Diffs */ -.highlight .gd { - color: var(--diff-del); -} -.highlight .gi { - color: var(--diff-add); -} - -/* -- Markdown render -- */ -#content h3 { - margin-top: 1em; - margin-bottom: 0.5em; - border-bottom: 1px solid var(--border-subtle); - padding-bottom: 0.2em; - font-style: oblique; -} - -/* GFM Alerts*/ -.admonition { - margin: 1.5em 0; - padding: 12px; - border-left: 3px solid var(--secondary); - background-color: var(--secondary-alpha-20); - border-radius: 0 3px 3px 0; - page-break-inside: avoid; - font-family: var(--font-main); -} - -.admonition .admonition-title { - margin: 0 0 8px 0; - color: var(--accent); - align-items: center; - display: flex; - font-size: 0.95em; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.admonition > p:last-child { - margin-bottom: 0; -} - -.admonition .admonition-title::before { - margin-right: 8px; - font-size: 1.2em; -} - -.admonition.note .admonition-title::before { - content: "ℹ"; -} - -.admonition.tip .admonition-title::before { - content: ""; -} - -.admonition.important .admonition-title::before { - content: "!"; -} - -.admonition.warning .admonition-title::before { - content: ""; -} - -.admonition.danger .admonition-title::before { - content: ""; -} diff --git a/site/git/theme.nix b/site/git/theme.nix @@ -1,72 +0,0 @@ -pkgs: let - style = builtins.readFile ./style.css; - mkTheme = name: colors: - with colors; - pkgs.writeText "style-${name}.css" '' - ${style} - :root { - --bg: ${bg}; - --fg: ${fg}; - --fg-dim: ${fgDim}; - --border: ${border}; - --border-subtle: rgba(255, 255, 255, 0.05); - - /* Theme Colors */ - --accent: ${accent}; - --accent-alpha-70: ${accentAlpha70}; - --secondary: ${secondary}; - --secondary-alpha-20: ${secondaryAlpha20}; - --secondary-alpha-70: ${secondaryAlpha70}; - --bg-hover: rgba(255, 255, 255, 0.03); - --bg-code: rgba(0, 0, 0, 0.2); - --diff-add: ${diffAdd}; - --diff-del: ${diffDel}; - --diff-mod: ${diffMod}; - } - ''; -in { - red = mkTheme "red" { - bg = "#221f29"; - fg = "whitesmoke"; - fgDim = "#888888"; - border = "rgba(255,255,255,0.1)"; - accent = "#ff6266"; - accentAlpha70 = "rgba(255, 98, 102, 0.7)"; - secondary = "#23b0ff"; - secondaryAlpha20 = "rgba(35, 176, 255, 0.2)"; - secondaryAlpha70 = "rgba(35, 176, 255, 0.7)"; - diffAdd = "#23b0ff"; - diffDel = "#ff6266"; - diffMod = "#e59c19"; - }; - - blue = mkTheme "blue" { - bg = "#1d1e28"; - fg = "whitesmoke"; - fgDim = "#888888"; - border = "rgba(255,255,255,0.1)"; - accent = "#23b0ff"; - accentAlpha70 = "rgba(35, 176, 255, 0.7)"; - secondary = "#ee72f1"; - secondaryAlpha20 = "rgba(238, 114, 241, 0.2)"; - secondaryAlpha70 = "rgba(238, 114, 241, 0.7)"; - diffAdd = "#23b0ff"; - diffDel = "#ee72f1"; - diffMod = "#e59c19"; - }; - - black = mkTheme "black" { - bg = "#1d1d1d"; - fg = "whitesmoke"; - fgDim = "#bbbbbb"; - border = "rgba(255,255,255,0.1)"; - accent = "#7b7b7b"; - accentAlpha70 = "rgba(123, 123, 123, 0.7)"; - secondary = "#e59c19"; - secondaryAlpha20 = "rgba(229, 156, 25, 0.2)"; - secondaryAlpha70 = "rgba(229, 156, 25, 0.7)"; - diffAdd = "#e59c19"; - diffDel = "#7b7b7b"; - diffMod = "#ffffff"; - }; -} diff --git a/site/git/wrangler.toml b/site/git/wrangler.toml @@ -1,11 +1,8 @@ name = "site-git" -main = "index.js" compatibility_date = "2025-12-15" [assets] -binding = "ASSETS" directory = "./public" -run_worker_first = ["/style.css"] [env.production] routes = [ diff --git a/site/sass/_syntax.scss b/site/sass/_syntax.scss @@ -29,6 +29,13 @@ } // Comments +.highlight .c, +.highlight .ch, +.highlight .cm, +.highlight .cp, +.highlight .cpf, +.highlight .c1, +.highlight .cs, .z-comment { color: var(--color); opacity: 0.6; @@ -36,6 +43,14 @@ } // Keywords +.highlight .k, +.highlight .kd, +.highlight .kn, +.highlight .kp, +.highlight .kr, +.highlight .kt, +.highlight .o, +.highlight .ow, .z-keyword { color: var(--accent-alpha-70); } @@ -55,6 +70,13 @@ } // Constants +.highlight .m, +.highlight .mb, +.highlight .mf, +.highlight .mh, +.highlight .mi, +.highlight .il, +.highlight .mo, .z-constant { color: var(--accent-alpha-70); } @@ -65,11 +87,32 @@ } // Strings +.highlight .s, +.highlight .sa, +.highlight .sb, +.highlight .sc, +.highlight .dl, +.highlight .sd, +.highlight .s2, +.highlight .se, +.highlight .sh, +.highlight .si, +.highlight .sx, +.highlight .sr, +.highlight .s1, +.highlight .ss, .z-string { color: var(--color); } -.z-string.z-comment { +.z-string.highlight .c, +.highlight .ch, +.highlight .cm, +.highlight .cp, +.highlight .cpf, +.highlight .c1, +.highlight .cs, +.z-comment { color: var(--color); opacity: 0.6; } @@ -79,6 +122,10 @@ } // Variables +.highlight .nv, +.highlight .vc, +.highlight .vg, +.highlight .vi, .z-variable { color: var(--accent-secondary); font-weight: bold; @@ -111,21 +158,37 @@ color: var(--accent-secondary-alpha-70); } +.highlight .nc, +.highlight .no, +.highlight .nd, +.highlight .ne, +.highlight .nl, +.highlight .nf, +.highlight .fm, .z-entity.z-name { color: var(--accent-secondary-alpha-70); font-weight: bold; } +.highlight .nt, .z-entity.z-name.z-tag { color: var(--accent-alpha-70); font-weight: normal; } -.z-entity.z-name.z-constant { +.z-entity.z-name.highlight .m, +.highlight .mb, +.highlight .mf, +.highlight .mh, +.highlight .mi, +.highlight .il, +.highlight .mo, +.z-constant { color: var(--accent-secondary-alpha-70); font-weight: bold; } +.highlight .na, .z-entity.z-other.z-attribute-name { color: var(--accent-secondary); font-style: italic; @@ -136,11 +199,22 @@ color: var(--accent-alpha-70); } -.z-support.z-constant { +.z-support.highlight .m, +.highlight .mb, +.highlight .mf, +.highlight .mh, +.highlight .mi, +.highlight .il, +.highlight .mo, +.z-constant { color: var(--accent-alpha-70); } -.z-support.z-variable { +.z-support.highlight .nv, +.highlight .vc, +.highlight .vg, +.highlight .vi, +.z-variable { color: var(--accent-secondary); } @@ -150,11 +224,32 @@ } // Punctuation -.z-punctuation.z-definition.z-string { +.z-punctuation.z-definition.highlight .s, +.highlight .sa, +.highlight .sb, +.highlight .sc, +.highlight .dl, +.highlight .sd, +.highlight .s2, +.highlight .se, +.highlight .sh, +.highlight .si, +.highlight .sx, +.highlight .sr, +.highlight .s1, +.highlight .ss, +.z-string { color: var(--color); } -.z-punctuation.z-definition.z-comment { +.z-punctuation.z-definition.highlight .c, +.highlight .ch, +.highlight .cm, +.highlight .cp, +.highlight .cpf, +.highlight .c1, +.highlight .cs, +.z-comment { color: var(--color); opacity: 0.6; } @@ -164,7 +259,11 @@ font-weight: bold; } -.z-punctuation.z-definition.z-variable { +.z-punctuation.z-definition.highlight .nv, +.highlight .vc, +.highlight .vg, +.highlight .vi, +.z-variable { color: var(--accent-secondary-alpha-70); } @@ -245,10 +344,12 @@ text-decoration: line-through; } +.highlight .gd, .z-markup.z-deleted { color: var(--accent); } +.highlight .gi, .z-markup.z-inserted { color: var(--accent-secondary); } diff --git a/site/sass/color.scss b/site/sass/color.scss @@ -0,0 +1,3 @@ +@import "color/red"; +@import "color/blue"; +@import "color/black"; diff --git a/site/sass/color/black.scss b/site/sass/color/black.scss @@ -1,4 +1,4 @@ -:root { +:root[data-theme="black"] { --accent: rgb(123, 123, 123); --accent-alpha-20: rgba(123, 123, 123, 0.2); --accent-alpha-70: rgba(123, 123, 123, 0.7); diff --git a/site/sass/color/blue.scss b/site/sass/color/blue.scss @@ -1,4 +1,4 @@ -:root { +:root[data-theme="blue"] { --accent: rgb(35, 176, 255); --accent-alpha-20: rgba(35, 176, 255, 0.2); --accent-alpha-70: rgba(35, 176, 255, 0.7); diff --git a/site/sass/color/red.scss b/site/sass/color/red.scss @@ -1,4 +1,5 @@ -:root { +:root, +:root[data-theme="red"] { --accent: rgb(255, 98, 102); --accent-alpha-20: rgba(255, 98, 102, 0.2); --accent-alpha-70: rgba(255, 98, 102, 0.7); diff --git a/site/sass/git.scss b/site/sass/git.scss @@ -0,0 +1,103 @@ +.git-site { + .git-content { + display: block; + } + #content, + #repo-header { + min-width: 0; + } + .repo-menu { + margin-top: 20px; + ul { + display: flex; + flex-wrap: wrap; + gap: 10px 20px; + list-style: none; + margin: 0; + } + a { + font-weight: bold; + } + } + #repo-header .url { + overflow-wrap: anywhere; + margin: 15px 0; + } + .desc { + display: block; + margin-top: 10px; + opacity: 0.7; + } + .table-scroll { + max-width: 100%; + overflow: auto; + margin: 20px 0; + } + table { + table-layout: auto; + overflow-wrap: normal; + margin: 0; + } + th { + white-space: nowrap; + } + td, + th { + text-align: left; + vertical-align: top; + } + .num { + text-align: right; + white-space: nowrap; + } + #log td:first-child, + #index td:last-child { + white-space: nowrap; + } + #files td:nth-child(2), + #log td:nth-child(2) { + min-width: 16ch; + } + pre { + font: inherit; + font-size: 0.95rem; + padding: 15px 10px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 3px; + } + #blob pre { + background: var(--accent-alpha-20); + } + #blob .linenos a, + #blob .linenos { + opacity: 0.8; + } + #blob .linenos { + margin-right: 1ch; + } + .highlight { + color: var(--color); + } + .highlight pre { + line-height: 1.54; + } + .i, + .A { + color: var(--accent-secondary); + } + .d, + .D { + color: var(--accent); + } + .h, + .M { + color: var(--accent-secondary-alpha-70); + } + :target { + scroll-margin-top: 15px; + } + pre :target { + background: var(--accent-secondary-alpha-20); + } +} diff --git a/site/static/js/git-math.js b/site/static/js/git-math.js @@ -0,0 +1,24 @@ +(() => { + const elements = document.querySelectorAll(".markdown .arithmatex"); + if (!elements.length) return; + const css = document.createElement("link"); + css.rel = "stylesheet"; + css.href = "/math/katex.css"; + document.head.appendChild(css); + const script = document.createElement("script"); + script.src = "/js/katex.js"; + script.onload = () => { + for (const element of elements) { + const text = element.textContent.trim(); + const displayMode = text.startsWith("\\["); + const delimited = displayMode || text.startsWith("\\("); + window.katex.render(delimited ? text.slice(2, -2) : text, element, { + displayMode, + throwOnError: false, + trust: false, + }); + if (displayMode) element.tabIndex = 0; + } + }; + document.head.appendChild(script); +})(); diff --git a/site/static/js/load.js b/site/static/js/load.js @@ -1,2 +1,2 @@ -// The HTML is ready when this deferred script runs +// Loading Indicator document.querySelector(".loading")?.remove(); diff --git a/site/static/js/theme.js b/site/static/js/theme.js @@ -1,44 +1,30 @@ (() => { const selector = document.getElementById("theme-select"); - let current = document.getElementById("site-theme"); - if (!selector || !current) return; + if (!selector) return; + const root = document.documentElement; const allowed = new Set([...selector.options].map((option) => option.value)); - let pending; - function changeTheme(theme) { + function applyTheme(theme) { if (!allowed.has(theme)) return; - if (pending) { - pending.remove(); - pending = null; - } - if (theme === current.dataset.theme) { - selector.value = theme; - return; - } - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = new URL(`${theme}.css`, current.href).href; - link.dataset.theme = theme; - pending = link; - link.onload = () => { - if (pending !== link) return; - current.replaceWith(link); - link.id = "site-theme"; - current = link; - pending = null; - selector.value = theme; - document.cookie = `theme_color=${theme}; path=/; max-age=31536000; SameSite=Lax`; - }; - link.onerror = () => { - if (pending !== link) return; - link.remove(); - pending = null; - selector.value = current.dataset.theme; - }; - document.head.appendChild(link); + root.dataset.theme = theme; + selector.value = theme; } - selector.addEventListener("change", () => changeTheme(selector.value)); - const saved = document.cookie.match(/(?:^|;\s*)theme_color=([^;]*)/); - if (saved && allowed.has(saved[1])) changeTheme(saved[1]); + selector.addEventListener("change", () => { + const theme = selector.value; + applyTheme(theme); + const domain = selector.dataset.cookieDomain; + const shared = + domain && + (location.hostname === domain || + location.hostname.endsWith(`.${domain}`)); + const secure = location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `site_theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure}${shared ? `; Domain=${domain}` : ""}`; + }); + + applyTheme(root.dataset.theme); + window.addEventListener("pageshow", () => { + const saved = document.cookie.match(/(?:^|;\s*)site_theme=([^;]*)/); + if (saved) applyTheme(saved[1]); + }); })(); diff --git a/site/templates/base.html b/site/templates/base.html @@ -9,7 +9,9 @@ squiggle -%} {% import "macros/edit.html" as edit -%} {% import "macros/javascript.html" as js -%} <!doctype html> -<html lang="en"> +<html + lang="en" + data-theme="{{ config.extra.theme_color | default(value='red') }}"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> @@ -70,6 +72,6 @@ squiggle -%} {% import "macros/edit.html" as edit -%} {% import </footer> {%- endblock footer -%} </div> - {{ js::script() }} + {% block javascript %}{{ js::script() }}{% endblock javascript %} </body> </html> diff --git a/site/templates/git-shell.html b/site/templates/git-shell.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} {% block title %}<title> + Git · {{ config.title }}</title +>{% endblock title %} {% block general_meta %}{% endblock general_meta %} {% +block analytics %}{% endblock analytics %} {% block header %}{{ +header::header(config=config, current_url="", search=false, navigation=false) +}}{% endblock header %} {% block main %}{% endblock main %} {% block pagination +%}{% endblock pagination %} {% block javascript %} +<script defer src="{{ get_url(path='js/load.js') }}"></script> +<script defer src="{{ get_url(path='js/theme.js') }}"></script> +{% endblock javascript %} {% block css %}{{ super() }} +<link rel="stylesheet" href="/git.css" /> +{% endblock css %} {% block copyright %}{{ footer::copyright(config=config, +generator="Stagit", generator_url="https://git.codemadness.org/stagit/") }}{% +endblock copyright %} {% block script %}{% endblock script %} diff --git a/site/templates/macros/footer.html b/site/templates/macros/footer.html @@ -1,12 +1,13 @@ -{% macro copyright(config) %} +{% macro copyright(config, generator="Zola", +generator_url="https://getzola.org/") %} <div class="copyright"> <span> © {{ now() | date(format="%Y") }} {{ config.extra.copyright | safe}} </span> <span> - Powered by {{ url::external(url="https://getzola.org/", text="Zola", - icon=false) }} :: Site {{ url::external(url=config.extra.repo_url, - text="Source", icon=false) }} + Powered by {{ url::external(url=generator_url, text=generator, icon=false) + }} :: Site {{ url::external(url=config.extra.repo_url, text="Source", + icon=false) }} </span> </div> {% endmacro copyright %} {% macro theme(config) %} @@ -14,7 +15,9 @@ <div class="theme_before"></div> <div class="theme-content"> <label for="theme-select">Theme:</label> - <select id="theme-select"> + <select + id="theme-select" + data-cookie-domain="{{ config.extra.theme_cookie_domain | default(value='') }}"> {% for theme in config.extra.color_schemes %} {% if config.extra.theme_color %} {% set default_theme = config.extra.theme_color %} {% else %} {% set default_theme = "red" %} {% diff --git a/site/templates/macros/head.html b/site/templates/macros/head.html @@ -14,21 +14,17 @@ get_url(path='fonts.css', trailing_slash=false) }}" /> rel="stylesheet" href="{{ get_url(path='style.css', trailing_slash=false) }}" /> -{% if config.extra.theme_color -%} {% set color = "color/" -~config.extra.theme_color ~ ".css" -%} +<script> + (() => { + const theme = document.cookie.match(/(?:^|;\s*)site_theme=([^;]*)/); + if (theme && {{ config.extra.color_schemes | json_encode | safe }}.includes(theme[1])) + document.documentElement.dataset.theme = theme[1]; + })(); +</script> <link - id="site-theme" - data-theme="{{ config.extra.theme_color }}" rel="stylesheet" - href="{{ get_url(path=color, trailing_slash=false) }}" /> -{%- else -%} -<link - id="site-theme" - data-theme="red" - rel="stylesheet" - href="{{ get_url(path='color/red.css', -trailing_slash=false) }}" /> -{% endif %} {% endmacro styling %} {% macro feed_discovery(path) %} + href="{{ get_url(path='color.css', trailing_slash=false) }}" /> +{% endmacro styling %} {% macro feed_discovery(path) %} <link rel="alternate" type="application/rss+xml" diff --git a/site/templates/macros/header.html b/site/templates/macros/header.html @@ -1,8 +1,9 @@ {% import "macros/menu.html" as menu -%} {% import "macros/logo.html" as logo --%} {% macro header(config, current_url) %} +-%} {% macro header(config, current_url, search=true, navigation=true) %} <header class="header"> <div class="header-inner"> <div class="header-logo">{{ logo::logo(config=config) }}</div> + {% if navigation %} <button type="button" class="menu-trigger menu-trigger-button" @@ -10,9 +11,10 @@ aria-expanded="false"> menu </button> + {% endif %} </div> - {{ menu::menu(config=config, current_url=current_url) }} {% if - config.build_search_index %} + {% if navigation %}{{ menu::menu(config=config, current_url=current_url) }}{% + endif %} {% if config.build_search_index and search %} <div class="search-container js-only" role="search"> <input class="input" diff --git a/site/templates/macros/social.html b/site/templates/macros/social.html @@ -1,7 +1,9 @@ {% macro socials_bar() %} <div class="socials-bar"> {%- if config.generate_feeds -%} - <a href="{{ config.extra.feed_path }}" style="text-decoration: none"> + <a + href="{{ get_url(path=config.extra.feed_path) }}" + style="text-decoration: none"> {%- set rss_image = resize_image(path="../static/social/rss.png", width=33, height=33, op="scale", format="png") -%} {%- set rss_image_2x = resize_image(path="../static/social/rss.png", width=66, height=66, diff --git a/site/zola.toml b/site/zola.toml @@ -53,6 +53,7 @@ index_format = "fuse_json" # Site Theme theme_color = "red" color_schemes = ["black", "blue", "red"] +theme_cookie_domain = "maydayv7.dev" # Enable Math Typesetting with KaTeX # Use 'math=true' in [extra] on individual pages