wolfskin/wolfskin-design/generate_tokens.py

127 lines
6.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""Generate Compose and CSS tokens and a build-verifiable drift manifest."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
KOTLIN = Path("src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt")
def number(value: float) -> str:
"""Render stable Kotlin decimal literals."""
return str(float(value))
def property_name(name: str) -> str:
"""Keep source names valid as Kotlin identifiers."""
return name.replace("-", "_")
def compose_weight(weight: int) -> str:
"""Use the platform bold face for intermediate weights that resolve to Regular in native captures."""
return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})"
def render_tokens(tokens: dict) -> tuple[str, str]:
"""Translate locked values into platform-resolvable Compose and CSS tokens."""
lines = [
"// Generated by generate_tokens.py; edit the JSON sources instead.",
"package org.justinvisser.wolfskin.design",
"",
"import androidx.compose.ui.graphics.Color as ComposeColor",
"import androidx.compose.ui.text.TextStyle",
"import androidx.compose.ui.text.font.FontWeight",
"import androidx.compose.ui.unit.dp",
"import androidx.compose.ui.unit.em",
"import androidx.compose.ui.unit.sp",
"",
"/** The locked TV design, converted at the source density. */",
"object WolfskinTokens {",
]
css = ["/* Generated from the locked TV token sources. */", ":root {"]
for group_name, group in tokens.items():
if group_name.startswith("$") or group_name in ("meta", "font"):
continue
lines.append(f" object {group_name[0].upper() + group_name[1:]} {{")
for name, token in group.items():
if not isinstance(token, dict):
continue
name = property_name(name)
kind = token.get("$type")
value = token.get("$value")
if kind == "color":
rgba = value.lstrip("#")
argb = (rgba[6:8] if len(rgba) == 8 else "ff") + rgba[:6]
lines.append(f" val {name} = ComposeColor(0x{argb.upper()})")
css.append(f" --{group_name}-{name}: {value};")
elif kind == "typography" and "sp" in token:
size = token["sp"]
weight = token.get("weight", token["$value"]["fontWeight"])
height = size * token.get("lineHeight", 1.3)
tracking = token.get("letterSpacingEm", 0)
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
elif kind == "typography" and "sizes" in token:
for context, pixels in token["sizes"].items():
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})")
elif kind == "duration":
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
css.append(f" --{group_name}-{name}: {value};")
elif kind == "transition" and value.get("type") == "spring":
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")
elif kind == "shadow":
for suffix in ("gapPx", "ringPx"):
lines.append(f" val {name}{suffix[0].upper() + suffix[1:-2]} = {number(token[suffix] / 2)}.dp")
elif isinstance(token.get("dp"), (float, int)):
lines.append(f" val {name} = {number(token['dp'])}.dp")
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
elif isinstance(token.get("dp"), list):
for suffix, dimension in zip(("Width", "Height"), token["dp"]):
lines.append(f" val {name}{suffix} = {number(dimension)}.dp")
elif isinstance(token.get("dp"), dict):
for suffix, dimension in token["dp"].items():
lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp")
elif isinstance(value, (float, int)) and not isinstance(value, bool):
lines.append(f" const val {name} = {number(value)}f")
elif kind in ("gradient", "filter", "rule") and isinstance(value, str):
lines.append(f" const val {name} = {json.dumps(value)}")
if kind == "gradient":
css.append(f" --{group_name}-{name}: {value};")
lines.append(" }")
lines.append("}")
css.append("}")
return "\n".join(lines) + "\n", "\n".join(css) + "\n"
def main() -> None:
"""Write generated artifacts, or fail when committed artifacts drift."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
arguments = parser.parse_args()
tokens = json.loads((ROOT / "tokens.json").read_text())
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
kotlin, css = render_tokens(tokens)
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
inputs = ["tokens.json", "shell-tokens.json", "home-tokens.json", "generate_tokens.py"]
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
for name, content in outputs.items():
path = ROOT / name
if arguments.check:
if not path.exists() or path.read_text() != content:
raise SystemExit(f"Token drift: run python3 wolfskin-design/generate_tokens.py ({name})")
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
print("Token drift check passed" if arguments.check else "Generated Kotlin, CSS and drift manifest")
if __name__ == "__main__":
main()