xgit simple git

xgit

this project — static git page generator by polymath

kb.hax.al
clone git clone https://kb.hax.al/xgit

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>
MLICENSE+19-16
MREADME.md+2-2
Mconfig.toml+35-40
Mpyproject.toml+1-1
Mxgit/cli.py+1-1
Mxgit/config.py+25-3
Mxgit/fetch.py+12-9
Mxgit/generate.py+116-51
Mxgit/gitutil.py+36-0
Mxgit/htmlutil.py+99-6
Mxgit/markdown.py+133-14
Mxgit/serve.py+33-2
Mxgit/static/app.js+15-1
Mxgit/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);
 }