Initial commit
This commit is contained in:
19 files changed
+1652
No files matched your search
@@ -0,0 +1,299 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user