#!/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/.json becomes /index.html (served as //); 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""" {esc(title)} {extra_style} {body} """ def snippet(t): kw, fn, st, num, cm = t.syntax def sp(color, text): return f'{text}' 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'' for _, h in t.palette) return (f'' f'

{esc(t.name)}

{esc(t.description)}

' f'
{snippet(t)}
' f'
{strip}
') def index_page(themes): cards = "\n".join(card(t) for t in themes) body = f"""

{esc(SITE_NAME)}

{esc(SITE_TAGLINE)}

{cards}

{esc(FOOTNOTE)}

""" return document(SITE_NAME, SITE_TAGLINE, body) def theme_page(t): swatches = "".join( f'' for n, h in t.palette) configs = "" if t.configs: rows = "".join( f'
  • {esc(p)}' f'{esc(re.sub(r"^https?://", "", u))}
  • ' for p, u in t.configs) configs = f'

    Configuration files

    \n
      {rows}
    ' style = (f"\n") body = f"""

    {esc(t.name)}

    {esc(t.description)}

    Palette

    {swatches}
    {configs}
    """ 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()