Files
2026-07-13 13:05:52 +08:00

396 lines
12 KiB
Python
Executable File

#!/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))