#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "markdown-it-py[linkify]",
# "mdit-py-plugins",
# "pygments",
# ]
# ///
"""
repo-web-view.py — publish a directory tree as a static, GitHub-style browsable site.
For every folder it writes an index.html that shows the folder's rendered README.md
(if present) followed by a listing of the folder's contents. Rendered pages are fully
self-contained (CSS inlined, README images embedded as data URIs), so the only files
served as HTML are the generated index pages — everything else is meant to download.
With --render-markdown every .md file gets a page of its own too (NAME.md.html, same
layout: rendered markdown above the folder listing), and links to it — from the listing
and from inside other markdown — point at that page instead of downloading the source.
Every page carries a search box (top right, "/" or Ctrl-K) that searches the pages the site
renders — one result per heading section — and, in a second group, the name and path of every
file in the tree. The index is one shared search-index.js, loaded on first use; --no-search
leaves the whole thing out.
A generated .htaccess makes Apache force `Content-Disposition: attachment` on every
file except those index pages, so clicking any file downloads it while folders render.
Run (deps auto-installed from the inline metadata above):
uv run repo-web-view.py SOURCE OUTPUT # or ./repo-web-view.py SOURCE OUTPUT
uv run repo-web-view.py . ../site --serve # build, then preview at http://localhost:8000
uv run repo-web-view.py . ../site --render-markdown # every .md renders instead of downloading
SOURCE is copied into OUTPUT (dotfiles skipped) and an index.html is generated in every
folder. OUTPUT must not be the same as, or nested inside, SOURCE. Use --force to overwrite
a non-empty OUTPUT (what a deploy step wants).
"""
import argparse, base64, fnmatch, html, json, mimetypes, os, re, shutil, sys
from pathlib import Path
from urllib.parse import quote, unquote
from markdown_it import MarkdownIt
from markdown_it.token import Token
from mdit_py_plugins.anchors import anchors_plugin
from mdit_py_plugins.tasklists import tasklists_plugin
from pygments import highlight as pyg_highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
README_NAME = "readme.md"
INDEX_NAME = "index.html"
HTACCESS_NAME = ".htaccess"
MD_SUFFIX = ".md"
MD_PAGE_SUFFIX = ".md.html" # page generated for FILE.md, next to it (an .html name, so it renders)
SEARCH_INDEX_NAME = "search-index.js"
# --------------------------------------------------------------------- markdown rendering
def _highlight(code, lang, _attrs):
if not lang:
return "" # no language tag -> let markdown-it emit a plain, escaped code block
try:
lexer = get_lexer_by_name(lang)
except ClassNotFound:
return ""
inner = pyg_highlight(code, lexer, HtmlFormatter(nowrap=True))
return f'
')
return "\n".join(rows)
def footer_note_html(note, url):
# An optional stamp for the footer — a deploy step puts the commit it built here. The note is escaped, so it is text and nothing else;
# the link is the only markup added, and its URL comes from the command line (i.e. from whoever runs the build).
if not note:
return ""
label = html.escape(note)
return " · " + (f'{label}' if url else label)
def build_page(labels, depth, body_html, entries, note_html="", render_md=False, search=False):
crumbs = breadcrumb_html(labels, depth)
body_block = f'\n{body_html}\n\n' if body_html else ""
rows = listing_rows(entries, render_md) or '
—
empty folder
'
here = html.escape(" / ".join(labels))
return (
'\n\n\n\n'
'\n'
f"{here}\n\n\n\n"
f'
\n\n{body_block}'
f'
Contents
\n'
f"
\n{rows}\n
\n"
f'\n
\n'
f'{search_ui("../" * depth or "./") if search else ""}\n\n'
)
# --------------------------------------------------------------------- tree walk / generate
def find_readme(dir_path):
return next((c for c in dir_path.iterdir() if c.is_file() and c.name.lower() == README_NAME), None)
def is_markdown(path):
return path.is_file() and path.suffix.lower() == MD_SUFFIX
def list_entries(dir_path, extra_skip=()):
# Generated pages are part of the site, not of the tree being listed: index.html, the search index, and
# any NAME.md.html that belongs to a NAME.md sitting beside it.
entries = [e for e in dir_path.iterdir() if not e.name.startswith(".") and e.name != INDEX_NAME
and e.name not in extra_skip
and not (e.name.lower().endswith(MD_PAGE_SUFFIX) and (dir_path / e.name[:-len(".html")]).is_file())]
return sorted(entries, key=lambda e: (e.is_file(), e.name.lower()))
def generate(out_root, title, md, note_html="", render_md=False, search=True):
pages, index = 0, {"pages": [], "files": []}
for d in [out_root, *(p for p in out_root.rglob("*") if p.is_dir())]:
# read before anything is written into d, so a page never lists itself
entries = list_entries(d, (SEARCH_INDEX_NAME,) if search and d == out_root else ())
parts = list(d.relative_to(out_root).parts)
here = "".join(quote(part) + "/" for part in parts) # this folder, relative to the site root
readme = find_readme(d)
readme_html = render_markdown(readme, md, render_md) if readme else ""
(d / INDEX_NAME).write_text(build_page([title, *parts], len(parts), readme_html, entries, note_html, render_md, search),
encoding="utf-8")
pages += 1
if search:
index["files"] += file_records(entries, parts, here, render_md)
if readme:
index["pages"] += index_sections(readme_html, here, " / ".join([title, *parts]))
if render_md:
for f in (e for e in entries if is_markdown(e)):
f_html = render_markdown(f, md, True)
page = build_page([title, *parts, f.name], len(parts), f_html, entries, note_html, True, search)
f.with_name(f.name + ".html").write_text(page, encoding="utf-8") # FILE.md -> FILE.md.html, beside it
pages += 1
if search and f.name.lower() != README_NAME: # a README already went in at its folder's page
index["pages"] += index_sections(f_html, here + quote(f.name) + ".html",
" / ".join([title, *parts, f.name]))
if search:
write_search_index(out_root, index)
return pages
def copy_tree(src, out, excludes):
def ignore(_dir, names):
skip = {n for n in names if n.startswith(".")}
for pat in excludes:
skip |= set(fnmatch.filter(names, pat))
return skip
shutil.copytree(src, out, ignore=ignore)
HTACCESS = r"""# Generated by repo-web-view.
# Serve this tree as static files: plain files (scripts included) download, while
# folders (index.html) and .html tools render. Nothing here is executed server-side.
# Needs mod_headers, and AllowOverride FileInfo Indexes on this directory in the vhost.
Header set Content-Disposition "attachment"
Header set Content-Disposition "inline"
# Download scripts instead of running them (PHP-FPM, CGI, ...); serve them statically.
RemoveHandler .php .phtml .phps .php3 .php4 .php5 .php7 .php8 .cgi .fcgi .pl .py .rb .lua .sh .shtml
RemoveType .php .phtml .phps
SetHandler default-handler
DirectoryIndex index.html
"""
# --------------------------------------------------------------------- local preview server
def serve(out, port):
import functools, http.server, socketserver
class Handler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
path = self.translate_path(self.path)
if os.path.isfile(path) and not path.lower().endswith((".html", ".htm")):
self.send_header("Content-Disposition", "attachment") # mirror the .htaccess in preview
super().end_headers()
with socketserver.TCPServer(("", port), functools.partial(Handler, directory=str(out))) as httpd:
print(f"Serving {out} at http://localhost:{port}/ (Ctrl-C to stop)")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
# --------------------------------------------------------------------- cli
def main(argv=None):
ap = argparse.ArgumentParser(description="Publish a directory tree as a static, GitHub-style browsable site.")
ap.add_argument("source", help="directory to publish (e.g. a repo checkout)")
ap.add_argument("output", help="directory to write the generated site into (must be outside SOURCE)")
ap.add_argument("--title", help="name for the root breadcrumb (default: the source folder name)")
ap.add_argument("--exclude", action="append", default=[], metavar="GLOB",
help="filename glob to skip (repeatable); dotfiles are always skipped")
ap.add_argument("--footer-note", default="", metavar="TEXT",
help="text to append to every page's footer, e.g. the commit hash a deploy built (empty: no note)")
ap.add_argument("--footer-note-url", default="", metavar="URL",
help="make --footer-note a link to this URL, e.g. that commit on GitHub")
ap.add_argument("--render-markdown", action="store_true",
help="give every .md file a rendered page (NAME.md.html) and link to it, instead of downloading it")
ap.add_argument("--link-prefix", action="append", default=[], metavar="KEY=URL-TEMPLATE",
help="turn KEY:PATH references in markdown (inline code or link targets) into links to URL-TEMPLATE, "
"where {path} is PATH and {path~REGEX} its first REGEX match (repeatable)")
ap.add_argument("--no-search", action="store_true",
help="leave out the search box and the search index it loads")
ap.add_argument("--no-htaccess", action="store_true", help="do not write the force-download .htaccess")
ap.add_argument("--force", action="store_true", help="overwrite OUTPUT if it already exists")
ap.add_argument("--serve", nargs="?", type=int, const=8000, metavar="PORT",
help="after building, serve OUTPUT with production-like download headers (default port 8000)")
args = ap.parse_args(argv)
try:
link_prefixes = dict(parse_link_prefix(spec) for spec in args.link_prefix)
except (ValueError, re.error) as e:
ap.error(f"--link-prefix: {e}")
src, out = Path(args.source).resolve(), Path(args.output).resolve()
if not src.is_dir():
ap.error(f"source is not a directory: {src}")
if out == src or src in out.parents or out in src.parents:
ap.error("OUTPUT and SOURCE must not be the same or nested inside each other")
if out.exists():
if not args.force:
ap.error(f"output already exists: {out} (use --force to overwrite)")
shutil.rmtree(out)
copy_tree(src, out, args.exclude)
n = generate(out, args.title or src.name or "root", make_markdown(link_prefixes),
footer_note_html(args.footer_note.strip(), args.footer_note_url.strip()), args.render_markdown,
not args.no_search)
if not args.no_htaccess:
(out / HTACCESS_NAME).write_text(HTACCESS, encoding="utf-8")
print(f"Generated {n} page(s) into {out}")
if args.serve is not None:
serve(out, args.serve)
# --------------------------------------------------------------------- search UI (inlined into every page)
SEARCH_ICON = ('')
SEARCH_BUTTON = ('')
SEARCH_MODAL = ('
\n'
'
\n'
f'
{SEARCH_ICON}'
''
'
\n'
'\n\n
\n
\n')
SEARCH_JS = r"""
const SNIPPET = 200; // how much of a matching section a result shows (all of it is searched)
const MAX_PAGES = 20; // pages listed, each folding its own extra sections behind a "more" link
const MAX_FILES = 20;
const modal = document.querySelector(".search-modal");
const input = modal.querySelector(".search-input");
const list = modal.querySelector(".search-results");
const status = modal.querySelector(".search-status");
let data = null, pending = null, hits = [], active = -1;
// One shared index for the whole site, pulled in the first time someone searches. It is a script rather
// than a fetch on purpose: script tags are not subject to CORS, so this also works over file://.
function load() {
if (data) return Promise.resolve(data);
if (!pending) pending = new Promise((resolve) => {
const el = document.createElement("script");
el.src = ROOT + "search-index.js";
el.onload = el.onerror = () => {
const raw = window.__rwvSearch || { pages: [], files: [] };
data = {
pages: raw.pages.map(([loc, title, section, text]) => ({ loc, title, section, text,
fields: [[title.toLowerCase(), 3], [section.toLowerCase(), 6], [text.toLowerCase(), 1]] })),
files: raw.files.map(([href, path, size]) => ({ href, path, size,
fields: [[path.toLowerCase(), 4], [(path.split("/").filter(Boolean).pop() || "").toLowerCase(), 8]] })),
};
resolve(data);
};
document.head.appendChild(el);
});
return pending;
}
const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """ };
const esc = (s) => s.replace(/[&<>"]/g, (c) => ESCAPES[c]);
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function mark(text, terms) {
const out = esc(text);
return out && out.replace(new RegExp("(" + terms.map((t) => reEsc(esc(t))).join("|") + ")", "gi"), "$1");
}
// Every term has to turn up somewhere in the record; a term starting a word counts double, and the field it
// landed in sets the weight, so a heading hit outranks the same word buried in a paragraph.
function score(rec, terms) {
let total = 0;
for (const term of terms) {
let best = 0;
for (const [text, weight] of rec.fields) {
const at = text.indexOf(term);
if (at >= 0) best = Math.max(best, weight * (at === 0 || !/[a-z0-9]/.test(text[at - 1]) ? 2 : 1));
}
if (!best) return 0;
total += best;
}
return total;
}
function rank(records, terms) {
return records.map((rec) => [score(rec, terms), rec]).filter(([s]) => s > 0)
.sort((a, b) => b[0] - a[0]).map(([, rec]) => rec);
}
// Sections of one page collapse into a single result — its best section, with the rest behind a "more"
// link — so that one long README cannot fill the whole list with itself. Ranked order is kept, so a page
// arrives at the position its best section earned.
function byPage(records) {
const groups = new Map();
for (const rec of records) {
const page = rec.loc.split("#")[0];
if (!groups.has(page)) groups.set(page, []);
groups.get(page).push(rec);
}
return [...groups.values()];
}
// withTitle is off for the sections folded under a page: they all sit under the same one.
function pageHit(rec, terms, withTitle) {
const title = withTitle ? `\u{1F4C4} ${mark(rec.title, terms)}` : "";
const section = rec.section ? `${mark(rec.section, terms)}` : "";
const cut = rec.text.slice(0, SNIPPET).replace(/[\s.,;:]+$/, "");
const text = rec.text.length > SNIPPET ? cut + "\u2026" : rec.text;
return `${title}${section}`
+ `${mark(text, terms)}`;
}
function pageGroup(group, terms, id) {
const [best, ...rest] = group;
if (!rest.length) return pageHit(best, terms, true);
const label = `${rest.length} more on this page`;
const extra = rest.map((rec) => pageHit(rec, terms, !rec.section)).join("");
return pageHit(best, terms, true) + ``
+ `
${extra}
`;
}
function fileHit(rec, terms) {
return ``
+ `${rec.path.endsWith("/") ? "\u{1F4C1}" : "\u{1F4C4}"}`
+ `${mark(rec.path, terms)}${esc(rec.size)}`;
}
function render() {
const terms = input.value.toLowerCase().split(/\s+/).filter(Boolean);
active = -1;
if (!terms.length) {
list.innerHTML = status.textContent = "";
} else {
const pages = rank(data.pages, terms), files = rank(data.files, terms), n = pages.length + files.length;
const groups = byPage(pages).slice(0, MAX_PAGES);
status.textContent = n ? `${n} matching result${n === 1 ? "" : "s"}` : "No results";
list.innerHTML = (groups.length ? '