chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+395
@@ -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))
|
||||
@@ -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())
|
||||
Executable
+56
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Executable
+220
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Executable
+111
@@ -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"
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user