300 lines
11 KiB
Python
300 lines
11 KiB
Python
#!/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) => {{\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()
|