repo-web-view

Publish a directory tree (a repo, a folder of docs) as a static, GitHub-style browsable site. For every folder it generates an index.html that shows the folder's rendered README.md at the top, and below it a listing of that folder's contents. Folders open as more rendered pages, and the repo's self-contained .html tools open and run in the browser; every other file downloads rather than displaying. With --render-markdown, every .md file in the tree gets a rendered page too, so clicking one reads it instead of downloading it. Every page carries a search box over the whole site — the rendered pages by heading section, and every file in the tree by name.

It is built to sit behind a plain Apache — the same "I have an SSH login and /var/www" box that github-push-deploy targets — and pairs with that tool as the publish step: a push regenerates the browsable site.

What it produces

Markdown is rendered with CommonMark + GitHub niceties: tables, strikethrough, task lists, autolinks, fenced code with syntax highlighting (Pygments), and GitHub-compatible heading anchors so in-page #links work.

Requirements

Usage

uv run repo-web-view.py SOURCE OUTPUT        # or ./repo-web-view.py SOURCE OUTPUT

SOURCE is copied into OUTPUT (dotfiles skipped) and an index.html is generated in every folder. OUTPUT must be outside SOURCE (it may not be the same folder or nested inside it).

Preview locally with a built-in server that mirrors the production download headers, so you can check that files download and folders render before deploying:

uv run repo-web-view.py . ../tools-site --serve      # http://localhost:8000

Rebuild over an existing output (what a deploy does) with --force:

uv run repo-web-view.py /var/www/tools/repo /var/www/tools/html --force

Rendering every markdown file

