Files
tw93--kami/scripts/shared.py
T
2026-07-13 13:05:52 +08:00

284 lines
9.9 KiB
Python

"""Shared constants and helpers for kami build scripts."""
from __future__ import annotations
import functools
import json
import os
import sys
from pathlib import Path
from typing import Any, NamedTuple
class TemplateSpec(NamedTuple):
"""Per-template configuration.
build_max_pages: hard ceiling enforced by `build.py --verify`. 0 = no limit.
"""
source: str
build_max_pages: int
ROOT = Path(__file__).resolve().parent.parent
TEMPLATES = ROOT / "assets" / "templates"
DIAGRAMS = ROOT / "assets" / "diagrams"
EXAMPLES = ROOT / "assets" / "examples"
TOKENS_FILE = ROOT / "references" / "tokens.json"
CHECKS_THRESHOLDS_FILE = ROOT / "references" / "checks_thresholds.json"
PUBLIC_REPO = "tw93/kami"
CLAUDE_CODE_MIN_VERSION = "2.1.142"
CLAUDE_CODE_INSTALL_COMMANDS = (
f"/plugin marketplace add {PUBLIC_REPO}",
"/plugin install kami@kami",
)
CODEX_PLUGIN_INSTALL_COMMANDS = (
f"codex plugin marketplace add {PUBLIC_REPO}",
"codex plugin add kami@kami",
)
GENERIC_AGENT_INSTALL_COMMAND = f"npx skills add {PUBLIC_REPO}/plugins/kami -a universal -g -y"
CLAUDE_DESKTOP_PACKAGE_URL = "https://github.com/tw93/kami/releases/latest/download/kami.zip"
# Canonical parchment background color, kept here so build/density
# checks share one source of truth instead of redefining the RGB triple.
PARCHMENT_RGB = (0xF5, 0xF4, 0xED)
_HOMEBREW_PREFIXES = (Path("/opt/homebrew"), Path("/usr/local"))
def _default_cache_dir() -> Path:
"""Return a sensible per-platform fontconfig cache directory."""
if sys.platform == "darwin":
return Path("/private/tmp/kami-fontconfig-cache")
xdg = os.environ.get("XDG_CACHE_HOME")
if xdg:
return Path(xdg) / "kami"
return Path.home() / ".cache" / "kami"
def configure_weasyprint_runtime() -> None:
"""Make platform-native libraries discoverable before importing WeasyPrint.
On macOS, also surface Homebrew's gobject lib so cairo/pango can load.
On Linux/other, only the fontconfig cache hint is set; the system loader
is expected to find the libraries.
"""
os.environ.setdefault("XDG_CACHE_HOME", str(_default_cache_dir()))
if sys.platform != "darwin":
return
brew_lib = next(
(p / "lib" for p in _HOMEBREW_PREFIXES if (p / "lib" / "libgobject-2.0.dylib").exists()),
None,
)
if brew_lib is None:
return
existing = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "")
paths = [path for path in existing.split(":") if path]
brew_lib_str = str(brew_lib)
if brew_lib_str in paths:
return
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join([brew_lib_str, *paths])
# Cool / neutral gray hex values that violate the "warm undertone only" rule.
COOL_GRAY_BLOCKLIST = {
"#888", "#888888", "#666", "#666666", "#999", "#999999",
"#ccc", "#cccccc", "#ddd", "#dddddd", "#eee", "#eeeeee",
"#111", "#111111", "#222", "#222222", "#333", "#333333",
"#444", "#444444", "#555", "#555555", "#777", "#777777",
"#aaa", "#aaaaaa", "#bbb", "#bbbbbb",
# Tailwind cool grays
"#6b7280", "#9ca3af", "#d1d5db", "#e5e7eb", "#f3f4f6",
"#4b5563", "#374151", "#1f2937", "#111827",
# Bootstrap-like neutrals
"#f8f9fa", "#e9ecef", "#dee2e6", "#ced4da", "#adb5bd",
"#6c757d", "#495057", "#343a40", "#212529",
}
# ---------------------------------------------------------------------------
# Template registry
#
# Single source of truth for HTML targets used by build.py.
# See TemplateSpec for field meanings.
# ---------------------------------------------------------------------------
HTML_TEMPLATES: dict[str, TemplateSpec] = {
# Core six
"one-pager": TemplateSpec("one-pager.html", 1),
"letter": TemplateSpec("letter.html", 1),
"long-doc": TemplateSpec("long-doc.html", 0),
"portfolio": TemplateSpec("portfolio.html", 0),
"resume": TemplateSpec("resume.html", 2),
"one-pager-en": TemplateSpec("one-pager-en.html", 1),
"letter-en": TemplateSpec("letter-en.html", 1),
"long-doc-en": TemplateSpec("long-doc-en.html", 0),
"portfolio-en": TemplateSpec("portfolio-en.html", 0),
"resume-en": TemplateSpec("resume-en.html", 2),
# Korean
"one-pager-ko": TemplateSpec("one-pager-ko.html", 1),
"letter-ko": TemplateSpec("letter-ko.html", 1),
"long-doc-ko": TemplateSpec("long-doc-ko.html", 0),
"portfolio-ko": TemplateSpec("portfolio-ko.html", 0),
"resume-ko": TemplateSpec("resume-ko.html", 2),
"equity-report-ko": TemplateSpec("equity-report-ko.html", 3),
"changelog-ko": TemplateSpec("changelog-ko.html", 2),
"slides-weasy-ko": TemplateSpec("slides-weasy-ko.html", 0),
# Equity report
"equity-report": TemplateSpec("equity-report.html", 3),
"equity-report-en": TemplateSpec("equity-report-en.html", 3),
# Changelog
"changelog": TemplateSpec("changelog.html", 2),
"changelog-en": TemplateSpec("changelog-en.html", 2),
# Slides (WeasyPrint default)
"slides-weasy": TemplateSpec("slides-weasy.html", 0),
"slides-weasy-en": TemplateSpec("slides-weasy-en.html", 0),
}
SCREEN_TEMPLATES: dict[str, str] = {
"landing-page": "landing-page.html",
"landing-page-en": "landing-page-en.html",
"landing-page-ko": "landing-page-ko.html",
}
# Diagram HTMLs live in assets/diagrams and have no page-count contract.
# Registered here (not in build.py) so all template registries share one home.
# The Mermaid-sourced ones are produced via scripts/mermaid_normalize.py.
DIAGRAM_TEMPLATES: dict[str, str] = {
"diagram-architecture": "architecture.html",
"diagram-architecture-board": "architecture-board.html",
"diagram-flowchart": "flowchart.html",
"diagram-quadrant": "quadrant.html",
"diagram-bar-chart": "bar-chart.html",
"diagram-line-chart": "line-chart.html",
"diagram-donut-chart": "donut-chart.html",
"diagram-state-machine": "state-machine.html",
"diagram-timeline": "timeline.html",
"diagram-swimlane": "swimlane.html",
"diagram-tree": "tree.html",
"diagram-layer-stack": "layer-stack.html",
"diagram-venn": "venn.html",
"diagram-candlestick": "candlestick.html",
"diagram-waterfall": "waterfall.html",
# Mermaid-sourced (beautiful-mermaid + scripts/mermaid_normalize.py)
"diagram-sequence": "sequence.html",
"diagram-class": "class.html",
"diagram-er": "er.html",
}
PUBLIC_DOCUMENT_TEMPLATE_KINDS = {
"one-pager",
"letter",
"long-doc",
"portfolio",
"resume",
"slides",
"equity-report",
"changelog",
}
def _public_template_kind(name: str) -> str:
for suffix in ("-en", "-ko"):
if name.endswith(suffix):
name = name[: -len(suffix)]
break
if name.startswith("slides-weasy"):
return "slides"
return name
def public_document_template_kinds() -> set[str]:
"""Return public document-template kinds represented by HTML_TEMPLATES."""
return {
_public_template_kind(name)
for name in HTML_TEMPLATES
if _public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS
}
def public_document_template_count() -> int:
return len(public_document_template_kinds())
def rel_to_root(path: Path) -> Path:
"""Return `path` relative to ROOT when possible, else the path unchanged."""
return path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
def default_example_pdfs() -> list[str]:
"""Return every rendered example PDF, the default scan set for PDF checks."""
return [str(p) for p in sorted(EXAMPLES.glob("*.pdf"))]
def iter_template_files(
*,
include_py: bool = False,
include_diagrams: bool = False,
include_marp_css: bool = False,
) -> list[Path]:
"""Collect template-family files for scanning.
One shared walker so lint, token-sync, and future checks cannot silently
diverge in coverage (a divergence is how the Marp CSS family once slipped
out of the lint scan while staying in the token scan).
"""
targets: list[Path] = list(TEMPLATES.glob("*.html"))
if include_py:
targets.extend(TEMPLATES.glob("*.py"))
if include_diagrams and DIAGRAMS.exists():
targets.extend(DIAGRAMS.glob("*.html"))
if include_marp_css:
marp_dir = TEMPLATES / "marp"
if marp_dir.exists():
targets.extend(marp_dir.glob("*.css"))
return sorted(targets)
@functools.lru_cache(maxsize=1)
def kami_version() -> str:
"""Return the canonical Kami version from the tracked VERSION file."""
return (ROOT / "VERSION").read_text(encoding="utf-8").strip()
@functools.lru_cache(maxsize=1)
def load_tokens() -> dict[str, str]:
return json.loads(TOKENS_FILE.read_text(encoding="utf-8"))
def token_value(name: str) -> str:
key = name if name.startswith("--") else f"--{name}"
return load_tokens()[key]
def build_targets() -> dict[str, tuple[str, int]]:
"""Return target -> (source, max_pages) mapping for build.py."""
return {name: (spec.source, spec.build_max_pages) for name, spec in HTML_TEMPLATES.items()}
def screen_targets() -> dict[str, str]:
"""Return target -> source mapping for browser-only HTML templates."""
return dict(SCREEN_TEMPLATES)
def diagram_targets() -> dict[str, str]:
"""Return target -> source mapping for assets/diagrams HTML templates."""
return dict(DIAGRAM_TEMPLATES)
@functools.lru_cache(maxsize=1)
def load_checks_thresholds() -> dict[str, Any]:
"""Return rhythm / density / orphan thresholds.
Falls back to baked-in defaults if the JSON is missing so build.py works
on a half-installed checkout.
"""
if CHECKS_THRESHOLDS_FILE.exists():
return json.loads(CHECKS_THRESHOLDS_FILE.read_text(encoding="utf-8"))
return {
"rhythm": {"max_content_run": 5, "divider_min_deck_size": 12},
"density": {"warn_pct": 0.25, "sparse_pct": 0.50, "dpi": 36},
"orphan": {"max_words": 2, "max_chars": 15},
}