chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:05:52 +08:00
commit d436a5e4ff
254 changed files with 73714 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""kami build & check
Thin CLI shell. Implementation lives in:
- lint.py (scan_file, check_all, check_cross_template_consistency)
- tokens.py (sync_check)
- site_facts.py (check_site_facts)
- verify.py (verify_target, verify_all, show_fonts, font checks)
- checks.py (check_placeholders, check_markdown_residue, check_orphans, check_density, check_resume_balance, check_rhythm)
Usage:
python3 scripts/build.py # build all examples (HTML + diagrams + PPTX)
python3 scripts/build.py resume # build one template, print pages + fonts
python3 scripts/build.py landing-page # check one browser-only static template
python3 scripts/build.py --check # lint + token/theme + public-site fact checks
python3 scripts/build.py --check -v # verbose (show each scanned file)
python3 scripts/build.py --sync # check CSS token drift across templates
python3 scripts/build.py --verify # build all + page count + font checks
python3 scripts/build.py --verify resume-en # single target full verification
python3 scripts/build.py --check-placeholders path/to/doc.html
python3 scripts/build.py --check-markdown path/to/doc.pdf
python3 scripts/build.py --check-orphans # scan example PDFs for orphan text
python3 scripts/build.py --check-orphans path/to/doc.pdf
python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace
python3 scripts/build.py --check-density path/to/doc.pdf
python3 scripts/build.py --check-resume-balance path/to/resume.pdf
python3 scripts/build.py --check-rhythm # warn on monotonous slide sequences
python3 scripts/build.py --check-rhythm slides slides-en
"""
from __future__ import annotations
import functools
import os
import subprocess
import sys
from pathlib import Path
from highlight import highlight_code_blocks
from optional_deps import (
MissingDepError,
require_pypdf_reader,
require_pypdf_writer,
require_weasyprint_html,
)
from shared import (
DIAGRAMS,
EXAMPLES,
TEMPLATES,
build_targets,
diagram_targets,
screen_targets,
)
# Implementation modules (also re-exported for tests / external callers).
from checks import ( # noqa: F401 re-exported for test_build.py
_BG_B,
_BG_G,
_BG_R,
_density_bucket,
_last_content_y,
_markdown_residue_issues,
_orphan_last_line,
_parse_slide_sequence,
_resume_balance_issues,
_rhythm_issues,
_scan_density,
check_density,
check_markdown_residue,
check_orphans,
check_placeholders,
check_resume_balance,
check_rhythm,
)
from lint import ( # noqa: F401 re-exported for test_build.py
_extract_root_vars,
_off_palette_findings,
_pair_names,
_root_token_findings,
check_all,
check_cross_template_consistency,
check_off_palette,
scan_file,
)
from site_facts import check_site_facts
from tokens import sync_check
from verify import (
show_fonts,
verify_all,
)
# name -> (source, max_pages). max_pages=0 means no hard check.
# Sourced from shared.HTML_TEMPLATES (single source of truth for targets).
HTML_TARGETS: dict[str, tuple[str, int]] = build_targets()
SCREEN_TARGETS: dict[str, str] = screen_targets()
PPTX_TARGETS: dict[str, str] = {
"slides": "slides.py",
"slides-en": "slides-en.py",
}
# Diagram HTMLs live in a separate directory and have no page-count contract.
# Registry lives in shared.DIAGRAM_TEMPLATES (single home for all template lists).
DIAGRAM_TARGETS: dict[str, str] = diagram_targets()
# ------------------------- build helpers -------------------------
@functools.lru_cache(maxsize=1)
def infer_author() -> str:
"""Infer author name from git config or environment.
Priority:
1. git config user.name
2. KAMI_AUTHOR env var
3. fallback to "Kami"
Cached so a full build doesn't shell out for every PDF target.
"""
try:
result = subprocess.run(
["git", "config", "user.name"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except FileNotFoundError:
pass
if env_author := os.environ.get("KAMI_AUTHOR"):
return env_author
return "Kami"
def set_pdf_metadata(pdf_path: Path, author: str | None = None) -> None:
"""Set PDF metadata using pypdf, only if placeholders are still present."""
try:
PdfReader = require_pypdf_reader()
PdfWriter = require_pypdf_writer()
except MissingDepError:
return
if not pdf_path.exists():
return
reader = PdfReader(str(pdf_path))
existing = reader.metadata or {}
needs_update = False
metadata = dict(existing)
if author and existing.get("/Author"):
author_value = str(existing["/Author"])
if "{{" in author_value and "}}" in author_value:
metadata["/Author"] = author
needs_update = True
if metadata.get("/Producer") != "Kami":
metadata["/Producer"] = "Kami"
needs_update = True
if metadata.get("/Creator") != "Kami":
metadata["/Creator"] = "Kami"
needs_update = True
if not needs_update:
return
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
writer.add_metadata(metadata)
with open(pdf_path, "wb") as f:
writer.write(f)
# ------------------------- build -------------------------
def build_html(name: str, source: str, max_pages: int,
src_dir: Path = TEMPLATES) -> bool:
try:
HTML = require_weasyprint_html()
PdfReader = require_pypdf_reader()
except MissingDepError as exc:
print(f"ERROR: {exc}")
return False
src = src_dir / source
if not src.exists():
print(f"ERROR: {name}: source not found ({src})")
return False
EXAMPLES.mkdir(parents=True, exist_ok=True)
out = EXAMPLES / f"{name}.pdf"
html_text = src.read_text(encoding="utf-8")
html_text = highlight_code_blocks(html_text)
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out))
set_pdf_metadata(out, author=infer_author())
n = len(PdfReader(str(out)).pages)
msg = f"OK: {name}: {n} pages"
if max_pages and n > max_pages:
msg = f"ERROR: {name}: {n} pages (limit {max_pages})"
print(msg)
return False
print(msg)
return True
def build_slides(name: str = "slides") -> bool:
source = PPTX_TARGETS.get(name)
if source is None:
print(f"ERROR: {name}: unknown slides target")
return False
src = TEMPLATES / source
if not src.exists():
print(f"ERROR: {name}: source not found ({src})")
return False
EXAMPLES.mkdir(parents=True, exist_ok=True)
out = EXAMPLES / f"{name}.pptx"
# Pass --out so the slides script writes directly to the target path. Older
# slides.py defaults to 'output.pptx' in cwd; new copies accept --out.
result = subprocess.run(
[sys.executable, str(src), "--out", str(out)],
cwd=str(src.parent),
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"ERROR: {name}: {result.stderr.strip() or 'script failed'}")
return False
if out.exists():
print(f"OK: {name}: generated {out.name}")
return True
print(f"ERROR: {name}: {out.name} not produced")
return False
def build_screen_template(name: str, source: str) -> bool:
src = TEMPLATES / source
if not src.exists():
print(f"ERROR: {name}: source not found ({src})")
return False
findings = scan_file(src)
if findings:
print(f"ERROR: {name}: {len(findings)} template violation(s)")
return False
print(f"OK: {name}: static HTML template")
return True
def build_all() -> int:
failures = 0
for name, (source, max_pages) in HTML_TARGETS.items():
if not build_html(name, source, max_pages):
failures += 1
for name, source in SCREEN_TARGETS.items():
if not build_screen_template(name, source):
failures += 1
for name, source in DIAGRAM_TARGETS.items():
if not build_html(name, source, 0, src_dir=DIAGRAMS):
failures += 1
for name in PPTX_TARGETS:
if not build_slides(name):
failures += 1
return failures
def build_single(name: str) -> int:
if name in HTML_TARGETS:
source, max_pages = HTML_TARGETS[name]
ok = build_html(name, source, max_pages)
if ok:
show_fonts(EXAMPLES / f"{name}.pdf")
return 0 if ok else 1
if name in SCREEN_TARGETS:
ok = build_screen_template(name, SCREEN_TARGETS[name])
return 0 if ok else 1
if name in DIAGRAM_TARGETS:
source = DIAGRAM_TARGETS[name]
ok = build_html(name, source, 0, src_dir=DIAGRAMS)
return 0 if ok else 1
if name in PPTX_TARGETS:
return 0 if build_slides(name) else 1
known = list(HTML_TARGETS) + list(SCREEN_TARGETS) + list(DIAGRAM_TARGETS) + list(PPTX_TARGETS)
print(f"ERROR: unknown target: {name}. Known: {', '.join(known)}")
return 2
# ------------------------- verify glue -------------------------
def verify_slides_target(name: str) -> list[str]:
return [] if build_slides(name) else ["slides build failed"]
def _verify_all(target: str | None) -> int:
return verify_all(
target,
html_targets=HTML_TARGETS,
screen_targets=SCREEN_TARGETS,
diagram_targets=DIAGRAM_TARGETS,
pptx_targets=PPTX_TARGETS,
verify_slides_fn=verify_slides_target,
scan_file_fn=scan_file,
scan_density_fn=_scan_density,
infer_author_fn=infer_author,
set_pdf_metadata_fn=set_pdf_metadata,
)
# ------------------------- entry -------------------------
def _unexpected_arg(args: list[str], allowed: set[str] | None = None) -> str | None:
for arg in args:
if allowed is not None:
if arg not in allowed:
return arg
elif arg.startswith("-"):
return arg
return None
def _error_unexpected(arg: str) -> int:
print(f"ERROR: unexpected argument: {arg}")
return 2
def main(argv: list[str]) -> int:
args = argv[1:]
if not args:
return build_all()
if args[0] in ("-h", "--help"):
print(__doc__)
return 0
if args[0] == "--check":
unexpected = _unexpected_arg(args[1:], {"-v", "--verbose"})
if unexpected:
return _error_unexpected(unexpected)
verbose = "-v" in args[1:] or "--verbose" in args[1:]
css_result = check_all(verbose)
sync_result = sync_check(verbose)
cross_result = check_cross_template_consistency(verbose)
palette_result = check_off_palette(verbose)
site_result = check_site_facts(verbose)
return max(css_result, sync_result, cross_result, palette_result, site_result)
if args[0] == "--sync":
unexpected = _unexpected_arg(args[1:], {"-v", "--verbose"})
if unexpected:
return _error_unexpected(unexpected)
verbose = "-v" in args[1:] or "--verbose" in args[1:]
return sync_check(verbose)
if args[0] == "--verify":
if len(args) > 2:
return _error_unexpected(args[2])
if len(args) == 2 and args[1].startswith("-"):
return _error_unexpected(args[1])
target = args[1] if len(args) > 1 else None
return _verify_all(target)
# Path-taking check subcommands share one guard + dispatch table.
path_checks = {
"--check-orphans": check_orphans,
"--check-density": check_density,
"--check-resume-balance": check_resume_balance,
"--check-placeholders": check_placeholders,
"--check-markdown": check_markdown_residue,
}
handler = path_checks.get(args[0])
if handler is not None:
unexpected = _unexpected_arg(args[1:])
if unexpected:
return _error_unexpected(unexpected)
return handler(args[1:])
if args[0] == "--check-rhythm":
unexpected = _unexpected_arg(args[1:])
if unexpected:
return _error_unexpected(unexpected)
slide_targets = [a for a in args[1:] if not a.startswith("-")]
return check_rhythm(slide_targets, PPTX_TARGETS, TEMPLATES)
if args[0].startswith("-"):
print(f"ERROR: unknown option: {args[0]}")
return 2
if len(args) > 1:
return _error_unexpected(args[1])
return build_single(args[0])
if __name__ == "__main__":
sys.exit(main(sys.argv))
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Generate Kami plugin metadata and mirror files.
Source of truth:
- VERSION
- root SKILL.md
- CHEATSHEET.md
- references/
- scripts/
- assets/templates/
- assets/diagrams/
- selected lightweight assets
Generated files:
- .claude-plugin/marketplace.json
- .agents/plugins/marketplace.json
- plugins/kami/.claude-plugin/plugin.json
- plugins/kami/.codex-plugin/plugin.json
- plugins/kami/skills/kami/
Modes:
--write (default) regenerate plugin files from source
--check compare generated bytes against committed files
Run as: python3 scripts/build_metadata.py [--check] [--root PATH]
"""
from __future__ import annotations
import argparse
import difflib
import json
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PLUGIN_NAME = "kami"
CODEX_CATEGORY = "Productivity"
HOMEPAGE = "https://github.com/tw93/kami"
REPOSITORY = "https://github.com/tw93/kami"
AUTHOR = {
"name": "Tw93",
"email": "hitw93@gmail.com",
"url": "https://github.com/tw93",
}
CODEX_DESCRIPTION = (
"Professional document and landing-page typesetting skill for Codex: "
"resumes, one-pagers, reports, letters, portfolios, slides, and more."
)
CLAUDE_MARKETPLACE_DESCRIPTION = "Document typesetting skill for Claude Code."
CLAUDE_PLUGIN_DESCRIPTION = (
"Typeset professional documents and landing pages with the Kami design system."
)
SKILL_MIRROR_ROOT = Path("plugins/kami/skills/kami")
SKILL_MIRROR_FILES = (
"CHEATSHEET.md",
"LICENSE",
"SKILL.md",
"VERSION",
)
SKILL_MIRROR_DIRS = (
"assets/diagrams",
"assets/fonts",
"assets/images",
"assets/templates",
"references",
"scripts",
)
SKILL_MIRROR_ALLOWED_FONT_FILES = {
"JetBrainsMono.woff2",
"LICENSE-SourceHanSerifK.txt",
}
SKILL_MIRROR_IGNORED_DIRS = {
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
}
SKILL_MIRROR_IGNORED_NAMES = {
".DS_Store",
}
SKILL_MIRROR_IGNORED_SUFFIXES = {
".pyc",
".pyo",
}
def read_version(root: Path) -> str:
version_file = root / "VERSION"
if not version_file.exists():
raise SystemExit(f"ERROR: missing VERSION file at {version_file}")
version = version_file.read_text().strip()
if not version:
raise SystemExit("ERROR: VERSION file is empty")
return version
def read_token_value(root: Path, name: str) -> str:
key = name if name.startswith("--") else f"--{name}"
token_file = root / "references" / "tokens.json"
try:
tokens = json.loads(token_file.read_text(encoding="utf-8"))
except OSError as exc:
raise SystemExit(f"ERROR: missing token file at {token_file}: {exc}") from exc
except json.JSONDecodeError as exc:
raise SystemExit(f"ERROR: tokens.json is malformed: {exc}") from exc
try:
return tokens[key]
except KeyError as exc:
raise SystemExit(f"ERROR: missing token {key} in {token_file}") from exc
def render_json(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def build_codex_plugin(version: str, brand_color: str) -> dict:
return {
"name": PLUGIN_NAME,
"version": version,
"description": CODEX_DESCRIPTION,
"author": AUTHOR,
"homepage": HOMEPAGE,
"repository": REPOSITORY,
"license": "MIT",
"keywords": [
"codex",
"skills",
"documents",
"typesetting",
"resume",
"slides",
"landing-page",
],
"skills": "./skills/",
"interface": {
"displayName": "Kami",
"shortDescription": "Typeset polished documents and landing pages",
"longDescription": (
"Kami packages a warm parchment design system for Codex. Use it "
"to turn briefs and raw material into resumes, one-pagers, long "
"documents, letters, portfolios, slide decks, equity reports, "
"changelogs, and product landing pages."
),
"developerName": "Tw93",
"category": CODEX_CATEGORY,
"capabilities": [
"Interactive",
"Write",
],
"websiteURL": HOMEPAGE,
"defaultPrompt": [
"Make a polished one-pager from this brief",
"Build a resume using Kami",
"Turn this outline into a slide deck",
],
"brandColor": brand_color,
},
}
def build_codex_marketplace() -> dict:
return {
"name": PLUGIN_NAME,
"interface": {
"displayName": "Kami",
},
"plugins": [
{
"name": PLUGIN_NAME,
"source": {
"source": "local",
"path": "./plugins/kami",
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL",
},
"category": CODEX_CATEGORY,
}
],
}
def build_claude_plugin(version: str) -> dict:
return {
"name": PLUGIN_NAME,
"version": version,
"description": CLAUDE_PLUGIN_DESCRIPTION,
"author": {
"name": AUTHOR["name"],
"email": AUTHOR["email"],
},
"homepage": HOMEPAGE,
"repository": REPOSITORY,
"license": "MIT",
"skills": "./skills/",
}
def build_claude_marketplace(version: str) -> dict:
return {
"name": PLUGIN_NAME,
"description": CLAUDE_MARKETPLACE_DESCRIPTION,
"owner": {
"name": AUTHOR["name"],
"email": AUTHOR["email"],
},
"plugins": [
{
"name": PLUGIN_NAME,
"version": version,
"description": CLAUDE_PLUGIN_DESCRIPTION,
"category": "documents",
"source": "./plugins/kami",
"homepage": HOMEPAGE,
}
],
}
def should_include_skill_mirror_file(path: Path) -> bool:
if any(part in SKILL_MIRROR_IGNORED_DIRS for part in path.parts):
return False
if path.name in SKILL_MIRROR_IGNORED_NAMES:
return False
if path.suffix in SKILL_MIRROR_IGNORED_SUFFIXES:
return False
if path.parts[:2] == ("assets", "fonts"):
return path.name in SKILL_MIRROR_ALLOWED_FONT_FILES
return True
def collect_plugin_tree(root: Path, codex_manifest_rendered: str, claude_manifest_rendered: str) -> dict[str, bytes]:
"""Build the generated file set for the shared plugin directory.
Claude Code and Codex install only the directory referenced by their
marketplace source path. Kami's source skill lives at the repository root,
so the plugin mirrors a lightweight skill root under plugins/kami/skills/kami
and keeps all paths referenced by SKILL.md relative to that generated skill
directory.
"""
generated = {
"plugins/kami/.claude-plugin/plugin.json": claude_manifest_rendered.encode(),
"plugins/kami/.codex-plugin/plugin.json": codex_manifest_rendered.encode(),
}
for source in SKILL_MIRROR_FILES:
path = root / source
if not path.exists():
raise SystemExit(f"ERROR: missing required plugin source file {path}")
generated[(SKILL_MIRROR_ROOT / source).as_posix()] = path.read_bytes()
for source in SKILL_MIRROR_DIRS:
source_root = root / source
if not source_root.exists():
raise SystemExit(f"ERROR: missing required plugin source tree {source_root}")
for path in sorted(source_root.rglob("*")):
if not path.is_file():
continue
source_rel = path.relative_to(root)
if not should_include_skill_mirror_file(source_rel):
continue
generated[(SKILL_MIRROR_ROOT / source_rel).as_posix()] = path.read_bytes()
return generated
def diff(label: str, expected: str, actual: str) -> str:
return "".join(
difflib.unified_diff(
actual.splitlines(keepends=True),
expected.splitlines(keepends=True),
fromfile=f"committed:{label}",
tofile=f"generated:{label}",
)
)
def bytes_diff(label: str, expected: bytes, actual: bytes) -> str:
return diff(
label,
expected.decode("utf-8", errors="replace"),
actual.decode("utf-8", errors="replace"),
)
def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
drift = False
for generated_path, expected in generated_json_files:
actual = generated_path.read_text() if generated_path.exists() else ""
if actual != expected:
rel = generated_path.relative_to(root).as_posix()
print(
f"DRIFT: {rel} is out of sync with plugin metadata.\n"
"Run scripts/build_metadata.py (no flags) to regenerate.",
)
print(diff(rel, expected, actual), end="")
drift = True
for rel, expected in plugin_tree.items():
path = root / rel
actual = path.read_bytes() if path.exists() else b""
if actual != expected:
print(
f"DRIFT: {rel} is out of sync with Kami source files.\n"
"Run scripts/build_metadata.py (no flags) to regenerate.",
)
print(bytes_diff(rel, expected, actual), end="")
drift = True
codex_plugin_root = root / "plugins" / "kami"
if codex_plugin_root.exists():
expected_paths = set(plugin_tree)
for path in sorted(codex_plugin_root.rglob("*")):
if not path.is_file():
continue
rel_path = path.relative_to(root)
plugin_rel = path.relative_to(codex_plugin_root)
if not should_include_skill_mirror_file(plugin_rel):
continue
rel = rel_path.as_posix()
if rel not in expected_paths:
print(
f"DRIFT: {rel} is an extra file in the generated plugin tree.\n"
"Run scripts/build_metadata.py (no flags) to regenerate.",
)
drift = True
if drift:
return 1
for generated_path, _ in generated_json_files:
print(f"OK: {generated_path.relative_to(root)} matches generator")
print("OK: plugins/kami plugin tree matches generator")
return 0
def write_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
for generated_path, expected in generated_json_files:
generated_path.parent.mkdir(parents=True, exist_ok=True)
generated_path.write_text(expected)
print(f"OK: wrote {generated_path.relative_to(root)} ({len(expected)} bytes)")
codex_plugin_root = root / "plugins" / "kami"
shutil.rmtree(codex_plugin_root, ignore_errors=True)
for rel, expected in plugin_tree.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(expected)
print(f"OK: wrote plugins/kami ({len(plugin_tree)} generated files)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--root",
type=Path,
default=ROOT,
help="Repository root (default: parent of scripts/)",
)
parser.add_argument(
"--check",
action="store_true",
help="Compare generated bytes to committed files; exit non-zero on drift.",
)
args = parser.parse_args()
root = args.root.resolve()
version = read_version(root)
codex_plugin_rendered = render_json(build_codex_plugin(version, read_token_value(root, "brand")))
codex_marketplace_rendered = render_json(build_codex_marketplace())
claude_plugin_rendered = render_json(build_claude_plugin(version))
claude_marketplace_rendered = render_json(build_claude_marketplace(version))
plugin_tree = collect_plugin_tree(root, codex_plugin_rendered, claude_plugin_rendered)
generated_json_files = [
(root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered),
(root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered),
]
if args.check:
return check_generated(root, generated_json_files, plugin_tree)
return write_generated(root, generated_json_files, plugin_tree)
if __name__ == "__main__":
raise SystemExit(main())
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Quiet daily update check for the installed kami skill.
#
# Reads the public VERSION file on the default branch and compares it to the
# bundled VERSION. If a newer version exists, prints one line so the agent can
# relay it. No data is ever sent (a plain read-only GET); any failure is silent;
# the check runs at most once per day via a cache marker, so it never blocks work.
set -u
SKILL="kami"
REPO="tw93/Kami"
DEFAULT_UPDATE_CMD="npx skills add tw93/kami/plugins/kami -a universal -g -y"
# KAMI_UPDATE_URL overrides the source (used by tests); defaults to the public VERSION.
REMOTE_URL="${KAMI_UPDATE_URL:-https://raw.githubusercontent.com/${REPO}/main/VERSION}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
local_ver="$(tr -d '[:space:]' < "${root}/VERSION" 2>/dev/null)"
[ -n "${local_ver}" ] || exit 0
case "${root}" in
*/.claude/plugins/cache/kami/kami/*/skills/kami)
UPDATE_CMD="claude plugin update kami"
;;
*/plugins/cache/kami/kami/*/skills/kami)
UPDATE_CMD="codex plugin marketplace upgrade kami && codex plugin add kami@kami"
;;
*)
UPDATE_CMD="${DEFAULT_UPDATE_CMD}"
;;
esac
# Throttle: at most one check per calendar day, regardless of outcome. One
# dated marker file rewritten in place, so the cache dir does not accumulate
# a new empty update-checked-YYYY-MM-DD file every day.
day="$(date +%F 2>/dev/null)" || exit 0
cache_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/${SKILL}"
marker="${cache_dir}/update-checked"
[ "$(cat "${marker}" 2>/dev/null)" = "${day}" ] && exit 0
mkdir -p "${cache_dir}" 2>/dev/null
printf '%s' "${day}" > "${marker}" 2>/dev/null # write first so an offline run does not retry all day
rm -f "${cache_dir}"/update-checked-2* 2>/dev/null # sweep legacy per-day markers
command -v curl >/dev/null 2>&1 || exit 0
remote_ver="$(curl -fsSL --max-time 3 "${REMOTE_URL}" 2>/dev/null | tr -d '[:space:]')"
[ -n "${remote_ver}" ] || exit 0
[ "${remote_ver}" = "${local_ver}" ] && exit 0
# Only notify when the remote version sorts strictly higher. Numeric-field
# sort instead of `sort -V`: on a sort without -V support the old pipeline
# yielded an empty string and silently never notified again.
highest="$(printf '%s\n%s\n' "${local_ver}" "${remote_ver}" | sort -t. -k1,1n -k2,2n -k3,3n 2>/dev/null | tail -1)"
[ -n "${highest}" ] || exit 0
[ "${highest}" = "${remote_ver}" ] || exit 0
echo "Kami ${remote_ver} is available (you have ${local_ver}). Update: ${UPDATE_CMD}"
exit 0
+614
View File
@@ -0,0 +1,614 @@
"""PDF-side and content-shape checks for kami documents.
Splits out from build.py:
- check_placeholders: scan filled HTML for unreplaced `{{...}}` tokens.
- check_orphans: scan rendered PDFs for short trailing lines (typographic orphans).
- check_density: scan rendered PDFs for pages with too much trailing whitespace.
- check_resume_balance: scan resume PDFs for exact two-page balance.
- check_rhythm: scan slides Python source for monotonous deck sequences.
Density scanning uses a parchment-aware pixel sweep. The hot path is
vectorized with NumPy when available and falls back to a pure-Python loop.
Thresholds and DPI live in `references/checks_thresholds.json`.
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
from pathlib import Path
from optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader
from shared import (
PARCHMENT_RGB,
ROOT,
default_example_pdfs,
load_checks_thresholds,
rel_to_root,
)
PLACEHOLDER = re.compile(r"\{\{[^}]+\}\}")
MARKDOWN_THEMATIC_BREAK = re.compile(r"^\s*[-*_]{3,}\s*$")
MARKDOWN_RESIDUE_MARKERS = (
("markdown thematic break", MARKDOWN_THEMATIC_BREAK),
("unconverted bold marker", re.compile(r"\*\*")),
("unconverted inline-code marker", re.compile(r"`")),
)
# Parchment background RGB for pixel comparison (sourced from shared.PARCHMENT_RGB).
_BG_R, _BG_G, _BG_B = PARCHMENT_RGB
_BG_TOLERANCE = 10
def check_placeholders(paths: list[str]) -> int:
if not paths:
print("ERROR: provide at least one HTML file to scan")
return 2
failures = 0
for raw in paths:
path = Path(raw)
if not path.is_absolute():
path = ROOT / path
if not path.exists():
print(f"ERROR: {raw}: file not found")
failures += 1
continue
text = path.read_text(encoding="utf-8", errors="replace")
hits = list(dict.fromkeys(PLACEHOLDER.findall(text)))
rel = rel_to_root(path)
if hits:
print(f"ERROR: {rel}: unfilled placeholder(s): {', '.join(hits)}")
failures += 1
else:
print(f"OK: {rel}: no placeholders")
return 0 if failures == 0 else 1
# ---------- markdown residue check ----------
class _VisibleTextParser(HTMLParser):
"""Extract visible text from filled HTML while skipping code-like blocks."""
_SKIP_TAGS = {"code", "pre", "script", "style"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._skip_depth = 0
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in self._SKIP_TAGS:
self._skip_depth += 1
def handle_endtag(self, tag: str) -> None:
if tag in self._SKIP_TAGS and self._skip_depth > 0:
self._skip_depth -= 1
def handle_data(self, data: str) -> None:
if self._skip_depth == 0:
self.parts.append(data)
def _visible_html_text(raw: str) -> str:
parser = _VisibleTextParser()
parser.feed(raw)
return "\n".join(parser.parts)
def _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]:
issues: list[str] = []
for line_no, line in enumerate(text.splitlines(), start=1):
for label, pattern in MARKDOWN_RESIDUE_MARKERS:
if pattern.search(line):
where = f"p{page}" if page is not None else f"line {line_no}"
sample = " ".join(line.strip().split())[:80]
issues.append(f"{where}: {label}: {sample!r}")
return issues
def _markdown_text_chunks(path: Path) -> tuple[list[tuple[int | None, str]], str | None]:
suffix = path.suffix.lower()
if suffix == ".pdf":
try:
PdfReader = require_pypdf_reader()
except MissingDepError as exc:
return [], str(exc)
try:
reader = PdfReader(str(path))
except Exception as exc:
return [], f"could not read PDF text: {exc}"
return [
(index, page.extract_text() or "")
for index, page in enumerate(reader.pages, start=1)
], None
raw = path.read_text(encoding="utf-8", errors="replace")
if suffix in {".html", ".htm"}:
return [(None, _visible_html_text(raw))], None
return [(None, raw)], None
def check_markdown_residue(paths: list[str]) -> int:
"""Scan filled HTML/PDF outputs for visible raw Markdown markers.
This catches common hand-conversion misses such as literal `---`, `**bold**`,
and inline-code backticks leaking into the final PDF.
"""
if not paths:
paths = default_example_pdfs()
if not paths:
print("ERROR: no files to scan")
return 2
failures = 0
scanned = 0
for raw in paths:
path = Path(raw)
if not path.is_absolute():
path = ROOT / path
rel = rel_to_root(path)
if not path.exists():
print(f"ERROR: {raw}: file not found")
failures += 1
continue
chunks, error = _markdown_text_chunks(path)
if error:
print(f"ERROR: {rel}: {error}")
failures += 1
continue
scanned += 1
issues: list[str] = []
for page, text in chunks:
issues.extend(_markdown_residue_issues(text, page=page))
if issues:
failures += 1
print(f"ERROR: {rel}: markdown residue found")
for issue in issues:
print(f" {issue}")
else:
print(f"OK: {rel}: no markdown residue")
if scanned == 0:
print("ERROR: no files scanned")
return 2
return 0 if failures == 0 else 1
# ---------- orphan check ----------
def _orphan_last_line(text: str, max_words: int, max_chars: int) -> str | None:
"""Return a block's last line if it is an orphan, else None.
A block orphans when it has 2+ lines and the trailing line is short by
both word count (<= max_words) and length (< max_chars). Pure so the
predicate is unit-testable without a rendered PDF.
"""
lines = text.strip().splitlines()
if len(lines) < 2:
return None
last = lines[-1].strip()
if len(last.split()) <= max_words and len(last) < max_chars:
return last
return None
def check_orphans(paths: list[str]) -> int:
"""Scan PDF for text blocks whose last line has <= max_words and < max_chars."""
try:
fitz = require_pymupdf()
except MissingDepError as exc:
print(f"ERROR: {exc}")
return 2
if not paths:
paths = default_example_pdfs()
if not paths:
print("ERROR: no PDF files to scan")
return 2
orphan_cfg = load_checks_thresholds()["orphan"]
max_words = int(orphan_cfg["max_words"])
max_chars = int(orphan_cfg["max_chars"])
total = 0
missing = 0
scanned = 0
for raw in paths:
path = Path(raw)
if not path.exists():
print(f"ERROR: {raw}: not found")
missing += 1
continue
try:
doc = fitz.open(str(path))
except Exception as exc:
print(f"ERROR: {raw}: could not read PDF: {exc}")
missing += 1
continue
scanned += 1
rel = rel_to_root(path)
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("blocks")
for bx0, by0, bx1, by1, text, block_no, block_type in blocks:
if block_type != 0: # text blocks only
continue
last = _orphan_last_line(text, max_words, max_chars)
if last is not None:
total += 1
print(f" {rel} p{page_num + 1}: orphan: \"{last}\" ({len(last.split())} word(s), {len(last)} chars)")
doc.close()
if scanned == 0:
print(f"ERROR: no PDFs scanned ({missing} missing)")
return 2
if total == 0 and missing == 0:
print(f"OK: no orphans found across {scanned} PDF(s)")
return 0
if total:
print(f"\n{total} orphan(s) found across {scanned} PDF(s)")
if missing:
print(f"{missing} input(s) missing")
return 1
# ---------- density check ----------
def _last_content_y(samples: bytes, w: int, h: int, stride: int, n: int) -> int:
"""Return the highest y row index that contains non-parchment content.
Uses numpy when available (vectorized scan, ~50-100x faster on multi-page
PDFs); falls back to a pure Python loop otherwise. Both paths sample every
fourth column for parity, so the result is identical.
"""
try:
import numpy as np
except ImportError:
last_y = 0
for y in range(h - 1, -1, -1):
row_start = y * stride
is_bg = True
for x in range(0, w, 4):
offset = row_start + x * n
if (abs(samples[offset] - _BG_R) > _BG_TOLERANCE
or abs(samples[offset + 1] - _BG_G) > _BG_TOLERANCE
or abs(samples[offset + 2] - _BG_B) > _BG_TOLERANCE):
is_bg = False
break
if not is_bg:
last_y = y
break
return last_y
arr = np.frombuffer(samples, dtype=np.uint8).reshape((h, stride))
pixels = arr[:, : w * n].reshape((h, w, n))
rgb = pixels[:, ::4, :3].astype(np.int16)
bg = np.array([_BG_R, _BG_G, _BG_B], dtype=np.int16)
row_is_bg = (np.abs(rgb - bg).max(axis=2) <= _BG_TOLERANCE).all(axis=1)
non_bg = np.where(~row_is_bg)[0]
return int(non_bg[-1]) if non_bg.size else 0
def _density_bucket(empty: float, warn_pct: float, sparse_pct: float) -> str:
"""Categorize a page by its trailing-whitespace fraction.
Pure so `_scan_density` and its tests share one decision. A test that
reimplements these comparisons would stay green if the real operators
drifted (`>` to `>=`, or warn/sparse swapped); calling this keeps the
assertion anchored to production logic.
"""
if empty > sparse_pct:
return "SPARSE"
if empty > warn_pct:
return "WARN"
return "OK"
def _scan_density(paths: list[str]) -> tuple[int, int, int, int] | None:
"""Scan PDFs and print SPARSE/WARN lines.
Returns (sparse, warn, missing, scanned), or None if PyMuPDF is missing.
Thresholds (warn_pct, sparse_pct, dpi) come from
references/checks_thresholds.json.
"""
try:
fitz = require_pymupdf()
except MissingDepError as exc:
print(f"ERROR: {exc}")
return None
density_cfg = load_checks_thresholds()["density"]
warn_pct = float(density_cfg["warn_pct"])
sparse_pct = float(density_cfg["sparse_pct"])
dpi = int(density_cfg["dpi"])
sparse = 0
warn = 0
missing = 0
scanned = 0
for raw in paths:
path = Path(raw)
if not path.exists():
print(f"ERROR: {raw}: not found")
missing += 1
continue
try:
doc = fitz.open(str(path))
except Exception as exc:
print(f"ERROR: {raw}: could not read PDF: {exc}")
missing += 1
continue
scanned += 1
rel = rel_to_root(path)
for page_num in range(len(doc)):
if page_num == 0:
continue
page = doc[page_num]
pix = page.get_pixmap(dpi=dpi)
w, h = pix.width, pix.height
if h == 0:
continue
last_content_y = _last_content_y(pix.samples, w, h, pix.stride, pix.n)
empty = (h - last_content_y) / h
bucket = _density_bucket(empty, warn_pct, sparse_pct)
if bucket == "SPARSE":
print(f" SPARSE: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace")
sparse += 1
elif bucket == "WARN":
print(f" WARN: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace")
warn += 1
doc.close()
return sparse, warn, missing, scanned
def check_density(paths: list[str]) -> int:
"""Scan PDF pages for sparse content (large trailing whitespace from
break-inside:avoid pushing content to the next page)."""
if not paths:
paths = default_example_pdfs()
if not paths:
print("ERROR: no PDF files to scan")
return 2
result = _scan_density(paths)
if result is None:
return 2
sparse, warn, missing, scanned = result
if scanned == 0:
print(f"ERROR: no PDFs scanned ({missing} missing)")
return 2
total = sparse + warn
if total == 0 and missing == 0:
print(f"OK: no density issues across {scanned} PDF(s)")
return 0
if total:
print(f"\n{total} density warning(s) across {scanned} PDF(s)")
if missing:
print(f"{missing} input(s) missing")
return 1
# ---------- resume balance check ----------
def _resume_balance_issues(
fills: list[float],
page_count: int,
min_fill: float,
max_fill: float,
max_gap: float,
) -> list[str]:
"""Return human-readable resume balance failures for unit tests and CLI."""
issues: list[str] = []
if page_count != 2:
issues.append(f"{page_count} pages (expected 2)")
for index, fill in enumerate(fills[:2], start=1):
if fill < min_fill:
issues.append(f"p{index} fill {fill:.0%} below {min_fill:.0%}")
elif fill > max_fill:
issues.append(f"p{index} fill {fill:.0%} above {max_fill:.0%}")
if len(fills) >= 2:
gap = abs(fills[0] - fills[1])
if gap > max_gap:
issues.append(f"page fill gap {gap:.0%} above {max_gap:.0%}")
return issues
def _resume_page_fills(path: Path, dpi: int) -> tuple[list[float], int] | None:
try:
fitz = require_pymupdf()
except MissingDepError as exc:
print(f"ERROR: {exc}")
return None
try:
doc = fitz.open(str(path))
except Exception as exc:
print(f"ERROR: {path}: could not read PDF: {exc}")
return None
fills: list[float] = []
for page in doc:
pix = page.get_pixmap(dpi=dpi)
if pix.height == 0:
fills.append(0.0)
continue
last_content_y = _last_content_y(pix.samples, pix.width, pix.height, pix.stride, pix.n)
fills.append((last_content_y + 1) / pix.height)
page_count = len(doc)
doc.close()
return fills, page_count
def check_resume_balance(paths: list[str]) -> int:
"""Require resume PDFs to be exactly 2 pages with balanced content fill."""
if not paths:
print("ERROR: provide at least one filled resume PDF to scan")
return 2
# Resolve the dependency once up front so a missing PyMuPDF stays a
# tooling error (exit 2) while a single unreadable PDF inside the loop
# tallies as missing and lets the remaining files still get scanned.
try:
require_pymupdf()
except MissingDepError as exc:
print(f"ERROR: {exc}")
return 2
cfg = load_checks_thresholds()["resume_balance"]
min_fill = float(cfg["min_fill_pct"])
max_fill = float(cfg["max_fill_pct"])
max_gap = float(cfg["max_gap_pct"])
dpi = int(cfg["dpi"])
failures = 0
missing = 0
scanned = 0
for raw in paths:
path = Path(raw)
if not path.exists():
print(f"ERROR: {raw}: not found")
missing += 1
continue
result = _resume_page_fills(path, dpi)
if result is None:
missing += 1
continue
fills, page_count = result
scanned += 1
rel = rel_to_root(path)
fill_text = " / ".join(f"{fill:.0%}" for fill in fills)
issues = _resume_balance_issues(fills, page_count, min_fill, max_fill, max_gap)
if issues:
failures += 1
print(f"ERROR: {rel}: {fill_text} ({'; '.join(issues)})")
else:
gap = abs(fills[0] - fills[1])
print(f"OK: {rel}: 2 pages, fill {fill_text}, gap {gap:.0%}")
if scanned == 0:
print(f"ERROR: no PDFs scanned ({missing} missing)")
return 2
if missing:
print(f"{missing} input(s) missing")
return 0 if failures == 0 and missing == 0 else 1
# ---------- rhythm check ----------
# Layout functions that count as "divider" slides (break monotony).
_DIVIDER_FUNCS = {"chapter_slide"}
# Layout functions that count as "density variation" slides.
_DENSITY_VARIATION_FUNCS = {"quote_slide", "metrics_slide"}
# Layout function call pattern in slides.py source.
_SLIDE_CALL = re.compile(r"^\s*(\w+_slide)\s*\(")
def _parse_slide_sequence(src: Path) -> list[str]:
"""Return the ordered list of slide-function names called in main()."""
text = src.read_text(encoding="utf-8", errors="replace")
in_main = False
sequence: list[str] = []
for line in text.splitlines():
if re.match(r"^def main\s*\(", line):
in_main = True
continue
if in_main and re.match(r"^def \w", line):
break
if in_main:
m = _SLIDE_CALL.match(line)
if m:
sequence.append(m.group(1))
return sequence
def _rhythm_issues(seq: list[str], max_content_run: int, divider_min_deck_size: int) -> list[str]:
"""Return the rhythm warnings for one parsed slide sequence.
Pure so the three monotony rules are unit-testable without rendering a
deck, matching the `_resume_balance_issues` seam.
"""
issues: list[str] = []
# Rule 1: no run of more than `max_content_run` consecutive content_slides.
run = 0
max_run = 0
for fn in seq:
if fn == "content_slide":
run += 1
max_run = max(max_run, run)
else:
run = 0
if max_run > max_content_run:
issues.append(f"longest content_slide run is {max_run} (limit {max_content_run})")
# Rule 2: large decks need at least one chapter_slide divider.
if len(seq) >= divider_min_deck_size and not any(fn in _DIVIDER_FUNCS for fn in seq):
issues.append(f"{len(seq)} slides with no chapter_slide divider")
# Rule 3: deck must contain at least one density-variation slide.
if not any(fn in _DENSITY_VARIATION_FUNCS for fn in seq):
issues.append("no quote_slide or metrics_slide for density variation")
return issues
def check_rhythm(targets: list[str], pptx_targets: dict[str, str], templates_dir: Path) -> int:
"""Scan slide templates for monotony: too many consecutive content_slides,
missing dividers, and missing density variation.
Thresholds come from references/checks_thresholds.json.
"""
names = targets if targets else list(pptx_targets.keys())
failures = 0
rhythm_cfg = load_checks_thresholds()["rhythm"]
max_content_run = int(rhythm_cfg["max_content_run"])
divider_min_deck_size = int(rhythm_cfg["divider_min_deck_size"])
for name in names:
source = pptx_targets.get(name)
if source is None:
print(f"ERROR: {name}: not a known slides target")
failures += 1
continue
src = templates_dir / source
if not src.exists():
print(f"ERROR: {name}: source not found ({src})")
failures += 1
continue
seq = _parse_slide_sequence(src)
if not seq:
print(f"ERROR: {name}: no slide calls found in main() (deck unparseable)")
failures += 1
continue
issues = _rhythm_issues(seq, max_content_run, divider_min_deck_size)
if issues:
# These fail the run (exit 1), so label them ERROR; WARN is
# reserved for advisory output that does not gate the build.
for issue in issues:
print(f"ERROR: {name}: {issue}")
failures += 1
else:
content_run = 0
max_run = 0
for fn in seq:
content_run = content_run + 1 if fn == "content_slide" else 0
max_run = max(max_run, content_run)
print(f"OK: {name}: rhythm ok ({len(seq)} slides, max run {max_run})")
return 0 if failures == 0 else 1
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Draft the next Kami release notes from git log.
Pulls commit subjects in a rev range and pours them into the V1.4.0-style
template (centered logo + bilingual changelog). The output is a starting
point: regroup the commits into 5-8 product-themed bullets and translate
each to Chinese before publishing.
Usage:
python3 scripts/draft-release-notes.py
python3 scripts/draft-release-notes.py V1.4.0..HEAD
python3 scripts/draft-release-notes.py \\
--version V1.5.0 \\
--title "Steadier Hand" \\
--subtitle-en "Plugin install fix and audit cleanup." \\
--subtitle-cn "插件安装修复,审计清理沉淀。"
The default rev range is `<latest tag>..HEAD`. Output goes to stdout; pipe
to a file or `pbcopy` to feed `gh release create --notes-file`.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from textwrap import dedent
_HEADER = dedent("""\
<div align="center">
<img src="https://gw.alipayobjects.com/zos/k/vl/logo.svg" alt="Kami Logo" width="120" />
<h1 style="margin: 12px 0 6px;">Kami {version}</h1>
<p><em>{subtitle_en}</em></p>
</div>
""")
_FOOTER = dedent("""\
> Kami is a quiet design system for professional documents, one constraint set that any agent can trust. https://github.com/tw93/Kami
""")
# Conventional-commit prefix to a short product label, used as a hint when
# the user reorganizes the auto-listed commits into themed bullets.
_PREFIX_HINT = {
"build": "build",
"ci": "ci",
"feat": "feature",
"fix": "fix",
"docs": "docs",
"chore": "chore",
"refactor": "refactor",
"test": "test",
"perf": "perf",
"revert": "revert",
}
def _run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"{' '.join(cmd)}: {result.stderr.strip() or 'failed'}")
return result.stdout
def latest_tag() -> str | None:
try:
out = _run(["git", "describe", "--tags", "--abbrev=0"]).strip()
except RuntimeError:
return None
return out or None
def commits_in(rev_range: str) -> list[tuple[str, str]]:
"""Return [(short_sha, subject), ...] in chronological order."""
out = _run(["git", "log", rev_range, "--format=%h\t%s", "--reverse"])
rows: list[tuple[str, str]] = []
for line in out.splitlines():
if "\t" in line:
sha, subject = line.split("\t", 1)
rows.append((sha.strip(), subject.strip()))
return rows
def classify(subject: str) -> str:
"""Return a one-word commit category derived from the conventional-commit prefix."""
head = subject.split(":", 1)[0].split("(", 1)[0].strip().lower()
return _PREFIX_HINT.get(head, "other")
def render(
version: str,
title: str,
subtitle_en: str,
subtitle_cn: str,
rev_range: str,
commits: list[tuple[str, str]],
) -> str:
out: list[str] = []
out.append(_HEADER.format(version=version, subtitle_en=subtitle_en))
out.append(f"<!-- title: {version} {title} -->")
out.append(f"<!-- range: {rev_range} ({len(commits)} commits) -->")
out.append("<!-- regroup the bullets below into 5-8 product-themed items -->")
out.append("")
out.append("### Changelog")
out.append("")
for i, (sha, subject) in enumerate(commits, 1):
out.append(f"{i}. **TODO**: {subject} <!-- {sha} {classify(subject)} -->")
out.append("")
out.append("### 更新日志")
out.append("")
out.append(f"<!-- 副标题: {subtitle_cn} -->")
out.append("<!-- 翻译并对齐到上面英文条目,保持一一对应 -->")
out.append("")
for i in range(1, len(commits) + 1):
out.append(f"{i}. **TODO**:(对应英文第 {i} 条)")
out.append("")
out.append(_FOOTER)
return "\n".join(out)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"rev_range",
nargs="?",
default=None,
help="Git rev range (default: <latest tag>..HEAD)",
)
parser.add_argument("--version", default="VX.Y.Z", help="Version label, e.g. V1.5.0")
parser.add_argument("--title", default="<title>", help="Release title, e.g. 'Steadier Hand'")
parser.add_argument(
"--subtitle-en",
default="<one-line subtitle>",
help="Short English subtitle for the centered hero block",
)
parser.add_argument(
"--subtitle-cn",
default="<一句话中文副标题>",
help="Short Chinese subtitle, kept as a comment hint for translators",
)
return parser.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = parse_args(argv)
rev_range = args.rev_range
if rev_range is None:
tag = latest_tag()
if not tag:
print("ERROR: no tag found; pass an explicit rev range like V1.0.0..HEAD",
file=sys.stderr)
return 2
rev_range = f"{tag}..HEAD"
try:
commits = commits_in(rev_range)
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if not commits:
print(f"ERROR: no commits in {rev_range}", file=sys.stderr)
return 1
out = render(
version=args.version,
title=args.title,
subtitle_en=args.subtitle_en,
subtitle_cn=args.subtitle_cn,
rev_range=rev_range,
commits=commits,
)
print(out)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bash
set -euo pipefail
# Portable across bash 3.2+ (macOS stock /bin/bash) and bash 4+ (Linux, Homebrew).
# Avoids `declare -A` so the script runs on a fresh macOS without `brew install bash`.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_FONT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/assets/fonts"
# Download target lives OUTSIDE the skill directory on purpose.
#
# Claude Desktop skill ZIPs exclude the large bundled fonts (TsangerJinKai TTFs,
# Source Han Serif K OTFs). The old code downloaded them back into the skill's
# own assets/fonts, which pushed the installed skill past Claude Desktop's size
# limit ("upload/execution too big"). We instead drop them in the XDG user font
# dir, which fontconfig scans by default on both macOS (Homebrew) and Linux, yet
# does NOT show up in macOS Font Book. WeasyPrint then resolves "TsangerJinKai02"
# / "Source Han Serif K" from here when the template's relative @font-face path
# is absent; online renders still fall back to the jsDelivr URL baked alongside
# each @font-face declaration.
FONT_DIR="${KAMI_FONT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fonts/kami}"
# Partial downloads from an interrupted run must not linger in FONT_DIR, and
# two concurrent runs must not fight over one temp path: temp files carry this
# run's PID and are swept on exit.
TMP_SUFFIX="tmp.$$"
cleanup_tmp() { rm -f "$FONT_DIR"/*."$TMP_SUFFIX" 2>/dev/null || true; }
trap cleanup_tmp EXIT
MIN_SIZE_CN=10000000 # 10MB for TsangerJinKai (large CJK glyph set)
MIN_SIZE_KO=6500000 # 6.5MB for Source Han Serif K (Adobe full subset)
# TsangerJinKai (CN): index N pairs CN_NAMES[N] with CN_LOCAL_NAMES[N].
CN_NAMES=("仓耳今楷02-W04.ttf" "仓耳今楷02-W05.ttf")
CN_LOCAL_NAMES=("TsangerJinKai02-W04.ttf" "TsangerJinKai02-W05.ttf")
# Source Han Serif K (KO): mirror filenames match the repo filenames, so there
# is no rename step (unlike Tsanger's Chinese-named official downloads).
KO_NAMES=("SourceHanSerifKR-Regular.otf" "SourceHanSerifKR-Medium.otf")
# Mirror order is intentionally jsdmirror-first here, opposite of the
# templates' @font-face fallback (which lists jsdelivr first). Reasoning:
# this script runs interactively when fonts are missing locally, often from
# China where jsdmirror is reachable and faster than jsdelivr; templates run
# anywhere and prioritize jsdelivr's broader global coverage.
MIRROR_SOURCES=(
"https://cdn.jsdmirror.com/gh/tw93/Kami@main/assets/fonts"
"https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts"
)
check_size() {
local file="$1"
local min_size="$2"
[[ -f "$file" ]] || return 1
local size
size=$(wc -c < "$file" | tr -d ' ')
[[ "$size" -ge "$min_size" ]]
}
cn_present_in() {
local dir="$1" name
for name in "${CN_LOCAL_NAMES[@]}"; do
check_size "$dir/$name" "$MIN_SIZE_CN" || return 1
done
return 0
}
ko_present_in() {
local dir="$1" name
for name in "${KO_NAMES[@]}"; do
check_size "$dir/$name" "$MIN_SIZE_KO" || return 1
done
return 0
}
refresh_fontconfig() {
# The XDG font dir is already on fontconfig's default scan path, so a cache
# refresh is all that is needed for WeasyPrint to pick the fonts up. Optional:
# absence of fc-cache (e.g. minimal sandbox) is non-fatal, fontconfig rescans
# the directory lazily on next use.
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f "$FONT_DIR" >/dev/null 2>&1 || true
fi
}
download_tsanger() {
local cn_name="$1"
local local_name="$2"
local target="$FONT_DIR/$local_name"
# Source 1: official tsanger.cn
local official_url="https://tsanger.cn/download/${cn_name}"
echo " Trying: tsanger.cn (official)"
if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$official_url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then
if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_CN"; then
mv "$target.$TMP_SUFFIX" "$target"
echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))"
return 0
else
rm -f "$target.$TMP_SUFFIX"
fi
else
rm -f "$target.$TMP_SUFFIX"
fi
# Source 2+: CDN mirrors (already named TsangerJinKai02-W0x.ttf)
for src in "${MIRROR_SOURCES[@]}"; do
local url="$src/$local_name"
echo " Trying: $url"
if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then
if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_CN"; then
mv "$target.$TMP_SUFFIX" "$target"
echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))"
return 0
else
rm -f "$target.$TMP_SUFFIX"
fi
else
rm -f "$target.$TMP_SUFFIX"
fi
done
echo " ERROR: all sources failed for $local_name"
return 1
}
download_ko_serif() {
local local_name="$1"
local target="$FONT_DIR/$local_name"
# CDN mirrors only: Source Han Serif K has no single official direct-download
# URL, so we serve the committed OTFs from the same jsDelivr/jsdmirror gh path.
for src in "${MIRROR_SOURCES[@]}"; do
local url="$src/$local_name"
echo " Trying: $url"
if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then
if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_KO"; then
mv "$target.$TMP_SUFFIX" "$target"
echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))"
return 0
else
rm -f "$target.$TMP_SUFFIX"
fi
else
rm -f "$target.$TMP_SUFFIX"
fi
done
echo " ERROR: all sources failed for $local_name"
return 1
}
# A repo checkout ships the committed font files. Templates resolve their
# relative `../fonts/*` @font-face path against them directly, so there is
# nothing to download or register. These branches are skipped inside a Claude
# Desktop skill, whose assets/fonts has the large fonts stripped out.
cn_failed=0
if cn_present_in "$REPO_FONT_DIR"; then
echo "OK: TsangerJinKai fonts present in repo checkout ($REPO_FONT_DIR)"
else
mkdir -p "$FONT_DIR"
if cn_present_in "$FONT_DIR"; then
echo "OK: TsangerJinKai fonts present ($FONT_DIR)"
else
echo "Downloading TsangerJinKai fonts to $FONT_DIR ..."
for i in "${!CN_NAMES[@]}"; do
cn_name="${CN_NAMES[$i]}"
local_name="${CN_LOCAL_NAMES[$i]}"
if check_size "$FONT_DIR/$local_name" "$MIN_SIZE_CN"; then
echo " OK: $local_name already present"
continue
fi
if ! download_tsanger "$cn_name" "$local_name"; then
cn_failed=$((cn_failed + 1))
fi
done
if [[ "$cn_failed" -gt 0 ]]; then
echo ""
echo "Some TsangerJinKai files could not be downloaded. Alternatives:"
echo " 1. Install Source Han Serif SC: brew install --cask font-source-han-serif-sc"
echo " 2. Copy TsangerJinKai02-W04.ttf and W05.ttf manually into $FONT_DIR"
# Don't exit yet, try the KO recovery too so a Korean-only user still gets KO fonts.
fi
fi
fi
ko_failed=0
if ko_present_in "$REPO_FONT_DIR"; then
echo "OK: Source Han Serif K fonts present in repo checkout ($REPO_FONT_DIR)"
else
mkdir -p "$FONT_DIR"
if ko_present_in "$FONT_DIR"; then
echo "OK: Source Han Serif K fonts present ($FONT_DIR)"
else
echo "Downloading Source Han Serif K fonts to $FONT_DIR ..."
for local_name in "${KO_NAMES[@]}"; do
if check_size "$FONT_DIR/$local_name" "$MIN_SIZE_KO"; then
echo " OK: $local_name already present"
continue
fi
if ! download_ko_serif "$local_name"; then
ko_failed=$((ko_failed + 1))
fi
done
if [[ "$ko_failed" -gt 0 ]]; then
echo ""
echo "Some Source Han Serif K files could not be downloaded. Alternatives:"
echo " 1. Download from https://github.com/adobe-fonts/source-han-serif/releases"
echo " 2. Copy SourceHanSerifKR-Regular.otf and -Medium.otf manually into $FONT_DIR"
fi
fi
fi
if [[ "$cn_failed" -gt 0 || "$ko_failed" -gt 0 ]]; then
exit 1
fi
refresh_fontconfig
echo "OK: all fonts ready"
+141
View File
@@ -0,0 +1,141 @@
"""Lightweight syntax highlighting for Kami HTML templates.
Scans HTML for <pre><code class="language-*"> blocks and applies
Pygments-based inline-style highlighting using Kami design tokens.
Blocks without a language- class pass through unchanged.
"""
from __future__ import annotations
import html as html_mod
import re
import sys
from shared import token_value
CODE_BLOCK_RE = re.compile(
r'(<pre[^>]*>\s*<code\s+class="language-([\w+-]+)"[^>]*>)'
r'(.*?)'
r'(</code>\s*</pre>)',
re.DOTALL,
)
_KAMI_PALETTE: dict[str, str] | None = None
def _kami_palette() -> dict[str, str]:
"""Resolve design-token colors lazily.
Kept out of module scope so importing this module (e.g. by build.py for a
command that never highlights code) does not read tokens.json. That keeps
the import resilient on a half-installed checkout, matching shared.py's
baked-in fallbacks.
"""
global _KAMI_PALETTE
if _KAMI_PALETTE is None:
_KAMI_PALETTE = {
"brand": token_value("brand"),
"stone": token_value("stone"),
"olive": token_value("olive"),
"dark_warm": token_value("dark-warm"),
"near_black": token_value("near-black"),
}
return _KAMI_PALETTE
_WARNED_MISSING_PYGMENTS = False
def _warn_missing_pygments() -> None:
global _WARNED_MISSING_PYGMENTS
if _WARNED_MISSING_PYGMENTS:
return
print(
"WARN: Pygments is not installed; language-tagged code blocks will render monochrome. "
"Install with `python3 -m pip install Pygments` to enable syntax highlighting.",
file=sys.stderr,
)
_WARNED_MISSING_PYGMENTS = True
def _build_kami_style():
from pygments.style import Style
from pygments.token import (
Comment, Keyword, Literal, Name, Number, Operator,
Punctuation, String, Token,
)
palette = _kami_palette()
class KamiStyle(Style):
background_color = ""
default_style = ""
styles = {
Token: "",
Comment: palette["stone"],
Comment.Single: palette["stone"],
Comment.Multiline: palette["stone"],
Comment.Preproc: palette["stone"],
Keyword: palette["brand"],
Keyword.Constant: palette["brand"],
Keyword.Namespace: palette["brand"],
Keyword.Type: palette["brand"],
Name.Builtin: palette["brand"],
Name.Function: palette["near_black"],
Name.Class: palette["near_black"],
Name.Decorator: palette["olive"],
String: palette["olive"],
String.Doc: palette["stone"],
Number: palette["dark_warm"],
Number.Float: palette["dark_warm"],
Number.Integer: palette["dark_warm"],
Literal: palette["dark_warm"],
Operator: "",
Punctuation: "",
}
return KamiStyle
def _highlight_block(match: re.Match[str]) -> str:
from pygments import highlight as pyg_highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name
open_tag = match.group(1)
language = match.group(2)
code = match.group(3)
close_tag = match.group(4)
code_text = html_mod.unescape(code)
try:
lexer = get_lexer_by_name(language, stripall=False)
except Exception:
return match.group(0)
formatter = HtmlFormatter(
style=_build_kami_style(),
noclasses=True,
nowrap=True,
)
highlighted = pyg_highlight(code_text, lexer, formatter)
return f'{open_tag}{highlighted}{close_tag}'
def highlight_code_blocks(html_text: str) -> str:
"""Apply syntax highlighting to language-tagged code blocks.
Returns HTML unchanged if Pygments is not installed or no
language-tagged blocks are found.
"""
if not CODE_BLOCK_RE.search(html_text):
return html_text
try:
import pygments # noqa: F401
except ImportError:
_warn_missing_pygments()
return html_text
return CODE_BLOCK_RE.sub(_highlight_block, html_text)
+425
View File
@@ -0,0 +1,425 @@
"""Template lint rules and cross-template consistency checks.
Splits out from build.py:
- scan_file: per-line + per-block lint for HTML/CSS/PPTX templates.
- check_all: scan every template and aggregate findings by rule.
- check_cross_template_consistency: pair CN/EN templates and report :root
variable drift outside the allowlist.
Each `Finding` is anchored to a file path + line number so editors can jump
straight to the violation. Rules encode real WeasyPrint pitfalls (rgba on
background, thin border with border-radius, etc.), not style preferences.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from shared import (
COOL_GRAY_BLOCKLIST,
HTML_TEMPLATES,
ROOT,
SCREEN_TEMPLATES,
TEMPLATES,
TOKENS_FILE,
iter_template_files,
)
from tokens import ROOT_BLOCK, parse_root_vars
# Font-stack vars legitimately differ between a base template and its locale
# variants (-en, -ko); every other :root var must match across the pair.
CROSS_TEMPLATE_ALLOWED_VARS = {"--serif", "--sans", "--mono", "--latin-ui"}
RGBA_BG_DIRECT = re.compile(r"background(?:-color)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE)
RGBA_VAR_DEF = re.compile(r"--([\w-]+)\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE)
BG_VAR_USE = re.compile(r"background(?:-color)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE)
RGBA_BORDER_DIRECT = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE)
BORDER_VAR_USE = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE)
LINE_HEIGHT_LOOSE = re.compile(r"line-height\s*:\s*1\.[6-9]\d*", re.IGNORECASE)
UNICODE_ARROW = re.compile(r"") # U+2192; should not appear in EN template body
HEX_ANY = re.compile(r"#[0-9a-fA-F]{3,6}\b")
# Thin closed border: border shorthand (not single-side) with sub-1pt width -- pitfall #2
THIN_CLOSED_BORDER = re.compile(
r"border(?!-(?:left|right|top|bottom))\s*:\s*[^;]*0\.\d+pt",
re.IGNORECASE,
)
BORDER_RADIUS_PROP = re.compile(r"border-radius\s*:", re.IGNORECASE)
CSS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
SVG_BLOCK_RE = re.compile(r"<svg\b.*?</svg>", re.DOTALL | re.IGNORECASE)
# WeasyPrint-unsafe artifacts of un-normalized beautiful-mermaid SVG. These must
# never reach a PDF-bound template/diagram: WeasyPrint does not resolve
# color-mix(), render <foreignObject>, or fetch a runtime web font. The author
# must pipe Mermaid output through scripts/mermaid_normalize.py first. Screen-only
# landing pages are exempt (color-mix in CSS is fine in a real browser).
MERMAID_UNSAFE = {
"mermaid-color-mix": re.compile(r"color-mix\s*\(", re.IGNORECASE),
"mermaid-foreignobject": re.compile(r"<foreignObject\b", re.IGNORECASE),
"mermaid-webfont-import": re.compile(r"fonts\.googleapis\.com", re.IGNORECASE),
}
@dataclass
class Finding:
file: Path
line: int
rule: str
excerpt: str
def _strip_css_block_comments(text: str) -> str:
"""Replace `/* ... */` with spaces of the same length so commented-out
rgba()/cool-gray literals don't trip the per-line scan. Length-preserving
so line numbers and per-line search offsets remain correct.
"""
def repl(m: re.Match[str]) -> str:
return "".join(ch if ch == "\n" else " " for ch in m.group(0))
return CSS_BLOCK_COMMENT_RE.sub(repl, text)
def scan_file(path: Path) -> list[Finding]:
findings: list[Finding] = []
raw_text = path.read_text(encoding="utf-8", errors="replace")
text = _strip_css_block_comments(raw_text)
lines = text.splitlines()
# Pass 1: collect variable names that hold rgba(...) so the tag-background
# bug can be detected through one level of indirection.
rgba_vars: set[str] = set()
for raw in lines:
m = RGBA_VAR_DEF.search(raw)
if m:
rgba_vars.add(m.group(1))
is_en = path.name.endswith("-en.html")
# Screen-only templates (landing pages) never go through WeasyPrint, so the
# Mermaid-unsafe-SVG rule does not apply to them.
is_screen = path.name in set(SCREEN_TEMPLATES.values())
# Pass 2: per-line rule checks
is_python = path.suffix == ".py"
for i, raw in enumerate(lines, start=1):
line = raw.strip()
if not line:
continue
# Skip comment lines. Note: '#' alone is NOT a CSS or HTML comment; it
# is the start of a CSS id selector (e.g. `#hero-bg { ... }`) or part of
# a hex literal. Only treat '#' as a comment when scanning Python.
if line.startswith("//"):
continue
if line.startswith("<!--"):
continue
if is_python and line.startswith("#"):
continue
if RGBA_BG_DIRECT.search(raw):
findings.append(Finding(path, i, "rgba-background",
"rgba() used directly on background (tag double-rectangle bug)"))
bg_var = BG_VAR_USE.search(raw)
if bg_var and bg_var.group(1) in rgba_vars:
findings.append(Finding(path, i, "rgba-background",
f"background: var(--{bg_var.group(1)}) resolves to rgba() (tag double-rectangle bug)"))
if RGBA_BORDER_DIRECT.search(raw):
findings.append(Finding(path, i, "rgba-border",
"rgba() used on border (violates solid-color invariant)"))
border_var = BORDER_VAR_USE.search(raw)
if border_var and border_var.group(1) in rgba_vars:
findings.append(Finding(path, i, "rgba-border",
f"border: var(--{border_var.group(1)}) resolves to rgba() (solid-color invariant)"))
if is_en and UNICODE_ARROW.search(raw):
# skip CSS comment lines (/* ... */) and the arrow-in-CSS-content patterns
stripped = raw.lstrip()
if not stripped.startswith("/*") and not stripped.startswith("*") and "content:" not in raw:
findings.append(Finding(path, i, "arrow-unicode-in-en",
"to (U+2192) in English template; use 'to' or '->' per patterns Section 2"))
m = LINE_HEIGHT_LOOSE.search(raw)
if m:
findings.append(Finding(path, i, "line-height-too-loose",
f"{m.group(0)} exceeds 1.55 ceiling"))
for hex_match in HEX_ANY.finditer(raw):
h = hex_match.group(0).lower()
if h in COOL_GRAY_BLOCKLIST:
findings.append(Finding(path, i, "cool-gray",
f"{h} is a cool / neutral gray, use warm undertone"))
if not is_screen and not is_python:
for rule, pattern in MERMAID_UNSAFE.items():
if pattern.search(raw):
findings.append(Finding(path, i, rule,
"un-normalized Mermaid SVG (run scripts/mermaid_normalize.py before embedding)"))
# Pass 3: thin-border-radius block scan (pitfall #2 double-ring).
# For each thin closed border line, scan backward to the block open and
# forward to the block close, checking for border-radius in the same block.
for i, raw in enumerate(lines):
if not THIN_CLOSED_BORDER.search(raw):
continue
if "skip-thin-border-radius" in raw:
continue
found = False
# Scan backward; stop at { or } (entering/leaving a block).
for j in range(i - 1, max(0, i - 6) - 1, -1):
if "{" in lines[j] or "}" in lines[j]:
break
if BORDER_RADIUS_PROP.search(lines[j]):
found = True
break
# Scan forward; stop at } (leaving the block).
if not found:
for j in range(i + 1, min(len(lines), i + 6)):
if "}" in lines[j]:
break
if BORDER_RADIUS_PROP.search(lines[j]):
found = True
break
if found:
findings.append(Finding(path, i + 1, "thin-border-radius",
"thin border (<1pt) with border-radius -- pitfall #2 double-ring risk"))
return findings
def check_all(verbose: bool) -> int:
targets = iter_template_files(include_py=True, include_diagrams=True, include_marp_css=True)
if not targets:
print("ERROR: no templates found to lint (bad checkout?)")
return 2
findings: list[Finding] = []
for p in targets:
file_findings = scan_file(p)
findings.extend(file_findings)
if verbose:
print(f"scanned {p.relative_to(ROOT)}: {len(file_findings)} finding(s)")
if not findings:
print(f"OK: no violations across {len(targets)} templates")
return 0
by_rule: dict[str, list[Finding]] = {}
for f in findings:
by_rule.setdefault(f.rule, []).append(f)
print(f"ERROR: {len(findings)} violation(s) across {len({f.file for f in findings})} file(s)")
for rule, items in by_rule.items():
print(f"\n[{rule}] {len(items)}")
for f in items:
rel = f.file.relative_to(ROOT)
print(f" {rel}:{f.line} {f.excerpt}")
return 1
# ---------- off-palette color guard ----------
#
# design.md core invariant: a single chromatic accent (ink-blue) plus warm
# neutrals, zero cool tones. The salmon-border regression slipped past the
# token-drift guard because it was a hardcoded hex inside a component rule, not
# a :root token. This guard mechanizes the invariant: any hex literal in an
# editorial template that is neither a registered token value nor a cool-gray
# (those have their own rule) is an off-palette color. The single sanctioned
# semantic exception (the changelog breaking-change badge) is registered as the
# --breaking-* tokens, so it lands in `allowed` and passes.
#
# Scope is deliberately narrow: editorial TEMPLATES/*.html only. Diagrams use
# warm-gray chart ramps that are intentionally not tokens, and inline <svg>
# charts carry their own fills -- both are skipped (diagrams by directory, svg
# by block). :root blocks define the tokens themselves, so they are skipped too.
def _blank_block(text: str, regex: re.Pattern[str]) -> str:
"""Replace each match with same-length whitespace (newlines preserved) so
line numbers stay accurate after a block is masked out."""
def repl(m: re.Match[str]) -> str:
return "".join(ch if ch == "\n" else " " for ch in m.group(0))
return regex.sub(repl, text)
def _load_token_values() -> set[str]:
"""Return the set of canonical token hex values (lowercased)."""
if not TOKENS_FILE.exists():
return set()
try:
data = json.loads(TOKENS_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return set()
return {v.lower() for v in data.values() if isinstance(v, str) and v.startswith("#")}
def _off_palette_findings(path: Path, allowed: set[str]) -> list[Finding]:
raw = path.read_text(encoding="utf-8", errors="replace")
text = _strip_css_block_comments(raw)
text = _blank_block(text, ROOT_BLOCK)
text = _blank_block(text, SVG_BLOCK_RE)
findings: list[Finding] = []
for i, line in enumerate(text.splitlines(), start=1):
for m in HEX_ANY.finditer(line):
h = m.group(0).lower()
if h in allowed:
continue
if h in COOL_GRAY_BLOCKLIST:
continue # reported by the cool-gray rule in scan_file
findings.append(Finding(path, i, "off-palette",
f"{h} is not a registered token; single-accent palette violated"))
return findings
ROOT_TOKEN_DEF = re.compile(r"(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,6})\b")
def _root_token_findings(path: Path, allowed: set[str]) -> list[Finding]:
"""Flag `:root` token definitions whose hex is off the registered palette.
`_off_palette_findings` blanks the `:root` block before scanning property
values, so a dead or off-palette token *defined* in `:root` but never written
as a literal hex in a property escapes every guard (this is how a stray
`--brand-deep: #a64f33` second accent hid in portfolio.html). This closes that
gap for print templates: every `:root` chromatic token must resolve to a
registered tokens.json value. Screen templates (landing pages) keep their own
local tokens outside the print palette, so callers exempt them.
"""
raw = path.read_text(encoding="utf-8", errors="replace")
text = _strip_css_block_comments(raw)
findings: list[Finding] = []
for block in ROOT_BLOCK.finditer(text):
body_start = block.start(1)
for vm in ROOT_TOKEN_DEF.finditer(block.group(1)):
h = vm.group(2).lower()
if h in allowed:
continue
if h in COOL_GRAY_BLOCKLIST:
continue # reported by the cool-gray rule in scan_file
line = text.count("\n", 0, body_start + vm.start(2)) + 1
findings.append(Finding(path, line, "off-palette-token",
f"{vm.group(1)}: {h} is a :root token off the registered palette "
"(single-accent invariant; register in tokens.json or remove)"))
return findings
def check_off_palette(verbose: bool = False) -> int:
allowed = _load_token_values()
screen_names = set(SCREEN_TEMPLATES.values())
targets = sorted(TEMPLATES.glob("*.html"))
if not targets:
print("ERROR: no templates found for off-palette scan (bad checkout?)")
return 2
findings: list[Finding] = []
for p in targets:
file_findings = _off_palette_findings(p, allowed)
if p.name not in screen_names:
file_findings.extend(_root_token_findings(p, allowed))
findings.extend(file_findings)
if verbose:
print(f"scanned {p.relative_to(ROOT)}: {len(file_findings)} off-palette finding(s)")
if not findings:
print(f"OK: no off-palette colors across {len(targets)} template(s)")
return 0
print(f"\nERROR: [off-palette] {len(findings)}")
for f in findings:
print(f" {f.file.relative_to(ROOT)}:{f.line} {f.excerpt}")
return 1
# ---------- cross-template consistency ----------
#
# The project intentionally ships CN/EN templates as forked single-file HTML
# (no shared partials). The price of that decision is drift: a maintainer
# updating one side of a pair can silently leave the other behind. This check
# pairs each base template (e.g. `foo.html`) with every recognized locale
# variant (`foo-en.html`, `foo-ko.html`), parses the `:root { ... }` block of
# each, and flags variables that differ. Font-stack variables (`--serif`,
# `--sans`, `--mono`, `--latin-ui`) are allowlisted because each locale
# deliberately uses different fonts.
_VARIANT_SUFFIXES: tuple[str, ...] = ("-en", "-ko")
def _pair_names() -> list[tuple[str, str]]:
"""Return [(base_name, variant_name), ...] for every base template that has
one of the recognized locale-variant siblings (`-en`, `-ko`).
A base template is any registered name that does not itself end in a
recognized variant suffix.
"""
pairs: list[tuple[str, str]] = []
seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES)
for name in sorted(seen):
if any(name.endswith(s) for s in _VARIANT_SUFFIXES):
continue
for suffix in _VARIANT_SUFFIXES:
variant = f"{name}{suffix}"
if variant in seen:
pairs.append((name, variant))
return pairs
def _source_for(name: str) -> tuple[Path, Path]:
"""Return (source path, directory) for a template name across registries."""
if name in HTML_TEMPLATES:
return TEMPLATES / HTML_TEMPLATES[name].source, TEMPLATES
if name in SCREEN_TEMPLATES:
return TEMPLATES / SCREEN_TEMPLATES[name], TEMPLATES
raise KeyError(f"unknown template name: {name}")
def _extract_root_vars(html_path: Path) -> dict[str, str]:
"""Return {var_name: value} merged across every `:root { ... }` block."""
text = html_path.read_text(encoding="utf-8", errors="replace")
return parse_root_vars(text)
def check_cross_template_consistency(verbose: bool = False) -> int:
pairs = _pair_names()
if not pairs:
print("ERROR: no base-variant template pairs found (bad checkout?)")
return 2
drift: list[tuple[str, str, str, str]] = [] # (pair, var, base_value, variant_value)
for base_name, variant_name in pairs:
try:
base_path, _ = _source_for(base_name)
variant_path, _ = _source_for(variant_name)
except KeyError:
continue
if not base_path.exists() or not variant_path.exists():
continue
base_vars = _extract_root_vars(base_path)
variant_vars = _extract_root_vars(variant_path)
shared_keys = set(base_vars) & set(variant_vars)
for key in sorted(shared_keys):
if key in CROSS_TEMPLATE_ALLOWED_VARS:
continue
if base_vars[key].lower() != variant_vars[key].lower():
drift.append((base_name, key, base_vars[key], variant_vars[key]))
# A var defined on only one side is drift too: it usually means a
# maintainer added or dropped a token on one side of the fork. That is
# exactly the "left the other side behind" failure this check exists
# to catch, so report it instead of silently comparing the overlap.
for key in sorted((set(base_vars) ^ set(variant_vars)) - CROSS_TEMPLATE_ALLOWED_VARS):
if key in base_vars:
drift.append((base_name, key, base_vars[key], f"missing from {variant_name}"))
else:
drift.append((base_name, key, f"missing from {base_name}", variant_vars[key]))
if verbose:
print(f" pair {base_name}/{variant_name}: checked {len(shared_keys)} shared vars")
if not drift:
print(f"OK: cross-template :root vars in sync across {len(pairs)} base-variant pair(s)")
return 0
print(f"\nERROR: [cross-template-drift] {len(drift)}")
for pair, var, base_val, variant_val in drift:
print(f" {pair}: {var} base={base_val} variant={variant_val}")
return 1
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Re-theme and normalize a beautiful-mermaid SVG into a Kami-styled, WeasyPrint-safe SVG.
beautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid) hangs its seven
color roles on CSS custom properties on the root ``<svg>`` and derives the rest with
``color-mix(in srgb, ...)``. WeasyPrint's inline-SVG renderer does not resolve
``color-mix()``, does not reliably cascade SVG ``<style>`` custom properties, and
should not fetch a runtime web font. This script:
1. **Re-themes** the SVG to the Kami palette by overriding the seven root color
roles with ``references/mermaid-theme.json`` values, so the diagram looks like
Kami no matter which theme it was generated with.
2. **Resolves** every ``var()`` and ``color-mix(in srgb, ...)`` to a static hex, so
colors land on inline presentation attributes WeasyPrint renders directly.
3. **Fixes fonts**: strips beautiful-mermaid's Google-Fonts ``@import`` and rewrites
the (mis-quoted) ``font-family`` to the Kami serif stack (with CJK fallback).
No Node, no network, pure stdlib. Generate the SVG anywhere beautiful-mermaid runs
(e.g. https://agents.craft.do/mermaid or your own one-off script), then run this.
Scope: graph-type diagrams (flowchart / state / sequence / class / ER) whose colors
flow from the seven root roles. xychart-beta styles via ``<style>`` class selectors,
which WeasyPrint will not apply to inline SVG, so charts stay browser-only; use
Kami's hand-drawn bar/line/donut/candlestick/waterfall diagrams for PDF. See
references/mermaid.md.
Usage:
python3 scripts/mermaid_normalize.py raw.svg # cleaned SVG to stdout
python3 scripts/mermaid_normalize.py raw.svg -o out.svg
cat raw.svg | python3 scripts/mermaid_normalize.py - # read from stdin
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_THEME_FILE = _ROOT / "references" / "mermaid-theme.json"
# Fallbacks if references/mermaid-theme.json is missing. Mirror that file and
# references/design.md.
_DEFAULT_FONT_STACK = (
'Charter, Georgia, "TsangerJinKai02", "Source Han Serif SC", '
'"Noto Serif CJK SC", serif'
)
_DEFAULT_COLORS = {
"--bg": "#f5f4ed", "--fg": "#141413", "--line": "#504e49",
"--accent": "#1B365D", "--muted": "#6b6a64", "--surface": "#faf9f5",
"--border": "#e8e6dc",
}
# A handful of CSS named colors beautiful-mermaid may emit. Hex is preferred.
_NAMED = {"white": (255, 255, 255), "black": (0, 0, 0), "transparent": None}
def _load_theme() -> tuple[dict[str, str], str]:
"""Return (kami color-role overrides, font stack) from the theme file."""
try:
data = json.loads(_THEME_FILE.read_text(encoding="utf-8"))
colors = {f"--{k}": v for k, v in data.get("colors", {}).items()}
font = data.get("cssFontStack") or _DEFAULT_FONT_STACK
if colors:
return colors, font
except (OSError, ValueError):
pass
return dict(_DEFAULT_COLORS), _DEFAULT_FONT_STACK
# ---------------------------------------------------------------------------
# color helpers
# ---------------------------------------------------------------------------
def _parse_hex(value: str) -> tuple[int, int, int]:
h = value.strip().lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
if len(h) != 6:
raise ValueError(f"not a hex color: {value!r}")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _to_hex(rgb: tuple[float, float, float]) -> str:
return "#" + "".join(f"{max(0, min(255, round(c))):02x}" for c in rgb)
def _mix_srgb(c1: tuple[int, int, int], p1: float,
c2: tuple[int, int, int], p2: float) -> tuple[float, float, float]:
"""color-mix(in srgb, c1 p1%, c2 p2%): linear blend in gamma sRGB.
Percentages are normalized to sum to 100 (per CSS Color 4).
"""
total = p1 + p2 or 1.0
w1, w2 = p1 / total, p2 / total
return tuple(a * w1 + b * w2 for a, b in zip(c1, c2))
# ---------------------------------------------------------------------------
# balanced-paren parsing
# ---------------------------------------------------------------------------
def _match_paren(s: str, open_idx: int) -> int:
"""Given index of a '(', return index of its matching ')'."""
depth = 0
for i in range(open_idx, len(s)):
if s[i] == "(":
depth += 1
elif s[i] == ")":
depth -= 1
if depth == 0:
return i
raise ValueError("unbalanced parentheses")
def _split_top(s: str, sep: str = ",") -> list[str]:
"""Split on `sep` at paren depth 0 only."""
parts, depth, start = [], 0, 0
for i, ch in enumerate(s):
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif ch == sep and depth == 0:
parts.append(s[start:i])
start = i + 1
parts.append(s[start:])
return [p.strip() for p in parts]
class _Resolver:
"""Resolves a CSS color value to a static hex using a custom-property map."""
def __init__(self, raw_defs: dict[str, str]):
self._raw = raw_defs
def hex_of(self, value: str) -> str | None:
rgb = self._rgb(value)
return None if rgb is None else _to_hex(rgb)
def var_map(self) -> dict[str, str]:
out: dict[str, str] = {}
for name in self._raw:
h = self.hex_of(f"var({name})")
if h is not None:
out[name] = h
return out
def _rgb(self, value: str, _seen: frozenset[str] = frozenset()) -> tuple[int, int, int] | None:
value = value.strip().rstrip(";").strip()
if not value:
return None
if value.startswith("#"):
return _parse_hex(value)
low = value.lower()
if low in _NAMED:
return _NAMED[low]
if value.startswith("var(") and value.endswith(")"):
inner = value[4:_match_paren(value, 3)]
args = _split_top(inner)
name = args[0].strip()
fallback = args[1] if len(args) > 1 else None
if name in _seen:
return None # cycle guard
if name in self._raw:
return self._rgb(self._raw[name], _seen | {name})
if fallback is not None:
return self._rgb(fallback, _seen | {name})
return None
if value.startswith("color-mix(") and value.endswith(")"):
inner = value[len("color-mix("):_match_paren(value, len("color-mix"))]
args = _split_top(inner)
# args[0] is the color space, e.g. "in srgb"
if not args or "srgb" not in args[0]:
raise ValueError(f"unsupported color-mix space: {args[0] if args else '?'}")
ops = [self._parse_operand(a, _seen) for a in args[1:3]]
(c1, p1), (c2, p2) = ops[0], ops[1]
if p1 is None and p2 is None:
p1 = p2 = 50.0
elif p1 is None:
p1 = max(0.0, 100.0 - p2)
elif p2 is None:
p2 = max(0.0, 100.0 - p1)
if c1 is None or c2 is None:
return None
return tuple(round(x) for x in _mix_srgb(c1, p1, c2, p2))
return None
def _parse_operand(self, token: str,
_seen: frozenset[str]) -> tuple[tuple[int, int, int] | None, float | None]:
"""Parse a color-mix operand 'COLOR' or 'COLOR P%'."""
token = token.strip()
pct = None
m = re.search(r"\s(\d+(?:\.\d+)?)%\s*$", " " + token)
if m:
pct = float(m.group(1))
token = token[: token.rfind(m.group(1) + "%")].strip()
return self._rgb(token, _seen), pct
# ---------------------------------------------------------------------------
# SVG transforms
# ---------------------------------------------------------------------------
_STYLE_RE = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL)
_CUSTOM_PROP_RE = re.compile(r"(--[\w-]+)\s*:\s*([^;]*?(?:\([^)]*\)[^;]*?)*);")
_IMPORT_RE = re.compile(r"@import\s+url\([^)]*\)\s*;", re.IGNORECASE)
_FONT_FAMILY_RE = re.compile(r"font-family\s*:\s*[^;}]+")
def _collect_raw_defs(svg: str, overrides: dict[str, str]) -> dict[str, str]:
"""Gather custom-property defs from the root <svg style> and <style> svg{} rules.
`overrides` (the Kami color roles) replace any same-named root role, re-theming
the diagram regardless of the theme it was generated with.
"""
raw: dict[str, str] = {}
m = re.search(r"<svg\b[^>]*\bstyle=\"([^\"]*)\"", svg)
if m:
for prop in m.group(1).split(";"):
if ":" in prop:
k, v = prop.split(":", 1)
k = k.strip()
if k.startswith("--"):
raw[k] = v.strip()
for sm in _STYLE_RE.finditer(svg):
for pm in _CUSTOM_PROP_RE.finditer(sm.group(1)):
raw[pm.group(1)] = pm.group(2).strip()
raw.update(overrides) # Kami palette wins
return raw
def _resolve_functions(text: str, resolver: _Resolver) -> str:
"""Replace every var()/color-mix() in `text` with a static hex, innermost-first.
Raises if any function cannot be resolved to a color: in well-formed
beautiful-mermaid output every var()/color-mix() resolves, so an unresolved
one means the input structure changed. Failing loudly beats silently emitting
an invalid color that renders as a black or invisible diagram.
"""
while True:
starts = [m.start() for m in re.finditer(r"\b(?:var|color-mix)\(", text)]
if not starts:
break
# rightmost opening = guaranteed leaf (no nested var/color-mix after it)
start = max(starts)
open_paren = text.index("(", start)
close = _match_paren(text, open_paren)
expr = text[start:close + 1]
hexval = resolver.hex_of(expr)
if hexval is None:
raise ValueError(
f"could not resolve {expr!r} to a color; the input may not be "
"beautiful-mermaid output or uses an unsupported structure"
)
text = text[:start] + hexval + text[close + 1:]
return text
def _assert_beautiful_mermaid(svg: str) -> None:
"""Raise unless the SVG carries beautiful-mermaid's root color-role props.
beautiful-mermaid v1.x defines --bg / --fg (plus the optional roles) as inline
custom properties on the root <svg>. Their absence means the input came from a
different renderer whose structure this normalizer does not understand, so we
fail loudly here instead of silently producing unresolved colors downstream.
Verified against beautiful-mermaid v1.1.3; see references/mermaid.md.
"""
m = re.search(r"<svg\b[^>]*\bstyle=\"([^\"]*)\"", svg)
style = m.group(1) if m else ""
if "--bg" not in style or "--fg" not in style:
raise ValueError(
"input does not look like beautiful-mermaid output: the root <svg> is "
"missing the --bg/--fg color roles (verified against v1.1.3)"
)
def normalize(svg: str, theme: dict[str, str] | None = None,
font_stack: str | None = None) -> str:
"""Return a Kami-re-themed, WeasyPrint-safe copy of a beautiful-mermaid SVG."""
if theme is None or font_stack is None:
default_colors, default_font = _load_theme()
theme = theme or default_colors
font_stack = font_stack or default_font
_assert_beautiful_mermaid(svg)
raw_defs = _collect_raw_defs(svg, theme)
resolver = _Resolver(raw_defs)
out = _resolve_functions(svg, resolver)
out = _IMPORT_RE.sub("", out)
out = _FONT_FAMILY_RE.sub(f"font-family: {font_stack}", out)
# The derived custom props were inlined into presentation attributes, so the
# <style> svg{} rules and the root role decls are now dead. Strip them, keeping
# only live rules (e.g. the rewritten font-family).
def _clean_style(m: re.Match[str]) -> str:
body = m.group(1)
body = re.sub(r"--[\w-]+\s*:\s*#[0-9a-fA-F]{3,8}\s*;", "", body)
body = re.sub(r"[\w*.#-]+\s*\{\s*(?:/\*.*?\*/\s*)?\}", "", body, flags=re.DOTALL)
body = re.sub(r"\n\s*\n+", "\n", body).strip()
return f"<style>\n {body}\n</style>" if body else ""
out = _STYLE_RE.sub(_clean_style, out)
def _clean_root_style(m: re.Match[str]) -> str:
kept = [d.strip() for d in m.group(1).split(";")
if d.strip() and not d.strip().startswith("--")]
return f' style="{";".join(kept)}"' if kept else ""
out = re.sub(r'\s+style="([^"]*)"', _clean_root_style, out, count=1)
return out
def main(argv: list[str]) -> int:
if len(argv) == 1:
print(__doc__)
return 0
parser = argparse.ArgumentParser(
description="Re-theme a beautiful-mermaid SVG into a Kami, WeasyPrint-safe SVG.",
)
parser.add_argument("src", help="Input SVG path, or '-' to read from stdin")
parser.add_argument("-o", "--output", help="Output SVG path (default: stdout)")
args = parser.parse_args(argv[1:])
try:
raw = sys.stdin.read() if args.src == "-" else Path(args.src).read_text(encoding="utf-8")
result = normalize(raw)
if args.output:
Path(args.output).write_text(result, encoding="utf-8")
print(f"OK: wrote {args.output}")
else:
sys.stdout.write(result)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+74
View File
@@ -0,0 +1,74 @@
"""Centralized loader for optional third-party deps (weasyprint, pypdf, PyMuPDF).
build.py previously had inline `from weasyprint import HTML` try/except blocks
with duplicated install hints; this module collapses those into one resolver
each so the import error message stays consistent and the WeasyPrint runtime
is configured once at the import call site.
"""
from __future__ import annotations
import sys
from shared import configure_weasyprint_runtime
# On Linux, WeasyPrint links against cairo / pango / harfbuzz at runtime; a bare
# `pip install weasyprint` succeeds but then fails to load with a cryptic
# "cannot load library 'libgobject-2.0'" until the native libs are present. Spell
# them out so the error message is actionable on a fresh Linux box.
_LINUX_NATIVE_LIBS = (
"Linux also needs native libs: "
"sudo apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b "
"(Debian/Ubuntu), or `sudo dnf install cairo pango harfbuzz` (Fedora/RHEL)"
)
WEASYPRINT_INSTALL_HINT = "pip install weasyprint pypdf --break-system-packages"
if sys.platform.startswith("linux"):
WEASYPRINT_INSTALL_HINT = f"{WEASYPRINT_INSTALL_HINT}. {_LINUX_NATIVE_LIBS}"
PYMUPDF_INSTALL_HINT = "pip install pymupdf --break-system-packages"
class MissingDepError(RuntimeError):
"""Raised when an optional dependency is requested but not installed."""
def require_weasyprint_html():
"""Return the weasyprint.HTML class, configuring native libs first."""
configure_weasyprint_runtime()
try:
from weasyprint import HTML
return HTML
except ImportError as exc:
raise MissingDepError(
f"missing weasyprint. {WEASYPRINT_INSTALL_HINT}"
) from exc
def _require_pypdf_attr(name: str):
try:
import pypdf
except ImportError as exc:
raise MissingDepError(
f"missing pypdf. {WEASYPRINT_INSTALL_HINT}"
) from exc
return getattr(pypdf, name)
def require_pypdf_reader():
"""Return the pypdf.PdfReader class."""
return _require_pypdf_attr("PdfReader")
def require_pypdf_writer():
"""Return the pypdf.PdfWriter class."""
return _require_pypdf_attr("PdfWriter")
def require_pymupdf():
"""Return the PyMuPDF module (imported as fitz)."""
try:
import fitz
return fitz
except ImportError as exc:
raise MissingDepError(
f"missing PyMuPDF. {PYMUPDF_INSTALL_HINT}"
) from exc
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="${1:-"$ROOT/dist/kami.zip"}"
case "$OUT" in
/*) ;;
*) OUT="$ROOT/$OUT" ;;
esac
PACKAGE_ROOT_NAME="${KAMI_PACKAGE_ROOT_NAME:-kami}"
PACKAGE_MAX_BYTES="${KAMI_PACKAGE_MAX_BYTES:-6000000}"
PACKAGE_FORBIDDEN_RE='^(\.agents/|\.claude/|\.claude-plugin/|\.github/|plugins/|assets/(showcase|demos|examples|illustrations)/|assets/images/[123]\.png$|assets/fonts/TsangerJinKai02-W0[45]\.ttf$|assets/fonts/SourceHanSerifKR-(Regular|Medium)\.otf$|dist/|index(-[^/]+)?\.html$|styles\.css$|llms\.txt$|robots\.txt$|sitemap\.xml$|vercel\.json$|AGENTS\.md$|CLAUDE\.md$|README\.md$|\.gitignore$|scripts/(build_metadata|draft-release-notes|package-skill)\.py$|scripts/package-skill\.sh$|scripts/tests/)'
PACKAGE_REQUIRED_ENTRIES=(
"SKILL.md"
"CHEATSHEET.md"
"VERSION"
"LICENSE"
"assets/images/logo.svg"
"assets/fonts/JetBrainsMono.woff2"
"assets/templates/resume.html"
"assets/templates/landing-page.html"
"assets/diagrams/sequence.html"
"references/design.md"
"scripts/build.py"
"scripts/ensure-fonts.sh"
"scripts/site_facts.py"
)
mkdir -p "$(dirname "$OUT")"
rm -f "$OUT"
cd "$ROOT"
MANIFEST="$(mktemp)"
FILTERED_MANIFEST="$(mktemp)"
ZIP_MANIFEST="$(mktemp)"
STAGING="$(mktemp -d)"
trap 'rm -f "$MANIFEST" "$FILTERED_MANIFEST" "$ZIP_MANIFEST"; rm -rf "$STAGING"' EXIT
git ls-files > "$MANIFEST"
awk '
/(^|\/)__pycache__\// { next }
/\.pyc$/ { next }
/(^|\/)\.DS_Store$/ { next }
/^(SKILL\.md|CHEATSHEET\.md|VERSION|LICENSE)$/ { print; next }
/^assets\/templates\// { print; next }
/^assets\/diagrams\// { print; next }
/^assets\/images\/logo\.svg$/ { print; next }
/^assets\/fonts\/JetBrainsMono\.woff2$/ { print; next }
/^assets\/fonts\/LICENSE-SourceHanSerifK\.txt$/ { print; next }
/^references\// { print; next }
/^scripts\/(build|check-update|checks|ensure-fonts|highlight|lint|mermaid_normalize|optional_deps|shared|site_facts|tokens|verify)\.(py|sh)$/ { print; next }
' "$MANIFEST" > "$FILTERED_MANIFEST"
# Coverage gate: every tracked scripts/ file must be either packaged by the
# allowlist above or named in the repo-only exclusion below. Without this, a
# new runtime module that build.py imports would silently miss the zip and
# the installed skill would ImportError while every local check stays green.
SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|package-skill\.sh|tests/)'
unaccounted="$(grep '^scripts/' "$MANIFEST" \
| grep -Ev "$SCRIPTS_REPO_ONLY_RE" \
| grep -Fvx -f <(grep '^scripts/' "$FILTERED_MANIFEST" || true) || true)"
if [ -n "$unaccounted" ]; then
echo "ERROR: tracked scripts neither packaged nor listed as repo-only:" >&2
printf '%s\n' "$unaccounted" >&2
echo "Add them to the packaging allowlist or to SCRIPTS_REPO_ONLY_RE." >&2
exit 1
fi
while IFS= read -r entry; do
dest="$STAGING/$PACKAGE_ROOT_NAME/$entry"
mkdir -p "$(dirname "$dest")"
cp -p "$entry" "$dest"
done < "$FILTERED_MANIFEST"
(
cd "$STAGING"
find "$PACKAGE_ROOT_NAME" -type f | sort > "$ZIP_MANIFEST"
zip -X -q "$OUT" -@ < "$ZIP_MANIFEST"
)
entries="$(zipinfo -1 "$OUT")"
bad_root="$(printf '%s\n' "$entries" | awk -v prefix="${PACKAGE_ROOT_NAME}/" 'index($0, prefix) != 1 { print }')"
if [ -n "$bad_root" ]; then
echo "ERROR: package entries must live under ${PACKAGE_ROOT_NAME}/:" >&2
printf '%s\n' "$bad_root" >&2
exit 1
fi
stripped_entries="$(printf '%s\n' "$entries" | sed "s#^${PACKAGE_ROOT_NAME}/##")"
if forbidden_entries="$(printf '%s\n' "$stripped_entries" | grep -E "$PACKAGE_FORBIDDEN_RE")"; then
echo "ERROR: disallowed package entry found in $OUT:" >&2
printf '%s\n' "$forbidden_entries" >&2
exit 1
fi
for required in "${PACKAGE_REQUIRED_ENTRIES[@]}"; do
if ! printf '%s\n' "$entries" | grep -Fxq "${PACKAGE_ROOT_NAME}/${required}"; then
echo "ERROR: required package entry missing from $OUT: ${PACKAGE_ROOT_NAME}/${required}" >&2
exit 1
fi
done
size_bytes="$(wc -c < "$OUT" | tr -d '[:space:]')"
if (( size_bytes > PACKAGE_MAX_BYTES )); then
echo "ERROR: package exceeds ${PACKAGE_MAX_BYTES} bytes: ${size_bytes} bytes" >&2
exit 1
fi
echo "OK: package audit passed (${size_bytes} bytes, limit ${PACKAGE_MAX_BYTES})"
echo "OK: wrote $OUT"
+283
View File
@@ -0,0 +1,283 @@
"""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},
}
+282
View File
@@ -0,0 +1,282 @@
"""Public-site fact drift checks for Kami.
The hosted pages, README, and llms.txt intentionally repeat install and product
facts in multiple languages. This module keeps those facts tied to the shared
registry and public constants so `build.py --check` catches drift before CI.
"""
from __future__ import annotations
import difflib
import html
import re
from collections.abc import Mapping
from html.parser import HTMLParser
from shared import (
CLAUDE_CODE_INSTALL_COMMANDS,
CLAUDE_CODE_MIN_VERSION,
CLAUDE_DESKTOP_PACKAGE_URL,
CODEX_PLUGIN_INSTALL_COMMANDS,
DIAGRAM_TEMPLATES,
GENERIC_AGENT_INSTALL_COMMAND,
PUBLIC_DOCUMENT_TEMPLATE_KINDS,
ROOT,
kami_version,
public_document_template_count,
public_document_template_kinds,
)
# Locale pages are hand-maintained forks of index.html. Their DOM skeletons
# must stay identical; the only allowed divergence is the language-redirect
# <script> that exists solely on the default page.
SITE_BASE_PAGE = "index.html"
SITE_LOCALE_PAGES = (
"index-zh.html",
"index-ja.html",
"index-ko.html",
"index-tw.html",
)
# Every surface that must carry the full public fact set. Derived from the
# locale-page tuple so adding a locale automatically joins both checks.
FULL_PUBLIC_FACT_FILES = (
"README.md",
"llms.txt",
SITE_BASE_PAGE,
*SITE_LOCALE_PAGES,
)
REDIRECT_SITE_FILE = "index-en.html"
SITE_SURFACE_ABSENT = "__site_surface_absent__"
_SKELETON_TAGS = frozenset({
"article", "aside", "dl", "figure", "footer", "form", "header",
"h1", "h2", "h3", "h4", "h5", "h6",
"main", "nav", "ol", "section", "script", "svg", "table", "ul",
})
# Spelled-out numerals per template count. Digit patterns below are derived
# from the live registry count, so a registry change keeps the check honest;
# only the localized number words need a new entry here when the count moves.
_TEMPLATE_COUNT_WORDS = {
8: (
r"\bEight document template",
r"八种文档模板",
r"八種文件範本",
),
}
def _normalize(text: str) -> str:
return html.unescape(text)
def _contains_template_count(text: str, expected: int) -> bool:
patterns = [
rf"\b{expected} document template",
rf"{expected}种文档模板",
rf"{expected}種文件範本",
rf"{expected}種類のドキュメントテンプレート",
rf"{expected}가지 문서 템플릿",
*_TEMPLATE_COUNT_WORDS.get(expected, ()),
]
return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)
def _contains_diagram_count(text: str, expected: int) -> bool:
patterns = [
rf"\b{expected}\s+(?:inline\s+SVG\s+)?diagram",
rf"{expected}\s*(?:种|種).*?(?:图表|圖表)",
rf"{expected}種.*?図表",
rf"{expected}가지.*?다이어그램",
]
if expected == 18:
patterns.append(r"\bEighteen\s+inline\s+SVG")
return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)
def _file_texts(files: Mapping[str, str] | None) -> tuple[dict[str, str], list[str]]:
if files is not None:
return dict(files), []
texts: dict[str, str] = {}
issues: list[str] = []
site_files = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)
if not any((ROOT / rel).exists() for rel in site_files):
return {SITE_SURFACE_ABSENT: ""}, []
for rel in site_files:
path = ROOT / rel
if not path.exists():
issues.append(f"{rel}: missing public fact file")
continue
texts[rel] = path.read_text(encoding="utf-8", errors="replace")
return texts, issues
class _SkeletonParser(HTMLParser):
"""Collect the structural tag sequence of a page, annotated enough to
catch real drift (class identity, script type) without tracking text."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.rows: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag not in _SKELETON_TAGS:
return
attr_map = dict(attrs)
row = tag
css_class = (attr_map.get("class") or "").strip()
if css_class:
row += f".{css_class}"
if tag == "script":
script_type = (attr_map.get("type") or "").strip()
if script_type:
row += f"[{script_type}]"
self.rows.append(row)
def _page_skeleton(text: str) -> list[str]:
parser = _SkeletonParser()
parser.feed(text)
return parser.rows
def _drop_language_redirect_script(rows: list[str]) -> list[str]:
"""Return rows minus the first bare <script> (the default page's
language redirect), which locale pages intentionally omit."""
for index, row in enumerate(rows):
if row == "script":
return rows[:index] + rows[index + 1:]
return rows
def site_structure_issues(files: Mapping[str, str] | None = None) -> list[str]:
"""Compare each locale page's DOM skeleton against index.html."""
texts, issues = _file_texts(files)
if SITE_SURFACE_ABSENT in texts:
return []
base_raw = texts.get(SITE_BASE_PAGE)
if base_raw is None:
return issues
expected = _drop_language_redirect_script(_page_skeleton(base_raw))
for rel in SITE_LOCALE_PAGES:
raw = texts.get(rel)
if raw is None:
continue
rows = _page_skeleton(raw)
if rows == expected:
continue
delta = [
line for line in difflib.unified_diff(expected, rows, lineterm="", n=0)
if line[:1] in "+-" and line[:3] not in ("+++", "---")
]
preview = "; ".join(delta[:6]) + (" ..." if len(delta) > 6 else "")
issues.append(
f"{rel}: DOM skeleton drifted from {SITE_BASE_PAGE} "
f"({len(delta)} row(s): {preview})"
)
return issues
def site_fact_issues(files: Mapping[str, str] | None = None) -> list[str]:
texts, issues = _file_texts(files)
if SITE_SURFACE_ABSENT in texts:
return []
kinds = public_document_template_kinds()
if kinds != PUBLIC_DOCUMENT_TEMPLATE_KINDS:
missing = sorted(PUBLIC_DOCUMENT_TEMPLATE_KINDS - kinds)
extra = sorted(kinds - PUBLIC_DOCUMENT_TEMPLATE_KINDS)
detail = []
if missing:
detail.append(f"missing public kinds: {', '.join(missing)}")
if extra:
detail.append(f"extra public kinds: {', '.join(extra)}")
issues.append("registry: public document template kinds drifted" + (f" ({'; '.join(detail)})" if detail else ""))
template_count = public_document_template_count()
diagram_count = len(DIAGRAM_TEMPLATES)
for rel in FULL_PUBLIC_FACT_FILES:
raw = texts.get(rel)
if raw is None:
if files is not None:
issues.append(f"{rel}: missing public fact file")
continue
text = _normalize(raw)
if CLAUDE_CODE_MIN_VERSION not in text:
issues.append(f"{rel}: missing Claude Code minimum version {CLAUDE_CODE_MIN_VERSION}")
for command in CLAUDE_CODE_INSTALL_COMMANDS:
if command not in text:
issues.append(f"{rel}: missing Claude Code install command `{command}`")
for command in CODEX_PLUGIN_INSTALL_COMMANDS:
if command not in text:
issues.append(f"{rel}: missing Codex install command `{command}`")
if GENERIC_AGENT_INSTALL_COMMAND not in text:
issues.append(f"{rel}: missing generic agent install command `{GENERIC_AGENT_INSTALL_COMMAND}`")
if "kami.zip" not in text:
issues.append(f"{rel}: missing Claude Desktop package name kami.zip")
if rel != "llms.txt" and CLAUDE_DESKTOP_PACKAGE_URL not in text:
issues.append(f"{rel}: missing Claude Desktop package URL {CLAUDE_DESKTOP_PACKAGE_URL}")
# The site pages carry a hand-written Kami version badge; tie it to the
# tracked VERSION file so a release bump cannot leave a page behind.
# README and llms.txt intentionally carry no version string.
if rel.endswith(".html") and f"v{kami_version()}" not in text:
issues.append(f"{rel}: missing Kami version badge v{kami_version()}")
if not _contains_template_count(text, template_count):
issues.append(f"{rel}: missing public document template count {template_count}")
if not _contains_diagram_count(text, diagram_count):
issues.append(f"{rel}: missing diagram count {diagram_count}")
redirect = texts.get(REDIRECT_SITE_FILE)
if redirect is None:
if files is not None:
issues.append(f"{REDIRECT_SITE_FILE}: missing redirect page")
else:
text = _normalize(redirect)
for required in ('http-equiv="refresh"', "url=./", 'content="noindex"', 'rel="canonical"'):
if required not in text:
issues.append(f"{REDIRECT_SITE_FILE}: missing redirect marker `{required}`")
if CLAUDE_CODE_MIN_VERSION in text or "kami.zip" in text:
issues.append(f"{REDIRECT_SITE_FILE}: redirect page should not carry product fact copy")
return issues
def check_site_facts(verbose: bool = False) -> int:
if not any((ROOT / rel).exists() for rel in (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)):
print("OK: public site facts skipped (site files absent)")
return 0
result = 0
issues = site_fact_issues()
if not issues:
scanned = len(FULL_PUBLIC_FACT_FILES) + 1
print(f"OK: public site facts in sync across {scanned} file(s)")
else:
print(f"\nERROR: [site-fact-drift] {len(issues)}")
for issue in issues:
print(f" {issue}")
if verbose:
print(" source: shared public constants and template registries")
result = 1
structure_issues = site_structure_issues()
if not structure_issues:
print(f"OK: locale page structure matches {SITE_BASE_PAGE} across {len(SITE_LOCALE_PAGES)} page(s)")
else:
print(f"\nERROR: [site-structure-drift] {len(structure_issues)}")
for issue in structure_issues:
print(f" {issue}")
if verbose:
print(f" source: DOM skeleton comparison against {SITE_BASE_PAGE}")
result = 1
return result
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
"""Token-sync checker for kami templates.
Splits out from build.py: scans `:root { ... }` blocks across HTML templates
and `RGBColor(0xXX, 0xXX, 0xXX)` constants in the PPTX slide scripts, and
reports drift from `references/tokens.json` (the canonical color tokens).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from shared import ROOT, TEMPLATES, TOKENS_FILE, iter_template_files
ROOT_BLOCK = re.compile(r":root\s*\{([^}]*)\}", re.DOTALL)
CSS_VAR = re.compile(r"--([\w-]+)\s*:\s*([^;]+);")
def parse_root_vars(text: str) -> dict[str, str]:
"""Return {'--name': value} merged across every `:root { ... }` block.
Scanning all blocks (not just the first) keeps a future dark-mode or print
override inside a second `:root` from silently escaping the drift checks.
"""
found: dict[str, str] = {}
for block in ROOT_BLOCK.finditer(text):
for m in CSS_VAR.finditer(block.group(1)):
found[f"--{m.group(1)}"] = m.group(2).strip()
return found
PY_RGB = re.compile(
r"^([A-Z][A-Z_]+)\s*=\s*RGBColor\(\s*0x([0-9a-fA-F]{2})\s*,"
r"\s*0x([0-9a-fA-F]{2})\s*,\s*0x([0-9a-fA-F]{2})\s*\)",
re.MULTILINE,
)
# Python const name -> tokens.json key. Only constants that mirror a CSS token.
PY_TOKEN_MAP = {
"PARCHMENT": "--parchment",
"IVORY": "--ivory",
"BRAND": "--brand",
"NEAR_BLACK": "--near-black",
"DARK_WARM": "--dark-warm",
"CHARCOAL": "--charcoal",
"OLIVE": "--olive",
"STONE": "--stone",
}
MERMAID_THEME_FILE = ROOT / "references" / "mermaid-theme.json"
MERMAID_THEME_TOKEN_MAP = {
"bg": "--parchment",
"fg": "--near-black",
"line": "--olive",
"accent": "--brand",
"muted": "--stone",
"surface": "--ivory",
"border": "--border",
}
def _mermaid_theme_drift(canonical: dict[str, str]) -> list[str]:
if not MERMAID_THEME_FILE.exists():
return [f"mermaid-theme.json not found at {MERMAID_THEME_FILE.relative_to(ROOT)}"]
try:
theme = json.loads(MERMAID_THEME_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return [f"mermaid-theme.json is malformed: {exc}"]
colors = theme.get("colors", {})
roles = theme.get("roles", {})
drift: list[str] = []
for role, token in MERMAID_THEME_TOKEN_MAP.items():
expected = canonical.get(token)
actual = colors.get(role)
if expected is None:
drift.append(f"{role}: canonical token {token} missing")
continue
if actual is None:
drift.append(f"{role}: color missing, expected {expected} from {token}")
elif actual.lower() != expected.lower():
drift.append(f"{role}: expected {expected} from {token}, got {actual}")
role_doc = str(roles.get(role, ""))
if token not in role_doc:
drift.append(f"{role}: role doc should mention {token}")
return drift
def sync_check(verbose: bool = False) -> int:
if not TOKENS_FILE.exists():
print(f"ERROR: tokens.json not found at {TOKENS_FILE.relative_to(ROOT)}")
return 2
try:
canonical: dict[str, str] = json.loads(TOKENS_FILE.read_text())
except json.JSONDecodeError as exc:
print(f"ERROR: tokens.json is malformed: {exc}")
return 2
targets = iter_template_files(include_diagrams=True, include_marp_css=True)
py_targets: list[Path] = list(TEMPLATES.glob("*.py"))
if not targets and not py_targets:
print("ERROR: no templates found to token-check (bad checkout?)")
return 2
drift: list[tuple[str, str, str, str]] = [] # (file, token, expected, actual)
for path in targets:
text = path.read_text(encoding="utf-8", errors="replace")
found = parse_root_vars(text)
if not found:
if verbose:
print(f" (skip {path.name}: no :root block)")
continue
rel = path.relative_to(ROOT)
for token, expected in canonical.items():
actual = found.get(token)
# Only flag if the template defines the token but with a wrong value.
# Templates that don't use a token don't need to define it.
if actual is not None and actual.lower() != expected.lower():
drift.append((str(rel), token, expected, actual))
for path in sorted(py_targets):
text = path.read_text(encoding="utf-8", errors="replace")
rel = path.relative_to(ROOT)
for m in PY_RGB.finditer(text):
name = m.group(1)
token = PY_TOKEN_MAP.get(name)
if token is None:
continue
expected = canonical.get(token)
if expected is None:
continue
actual = f"#{m.group(2)}{m.group(3)}{m.group(4)}"
if actual.lower() != expected.lower():
drift.append((str(rel), token, expected, actual))
theme_drift = _mermaid_theme_drift(canonical)
if not drift and not theme_drift:
scanned = len(targets) + len(py_targets)
print(f"OK: tokens in sync across {scanned} template(s) and mermaid-theme.json")
return 0
if drift:
print(f"\nERROR: [token-drift] {len(drift)}")
for file, token, expected, actual in drift:
print(f" {file}: {token} expected {expected}, got {actual}")
if theme_drift:
print(f"\nERROR: [mermaid-theme-drift] {len(theme_drift)}")
for issue in theme_drift:
print(f" {issue}")
return 1
+293
View File
@@ -0,0 +1,293 @@
"""End-to-end render verification for kami templates.
Splits out from build.py: renders each template via WeasyPrint, validates
page-count against per-template ceilings, inspects embedded PDF fonts to
warn when only a fallback is used, and runs an advisory density scan over
the rendered examples when invoked for the full suite.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from highlight import highlight_code_blocks
from optional_deps import (
MissingDepError,
require_pypdf_reader,
require_weasyprint_html,
)
from shared import DIAGRAMS, EXAMPLES, TEMPLATES, default_example_pdfs, load_checks_thresholds
# Primary fonts expected in embedded PDF font names
CN_PRIMARY_FONTS = {"TsangerJinKai02"}
EN_PRIMARY_FONTS = {"Charter"}
KO_PRIMARY_FONTS = {"Source-Han-Serif-K", "SourceHanSerifK"}
RECOGNIZABLE_FALLBACK_FONT_MARKERS = (
"Georgia",
"Palatino",
"PT-Serif",
"PTSerif",
"TsangerJinKai",
"YuMincho",
"Hiragino",
"SourceHan",
"Noto",
"Charter",
"Songti",
"DejaVu",
"Liberation",
)
def show_fonts(pdf: Path) -> None:
if not pdf.exists():
return
try:
out = subprocess.run(["pdffonts", str(pdf)], capture_output=True, text=True, check=False)
if out.returncode == 0:
print("--- pdffonts ---")
print(out.stdout.rstrip())
except FileNotFoundError:
pass # pdffonts not installed; silent
def _pdf_font_names(pdf_path: Path) -> set[str]:
def _resolve_pdf_obj(obj):
if obj is None:
return None
try:
return obj.get_object() if hasattr(obj, "get_object") else obj
except Exception:
return obj
try:
PdfReader = require_pypdf_reader()
reader = PdfReader(str(pdf_path))
fonts: set[str] = set()
for page in reader.pages:
resources = _resolve_pdf_obj(page.get("/Resources"))
if resources is None or not hasattr(resources, "get"):
continue
font_dict = _resolve_pdf_obj(resources.get("/Font"))
if font_dict is None or not hasattr(font_dict, "values"):
continue
for obj in font_dict.values():
resolved = _resolve_pdf_obj(obj)
if resolved is None or not hasattr(resolved, "get"):
continue
base = resolved.get("/BaseFont")
if base:
fonts.add(str(base).lstrip("/"))
return fonts
except Exception as exc:
print(f" WARN: could not read font names from PDF: {exc}")
return set()
def _check_font_sources(html_path: Path) -> list[str]:
"""Return list of local @font-face src files that are missing on disk."""
text = html_path.read_text(encoding="utf-8", errors="replace")
missing: list[str] = []
for url in re.findall(r"""url\(["']?([^"')]+)["']?\)""", text):
if url.startswith(("http://", "https://", "data:", "#")):
continue
resolved = (html_path.parent / url).resolve()
if not resolved.exists():
missing.append(url)
return missing
def verify_target(
name: str,
source: str,
max_pages: int,
src_dir: Path,
*,
infer_author_fn,
set_pdf_metadata_fn,
) -> list[str]:
"""Render `source` to a PDF, then run page-count and font checks.
`infer_author_fn` and `set_pdf_metadata_fn` are passed in by the caller
(build.py) so this module avoids a circular import on those helpers.
"""
issues: list[str] = []
src = src_dir / source
if not src.exists():
issues.append(f"source not found: {src}")
return issues
try:
HTML = require_weasyprint_html()
PdfReader = require_pypdf_reader()
except MissingDepError as exc:
issues.append(str(exc))
return issues
EXAMPLES.mkdir(parents=True, exist_ok=True)
out = EXAMPLES / f"{name}.pdf"
# Warn about missing local font files before rendering
missing_fonts = _check_font_sources(src)
if missing_fonts:
for mf in missing_fonts:
print(f" [FONT MISS] {name}: {mf} not found")
print(f" [FONT MISS] Repo fix: git checkout -- assets/fonts (commercial TTFs are tracked)")
print(f" [FONT MISS] Skill recovery (downloads to the user font dir, not the skill): bash scripts/ensure-fonts.sh")
print(f" [FONT MISS] Fallback: brew install --cask font-source-han-serif-sc")
html_text = src.read_text(encoding="utf-8")
html_text = highlight_code_blocks(html_text)
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out))
# Set PDF metadata (only replaces placeholders, preserves filled values)
set_pdf_metadata_fn(out, author=infer_author_fn())
# page count check
n = len(PdfReader(str(out)).pages)
if max_pages and n > max_pages:
over = n - max_pages
hint = ""
if "resume" in name and over == 1:
hint = '; add class="resume--dense" to <body> or tighten .proj-text line-height to 1.38'
issues.append(f"page overflow: {n} pages (limit {max_pages}){hint}")
# font check
embedded = _pdf_font_names(out)
fallback_present = any(
kw in font for font in embedded
for kw in RECOGNIZABLE_FALLBACK_FONT_MARKERS
)
# Diagram templates are language-neutral and often rely on fallback stacks,
# so only enforce that at least one recognizable serif/sans fallback exists.
is_diagram = src_dir == DIAGRAMS
if is_diagram:
if not fallback_present:
issues.append(f"no recognizable font embedded in {out.name}")
return issues
is_en = name.endswith("-en")
is_ko = name.endswith("-ko")
expected = EN_PRIMARY_FONTS if is_en else (KO_PRIMARY_FONTS if is_ko else CN_PRIMARY_FONTS)
if not any(exp in font_name for exp in expected for font_name in embedded):
primary = next(iter(expected))
if not fallback_present:
issues.append(f"no recognizable font embedded in {out.name}")
elif os.environ.get("KAMI_ALLOW_FALLBACK_ONLY"):
# CI / headless boxes never have commercial fonts (TsangerJinKai02,
# Charter). Treat "primary missing, fallback present" as a warning
# there so CI can still gate page-count regressions.
print(f" WARN: {name}: primary font ({primary}) not embedded; using fallback")
else:
issues.append(f"primary font ({primary}) not embedded; using fallback")
return issues
def verify_screen_target(name: str, source: str, scan_file_fn) -> list[str]:
"""Lint a browser-only template via the caller-provided scan_file."""
src = TEMPLATES / source
if not src.exists():
return [f"source not found: {src}"]
findings = scan_file_fn(src)
if findings:
return [f"{len(findings)} template violation(s)"]
return []
def verify_all(
target: str | None,
*,
html_targets,
screen_targets,
diagram_targets,
pptx_targets,
verify_slides_fn,
scan_file_fn,
scan_density_fn,
infer_author_fn,
set_pdf_metadata_fn,
) -> int:
"""Drive verification across the requested target set.
All registries and helper callbacks are passed in to keep this module free
of circular imports back into build.py.
"""
targets_to_run: dict[str, tuple[str, int, Path] | None] = {}
screen_targets_to_run: dict[str, str] = {}
if target:
if target in html_targets:
src, mp = html_targets[target]
targets_to_run[target] = (src, mp, TEMPLATES)
elif target in screen_targets:
screen_targets_to_run[target] = screen_targets[target]
elif target in diagram_targets:
targets_to_run[target] = (diagram_targets[target], 0, DIAGRAMS)
elif target in pptx_targets:
targets_to_run[target] = None
else:
print(f"ERROR: unknown target: {target}")
return 2
else:
for name, (src, mp) in html_targets.items():
targets_to_run[name] = (src, mp, TEMPLATES)
for name, src in screen_targets.items():
screen_targets_to_run[name] = src
for name, src in diagram_targets.items():
targets_to_run[name] = (src, 0, DIAGRAMS)
for name in pptx_targets:
targets_to_run[name] = None
failures = 0
rows: list[tuple[str, str]] = []
for name, config in targets_to_run.items():
if config is None:
issues = verify_slides_fn(name)
else:
source, max_pages, src_dir = config
issues = verify_target(
name, source, max_pages, src_dir,
infer_author_fn=infer_author_fn,
set_pdf_metadata_fn=set_pdf_metadata_fn,
)
if issues:
rows.append((f"ERROR: {name}", "; ".join(issues)))
failures += 1
else:
rows.append((f"OK: {name}", "ok"))
for name, source in screen_targets_to_run.items():
issues = verify_screen_target(name, source, scan_file_fn)
if issues:
rows.append((f"ERROR: {name}", "; ".join(issues)))
failures += 1
else:
rows.append((f"OK: {name}", "static HTML template"))
for status, detail in rows:
print(f"{status}: {detail}")
if target is None:
pdfs = default_example_pdfs()
if pdfs:
print()
print("Density scan (advisory):")
scan = scan_density_fn(pdfs)
if scan is not None:
sparse, warn, _, scanned = scan
if sparse + warn == 0:
print(f" OK: no density issues across {scanned} PDF(s)")
else:
density_cfg = load_checks_thresholds()["density"]
sparse_pct_disp = int(round(float(density_cfg["sparse_pct"]) * 100))
warn_pct_disp = int(round(float(density_cfg["warn_pct"]) * 100))
if sparse:
print(f" {sparse} SPARSE page(s) (>{sparse_pct_disp}% trailing whitespace) across {scanned} PDF(s)")
if warn:
print(f" {warn} WARN page(s) (>{warn_pct_disp}%) across {scanned} PDF(s)")
print(" (advisory: re-author with SKILL.md Step 4.1 merge rule. Does not fail --verify.)")
return 0 if failures == 0 else 1