By default only README.md is rendered (into its folder's index.html); any other .md in the tree is a file like any other, so clicking it downloads it. --render-markdown changes that:

uv run repo-web-view.py . ../tools-site --render-markdown

README.md gets a page of its own too, even though its content is already at the top of the folder's index.html; that is what makes a link to ../other-tool/README.md render.

Every generated page has a search box at the right of the breadcrumb row. Click it, or press / or Ctrl/Cmd+K, and a panel opens over the page; ↑/↓ walk the results, Enter opens one, Esc closes.

Results come in two groups:

The bar above the results counts every match; the list itself shows the 20 best pages and the 20 best files.

Matching is plain substring matching, and every word of the query has to appear somewhere in the entry. A word matched at the start of a word beats one matched in the middle of one, a hit in a heading beats a hit in body text, and a hit in a file's name beats one elsewhere in its path.

The index

The whole site shares one search-index.js, written at the root beside the .htaccess. A page pulls it in the first time someone actually searches, so a visitor who never opens the box never downloads it. It is loaded as a script rather than fetched as JSON, which keeps it working when the built site is opened straight off disk — fetch() of a sibling file is blocked there, a <script> tag is not. The force-download .htaccess does not get in the way either: Content-Disposition applies to what the browser navigates to, not to a subresource a page pulls in.

The index holds the full text of every section, so its size tracks the amount of markdown you publish — for this repository, about 115 KB, or 40 KB over the wire once compressed. It is worth making sure Apache compresses it:

<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/css application/javascript
</IfModule>

--no-search leaves out the box, the panel and the index file.

Linking KEY:PATH references

Some repos name files that live elsewhere by a key and a relative path, e.g. onedrive:Teaching/slides.pptx, because the local root differs from host to host. --link-prefix KEY=URL-TEMPLATE makes those clickable on the site, by giving each key the web URL its paths hang off:

uv run repo-web-view.py . ../site \
  --link-prefix 'onedrive=https://contoso-my.sharepoint.com/personal/me_contoso_com/Documents/{path}' \
  --link-prefix 'zot=https://contoso-my.sharepoint.com/personal/me_contoso_com/Documents/Zotero/{path~\w{1,2}}/{path}.pdf'

Options

Flag Default Meaning
--title NAME source folder name Label for the root breadcrumb.
--exclude GLOB — Filename glob to skip; repeatable. Dotfiles (.git, .gitignore, …) are always skipped.
--force off Overwrite OUTPUT if it already exists (it is removed and rebuilt).
--footer-note TEXT — Text appended to every page's footer, e.g. the commit a deploy built. Escaped, so it is text and nothing else; empty means no note.
--footer-note-url URL — Turn --footer-note into a link to this URL (ignored without a note).
--render-markdown off Give every .md file a rendered page (NAME.md.html) and link to it from the listing, instead of downloading it.
--link-prefix KEY=URL-TEMPLATE — Turn KEY:PATH references in markdown into links; repeatable, one per key. See Linking KEY:PATH references.
--no-search off Leave out the search box and the search-index.js it loads.
--no-htaccess off Don't write the force-download .htaccess (e.g. you configure the rule in the vhost).
--serve [PORT] — After building, serve OUTPUT with production-like download headers (default port 8000). Put it last on the command line.

Apache: making files download

The generated .htaccess only takes effect if the directory allows those overrides. In your <VirtualHost>, on the served directory:

<Directory "/var/www/tools/html">
    AllowOverride FileInfo Indexes
    Require all granted
</Directory>

If you'd rather not enable .htaccess, run with --no-htaccess and put the rules straight in that <Directory> block instead:

<Directory "/var/www/tools/html">
    Require all granted
    <IfModule mod_headers.c>
        <FilesMatch ".">
            Header set Content-Disposition "attachment"
        </FilesMatch>
        <FilesMatch "(?i)\.html?$">
            Header set Content-Disposition "inline"
        </FilesMatch>
    </IfModule>
    RemoveHandler .php .phtml .cgi .fcgi .pl .py .rb .lua .sh .shtml
    <FilesMatch "(?i)\.(php[0-9]?|phtml|phps|phar|cgi|fcgi|pl|py|rb|lua|sh|shtml)$">
        SetHandler default-handler
    </FilesMatch>
    DirectoryIndex index.html
</Directory>

Using it as the github-push-deploy publish step

In your repo's github-push-deploy/deploy.sh, replace the "Publish the site" copy block with a build into a fresh directory that is then swapped in with renames, so the live site is never served mid-rebuild:

# --- Publish the site (rendered, GitHub-style browsable view) ----------------
SHORT_SHA="$(git rev-parse --short HEAD 2>/dev/null || true)"
COMMIT_URL=""
if [ -n "$SHORT_SHA" ] && [ -n "${REPO_FULL_NAME:-}" ]; then COMMIT_URL="https://github.com/$REPO_FULL_NAME/commit/$(git rev-parse HEAD)"; fi

rm -rf "$BASE_DIR/html-new" "$BASE_DIR/html-old"
uv run repo-web-view/repo-web-view.py . "$BASE_DIR/html-new" --footer-note "$SHORT_SHA" --footer-note-url "$COMMIT_URL"
if [ -d "$BASE_DIR/html" ]; then mv "$BASE_DIR/html" "$BASE_DIR/html-old"; fi
mv "$BASE_DIR/html-new" "$BASE_DIR/html"
rm -rf "$BASE_DIR/html-old"

Running it straight from GitHub (no checkout needed)

That assumes repo-web-view/repo-web-view.py is already checked out next to the deploying repo. When the deploy user is isolated and can't reach a local checkout of this repo — no shared home directory, no submodule, nothing vendored — uv run --script can fetch the file straight from GitHub instead, no clone needed:

uv run --script https://raw.githubusercontent.com/watercrossing/tools/main/repo-web-view/repo-web-view.py \
  . "$BASE_DIR/html-new" --footer-note "$SHORT_SHA" --footer-note-url "$COMMIT_URL"

Use the raw.githubusercontent.com URL, not a github.com/.../blob/... link — the latter serves an HTML page, not the script's source, so uv run can't parse its PEP 723 header. Pointing at main is deliberate rather than a loose end to pin down: this repo publishes from main and nothing else, so main is the released version, the same way tools.ibecker.eu (this repo, served over HTTP) always tracks it too.

Add --render-markdown to that call if you want every .md in the repo readable in the browser rather than downloadable. The deploy user needs uv on its PATH. The single repo-web-view call produces the whole tree — copied files, the index.html pages, and the .htaccess — and the two renames swap it in near-atomically. (For a single build with no swap, run it straight at "$BASE_DIR/html" --force, which clears and rebuilds in place.)

Every page's footer then ends with the deployed commit, linked to it on GitHub, which is what makes "is the live site up to date?" answerable from the site itself:

<footer>Generated by repo-web-view · <a href="https://github.com/owner/repo/commit/e7963a3…">e7963a3</a></footer>

git rev-parse works in update.sh's shallow clone, and REPO_FULL_NAME comes from deploy.conf. Both values may come out empty — a build from a copy with no .git, or a config without REPO_FULL_NAME — which just leaves the footer unstamped rather than failing the deploy.

Notes and limitations

Contents

📁tests/
📄README.md16.7 KB
📄repo-web-view.py35.7 KB