#!/usr/bin/env python3
"""Generate a single-page site from the repository's config files."""
import argparse
import fnmatch
import hashlib
import html
import os
import subprocess
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
from urllib.parse import quote
ROOT = Path(__file__).resolve().parent.parent
EMACS_SOURCE = Path(".emacs.d/bootstrap.org")
EMACS_RENDERER = ROOT / "scripts" / "render-emacs-config-to-html.sh"
STYLESHEET = ROOT / "scripts" / "config-site.css"
MAX_FILE_BYTES = 1_000_000
DEFAULT_EXCLUDES = (
".DS_Store",
"*.lock",
"*.db",
"*.elc",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.webp",
"*.icns",
"*.woff*",
"*.ttf",
"*.pdf",
".env*",
"*credentials*",
"*secret*",
"id_rsa*",
"site/**",
"scripts/output/**",
"**/__pycache__/**",
)
LANGUAGES = {
".css": "css",
".el": "lisp",
".html": "markup",
".ini": "ini",
".json": "json",
".jsonc": "json",
".lua": "lua",
".md": "markdown",
".nix": "nix",
".org": "markup",
".rasi": "css",
".scss": "scss",
".sh": "bash",
".toml": "toml",
".xml": "markup",
".yaml": "yaml",
".yml": "yaml",
".yuck": "lisp",
}
SPECIAL_LANGUAGES = {
"Brewfile": "ruby",
".gitconfig": "ini",
".ideavimrc": "vim",
".tmux.conf": "bash",
".vimrc": "vim",
".Xmodmap": "text",
}
GROUP_NAMES = {
".emacs.d": "Emacs",
".github": "GitHub",
".pi": "Pi",
"nix-darwin": "Nix Darwin",
"nixos": "NixOS",
"zsh": "Zsh",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-o",
"--output",
type=Path,
default=ROOT / "site" / "index.html",
help="output HTML path (default: site/index.html)",
)
parser.add_argument(
"--exclude",
action="append",
default=[],
metavar="GLOB",
help="additional repository-relative glob to exclude; repeatable",
)
return parser.parse_args()
def git_output(*arguments: str) -> str:
try:
result = subprocess.run(
["git", *arguments],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError as error:
raise RuntimeError("Git is required to generate the site") from error
except subprocess.CalledProcessError as error:
detail = error.stderr.strip()
raise RuntimeError(detail or f"git {' '.join(arguments)} failed") from error
return result.stdout.strip()
def github_source_base() -> str:
repository = os.environ.get("GITHUB_REPOSITORY")
if repository is None:
remote = git_output("remote", "get-url", "origin")
marker = "github.com"
if marker not in remote:
raise RuntimeError("the origin remote is not hosted on GitHub")
repository = remote.split(marker, 1)[1].lstrip(":/").removesuffix(".git")
revision = os.environ.get("GITHUB_SHA") or git_output("rev-parse", "HEAD")
return f"https://github.com/{repository}/blob/{revision}/"
def repository_files() -> list[Path]:
"""Return tracked files, failing closed outside a Git checkout."""
try:
result = subprocess.run(
["git", "ls-files", "-z"],
cwd=ROOT,
check=True,
capture_output=True,
)
except FileNotFoundError as error:
raise RuntimeError("Git is required to discover publishable files") from error
except subprocess.CalledProcessError as error:
detail = error.stderr.decode(errors="replace").strip()
message = "not inside a Git repository"
raise RuntimeError(f"could not list tracked files: {detail or message}") from error
return [ROOT / os.fsdecode(item) for item in result.stdout.split(b"\0") if item]
def is_excluded(relative: Path, patterns: tuple[str, ...]) -> bool:
name = relative.as_posix()
return any(
fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(relative.name, pattern)
for pattern in patterns
)
def read_text(path: Path) -> str | None:
try:
data = path.read_bytes()
except OSError as error:
print(f"warning: skipping {path}: {error}", file=sys.stderr)
return None
if len(data) > MAX_FILE_BYTES:
print(f"warning: skipping {path}: larger than {MAX_FILE_BYTES:,} bytes", file=sys.stderr)
return None
if b"\0" in data:
return None
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return None
def group_name(relative: Path) -> str:
if len(relative.parts) == 1:
return "General"
first = relative.parts[0]
return GROUP_NAMES.get(first, first.lstrip(".").replace("-", " ").title())
def language_for(path: Path, source: str) -> str:
if path.name in SPECIAL_LANGUAGES:
return SPECIAL_LANGUAGES[path.name]
if path.suffix in LANGUAGES:
return LANGUAGES[path.suffix]
if source.startswith("#!") and "sh" in source.splitlines()[0]:
return "bash"
return "text"
def file_id(relative: Path) -> str:
path = relative.as_posix()
slug = "".join(char if char.isalnum() else "-" for char in path).strip("-")
digest = hashlib.sha256(path.encode()).hexdigest()[:10]
return f"file-{slug}-{digest}"
def render_file(relative: Path, source: str, source_base: str) -> str:
path = relative.as_posix()
source_url = source_base + quote(path, safe="/")
line_count = len(source.splitlines())
language = language_for(relative, source)
return f"""
<article class="file" id="{file_id(relative)}">
<header class="file-header">
<div>
<h3>{html.escape(relative.name)}</h3>
<p>{html.escape(path)} · {line_count:,} lines</p>
</div>
<div class="file-actions">
<a class="source-link" href="{html.escape(source_url, quote=True)}">View on GitHub</a>
<button class="copy" type="button" aria-label="Copy {html.escape(path, quote=True)}">Copy</button>
</div>
</header>
<pre><code class="language-{language}">{html.escape(source)}</code></pre>
</article>"""
def render_site(
files: list[tuple[Path, str]], stylesheet: str, source_base: str
) -> str:
grouped: dict[str, list[tuple[Path, str]]] = defaultdict(list)
for relative, source in files:
grouped[group_name(relative)].append((relative, source))
navigation = []
sections = []
for group in sorted(grouped, key=str.casefold):
entries = sorted(grouped[group], key=lambda item: item[0].as_posix().casefold())
group_id = "group-" + group.lower().replace(" ", "-")
if group == "Emacs":
navigation.append(
'<a href="emacs.html"><span>Emacs guide</span><b>↗</b></a>'
)
heading = '<a href="emacs.html">Emacs <small>Open documented config →</small></a>'
else:
navigation.append(
f'<a href="#{group_id}"><span>{html.escape(group)}</span><b>{len(entries)}</b></a>'
)
heading = html.escape(group)
cards = "\n".join(
render_file(path, source, source_base) for path, source in entries
)
sections.append(
f'<section class="group" id="{group_id}"><h2>{heading}</h2>{cards}</section>'
)
return TEMPLATE.replace("{{NAVIGATION}}", "\n".join(navigation)).replace(
"{{SECTIONS}}", "\n".join(sections)
).replace("105", str(len(files))).replace(
"23", str(len(grouped))
).replace(":root { color-scheme: dark; --bg: #111318; --panel: #191c23; --sidebar: #15171d; --muted: #969dab; --text: #e6e9ef; --accent: #8aadf4; --border: #2c313c; }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, sans-serif; }
a { color: inherit; text-decoration: none; }
/* Dotfiles index */
aside { position: fixed; inset: 0 auto 0 0; width: 270px; padding: 28px 20px; overflow: auto; border-right: 1px solid var(--border); background: var(--sidebar); }
aside h1 { margin: 0; font-size: 1.4rem; }
aside > p { margin: 4px 0 20px; color: var(--muted); font-size: .85rem; }
nav a { display: flex; justify-content: space-between; padding: 7px 9px; border-radius: 6px; color: var(--muted); }
nav a:hover { color: var(--text); background: var(--panel); }
nav b { color: var(--accent); font-size: .75rem; }
.nav-toggle { display: none; width: 100%; margin-bottom: 10px; padding: 8px 10px; color: var(--text); border: 1px solid var(--border); border-radius: 6px; background: var(--panel); text-align: left; cursor: pointer; }
main { max-width: 1100px; margin-left: 270px; padding: 50px 5vw 100px; }
.intro h1 { margin: 0; font-size: clamp(2rem, 5vw, 4rem); letter-spacing: -.05em; }
.intro p { color: var(--muted); }
.group { padding-top: 28px; }
.group > h2 { margin: 0 0 14px; font-size: 1.5rem; }
.group > h2 small { margin-left: 8px; color: var(--accent); font-size: .75rem; font-weight: 500; }
.file { margin-bottom: 22px; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); scroll-margin-top: 18px; }
.file-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.file-header h3, .file-header p { margin: 0; }
.file-header h3 { font: 600 .9rem ui-monospace, monospace; }
.file-header p { color: var(--muted); font-size: .75rem; }
.file-actions { display: flex; align-items: center; gap: 10px; }
.source-link { color: var(--accent); font-size: .75rem; white-space: nowrap; }
.source-link:hover { color: var(--text); }
.copy { padding: 5px 10px; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; background: transparent; cursor: pointer; }
.copy:hover { color: var(--text); border-color: var(--accent); }
pre[class*="language-"] { max-height: 650px; margin: 0 !important; border-radius: 0; font-size: .8rem; }
/* Literate Emacs config */
#content { max-width: 1100px; margin-left: 270px; padding: 50px 5vw 100px; }
#content a { color: var(--accent); }
#content a:hover { color: var(--text); }
h1.title { margin: 0 0 32px; color: var(--text); font-size: clamp(2rem, 5vw, 4rem); letter-spacing: -.05em; text-align: left; }
.page-links { position: fixed; z-index: 2; top: 24px; left: 20px; display: flex; width: 230px; justify-content: space-between; font-size: .8rem; }
.page-links a { color: var(--accent); }
#table-of-contents { position: fixed; z-index: 1; inset: 0 auto 0 0; width: 270px; padding: 62px 20px 28px; overflow: auto; border-right: 1px solid var(--border); background: var(--sidebar); }
#table-of-contents h2 { margin: 0 0 14px; color: var(--text); font-size: 1.4rem; }
#text-table-of-contents ul { margin: 0; padding-left: 14px; list-style: none; }
#text-table-of-contents > ul { padding-left: 0; }
#text-table-of-contents a { display: block; padding: 4px 7px; border-radius: 6px; color: var(--muted); font-size: .82rem; line-height: 1.35; }
#text-table-of-contents a:hover { color: var(--text); background: var(--panel); }
.outline-2 { padding-top: 22px; }
h2 { margin: 0 0 14px; color: var(--text); font-size: 1.5rem; }
h3 { margin-top: 32px; color: var(--text); }
p, li { color: var(--text); }
.org-src-container { position: relative; }
.org-src-container > .copy { position: absolute; z-index: 1; top: 8px; right: 8px; background: var(--panel); }
pre.src, pre.example { max-height: 650px; margin: 16px 0; padding: 16px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text); font: .8rem/1.5 ui-monospace, monospace; }
.org-comment, .org-comment-delimiter { color: #6c7086; font-style: italic; }
.org-keyword, .org-negation-char { color: #cba6f7; }
.org-string, .org-doc { color: #a6e3a1; }
.org-function-name { color: #89b4fa; }
.org-variable-name { color: #f5c2e7; }
.org-builtin, .org-constant, .org-type { color: #f9e2af; }
.org-regexp-grouping-backslash, .org-regexp-grouping-construct { color: #94e2d5; }
.org-warning { color: #f38ba8; font-weight: 600; }
code { padding: 2px 5px; border-radius: 4px; background: var(--panel); font-family: ui-monospace, monospace; }
pre code { padding: 0; background: transparent; }
table { border-collapse: collapse; }
th, td { padding: 8px 12px; border: 1px solid var(--border); }
#postamble { margin-left: 270px; padding: 20px 5vw; color: var(--muted); border-top: 1px solid var(--border); font-size: .75rem; }
@media (prefers-color-scheme: light) {
:root { color-scheme: light; --bg: #f6f7f9; --panel: #ffffff; --sidebar: #eef1f5; --muted: #667085; --text: #1d2433; --accent: #315fa8; --border: #d8dee8; }
.org-comment, .org-comment-delimiter { color: #6a737d; }
.org-keyword, .org-negation-char { color: #8250df; }
.org-string, .org-doc { color: #116329; }
.org-function-name { color: #0550ae; }
.org-variable-name { color: #953800; }
.org-builtin, .org-constant, .org-type { color: #9a6700; }
.org-regexp-grouping-backslash, .org-regexp-grouping-construct { color: #0a7c72; }
.org-warning { color: #cf222e; }
}
@media (max-width: 760px) {
aside { position: static; width: auto; border-right: 0; border-bottom: 1px solid var(--border); }
.nav-toggle { display: block; }
aside nav { display: none; }
aside.nav-open nav { display: block; }
main { margin: 0; padding: 30px 18px 70px; }
.file-header { align-items: flex-start; gap: 8px; }
.file-actions { flex-direction: column; align-items: flex-end; gap: 6px; }
.page-links { position: static; width: auto; padding: 18px 18px 0; }
#table-of-contents { position: static; width: auto; max-height: 45vh; padding: 18px; border-right: 0; border-bottom: 1px solid var(--border); }
#content { margin: 0; padding: 30px 18px 70px; }
#postamble { margin: 0; padding: 20px 18px; }
}
", stylesheet)
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dotfiles</title>
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" media="(prefers-color-scheme: light)">
<style>
:root { color-scheme: dark; --bg: #111318; --panel: #191c23; --sidebar: #15171d; --muted: #969dab; --text: #e6e9ef; --accent: #8aadf4; --border: #2c313c; }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, sans-serif; }
a { color: inherit; text-decoration: none; }
/* Dotfiles index */
aside { position: fixed; inset: 0 auto 0 0; width: 270px; padding: 28px 20px; overflow: auto; border-right: 1px solid var(--border); background: var(--sidebar); }
aside h1 { margin: 0; font-size: 1.4rem; }
aside > p { margin: 4px 0 20px; color: var(--muted); font-size: .85rem; }
nav a { display: flex; justify-content: space-between; padding: 7px 9px; border-radius: 6px; color: var(--muted); }
nav a:hover { color: var(--text); background: var(--panel); }
nav b { color: var(--accent); font-size: .75rem; }
.nav-toggle { display: none; width: 100%; margin-bottom: 10px; padding: 8px 10px; color: var(--text); border: 1px solid var(--border); border-radius: 6px; background: var(--panel); text-align: left; cursor: pointer; }
main { max-width: 1100px; margin-left: 270px; padding: 50px 5vw 100px; }
.intro h1 { margin: 0; font-size: clamp(2rem, 5vw, 4rem); letter-spacing: -.05em; }
.intro p { color: var(--muted); }
.group { padding-top: 28px; }
.group > h2 { margin: 0 0 14px; font-size: 1.5rem; }
.group > h2 small { margin-left: 8px; color: var(--accent); font-size: .75rem; font-weight: 500; }
.file { margin-bottom: 22px; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); scroll-margin-top: 18px; }
.file-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.file-header h3, .file-header p { margin: 0; }
.file-header h3 { font: 600 .9rem ui-monospace, monospace; }
.file-header p { color: var(--muted); font-size: .75rem; }
.file-actions { display: flex; align-items: center; gap: 10px; }
.source-link { color: var(--accent); font-size: .75rem; white-space: nowrap; }
.source-link:hover { color: var(--text); }
.copy { padding: 5px 10px; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; background: transparent; cursor: pointer; }
.copy:hover { color: var(--text); border-color: var(--accent); }
pre[class*="language-"] { max-height: 650px; margin: 0 !important; border-radius: 0; font-size: .8rem; }
/* Literate Emacs config */
#content { max-width: 1100px; margin-left: 270px; padding: 50px 5vw 100px; }
#content a { color: var(--accent); }
#content a:hover { color: var(--text); }
h1.title { margin: 0 0 32px; color: var(--text); font-size: clamp(2rem, 5vw, 4rem); letter-spacing: -.05em; text-align: left; }
.page-links { position: fixed; z-index: 2; top: 24px; left: 20px; display: flex; width: 230px; justify-content: space-between; font-size: .8rem; }
.page-links a { color: var(--accent); }
#table-of-contents { position: fixed; z-index: 1; inset: 0 auto 0 0; width: 270px; padding: 62px 20px 28px; overflow: auto; border-right: 1px solid var(--border); background: var(--sidebar); }
#table-of-contents h2 { margin: 0 0 14px; color: var(--text); font-size: 1.4rem; }
#text-table-of-contents ul { margin: 0; padding-left: 14px; list-style: none; }
#text-table-of-contents > ul { padding-left: 0; }
#text-table-of-contents a { display: block; padding: 4px 7px; border-radius: 6px; color: var(--muted); font-size: .82rem; line-height: 1.35; }
#text-table-of-contents a:hover { color: var(--text); background: var(--panel); }
.outline-2 { padding-top: 22px; }
h2 { margin: 0 0 14px; color: var(--text); font-size: 1.5rem; }
h3 { margin-top: 32px; color: var(--text); }
p, li { color: var(--text); }
.org-src-container { position: relative; }
.org-src-container > .copy { position: absolute; z-index: 1; top: 8px; right: 8px; background: var(--panel); }
pre.src, pre.example { max-height: 650px; margin: 16px 0; padding: 16px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text); font: .8rem/1.5 ui-monospace, monospace; }
.org-comment, .org-comment-delimiter { color: #6c7086; font-style: italic; }
.org-keyword, .org-negation-char { color: #cba6f7; }
.org-string, .org-doc { color: #a6e3a1; }
.org-function-name { color: #89b4fa; }
.org-variable-name { color: #f5c2e7; }
.org-builtin, .org-constant, .org-type { color: #f9e2af; }
.org-regexp-grouping-backslash, .org-regexp-grouping-construct { color: #94e2d5; }
.org-warning { color: #f38ba8; font-weight: 600; }
code { padding: 2px 5px; border-radius: 4px; background: var(--panel); font-family: ui-monospace, monospace; }
pre code { padding: 0; background: transparent; }
table { border-collapse: collapse; }
th, td { padding: 8px 12px; border: 1px solid var(--border); }
#postamble { margin-left: 270px; padding: 20px 5vw; color: var(--muted); border-top: 1px solid var(--border); font-size: .75rem; }
@media (prefers-color-scheme: light) {
:root { color-scheme: light; --bg: #f6f7f9; --panel: #ffffff; --sidebar: #eef1f5; --muted: #667085; --text: #1d2433; --accent: #315fa8; --border: #d8dee8; }
.org-comment, .org-comment-delimiter { color: #6a737d; }
.org-keyword, .org-negation-char { color: #8250df; }
.org-string, .org-doc { color: #116329; }
.org-function-name { color: #0550ae; }
.org-variable-name { color: #953800; }
.org-builtin, .org-constant, .org-type { color: #9a6700; }
.org-regexp-grouping-backslash, .org-regexp-grouping-construct { color: #0a7c72; }
.org-warning { color: #cf222e; }
}
@media (max-width: 760px) {
aside { position: static; width: auto; border-right: 0; border-bottom: 1px solid var(--border); }
.nav-toggle { display: block; }
aside nav { display: none; }
aside.nav-open nav { display: block; }
main { margin: 0; padding: 30px 18px 70px; }
.file-header { align-items: flex-start; gap: 8px; }
.file-actions { flex-direction: column; align-items: flex-end; gap: 6px; }
.page-links { position: static; width: auto; padding: 18px 18px 0; }
#table-of-contents { position: static; width: auto; max-height: 45vh; padding: 18px; border-right: 0; border-bottom: 1px solid var(--border); }
#content { margin: 0; padding: 30px 18px 70px; }
#postamble { margin: 0; padding: 20px 18px; }
}
</style>
</head>
<body>
<aside>
<h1>Dotfiles</h1>
<p>105 tracked files across 23 groups</p>
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-navigation">Browse sections</button>
<nav id="site-navigation">{{NAVIGATION}}</nav>
</aside>
<main>
<header class="intro">
<h1>Configuration,<br>documented by itself.</h1>
<p>Generated from the repository source.</p>
</header>
{{SECTIONS}}
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script>
const sidebar = document.querySelector('aside');
const navToggle = document.querySelector('.nav-toggle');
navToggle.addEventListener('click', () => {
const open = sidebar.classList.toggle('nav-open');
navToggle.setAttribute('aria-expanded', String(open));
});
document.querySelectorAll('.copy').forEach(button => button.addEventListener('click', async () => {
await navigator.clipboard.writeText(button.closest('.file').querySelector('code').textContent);
button.textContent = 'Copied';
setTimeout(() => button.textContent = 'Copy', 1200);
}));
</script>
</body>
</html>
"""
def main() -> int:
args = parse_args()
patterns = DEFAULT_EXCLUDES + tuple(args.exclude)
try:
candidates = repository_files()
source_base = github_source_base()
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 1
files: list[tuple[Path, str]] = []
for path in candidates:
try:
relative = path.relative_to(ROOT)
except ValueError:
continue
if is_excluded(relative, patterns) or relative == EMACS_SOURCE:
continue
source = read_text(path)
if source is not None:
files.append((relative, source))
if not files:
print("error: no publishable files found", file=sys.stderr)
return 1
output = args.output.expanduser().resolve()
emacs_output = output.with_name("emacs.html")
if output == emacs_output:
print("error: the main output cannot be named emacs.html", file=sys.stderr)
return 1
try:
stylesheet = STYLESHEET.read_text(encoding="utf-8")
except OSError as error:
print(f"error: could not read {STYLESHEET}: {error}", file=sys.stderr)
return 1
output.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(
prefix=".config-site-", dir=output.parent
) as staging_directory:
staging = Path(staging_directory)
staged_index = staging / output.name
staged_emacs = staging / emacs_output.name
emacs_source_url = source_base + quote(EMACS_SOURCE.as_posix(), safe="/")
subprocess.run(
[
"bash",
str(EMACS_RENDERER),
str(staged_emacs),
emacs_source_url,
],
cwd=ROOT,
check=True,
)
staged_index.write_text(
render_site(files, stylesheet, source_base), encoding="utf-8"
)
os.replace(staged_emacs, emacs_output)
os.replace(staged_index, output)
except (OSError, subprocess.CalledProcessError) as error:
print(f"error: could not generate site: {error}", file=sys.stderr)
return 1
print(f"Generated {output} from {len(files)} files")
print(f"Generated {emacs_output} from {EMACS_SOURCE}")
return 0
if __name__ == "__main__":
raise SystemExit(main())