commit 8b82bdd5902988f2880fbeae4168060eb59c5c21
author: xprillz <xprillz@proton.me>
date: 2026-08-26 12:50
parents: cd2f58e8
Add repo about blocks, public URL config, and README HTML preview. Show GitHub-style description and homepage on repo pages, support configurable public_url for clone commands, BSD 2-Clause license, and live HTML previews in README files. Co-authored-by: Cursor <cursoragent@cursor.com>
| M | LICENSE | +19 | -16 |
| M | README.md | +2 | -2 |
| M | config.toml | +35 | -40 |
| M | pyproject.toml | +1 | -1 |
| M | xgit/cli.py | +1 | -1 |
| M | xgit/config.py | +25 | -3 |
| M | xgit/fetch.py | +12 | -9 |
| M | xgit/generate.py | +116 | -51 |
| M | xgit/gitutil.py | +36 | -0 |
| M | xgit/htmlutil.py | +99 | -6 |
| M | xgit/markdown.py | +133 | -14 |
| M | xgit/serve.py | +33 | -2 |
| M | xgit/static/app.js | +15 | -1 |
| M | xgit/static/style.css | +98 | -0 |
diff --git a/LICENSE b/LICENSE index 71fadc1..ef32e8e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,24 @@ -MIT License +BSD 2-Clause License Copyright (c) 2026 polymath -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 7cd72e8..26ebde8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # xgit **Author:** [polymath](mailto:polymath@localhost) -**License:** MIT +**License:** BSD 2-Clause `xgit` is its own standalone Python project: a static git page generator (inspired by [stagit](https://git.codemadness.org/stagit/)). Point it at git @@ -177,4 +177,4 @@ xgit serve ## License -MIT © polymath +BSD 2-Clause © polymath diff --git a/config.toml b/config.toml index 6274c8e..694fe27 100644 --- a/config.toml +++ b/config.toml @@ -1,65 +1,60 @@ # xgit — site config (author: polymath) # # From repo root: -# ./demo clean seed + build + serve -# ./sync seed + fetch + build +# ./sync fetch + build # ./build generate html/ # ./serve build + serve on :3121 site_name = "xgit" site_description = "static git hosting pages — by polymath" owner = "polymath" -base_url = "http://127.0.0.1:3121" -clone_base = "http://127.0.0.1:3121" + +# Public URL shown in git clone commands and Atom feeds. +# Set this to your domain or IP (with scheme, no trailing slash). +public_url = "https://kb.hax.al" + +# Optional legacy alias (public_url takes precedence): +# clone_base = "https://kb.hax.al" +# base_url = "https://kb.hax.al" repos_dir = "data/repos" html_dir = "html" max_commits = 200 max_diff_bytes = 1048576 -# ── Demo repositories (local, created by ./demo or ./sync) ───────────── - [[repos]] name = "xgit" url = "path:." description = "this project — static git page generator by polymath" +homepage = "https://kb.hax.al" owner = "polymath" [[repos]] -name = "hello" -url = "local:hello" -description = "first demo — Python greeting + Markdown docs" -owner = "polymath" +name = "dotfiles" +url = "https://github.com/hedho/dotfiles.git" +description = "hedho dotfiles" +owner = "hedho" [[repos]] -name = "notes" -url = "local:notes" -description = "second demo — tiny notes app sketch (JS/CSS/JSON)" -owner = "polymath" +name = "pdf2html" +url = "https://github.com/hedho/pdf2html.git" +description = "pdf to html converter" +owner = "hedho" -# ── Add your own remotes (uncomment / copy) ──────────────────────────── -# -# HTTPS: -# [[repos]] -# name = "myproject" -# url = "https://github.com/you/myproject.git" -# description = "my project description" -# owner = "polymath" -# -# Git protocol: -# [[repos]] -# name = "sbase" -# url = "git://git.suckless.org/sbase" -# description = "suckless unix tools" -# owner = "suckless" -# -# SSH (needs your keys / agent): -# [[repos]] -# name = "private-app" -# url = "git@github.com:you/private-app.git" -# description = "private work" -# owner = "polymath" -# -# After editing, run: -# ./sync -# ./serve --no-build +[[repos]] +name = "nexus" +url = "https://github.com/hedho/nexus.git" +description = "nexus" +owner = "hedho" + +[[repos]] +name = "KISSmoDocker" +url = "https://github.com/hedho/KISSmoDocker.git" +description = "KISSmo Docker setup" +owner = "hedho" + +[[repos]] +name = "KISSmoPerl" +url = "https://github.com/hedho/KISSmoPerl.git" +description = "KISSmo Perl" +owner = "hedho" diff --git a/pyproject.toml b/pyproject.toml index 2ed0939..cf1fd41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "Python static git page generator with auto-fetch (stagit-inspired)" readme = "README.md" requires-python = ">=3.10" -license = { text = "MIT" } +license = { text = "BSD-2-Clause" } authors = [{ name = "polymath" }] dependencies = [] diff --git a/xgit/cli.py b/xgit/cli.py index 144d89e..542ef4d 100644 --- a/xgit/cli.py +++ b/xgit/cli.py @@ -91,7 +91,7 @@ def main(argv: list[str] | None = None) -> int: fetch_all(cfg) print("building static pages…") generate_all(cfg) - serve(cfg.html_dir, host=args.host, port=args.port) + serve(cfg.html_dir, host=args.host, port=args.port, public_url=cfg.clone_origin()) else: parser.error(f"unknown command {args.cmd}") except Exception as e: diff --git a/xgit/config.py b/xgit/config.py index 04632bc..16fdcf5 100644 --- a/xgit/config.py +++ b/xgit/config.py @@ -13,6 +13,7 @@ class RepoConfig: name: str url: str description: str = "" + homepage: str = "" owner: str = "" @@ -22,7 +23,8 @@ class Config: site_description: str = "static git page generator" owner: str = "" base_url: str = "http://127.0.0.1:3121" - clone_base: str = "http://127.0.0.1:3121" + public_url: str = "" # domain/IP shown in git clone commands + clone_base: str = "" # alias for public_url (legacy) repos_dir: Path = Path("data/repos") html_dir: Path = Path("html") max_commits: int = 200 @@ -36,6 +38,13 @@ class Config: def html_repo_dir(self, name: str) -> Path: return self.html_dir / name + def clone_origin(self) -> str: + """Base URL used in git clone commands and Atom feeds.""" + for candidate in (self.public_url, self.clone_base, self.base_url): + if candidate: + return candidate.rstrip("/") + return "http://127.0.0.1:3121" + def _as_path(root: Path, value: str | Path) -> Path: p = Path(value) @@ -61,16 +70,29 @@ def load_config(path: Path) -> Config: name=name, url=url, description=str(entry.get("description") or ""), + homepage=str( + entry.get("homepage") + or entry.get("website") + or entry.get("demo") + or entry.get("homepage_url") + or "" + ), owner=str(entry.get("owner") or data.get("owner") or ""), ) ) + public_url = str( + data.get("public_url") or data.get("clone_base") or data.get("base_url") or "" + ).rstrip("/") + base_url = str(data.get("base_url") or public_url or "http://127.0.0.1:3121").rstrip("/") + return Config( site_name=str(data.get("site_name") or "xgit"), site_description=str(data.get("site_description") or ""), owner=str(data.get("owner") or ""), - base_url=str(data.get("base_url") or "http://127.0.0.1:3121").rstrip("/"), - clone_base=str(data.get("clone_base") or data.get("base_url") or "http://127.0.0.1:3121").rstrip("/"), + base_url=base_url, + public_url=public_url, + clone_base=public_url, repos_dir=_as_path(root, data.get("repos_dir") or "data/repos"), html_dir=_as_path(root, data.get("html_dir") or "html"), max_commits=int(data.get("max_commits") or 200), diff --git a/xgit/fetch.py b/xgit/fetch.py index 95bb2bb..d23351f 100644 --- a/xgit/fetch.py +++ b/xgit/fetch.py @@ -31,8 +31,7 @@ def fetch_one(cfg: Config, repo: RepoConfig) -> Path: else: if seed_dir.exists(): _mirror_from_workdir(seed_dir, dest) - if repo.description: - gitutil.set_description(dest, repo.description) + _apply_repo_metadata(dest, repo) return dest path_src = _local_git_path(cfg, repo.url) @@ -44,8 +43,7 @@ def fetch_one(cfg: Config, repo: RepoConfig) -> Path: raise gitutil.GitError(f"refusing to mirror {path_src} onto itself") print(f" mirroring {path_src}") _mirror_from_workdir(path_src, dest) - if repo.description: - gitutil.set_description(dest, repo.description) + _apply_repo_metadata(dest, repo) return dest if dest.exists() and gitutil.is_git_repo(dest): @@ -56,9 +54,15 @@ def fetch_one(cfg: Config, repo: RepoConfig) -> Path: print(f" cloning {repo.url}") gitutil.clone_mirror(repo.url, dest) + _apply_repo_metadata(dest, repo) + return dest + + +def _apply_repo_metadata(dest: Path, repo: RepoConfig) -> None: if repo.description: gitutil.set_description(dest, repo.description) - return dest + if repo.homepage: + gitutil.set_homepage(dest, repo.homepage) def _local_git_path(cfg: Config, url: str) -> Path | None: @@ -108,10 +112,9 @@ def seed_hello(cfg: Config) -> Path: "| Refs | branches & tags |\n" ), "LICENSE": ( - "MIT License\n\nCopyright (c) 2026 polymath\n\n" - "Permission is hereby granted, free of charge, to any person obtaining a copy " - "of this software and associated documentation files (the \"Software\"), to deal " - "in the Software without restriction.\n" + "BSD 2-Clause License\n\nCopyright (c) 2026 polymath\n\n" + "Redistribution and use in source and binary forms, with or without " + "modification, are permitted provided that the following conditions are met.\n" ), "hello.py": ( '#!/usr/bin/env python3\n"""Say hello."""\n\n\n' diff --git a/xgit/generate.py b/xgit/generate.py index 33fc808..948a688 100644 --- a/xgit/generate.py +++ b/xgit/generate.py @@ -10,7 +10,7 @@ from xgit.config import Config, RepoConfig from xgit import gitutil from xgit import highlight as hl from xgit import markdown as md -from xgit.htmlutil import author_link, esc, fmt_atom, fmt_date, human_size, page, repo_nav +from xgit.htmlutil import author_link, esc, fmt_atom, fmt_date, homepage_label, human_size, page, repo_nav from xgit.stats import SiteStats @@ -70,8 +70,9 @@ def generate_repo(cfg: Config, repo_cfg: RepoConfig, site: SiteStats) -> dict: branches = gitutil.list_branches(repo) tags = gitutil.list_tags(repo) description = repo_cfg.description or gitutil.read_description(repo) + homepage = _resolve_homepage(repo_cfg, repo, special) owner = repo_cfg.owner or cfg.owner - clone_url = f"{cfg.clone_base}/{repo_cfg.name}" + clone_repo = repo_cfg.name nav_kwargs = dict( name=repo_cfg.name, @@ -91,27 +92,30 @@ def generate_repo(cfg: Config, repo_cfg: RepoConfig, site: SiteStats) -> dict: ) author_prefix = "../" - _write_log(cfg, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, author_prefix) - _write_files(cfg, repo_cfg, out, tree, description, clone_url, nav_kwargs) - _write_refs(cfg, repo_cfg, out, branches, tags, description, clone_url, nav_kwargs) + _write_log( + cfg, repo_cfg, out, commits, stats_by_hash, description, homepage, clone_repo, nav_kwargs, author_prefix + ) + _write_files(cfg, repo_cfg, out, tree, description, homepage, clone_repo, nav_kwargs) + _write_refs(cfg, repo_cfg, out, branches, tags, description, homepage, clone_repo, nav_kwargs) _write_commits( - cfg, repo, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, "../../" + cfg, repo, repo_cfg, out, commits, stats_by_hash, description, homepage, clone_repo, nav_kwargs, "../../" ) - _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kwargs, special) + _write_blobs(cfg, repo, repo_cfg, out, paths, description, homepage, clone_repo, nav_kwargs, special) _write_atom(cfg, repo_cfg, out, commits, description) _write_tags_atom(cfg, repo_cfg, out, tags, description) (out / "index.html").write_text( - page( - title=f"{repo_cfg.name} - {description}" if description else repo_cfg.name, - site_name=cfg.site_name, + _repo_page( + cfg, + repo_cfg, + title=f"{repo_cfg.name}" + (f" - {description}" if description else ""), nav=repo_nav(**nav_kwargs, active="log"), body='<p>Redirecting to <a href="log.html">log</a>…</p>\n' '<meta http-equiv="refresh" content="0; url=log.html"/>\n', css_href="../style.css", - clone_url=clone_url, - subtitle=description or None, - site_nav_active="repos", + clone_repo=clone_repo, + description=description, + homepage=homepage, ), encoding="utf-8", ) @@ -120,6 +124,7 @@ def generate_repo(cfg: Config, repo_cfg: RepoConfig, site: SiteStats) -> dict: print(f" generated {repo_cfg.name} ({len(commits)} commits, {len(paths)} files)") return { "description": description, + "homepage": homepage, "owner": owner, "last_commit": last, "branch": branch, @@ -132,16 +137,66 @@ def _copy_assets(cfg: Config) -> None: shutil.copyfile(static / "app.js", cfg.html_dir / "app.js") +def _resolve_homepage(repo_cfg: RepoConfig, repo: Path, special: dict[str, str]) -> str: + if repo_cfg.homepage.strip(): + return repo_cfg.homepage.strip() + stored = gitutil.read_homepage(repo) + if stored: + return stored + readme = special.get("readme") + if not readme: + return "" + try: + raw = gitutil.show_blob(repo, "HEAD", readme) + return gitutil.detect_homepage_from_readme(raw.decode("utf-8", errors="replace")) + except Exception: + return "" + + +def _repo_page( + cfg: Config, + repo_cfg: RepoConfig, + *, + title: str, + nav: str, + body: str, + css_href: str, + clone_repo: str, + description: str, + homepage: str, +) -> str: + return page( + title=title, + site_name=cfg.site_name, + nav=nav, + body=body, + css_href=css_href, + heading=repo_cfg.name, + repo_description=description or None, + repo_homepage=homepage or None, + clone_repo=clone_repo, + public_url=cfg.clone_origin(), + site_nav_active="repos", + ) + + def _index_row(cfg: Config, repo_cfg: RepoConfig, meta: dict, site: SiteStats) -> str: last = meta.get("last_commit") date = fmt_date(last.author_date) if last else "" desc = meta.get("description") or "" + homepage = meta.get("homepage") or "" + desc_html = esc(desc) + if homepage: + desc_html += ( + f' <a class="repo-homepage-inline" href="{esc(homepage)}" ' + f'target="_blank" rel="noopener noreferrer">{esc(homepage_label(homepage))}</a>' + ) owner = meta.get("owner") or "" owner_html = _owner_link(owner, site, repo_cfg.name) return ( "<tr>" f'<td><a href="{esc(repo_cfg.name)}/log.html">{esc(repo_cfg.name)}</a></td>' - f'<td class="desc">{esc(desc)}</td>' + f'<td class="desc">{desc_html}</td>' f"<td>{owner_html}</td>" f"<td>{esc(date)}</td>" "</tr>\n" @@ -182,7 +237,7 @@ def _match_owner_profile(owner: str, site: SiteStats, repo_name: str): def _write_log( - cfg, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, author_prefix + cfg, repo_cfg, out, commits, stats_by_hash, description, homepage, clone_repo, nav_kwargs, author_prefix ) -> None: rows = [] for c in commits: @@ -209,21 +264,22 @@ def _write_log( + "</tbody>\n</table>\n" ) (out / "log.html").write_text( - page( + _repo_page( + cfg, + repo_cfg, title=f"Log - {repo_cfg.name}" + (f" - {description}" if description else ""), - site_name=cfg.site_name, nav=repo_nav(**nav_kwargs, active="log"), body=body, css_href="../style.css", - clone_url=clone_url, - subtitle=description or None, - site_nav_active="repos", + clone_repo=clone_repo, + description=description, + homepage=homepage, ), encoding="utf-8", ) -def _write_files(cfg, repo_cfg, out, tree, description, clone_url, nav_kwargs) -> None: +def _write_files(cfg, repo_cfg, out, tree, description, homepage, clone_repo, nav_kwargs) -> None: rows = [] for typ, path, size in tree: if typ != "blob": @@ -249,21 +305,22 @@ def _write_files(cfg, repo_cfg, out, tree, description, clone_url, nav_kwargs) - + "</tbody>\n</table>\n" ) (out / "files.html").write_text( - page( + _repo_page( + cfg, + repo_cfg, title=f"Files - {repo_cfg.name}" + (f" - {description}" if description else ""), - site_name=cfg.site_name, nav=repo_nav(**nav_kwargs, active="files"), body=body, css_href="../style.css", - clone_url=clone_url, - subtitle=description or None, - site_nav_active="repos", + clone_repo=clone_repo, + description=description, + homepage=homepage, ), encoding="utf-8", ) -def _write_refs(cfg, repo_cfg, out, branches, tags, description, clone_url, nav_kwargs) -> None: +def _write_refs(cfg, repo_cfg, out, branches, tags, description, homepage, clone_repo, nav_kwargs) -> None: b_rows = [] for r in branches: b_rows.append( @@ -295,22 +352,23 @@ def _write_refs(cfg, repo_cfg, out, branches, tags, description, clone_url, nav_ f"<tbody>\n{''.join(t_rows)}</tbody>\n</table>\n" ) (out / "refs.html").write_text( - page( + _repo_page( + cfg, + repo_cfg, title=f"Refs - {repo_cfg.name}" + (f" - {description}" if description else ""), - site_name=cfg.site_name, nav=repo_nav(**nav_kwargs, active="refs"), body=body, css_href="../style.css", - clone_url=clone_url, - subtitle=description or None, - site_nav_active="repos", + clone_repo=clone_repo, + description=description, + homepage=homepage, ), encoding="utf-8", ) def _write_commits( - cfg, repo, repo_cfg, out, commits, stats_by_hash, description, clone_url, nav_kwargs, author_prefix + cfg, repo, repo_cfg, out, commits, stats_by_hash, description, homepage, clone_repo, nav_kwargs, author_prefix ) -> None: for c in commits: dest = out / "commit" / f"{c.hash}.html" @@ -354,15 +412,16 @@ def _write_commits( + "</table>\n" + diff_html ) - html = page( + html = _repo_page( + cfg, + repo_cfg, title=f"Commit {c.short} - {repo_cfg.name}", - site_name=cfg.site_name, nav=repo_nav(**nav_kwargs, active=""), body=body, css_href="../../style.css", - clone_url=clone_url, - subtitle=description or None, - site_nav_active="repos", + clone_repo=clone_repo, + description=description, + homepage=homepage, ) html = html.replace('href="log.html"', 'href="../log.html"') html = html.replace('href="files.html"', 'href="../files.html"') @@ -371,7 +430,7 @@ def _write_commits( dest.write_text(html, encoding="utf-8") -def _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kwargs, special) -> None: +def _write_blobs(cfg, repo, repo_cfg, out, paths, description, homepage, clone_repo, nav_kwargs, special) -> None: for path in paths: page_name = _file_page_name(path) assert page_name is not None @@ -410,7 +469,7 @@ def _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kw "<pre>Binary file</pre>\n" ) dest.write_text( - _file_page(cfg, repo_cfg, nav, body, css_href, clone_url, description, path), + _file_page(cfg, repo_cfg, nav, body, css_href, clone_repo, description, homepage), encoding="utf-8", ) continue @@ -427,7 +486,9 @@ def _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kw if as_markdown: primary_label = "Markdown" - primary_body = f'<article class="markdown">\n{md.render(text)}\n</article>\n' + is_readme = special.get("readme") == path + rendered = md.render(text, html_preview=is_readme) + primary_body = f'<article class="markdown">\n{rendered}\n</article>\n' mode_primary = "md" elif as_code: primary_label = "Code" @@ -451,7 +512,7 @@ def _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kw f"{primary_body}" ) dest.write_text( - _file_page(cfg, repo_cfg, nav, body, css_href, clone_url, description, path), + _file_page(cfg, repo_cfg, nav, body, css_href, clone_repo, description, homepage), encoding="utf-8", ) @@ -475,24 +536,28 @@ def _write_blobs(cfg, repo, repo_cfg, out, paths, description, clone_url, nav_kw nav, raw_body, css_href, - clone_url, + clone_repo, description, - f"{path} · raw", + homepage, ), encoding="utf-8", ) -def _file_page(cfg, repo_cfg, nav, body, css_href, clone_url, description, heading) -> str: +def _file_page( + cfg, repo_cfg, nav, body, css_href, clone_repo, description, homepage +) -> str: return page( - title=f"{heading} - {repo_cfg.name}", + title=f"{repo_cfg.name}", site_name=cfg.site_name, nav=nav, body=body, css_href=css_href, - clone_url=clone_url, - subtitle=description or None, - heading=heading, + heading=repo_cfg.name, + repo_description=description or None, + repo_homepage=homepage or None, + clone_repo=clone_repo, + public_url=cfg.clone_origin(), site_nav_active="repos", ) @@ -729,7 +794,7 @@ def _overall_summary_html(site: SiteStats, *, prefix: str) -> str: def _write_atom(cfg, repo_cfg, out, commits, description) -> None: - base = f"{cfg.base_url}/{repo_cfg.name}" + base = f"{cfg.clone_origin()}/{repo_cfg.name}" entries = [] for c in commits[:100]: entries.append( @@ -761,7 +826,7 @@ def _write_atom(cfg, repo_cfg, out, commits, description) -> None: def _write_tags_atom(cfg, repo_cfg, out, tags, description) -> None: - base = f"{cfg.base_url}/{repo_cfg.name}" + base = f"{cfg.clone_origin()}/{repo_cfg.name}" entries = [] for t in tags[:100]: entries.append( diff --git a/xgit/gitutil.py b/xgit/gitutil.py index 6ebb251..28cb1ee 100644 --- a/xgit/gitutil.py +++ b/xgit/gitutil.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re import subprocess from dataclasses import dataclass from datetime import datetime, timezone @@ -132,6 +133,41 @@ def set_description(repo: Path, description: str) -> None: path.write_text(description + "\n", encoding="utf-8") +def read_homepage(repo: Path) -> str: + path = repo / "homepage" + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="replace").strip() + if text: + return text + try: + return run_git(["config", "--get", "xgit.homepage"], cwd=repo).strip() + except GitError: + return "" + + +def set_homepage(repo: Path, homepage: str) -> None: + homepage = homepage.strip() + if not homepage: + return + (repo / "homepage").write_text(homepage + "\n", encoding="utf-8") + run_git(["config", "xgit.homepage", homepage], cwd=repo) + + +def detect_homepage_from_readme(text: str) -> str: + """Find demo/website links commonly used in README files.""" + patterns = ( + r"^\s*(?:demo|live\s+demo|website|homepage|site)\s*:\s*(https?://\S+)", + r"\[(?:live\s+)?demo[^\]]*\]\((https?://[^)]+)\)", + r"\[(?:website|homepage|site|web)[^\]]*\]\((https?://[^)]+)\)", + r"^\s*-\s*\*\*(?:demo|website|homepage)\*\*:\s*(https?://\S+)", + ) + for pat in patterns: + match = re.search(pat, text, re.I | re.M) + if match: + return match.group(1).rstrip(").,") + return "" + + def list_commits(repo: Path, ref: str = "HEAD", limit: int = 200) -> list[CommitInfo]: fmt = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%aI%x00%cn%x00%cI%x00%P%x1e" out = run_git( diff --git a/xgit/htmlutil.py b/xgit/htmlutil.py index e622a74..2ee56de 100644 --- a/xgit/htmlutil.py +++ b/xgit/htmlutil.py @@ -3,8 +3,42 @@ from __future__ import annotations import html +import re from datetime import datetime +_CLONE_CMD_RE = re.compile( + r'(<div class="clone" data-clone-repo="([^"]+)">\s*' + r'<span class="clone-label">clone</span>\s*)' + r'<code id="clone-cmd">[^<]*</code>', + re.MULTILINE, +) + + +def inject_clone_origin(page_html: str, origin: str) -> str: + """Replace clone placeholder with git clone URL for the request origin.""" + origin = origin.rstrip("/") + + def repl(match: re.Match[str]) -> str: + prefix, repo = match.group(1), match.group(2) + cmd = f"git clone {origin}/{repo}" + return f'{prefix}<code id="clone-cmd">{html.escape(cmd)}</code>' + + return _CLONE_CMD_RE.sub(repl, page_html) + + +def request_origin( + *, + host: str | None, + forwarded_host: str | None = None, + forwarded_proto: str | None = None, +) -> str: + """Build http(s)://host from incoming request headers.""" + host = (forwarded_host or host or "localhost").split(",")[0].strip() + proto = (forwarded_proto or "http").split(",")[0].strip().lower() + if proto not in ("http", "https"): + proto = "http" + return f"{proto}://{host}" + def esc(value: object) -> str: return html.escape("" if value is None else str(value), quote=True) @@ -40,6 +74,45 @@ def human_size(n: int | None) -> str: return f"{n}B" +def homepage_label(url: str) -> str: + url = url.strip() + for prefix in ("https://", "http://"): + if url.lower().startswith(prefix): + return url[len(prefix) :].rstrip("/") + return url + + +def repo_about(*, description: str | None = None, homepage: str | None = None) -> str: + """GitHub-style repository description and website link.""" + if not description and not homepage: + return "" + parts: list[str] = [] + if description: + parts.append(f'<p class="repo-desc">{esc(description)}</p>') + if homepage: + href = _safe_homepage_url(homepage) + label = homepage_label(homepage) + parts.append( + f'<a class="repo-homepage" href="{esc(href)}" ' + f'target="_blank" rel="noopener noreferrer">' + f'<span class="repo-homepage-icon" aria-hidden="true">' + f'<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">' + f'<path d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/>' + f'<path d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/>' + f"</svg></span>{esc(label)}</a>" + ) + return f'<div class="repo-about">\n{"".join(parts)}\n</div>\n' + + +def _safe_homepage_url(url: str) -> str: + url = url.strip() + if re.match(r"^https?://", url, re.I): + return url + if url.startswith("/") or re.match(r"^[\w.-]+\.[a-z]{2,}", url, re.I): + return f"https://{url.lstrip('/')}" + return "#" + + def page( *, title: str, @@ -51,19 +124,39 @@ def page( subtitle: str | None = None, heading: str | None = None, site_nav_active: str = "", + clone_repo: str | None = None, + public_url: str | None = None, + repo_description: str | None = None, + repo_homepage: str | None = None, ) -> str: index_href = _index_href(css_href) root = _root_prefix(css_href) head = heading if heading is not None else (title.split(" - ")[0] if " - " in title else title) - sub = f'<p class="subtitle">{esc(subtitle)}</p>' if subtitle else "" + about_block = repo_about(description=repo_description, homepage=repo_homepage) + sub = ( + f'<p class="subtitle">{esc(subtitle)}</p>' + if subtitle and not about_block + else "" + ) + repo = clone_repo if clone_repo is not None else ( + clone_url.rsplit("/", 1)[-1] if clone_url else None + ) clone_block = "" - if clone_url: - cmd = f"git clone {clone_url}" - clone_block = f"""<div class="clone"> + if repo: + if public_url: + cmd = f"git clone {public_url.rstrip('/')}/{repo}" + clone_block = f"""<div class="clone"> <span class="clone-label">clone</span> <code id="clone-cmd">{esc(cmd)}</code> <button type="button" class="copy-btn" data-copy-target="#clone-cmd" aria-label="Copy clone command">Copy</button> </div> +""" + else: + clone_block = f"""<div class="clone" data-clone-repo="{esc(repo)}"> + <span class="clone-label">clone</span> + <code id="clone-cmd">git clone …/{esc(repo)}</code> + <button type="button" class="copy-btn" data-copy-target="#clone-cmd" aria-label="Copy clone command">Copy</button> +</div> """ site_nav = _site_nav(root, site_nav_active) js_href = f"{root}app.js" @@ -87,7 +180,7 @@ def page( <a class="brand" href="{esc(index_href)}"> <span class="brand-mark" aria-hidden="true"></span> <span class="brand-text">{esc(site_name)}</span> - <span class="brand-tag">polymath</span> + <span class="brand-tag">simple git</span> </a> <div class="heading-block"> <h1 class="page-title">{esc(head)}</h1> @@ -95,7 +188,7 @@ def page( </div> </header> {site_nav} - {clone_block}{nav} + {about_block}{clone_block}{nav} <main id="content"> {body} </main> diff --git a/xgit/markdown.py b/xgit/markdown.py index 59566c5..e76daf4 100644 --- a/xgit/markdown.py +++ b/xgit/markdown.py @@ -3,6 +3,9 @@ Covers common GitHub-flavored-ish constructs used in READMEs: headings, emphasis, links, images, fenced/indented code, lists, blockquotes, hr, tables, autolinks, and paragraphs. + +README pages can enable ``html_preview`` to render embedded HTML and +show live previews for fenced ``html`` code blocks. """ from __future__ import annotations @@ -13,6 +16,21 @@ import re _MD_EXTS = {".md", ".markdown", ".mdown", ".mkd", ".mdx"} +_HTML_BLOCK_RE = re.compile( + r"^\s*<\/?(?:address|article|aside|blockquote|details|div|dl|fieldset|" + r"figcaption|figure|footer|form|h[1-6]|header|hr|main|nav|ol|p|pre|section|" + r"table|ul|center|span|a|img|br|sub|sup|kbd|video|audio|svg|math|iframe)\b", + re.I, +) +_VOID_TAG_RE = re.compile( + r"^\s*<(br|hr|img|meta|link|input|source|area|base|col|embed|param|track|wbr)\b", + re.I, +) +_SCRIPT_RE = re.compile(r"<script\b[^>]*>.*?</script>", re.I | re.S) +_ON_ATTR_RE = re.compile( + r'\s+on\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)', re.I +) + def is_markdown_path(path: str) -> bool: lower = path.lower() @@ -41,7 +59,7 @@ def looks_like_markdown(text: str) -> bool: return any(re.search(p, sample, re.M) for p in patterns) -def render(text: str) -> str: +def render(text: str, *, html_preview: bool = False) -> str: text = text.replace("\r\n", "\n").replace("\r", "\n") if text.startswith("\ufeff"): text = text[1:] @@ -49,6 +67,7 @@ def render(text: str) -> str: out: list[str] = [] i = 0 n = len(lines) + allow_html = html_preview while i < n: line = lines[i] @@ -67,10 +86,25 @@ def render(text: str) -> str: break code_lines.append(lines[i]) i += 1 + raw = "\n".join(code_lines) cls = f' class="language-{html.escape(lang)}"' if lang else "" - out.append( - f"<pre><code{cls}>{html.escape(chr(10).join(code_lines))}</code></pre>" - ) + code_block = f"<pre><code{cls}>{html.escape(raw)}</code></pre>" + if html_preview and lang.lower() in ("html", "htm"): + preview = _sanitize_html_block(raw) + out.append( + code_block + + '\n<div class="html-preview-wrap">' + + '<div class="html-preview-label">Preview</div>' + + f'<div class="html-preview">{preview}</div></div>' + ) + else: + out.append(code_block) + continue + + # raw HTML blocks (README preview only) + if html_preview and _looks_like_html_block(line): + block, i = _collect_html_block(lines, i) + out.append(_sanitize_html_block(block)) continue # hr @@ -83,7 +117,7 @@ def render(text: str) -> str: hm = re.match(r"^(#{1,6})\s+(.*)$", line) if hm: level = len(hm.group(1)) - content = _inline(hm.group(2).rstrip("#").rstrip()) + content = _inline(hm.group(2).rstrip("#").rstrip(), allow_html=allow_html) slug = _slug(hm.group(2)) out.append(f'<h{level} id="{html.escape(slug)}">{content}</h{level}>') i += 1 @@ -95,7 +129,7 @@ def render(text: str) -> str: while i < n and lines[i].startswith(">"): quote_lines.append(re.sub(r"^>\s?", "", lines[i])) i += 1 - inner = render("\n".join(quote_lines)) + inner = render("\n".join(quote_lines), html_preview=html_preview) out.append(f"<blockquote>{inner}</blockquote>") continue @@ -107,12 +141,16 @@ def render(text: str) -> str: while i < n and "|" in lines[i] and lines[i].strip(): rows.append(_split_table_row(lines[i])) i += 1 - thead = "".join(f"<th>{_inline(c)}</th>" for c in header) + thead = "".join(f"<th>{_inline(c, allow_html=allow_html)}</th>" for c in header) body_rows = [] for row in rows: # pad/truncate to header width cells = (row + [""] * len(header))[: len(header)] - body_rows.append("<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in cells) + "</tr>") + body_rows.append( + "<tr>" + + "".join(f"<td>{_inline(c, allow_html=allow_html)}</td>" for c in cells) + + "</tr>" + ) out.append( "<table class=\"md-table\"><thead><tr>" + thead @@ -124,7 +162,7 @@ def render(text: str) -> str: # unordered / ordered list if re.match(r"^(\s*)([-*+]|\d+\.)\s+", line): - i = _render_list(lines, i, out) + i = _render_list(lines, i, out, allow_html=allow_html) continue # blank @@ -145,11 +183,84 @@ def render(text: str) -> str: break para.append(lines[i].rstrip()) i += 1 - out.append(f"<p>{_inline(' '.join(para))}</p>") + out.append(f"<p>{_inline(' '.join(para), allow_html=allow_html)}</p>") return "\n".join(out) +def _looks_like_html_block(line: str) -> bool: + s = line.lstrip() + if not s.startswith("<"): + return False + if s.startswith("<!--"): + return True + return bool(_HTML_BLOCK_RE.match(s)) + + +def _collect_html_block(lines: list[str], start: int) -> tuple[str, int]: + i = start + n = len(lines) + block_lines = [lines[i]] + first = lines[i].strip() + i += 1 + + if first.endswith("/>") or _VOID_TAG_RE.match(first): + return "\n".join(block_lines), i + + open_m = re.match(r"^<(\w+)\b", first.lstrip()) + if not open_m: + while i < n and lines[i].strip() and not _is_markdown_break(lines[i]): + block_lines.append(lines[i]) + i += 1 + return "\n".join(block_lines), i + + tag = open_m.group(1).lower() + if first.lower().startswith(f"</{tag}"): + return "\n".join(block_lines), i + + open_pat = re.compile(rf"<\s*{re.escape(tag)}\b", re.I) + close_pat = re.compile(rf"<\s*/\s*{re.escape(tag)}\s*>", re.I) + depth = len(open_pat.findall(first)) - len(close_pat.findall(first)) + if depth <= 0: + return "\n".join(block_lines), i + + while i < n and depth > 0: + if _is_markdown_break(lines[i]): + break + block_lines.append(lines[i]) + depth += len(open_pat.findall(lines[i])) + depth -= len(close_pat.findall(lines[i])) + i += 1 + return "\n".join(block_lines), i + + +def _is_markdown_break(line: str) -> bool: + if not line.strip(): + return True + if re.match(r"^(`{3,}|~{3,})", line): + return True + if re.match(r"^(#{1,6})\s+", line): + return True + if re.match(r"^(\*{3,}|-{3,}|_{3,})\s*$", line): + return True + if line.startswith(">"): + return True + if re.match(r"^(\s*)([-*+]|\d+\.)\s+", line): + return True + return False + + +def _sanitize_html_block(block: str) -> str: + block = _SCRIPT_RE.sub("", block) + return _ON_ATTR_RE.sub("", block) + + +def _sanitize_html_tag(tag: str) -> str: + if re.match(r"<\s*script", tag, re.I): + return html.escape(tag) + return _ON_ATTR_RE.sub("", tag) + + def _split_table_row(line: str) -> list[str]: line = line.strip() if line.startswith("|"): @@ -159,7 +270,7 @@ def _split_table_row(line: str) -> list[str]: return [c.strip() for c in line.split("|")] -def _render_list(lines: list[str], start: int, out: list[str]) -> int: +def _render_list(lines: list[str], start: int, out: list[str], *, allow_html: bool = False) -> int: i = start n = len(lines) first = re.match(r"^(\s*)([-*+]|\d+\.)\s+", lines[i]) @@ -184,9 +295,9 @@ def _render_list(lines: list[str], start: int, out: list[str]) -> int: nested = "" if i < n and re.match(r"^(\s+)([-*+]|\d+\.)\s+", lines[i]): nest_out: list[str] = [] - i = _render_list(lines, i, nest_out) + i = _render_list(lines, i, nest_out, allow_html=allow_html) nested = "".join(nest_out) - items.append(f"<li>{_inline(' '.join(item_bits))}{nested}</li>") + items.append(f"<li>{_inline(' '.join(item_bits), allow_html=allow_html)}{nested}</li>") out.append(f"<{tag}>{''.join(items)}</{tag}>") return i @@ -199,8 +310,16 @@ def _slug(text: str) -> str: return text or "section" -def _inline(text: str) -> str: +def _inline(text: str, *, allow_html: bool = False) -> str: """Process inline markdown. Order matters.""" + if allow_html and ("<" in text and ">" in text): + parts = re.split(r"(<[^>]+>)", text) + return "".join( + _sanitize_html_tag(part) if part.startswith("<") and part.endswith(">") else _inline(part) + for part in parts + if part + ) + # Extract code spans first to protect them placeholders: list[str] = [] diff --git a/xgit/serve.py b/xgit/serve.py index c1f13ca..9c21d9d 100644 --- a/xgit/serve.py +++ b/xgit/serve.py @@ -3,18 +3,25 @@ from __future__ import annotations import functools +import os from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from xgit.htmlutil import inject_clone_origin, request_origin -def serve(html_dir: Path, host: str = "0.0.0.0", port: int = 3121) -> None: + +def serve(html_dir: Path, host: str = "0.0.0.0", port: int = 3121, public_url: str = "") -> None: html_dir = html_dir.resolve() if not html_dir.is_dir(): raise SystemExit(f"html dir missing: {html_dir} (run sync first)") - handler = functools.partial(QuietHandler, directory=str(html_dir)) + handler = functools.partial(QuietHandler, directory=str(html_dir), public_url=public_url.rstrip("/")) httpd = ThreadingHTTPServer((host, port), handler) print(f"xgit serving {html_dir} at http://{host}:{port}") + if public_url: + print(f"clone URLs use public_url: {public_url.rstrip('/')}") + else: + print("clone URLs use the Host header from each request") try: httpd.serve_forever() except KeyboardInterrupt: @@ -24,6 +31,8 @@ def serve(html_dir: Path, host: str = "0.0.0.0", port: int = 3121) -> None: class QuietHandler(SimpleHTTPRequestHandler): + public_url: str = "" + extensions_map = { **SimpleHTTPRequestHandler.extensions_map, ".html": "text/html; charset=utf-8", @@ -31,5 +40,27 @@ class QuietHandler(SimpleHTTPRequestHandler): ".xml": "application/atom+xml; charset=utf-8", } + def do_GET(self) -> None: + path = self.translate_path(self.path) + if path.endswith(".html") and os.path.isfile(path): + try: + text = Path(path).read_text(encoding="utf-8") + origin = self.public_url or request_origin( + host=self.headers.get("Host"), + forwarded_host=self.headers.get("X-Forwarded-Host"), + forwarded_proto=self.headers.get("X-Forwarded-Proto"), + ) + text = inject_clone_origin(text, origin) + data = text.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + except OSError: + pass + return super().do_GET() + def log_message(self, fmt: str, *args) -> None: print(f"{self.address_string()} - {fmt % args}") diff --git a/xgit/static/app.js b/xgit/static/app.js index aa6136e..be1f1ac 100644 --- a/xgit/static/app.js +++ b/xgit/static/app.js @@ -1,4 +1,4 @@ -/* Copy-to-clipboard for clone commands and raw sources */ +/* xgit — copy buttons + dynamic clone URLs from current host */ (function () { function label(btn, text) { const prev = btn.getAttribute("data-label") || btn.textContent; @@ -28,6 +28,20 @@ document.body.removeChild(ta); } + function updateCloneUrls() { + var origin = window.location.protocol + "//" + window.location.host; + document.querySelectorAll("[data-clone-repo]").forEach(function (block) { + var repo = block.getAttribute("data-clone-repo"); + var code = block.querySelector("code"); + if (code && repo) { + code.textContent = "git clone " + origin + "/" + repo; + } + }); + } + + updateCloneUrls(); + window.addEventListener("pageshow", updateCloneUrls); + document.addEventListener("click", function (ev) { const btn = ev.target.closest("[data-copy], [data-copy-target]"); if (!btn) return; diff --git a/xgit/static/style.css b/xgit/static/style.css index c3722ac..f500d38 100644 --- a/xgit/static/style.css +++ b/xgit/static/style.css @@ -165,6 +165,68 @@ body { overflow-wrap: anywhere; } +/* —— Repo about (GitHub-style) —— */ +.repo-about { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem 1.25rem; + margin-bottom: 0.9rem; + padding: 0.85rem 1rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + animation: rise 0.55s ease 0.04s both; +} + +.repo-desc { + margin: 0; + flex: 1 1 14rem; + color: var(--ink); + font-size: 1rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.repo-homepage { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--king); + font-size: 0.92rem; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.repo-homepage:hover { + color: var(--king-deep); + text-decoration: underline; +} + +.repo-homepage-icon { + display: inline-flex; + opacity: 0.85; +} + +.repo-homepage-inline { + display: inline-block; + margin-left: 0.35rem; + color: var(--king); + font-size: 0.85rem; + text-decoration: none; + white-space: nowrap; +} + +.repo-homepage-inline:hover { + text-decoration: underline; +} + +.repo-homepage-inline::before { + content: "↗ "; + font-size: 0.78rem; +} + /* —— Clone —— */ .clone { display: flex; @@ -741,6 +803,42 @@ pre a.d { color: var(--ink); } +.html-preview-wrap { + margin: 0.75rem 0 1.25rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--surface); +} + +.html-preview-label { + padding: 0.35rem 0.75rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + background: var(--king-soft); + border-bottom: 1px solid var(--line); +} + +.html-preview { + padding: 1rem 1.1rem; + background: #fff; + color: #1a1028; + overflow-x: auto; +} + +.markdown .html-preview table { + border-collapse: collapse; +} + +.markdown .html-preview table th, +.markdown .html-preview table td { + border: 1px solid #ddd; + padding: 0.4rem 0.6rem; +} + .view-toggle a.download { color: var(--king); }