Files
ricehub/build.py
T
2026-10-29 19:12:14 +01:00

300 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""ricehub static site generator (Python 3.8+, standard library only).
Usage:
python build.py # reads ./themes, writes ./dist
python build.py --themes my/themes --assets my/assets --out public
Every themes/<slug>.json becomes <slug>/index.html (served as /<slug>/);
index.html is the card overview.
Files whose name starts with "_" (like themes/_template.json) are ignored.
Theme file format (only the first five keys are required):
{
"name": "Nord",
"description": "An arctic, north-bluish color palette.",
"site": "https://www.nordtheme.com",
"palette": { "nord0": "#2E3440", "nord1": "#3B4252" },
"configs": { "Vim": "https://github.com/nordtheme/vim" },
"colors": { (optional, otherwise guessed from palette)
"background": "#2E3440", "foreground": "#D8DEE9",
"surface": "#3B4252", "accent": "#88C0D0", "muted": "#4C566A"
},
"syntax": ["#81A1C1", "#88C0D0", "#A3BE8C", "#B48EAD", "#616E88"]
(optional: keyword, function, string,
number, comment; comment may be omitted)
}
"colors" and "syntax" only affect how the theme's card and page are styled.
Palette order is kept, themes are listed alphabetically by name.
"""
import argparse
import colorsys
import html
import json
import re
import shutil
import sys
from pathlib import Path
SITE_NAME = "ricehub"
SITE_TAGLINE = "A collection of color themes for your digital environment"
FOOTNOTE = ("Config links point to the official or best-known community "
"repositories. Check each project for install steps and current status.")
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
URL_RE = re.compile(r"^https?://\S+$")
# Files the pages link to; everything in assets/ is copied to the output root.
REQUIRED_ASSETS = ("style.css", "script.js", "logo.svg")
class ThemeError(Exception):
pass
# ---------------------------------------------------------------- colors
def _rgb(h):
return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5))
def luminance(h):
def f(v):
v /= 255
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
r, g, b = (f(v) for v in _rgb(h))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def mix(a, b, t):
ra, rb = _rgb(a), _rgb(b)
return "#%02X%02X%02X" % tuple(round(x + (y - x) * t) for x, y in zip(ra, rb))
def vividness(h):
_, s, v = colorsys.rgb_to_hsv(*(c / 255 for c in _rgb(h)))
return s * v
def ink(h):
"""Readable text color on top of background h."""
return "#111111" if luminance(h) > 0.35 else "#FFFFFF"
# ---------------------------------------------------------------- loading
class Theme:
def __init__(self, slug, data, filename):
def fail(msg):
raise ThemeError(f"{filename}: {msg}")
def text(key):
v = data.get(key)
if not isinstance(v, str) or not v.strip():
fail(f'"{key}" must be a non-empty string')
return v.strip()
def color(v, where):
if not isinstance(v, str) or not HEX_RE.match(v):
fail(f'{where} must be a color like "#1A2B3C", got {v!r}')
return v.upper()
def link(v, where):
if not isinstance(v, str) or not URL_RE.match(v):
fail(f"{where} must be an http(s) link, got {v!r}")
return v
self.slug = slug
self.name = text("name")
self.description = text("description")
self.site = link(data.get("site"), '"site"')
palette = data.get("palette")
if not isinstance(palette, dict) or not palette:
fail('"palette" must be an object of color name -> hex code')
self.palette = [(n, color(h, f'palette["{n}"]')) for n, h in palette.items()]
configs = data.get("configs", {})
if not isinstance(configs, dict):
fail('"configs" must be an object of program name -> link')
self.configs = [(p, link(u, f'configs["{p}"]')) for p, u in configs.items()]
# Colors used to style the card and page: explicit values win,
# everything else is derived from the palette.
hexes = sorted((h for _, h in self.palette), key=luminance)
bg, fg = hexes[0], hexes[-1]
given = data.get("colors", {})
if not isinstance(given, dict):
fail('"colors" must be an object')
unknown = set(given) - {"background", "foreground", "surface", "accent", "muted"}
if unknown:
fail(f'unknown keys in "colors": {", ".join(sorted(unknown))}')
vivid = sorted(hexes, key=vividness, reverse=True)
def pick(key, default):
return color(given[key], f'colors["{key}"]') if key in given else default
self.bg = pick("background", bg)
self.fg = pick("foreground", fg)
self.surface = pick("surface", mix(self.bg, self.fg, 0.12))
self.muted = pick("muted", mix(self.bg, self.fg, 0.4))
self.accent = pick("accent", vivid[0])
syntax = data.get("syntax")
if syntax is None:
syntax = [vivid[i % len(vivid)] for i in range(4)]
if not isinstance(syntax, list) or len(syntax) not in (4, 5):
fail('"syntax" must be a list of 4 or 5 colors')
syntax = [color(c, f'syntax[{i}]') for i, c in enumerate(syntax)]
if len(syntax) == 4:
syntax.append(self.muted)
self.syntax = syntax
def slugify(stem):
return re.sub(r"[^a-z0-9]+", "-", stem.lower()).strip("-")
def load_themes(folder):
files = sorted(f for f in folder.glob("*.json") if not f.name.startswith("_"))
if not files:
sys.exit(f"No .json files found in {folder}")
themes, errors, seen = [], [], {}
for f in files:
slug = slugify(f.stem)
try:
if not slug or slug == "index":
raise ThemeError(f"{f.name}: file name can't be turned into a page name")
if slug in seen:
raise ThemeError(f"{f.name}: page name '{slug}' is already used by {seen[slug]}")
try:
data = json.loads(f.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise ThemeError(f"{f.name}: invalid JSON ({e})")
if not isinstance(data, dict):
raise ThemeError(f"{f.name}: top level must be an object")
themes.append(Theme(slug, data, f.name))
seen[slug] = f.name
except ThemeError as e:
errors.append(str(e))
if errors:
sys.exit("Could not build:\n " + "\n ".join(errors))
return sorted(themes, key=lambda t: t.name.lower())
# ---------------------------------------------------------------- html
def esc(s):
return html.escape(str(s), quote=True)
def document(title, description, body, extra_style="", root=""):
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(title)}</title>
<meta name="description" content="{esc(description)}">
<link rel="icon" href="{root}logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="{root}style.css">
<script src="{root}script.js"></script>
{extra_style}</head>
<body>
{body}
</body>
</html>
"""
def snippet(t):
kw, fn, st, num, cm = t.syntax
def sp(color, text):
return f'<span style="color:{color}">{text}</span>'
return (f'{sp(cm, "// say hello")}\n'
f'{sp(kw, "const")} {sp(fn, "greet")} = (n) =&gt; {{\n'
f' {sp(kw, "return")} {sp(st, chr(34) + "hi, " + chr(34))} + n + {sp(num, "42")};\n'
f'}}')
def card(t):
strip = "".join(f'<i style="background:{h}"></i>' for _, h in t.palette)
return (f'<a class="card" href="{t.slug}/" style="background:{t.bg};color:{t.fg}">'
f'<h2 style="color:{t.accent}">{esc(t.name)}</h2><p>{esc(t.description)}</p>'
f'<div class="code" style="background:{t.surface}">{snippet(t)}</div>'
f'<div class="strip">{strip}</div></a>')
def index_page(themes):
cards = "\n".join(card(t) for t in themes)
body = f"""<div class="wrap">
<div class="top"><h1><img class="logo" src="logo.svg" alt="" width="44" height="44">{esc(SITE_NAME)}</h1><button id="mode" aria-label="Toggle light or dark page"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><g class="sun"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M4.9 19.1l1.8-1.8M17.3 6.7l1.8-1.8"/></g><path class="moon" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="currentColor"/></svg></button></div>
<p class="lead">{esc(SITE_TAGLINE)}</p>
<div class="grid">
{cards}
</div>
<p class="foot">{esc(FOOTNOTE)}</p>
</div>"""
return document(SITE_NAME, SITE_TAGLINE, body)
def theme_page(t):
swatches = "".join(
f'<button data-hex="{h}" style="background:{h};color:{ink(h)}" aria-label="Copy {h}">'
f'<b>{esc(n)}</b><span>{h}</span></button>'
for n, h in t.palette)
configs = ""
if t.configs:
rows = "".join(
f'<li><span class="p">{esc(p)}</span>'
f'<a href="{esc(u)}" target="_blank" rel="noopener">{esc(re.sub(r"^https?://", "", u))}</a></li>'
for p, u in t.configs)
configs = f'<h3>Configuration files</h3>\n<ul class="rows">{rows}</ul>'
style = (f"<style>:root,:root[data-theme]{{--bg:{t.bg};--fg:{t.fg};"
f"--mu:{t.muted};--ac:{t.accent}}}</style>\n")
body = f"""<div class="wrap theme">
<div class="top"><a href="../">← All themes</a><a href="{esc(t.site)}" target="_blank" rel="noopener">Project site</a></div>
<h1 style="color:{t.accent}">{esc(t.name)}</h1>
<p class="lead">{esc(t.description)}</p>
<h3>Palette</h3>
<div class="sw">{swatches}</div>
{configs}
</div>
<div class="toast" id="toast" role="status"></div>"""
return document(f"{t.name} – {SITE_NAME}", t.description, body, style, root="../")
# ---------------------------------------------------------------- main
def main():
here = Path(__file__).resolve().parent
ap = argparse.ArgumentParser(description="Build the ricehub static site.")
ap.add_argument("--themes", type=Path, default=here / "themes", help="folder with theme .json files")
ap.add_argument("--assets", type=Path, default=here / "assets", help="folder copied into the output as-is")
ap.add_argument("--out", type=Path, default=here / "dist", help="output folder")
args = ap.parse_args()
missing = [n for n in REQUIRED_ASSETS if not (args.assets / n).is_file()]
if missing:
sys.exit(f"Missing in {args.assets}: {', '.join(missing)}")
themes = load_themes(args.themes)
out = args.out
out.mkdir(parents=True, exist_ok=True)
shutil.copytree(args.assets, out, dirs_exist_ok=True)
(out / "index.html").write_text(index_page(themes), encoding="utf-8")
for t in themes:
(out / t.slug).mkdir(exist_ok=True)
(out / t.slug / "index.html").write_text(theme_page(t), encoding="utf-8")
print(f"Built {len(themes)} themes -> {out}")
if __name__ == "__main__":
main()