mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-11-09 10:39:41 -06:00
973 lines
45 KiB
Python
973 lines
45 KiB
Python
"""
|
|
Build command implementation for MLSysBook CLI.
|
|
|
|
Handles building chapters and full books in different formats (HTML, PDF, EPUB).
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
from rich.console import Console
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
from ..core.discovery import AmbiguousChapterError, format_volume_display_name
|
|
from ..core.errors import describe_exception
|
|
from ..core.process import (
|
|
interrupts_as_keyboard_interrupt,
|
|
local_render_env,
|
|
start_process_group,
|
|
stop_process_group,
|
|
)
|
|
|
|
console = Console()
|
|
|
|
#: Quarto ``--to`` target for each binder output format.
|
|
RENDER_TARGETS = {"html": "html", "pdf": "titlepage-pdf", "epub": "epub"}
|
|
|
|
|
|
def render_command(format_type: str) -> List[str]:
|
|
"""Return the ``quarto render`` command for *format_type*.
|
|
|
|
Raises:
|
|
ValueError: If the format is not html, pdf, or epub.
|
|
"""
|
|
if format_type not in RENDER_TARGETS:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
return ["quarto", "render", f"--to={RENDER_TARGETS[format_type]}"]
|
|
|
|
|
|
class BuildCommand:
|
|
"""Handles build operations for the MLSysBook."""
|
|
|
|
def __init__(self, config_manager, chapter_discovery, verbose: bool = False, open_after: bool = False):
|
|
"""Initialize build command.
|
|
|
|
Args:
|
|
config_manager: ConfigManager instance
|
|
chapter_discovery: ChapterDiscovery instance
|
|
verbose: If True, stream build output in real-time
|
|
open_after: If True, open build output after successful build
|
|
"""
|
|
self.config_manager = config_manager
|
|
self.chapter_discovery = chapter_discovery
|
|
self.verbose = verbose
|
|
self.open_after = open_after
|
|
#: Set when a build was stopped by Ctrl-C or a signal, so callers looping
|
|
#: over several builds can stop too.
|
|
self.interrupted = False
|
|
|
|
def _preflight_epub_hygiene(self, skip: bool = False) -> bool:
|
|
"""Run the `hygiene` scope of `./binder/binder check epub` before an EPUB build.
|
|
|
|
Returns True if the build should proceed, False if it should abort.
|
|
|
|
Rationale: the hygiene check runs in <1s across all SVG and
|
|
BibTeX source files, while an EPUB render takes ~2 minutes per
|
|
volume. Catching a regression now saves iterations later, and
|
|
short-circuiting avoids producing a half-broken artifact that
|
|
the user then has to diagnose.
|
|
|
|
If hygiene fails, the method tells the user how to auto-repair
|
|
(`./binder/binder check epub --scope hygiene --fix`) and how to bypass
|
|
(`--skip-hygiene`) so the blocker is never mysterious.
|
|
|
|
Opt-out: `--skip-hygiene` on the build command, or explicit
|
|
`skip=True` for programmatic callers.
|
|
"""
|
|
if skip:
|
|
console.print("[yellow]⚠ Skipping EPUB hygiene preflight (--skip-hygiene)[/yellow]")
|
|
return True
|
|
|
|
# Lazy import: don't pay the cost when building HTML or PDF.
|
|
from ._epub_checks import find_hygiene_issues
|
|
|
|
repo_root = self.config_manager.root_dir
|
|
issues, files_checked = find_hygiene_issues(repo_root)
|
|
|
|
if not issues:
|
|
console.print(
|
|
f"[green]✓ EPUB hygiene preflight[/green] [dim]"
|
|
f"({files_checked} SVG/BibTeX files scanned, 0 issues)[/dim]"
|
|
)
|
|
return True
|
|
|
|
console.print(
|
|
f"[red]✗ EPUB hygiene preflight failed[/red] [dim]"
|
|
f"({len(issues)} issue(s) across {files_checked} scanned files)[/dim]"
|
|
)
|
|
# Show up to 5 issues so the user sees the shape of the problem
|
|
# without drowning in output. Full detail is one command away.
|
|
for issue in issues[:5]:
|
|
console.print(
|
|
f" [red]{issue.code}[/red] "
|
|
f"[dim]{issue.file}:{issue.line}[/dim] — {issue.message}"
|
|
)
|
|
if len(issues) > 5:
|
|
console.print(f" [dim]… {len(issues) - 5} more. "
|
|
f"See `./binder/binder check epub --scope hygiene` for all.[/dim]")
|
|
console.print()
|
|
console.print("[yellow]Resolve with one of:[/yellow]")
|
|
console.print(" [cyan]./binder/binder check epub --scope hygiene --fix[/cyan] "
|
|
"[dim]# auto-repair source files in place[/dim]")
|
|
console.print(" [cyan]./binder/binder build epub --skip-hygiene[/cyan] "
|
|
"[dim]# build anyway (not recommended)[/dim]")
|
|
return False
|
|
|
|
def _postflight_epub_validation(self, skip: bool = False, output_dir: Optional[Path] = None) -> bool:
|
|
"""Run smoke + epubcheck against the built EPUB(s) after render.
|
|
|
|
Returns True if validation passed (or was skipped), False on a
|
|
hard failure the caller should surface via a non-zero exit code.
|
|
|
|
Called from every `./binder/binder build epub` entry point on success.
|
|
Mirrors the CI gate so a local build that passes here will pass
|
|
the `epub-validate` job in book-validate-dev.yml, and vice versa.
|
|
|
|
Rationale: the pre-render hygiene preflight catches source-level
|
|
problems, but it cannot catch renderer-emitted issues (markup
|
|
the post-process sanitizer handled, or a new class epubcheck
|
|
flags after a Quarto upgrade). Running smoke + epubcheck at
|
|
the end closes that loop without asking the user to remember
|
|
a follow-up command. Roughly ~7s added to a ~120s build.
|
|
|
|
Opt-out: `--skip-validate` on the build command, or explicit
|
|
`skip=True` for programmatic callers.
|
|
"""
|
|
if skip:
|
|
console.print("[yellow]⚠ Skipping post-build EPUB validation (--skip-validate)[/yellow]")
|
|
return True
|
|
|
|
from ._epub_checks import (
|
|
_discover_built_epubs,
|
|
emit_github_annotations,
|
|
run_epubcheck_on,
|
|
run_smoke_checks_on,
|
|
)
|
|
|
|
repo_root = self.config_manager.root_dir
|
|
epubs = sorted(output_dir.glob("*.epub")) if output_dir is not None else _discover_built_epubs(repo_root)
|
|
if not epubs:
|
|
console.print("[dim]ⓘ No EPUB artifacts found under _build/epub-vol*/; skipping post-build validation.[/dim]")
|
|
return True
|
|
|
|
console.print("[cyan]🔍 Post-build EPUB validation[/cyan]")
|
|
overall_ok = True
|
|
|
|
# --- Smoke (reader-compat, no Java) ----------------------------
|
|
smoke_issues = []
|
|
for epub in epubs:
|
|
smoke_issues.extend(run_smoke_checks_on(epub, repo_root=repo_root))
|
|
|
|
if smoke_issues:
|
|
console.print(
|
|
f" [red]✗ smoke[/red]: {len(smoke_issues)} reader-compat issue(s)"
|
|
)
|
|
for issue in smoke_issues[:5]:
|
|
console.print(
|
|
f" [red]{issue.code}[/red] "
|
|
f"[dim]{issue.file}:{issue.line}[/dim] — {issue.message}"
|
|
)
|
|
if len(smoke_issues) > 5:
|
|
console.print(f" [dim]… {len(smoke_issues) - 5} more[/dim]")
|
|
overall_ok = False
|
|
else:
|
|
console.print(f" [green]✓ smoke[/green] [dim]({len(epubs)} EPUB(s), reader-compat clean)[/dim]")
|
|
|
|
# --- Epubcheck (W3C, needs Java) -------------------------------
|
|
# Use the same baseline file CI uses so local behaviour matches.
|
|
baseline_path = repo_root / "binder" / "tools" / "audit" / "epubcheck-baseline.json"
|
|
baseline_counts: Dict[str, Dict[str, int]] = {}
|
|
if baseline_path.exists():
|
|
try:
|
|
import json as _json
|
|
baseline_counts = _json.loads(
|
|
baseline_path.read_text(encoding="utf-8")
|
|
).get("volumes", {}) or {}
|
|
except (OSError, ValueError):
|
|
baseline_counts = {}
|
|
|
|
total_fatal = 0
|
|
total_errors = 0
|
|
any_epubcheck_issue = False
|
|
ratchet_violations: List[str] = []
|
|
|
|
for epub in epubs:
|
|
vol_key = epub.parent.name.replace("epub-", "") or epub.stem
|
|
issues, counts = run_epubcheck_on(epub, repo_root=repo_root)
|
|
emit_github_annotations(issues)
|
|
total_fatal += counts.get("FATAL", 0)
|
|
total_errors += counts.get("ERROR", 0)
|
|
if issues:
|
|
any_epubcheck_issue = True
|
|
|
|
# Ratchet comparison per volume
|
|
recorded = baseline_counts.get(vol_key, {})
|
|
for severity in ("FATAL", "ERROR", "WARNING"):
|
|
current = counts.get(severity, 0)
|
|
allowed = int(recorded.get(severity, 0))
|
|
if current > allowed:
|
|
ratchet_violations.append(
|
|
f"{vol_key} {severity}: {current} > baseline {allowed} "
|
|
f"(+{current - allowed})"
|
|
)
|
|
|
|
# Missing-epubcheck sentinel — short-circuit gracefully.
|
|
if any(i.code == "epubcheck-missing" for i in issues):
|
|
console.print(
|
|
" [yellow]⚠ epubcheck[/yellow]: not installed locally; "
|
|
"skipping full validation "
|
|
"[dim](install via `pip install epubcheck` or `./binder/binder doctor` for details)[/dim]"
|
|
)
|
|
# Don't fail the build just because Java is missing locally —
|
|
# CI will catch anything this local pass would have.
|
|
return overall_ok
|
|
|
|
if ratchet_violations:
|
|
console.print(
|
|
f" [red]✗ epubcheck[/red]: regression against baseline "
|
|
f"({total_fatal} FATAL, {total_errors} ERROR total)"
|
|
)
|
|
for v in ratchet_violations:
|
|
console.print(f" [red]•[/red] {v}")
|
|
console.print(
|
|
" [dim]If this is an accepted increase, rerun with "
|
|
"`./binder/binder check epub --scope epubcheck --baseline "
|
|
"binder/tools/audit/epubcheck-baseline.json --update-baseline` "
|
|
"and commit the updated JSON.[/dim]"
|
|
)
|
|
overall_ok = False
|
|
elif any_epubcheck_issue:
|
|
# Issues exist but all within baseline — inform, don't fail.
|
|
console.print(
|
|
f" [yellow]⚠ epubcheck[/yellow]: "
|
|
f"{total_fatal} FATAL, {total_errors} ERROR "
|
|
"[dim](all within baseline — no regression)[/dim]"
|
|
)
|
|
else:
|
|
console.print(
|
|
f" [green]✓ epubcheck[/green] [dim]({len(epubs)} EPUB(s); "
|
|
f"0 FATAL, 0 ERROR)[/dim]"
|
|
)
|
|
|
|
if not overall_ok:
|
|
console.print()
|
|
console.print(
|
|
"[red]Build artifact written but validation failed.[/red] "
|
|
"Inspect the issues above, then either fix the underlying "
|
|
"cause or rebuild with [cyan]--skip-validate[/cyan] if "
|
|
"you need the artifact as-is."
|
|
)
|
|
|
|
return overall_ok
|
|
|
|
def _postflight_pdf_validation(self, volume: str, skip: bool = False, log_path=None) -> bool:
|
|
"""Scan the built volume PDF for unresolved refs and render leaks.
|
|
|
|
Returns True if validation passed (or was skipped), False on findings
|
|
the caller should surface via a non-zero exit code.
|
|
|
|
Called from ``./binder/binder build pdf --vol1|--vol2`` on success. Quarto
|
|
can exit 0 while leaving ``?@sec-foo`` literals or ``Figure ??`` in
|
|
the PDF — this closes that loop without a separate manual check.
|
|
|
|
Opt-out: ``--skip-validate`` on the build command.
|
|
"""
|
|
if skip:
|
|
console.print("[yellow]⚠ Skipping post-build PDF validation (--skip-validate)[/yellow]")
|
|
return True
|
|
|
|
console.print(f"[cyan]🔍 Post-build PDF validation ({format_volume_display_name(volume)})[/cyan]")
|
|
|
|
from ._pdf_checks import default_log_path, format_checklist, scan_build_log, verify_volume_pdf
|
|
|
|
quarto_dir = self.config_manager.book_dir
|
|
latex_log = default_log_path(quarto_dir, volume)
|
|
result = verify_volume_pdf(quarto_dir, volume, log_path=latex_log)
|
|
# Quarto stdout contains its own cross-reference warnings, but is not
|
|
# the LuaLaTeX log: treating it as that log hides dropped glyphs and
|
|
# overfull boxes. Check both independent evidence sources.
|
|
if log_path and log_path != latex_log:
|
|
for issue in scan_build_log(log_path):
|
|
if issue not in result.issues:
|
|
result.issues.append(issue)
|
|
for check in result.checks:
|
|
if check.check_id == issue.code:
|
|
check.passed = False
|
|
check.skipped = False
|
|
console.print(format_checklist(result))
|
|
|
|
# Rendered geometry detail. Physical trim crossings are release
|
|
# blockers; text-box overflow and margin crowding remain triage rows.
|
|
# The summary is computed inside verify_volume_pdf, so this reports the
|
|
# same Binder-native check instead of the older pdfplumber heuristic.
|
|
geom = getattr(result, "margin_geometry", None)
|
|
if geom is not None and getattr(geom, "findings", None):
|
|
console.print()
|
|
edge_overflows = list(getattr(geom, "page_edge_overflows", []))
|
|
if edge_overflows:
|
|
console.print(
|
|
f" [bold red]✗ RELEASE BLOCKER: {len(edge_overflows)} rendered "
|
|
f"object(s) cross the physical trim boundary[/bold red]"
|
|
)
|
|
for finding in edge_overflows[:10]:
|
|
console.print(
|
|
f" [red]sheet {finding.page}: {finding.issue} "
|
|
f"{finding.side} — {finding.detail}[/red]"
|
|
)
|
|
console.print(
|
|
f" [yellow]⚠ margin geometry[/yellow] [dim]("
|
|
f"{len(geom.findings)} rendered margin finding(s) — "
|
|
f"non-blocking in build validation)[/dim]"
|
|
)
|
|
edge_ids = {id(finding) for finding in edge_overflows}
|
|
issue_rank = {
|
|
"trim-overflow-bottom": 0,
|
|
"trim-overflow-top": 0,
|
|
"trim-overflow-left": 0,
|
|
"trim-overflow-right": 0,
|
|
"overlap": 1,
|
|
"overflow-bottom": 2,
|
|
"overflow-top": 3,
|
|
}
|
|
for finding in sorted(
|
|
geom.findings,
|
|
key=lambda f: (
|
|
0 if id(f) in edge_ids else 1,
|
|
issue_rank.get(f.issue, 9),
|
|
f.page,
|
|
),
|
|
)[:10]:
|
|
console.print(
|
|
f" [dim]sheet {finding.page}: {finding.issue} "
|
|
f"{finding.side} — {finding.detail}[/dim]"
|
|
)
|
|
console.print(
|
|
f" [dim]→ `binder layout overlaps {result.pdf_path}` to localize; "
|
|
f"`binder layout margins {result.pdf_path}` is the strict gate.[/dim]"
|
|
)
|
|
|
|
if not result.ok:
|
|
console.print()
|
|
console.print(
|
|
"[red]Build artifact written but PDF validation failed.[/red] "
|
|
"Inspect the issues above, fix the source QMD, and rebuild — or use "
|
|
"[cyan]--skip-validate[/cyan] if you need the artifact as-is."
|
|
)
|
|
return False
|
|
|
|
console.print(
|
|
f" [green]✓ pdf[/green] [dim]({result.pdf_path.name}: no unresolved refs or render errors in text)[/dim]"
|
|
)
|
|
return True
|
|
|
|
def _open_output(self, output_dir: Path, format_type: str) -> None:
|
|
"""Open the build output using the system's default application.
|
|
|
|
Uses shared rule: PDF = any .pdf, EPUB = any .epub, HTML = index.html.
|
|
Always prints a clickable "Output created:" line for Cursor/VSCode terminals.
|
|
"""
|
|
from ..core.config import get_output_file
|
|
target = get_output_file(output_dir, format_type)
|
|
|
|
if target is None:
|
|
if self.open_after:
|
|
console.print(f"[yellow]⚠️ No {format_type.upper()} output found to open in {output_dir}/[/yellow]")
|
|
return
|
|
|
|
# Print absolute path — Cursor/VSCode terminals auto-linkify file paths
|
|
console.print(f"Output created: {target.resolve()}")
|
|
|
|
if not self.open_after:
|
|
return
|
|
|
|
console.print(f"[cyan]🔗 Opening {target.name}...[/cyan]")
|
|
|
|
system = platform.system()
|
|
if system == "Darwin":
|
|
subprocess.Popen(["open", str(target)])
|
|
elif system == "Linux":
|
|
subprocess.Popen(["xdg-open", str(target)])
|
|
elif system == "Windows":
|
|
subprocess.Popen(["start", "", str(target)], shell=True)
|
|
|
|
def build_full(self, format_type: str = "html", skip_hygiene: bool = False, skip_validate: bool = False) -> bool:
|
|
"""Build the whole book in one format with the default (Volume I) configuration.
|
|
|
|
Args:
|
|
format_type: Format to build ('html', 'pdf', 'epub')
|
|
skip_hygiene: For EPUB builds, skip the pre-render hygiene
|
|
check. Opt-in escape hatch for when a build must proceed
|
|
despite source-level invariants (rare).
|
|
skip_validate: For EPUB builds, skip post-render validation
|
|
(epubcheck and smoke checks).
|
|
|
|
Returns:
|
|
True if build and post-build validation succeeded, False otherwise
|
|
"""
|
|
console.print(f"[green]🔨 Building full {format_type.upper()} book...[/green]")
|
|
console.print("[dim]📄 Building all files (full book mode)[/dim]")
|
|
|
|
# EPUB preflight: catch source-level regressions before the
|
|
# ~2-minute render rather than after.
|
|
if format_type == "epub" and not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
|
|
output_dir = self.config_manager.get_output_dir(format_type)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
self.config_manager.activate_config(format_type)
|
|
render_cmd = render_command(format_type)
|
|
console.print(f"[blue]💻 Command: {' '.join(render_cmd)}[/blue]")
|
|
|
|
try:
|
|
# Ctrl-C unwinds through _run_command, which stops the renderer.
|
|
with interrupts_as_keyboard_interrupt():
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description=f"Building full {format_type.upper()} book",
|
|
)
|
|
except KeyboardInterrupt:
|
|
self.interrupted = True
|
|
console.print("[yellow]Build interrupted.[/yellow]")
|
|
return False
|
|
|
|
if not success:
|
|
console.print(f"[red]❌ {format_type.upper()} build failed[/red]")
|
|
return False
|
|
console.print(f"[green]✅ {format_type.upper()} build completed: {output_dir}/[/green]")
|
|
self._open_output(output_dir, format_type)
|
|
# Post-flight validation closes the build→verify loop.
|
|
if format_type == "epub":
|
|
return self._postflight_epub_validation(skip=skip_validate, output_dir=output_dir)
|
|
return True
|
|
|
|
def build_chapters(self, chapter_names: List[str], format_type: str = "html", skip_hygiene: bool = False, skip_validate: bool = False) -> bool:
|
|
"""Build chapters named without a volume flag.
|
|
|
|
The names are resolved, their volume is inferred, and the build is
|
|
delegated to :meth:`build_chapters_with_volume`. A build renders one
|
|
volume's configuration, so chapters from several volumes are rejected.
|
|
|
|
Args:
|
|
chapter_names: Chapter names, titles, paths, or patterns.
|
|
format_type: Format to build ('html', 'pdf', 'epub')
|
|
skip_hygiene: EPUB-only; skip the pre-render hygiene check.
|
|
skip_validate: Skip post-render validation (EPUB smoke/epubcheck;
|
|
PDF unresolved-ref scan).
|
|
|
|
Returns:
|
|
True if build and post-build validation succeeded, False otherwise
|
|
"""
|
|
# Expand patterns like appendix* / re:^appendix_
|
|
chapter_names = self.chapter_discovery.expand_chapter_patterns(chapter_names)
|
|
try:
|
|
resolved = self.chapter_discovery.validate_chapters(chapter_names)
|
|
except Exception as error:
|
|
console.print(f"[red]Build failed: {error}[/red]")
|
|
return False
|
|
|
|
volumes = {self.chapter_discovery._get_volume_from_path(path) for path in resolved}
|
|
if len(volumes) != 1 or None in volumes:
|
|
owners = ", ".join(sorted(volume or "shared" for volume in volumes))
|
|
console.print(f"[red]These chapters do not belong to exactly one volume ({owners}).[/red]")
|
|
console.print("[yellow]Build one volume at a time: "
|
|
"./binder/binder build <fmt> <chapters> --volN[/yellow]")
|
|
return False
|
|
return self.build_chapters_with_volume(
|
|
[path.relative_to(self.config_manager.book_dir).as_posix() for path in resolved],
|
|
format_type, next(iter(volumes)), skip_hygiene=skip_hygiene, skip_validate=skip_validate)
|
|
|
|
def build_chapters_with_volume(self, chapter_names: List[str], format_type: str, volume: str, skip_hygiene: bool = False, skip_validate: bool = False) -> bool:
|
|
"""Build the volume index plus selected chapters into their own output directory.
|
|
|
|
Output goes to ``<volume output>/chapters/<stem>[--<stem>...]``. Only the
|
|
generated entry points (``_quarto.yml`` and ``index.qmd``) change during
|
|
rendering; the canonical volume configs stay untouched, and the previous
|
|
entry points are restored even if rendering fails or is interrupted
|
|
(2026-09-11).
|
|
|
|
Args:
|
|
chapter_names: Chapter names, titles, paths, or patterns within *volume*.
|
|
format_type: ``html``, ``pdf``, or ``epub``.
|
|
volume: Volume whose configuration drives the build.
|
|
skip_hygiene: EPUB-only; skip the pre-render hygiene check.
|
|
skip_validate: Skip post-render validation.
|
|
|
|
Returns:
|
|
True if the build produced its artifact and validation passed.
|
|
"""
|
|
import yaml
|
|
from ..core.config import ACTIVE_CONFIG_MARKER, get_output_file
|
|
from ..core.volume_index import volume_index_source
|
|
from ..core.discovery import VOLUME_DIRS
|
|
|
|
snapshots = {}
|
|
try:
|
|
if volume not in VOLUME_DIRS or format_type not in {"html", "pdf", "epub"}:
|
|
raise ValueError(f"Unsupported volume/format: {volume}/{format_type}")
|
|
chapter_names = self.chapter_discovery.expand_chapter_patterns(chapter_names, volume=volume)
|
|
prefixed = []
|
|
for name in chapter_names:
|
|
specified, _ = self.chapter_discovery._parse_chapter_spec(name)
|
|
if specified and specified != volume:
|
|
raise ValueError(f"Chapter {name!r} is outside selected {volume}")
|
|
prefixed.append(name if specified else f"{volume}/{name}")
|
|
chapter_files = list(dict.fromkeys(self.chapter_discovery.validate_chapters(prefixed)))
|
|
if not chapter_files:
|
|
raise ValueError("Select at least one chapter")
|
|
|
|
# Do not fall back to another volume when this manifest is missing.
|
|
books = self.config_manager.book_dir
|
|
config_file = books / "config" / f"_quarto-{format_type}-{volume}.yml"
|
|
config = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
index_source = volume_index_source(books, volume, format_type)
|
|
if not index_source.is_file():
|
|
raise FileNotFoundError(f"Volume entry point not found: {index_source}")
|
|
selected = [p.relative_to(books).as_posix() for p in chapter_files
|
|
if p.resolve() != index_source.resolve()]
|
|
files = ["index.qmd", *selected]
|
|
slug = "--".join(p.stem for p in chapter_files)
|
|
output_dir = self.config_manager.get_output_dir(format_type, volume) / "chapters" / slug
|
|
config.setdefault("project", {})["output-dir"] = output_dir.relative_to(books).as_posix()
|
|
if format_type == "html":
|
|
config["project"]["render"] = files
|
|
else:
|
|
config.setdefault("book", {})["chapters"] = files
|
|
config["book"].pop("appendices", None)
|
|
config["book"]["output-file"] = slug
|
|
# The canonical volume config can carry a render list as well.
|
|
config["project"].pop("render", None)
|
|
|
|
if format_type == "epub" and not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
for path in (self.config_manager.active_config, self.config_manager.active_index):
|
|
snapshots[path] = (os.readlink(path), None) if path.is_symlink() else (
|
|
None, path.read_bytes() if path.exists() else None)
|
|
|
|
with interrupts_as_keyboard_interrupt():
|
|
self.config_manager.activate_config(format_type, volume)
|
|
header = (f"{ACTIVE_CONFIG_MARKER}{config_file.relative_to(books).as_posix()}\n"
|
|
"# Temporary selective build; canonical config is unchanged.\n")
|
|
self.config_manager.active_config.write_text(
|
|
header + yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
console.print(f"[green]Building {volume} ({format_type}):[/green]")
|
|
for path in files:
|
|
console.print(f" {path}")
|
|
success = self._run_command(
|
|
render_command(format_type), cwd=books,
|
|
description=f"Building {volume}: {', '.join(p.stem for p in chapter_files)}")
|
|
if not success:
|
|
return False
|
|
artifact = get_output_file(output_dir, format_type)
|
|
if artifact is None:
|
|
console.print(f"[red]Build produced no {format_type} artifact in {output_dir}[/red]")
|
|
return False
|
|
console.print(f"[green]Build complete: {artifact}[/green]")
|
|
self._open_output(output_dir, format_type)
|
|
if format_type == "epub":
|
|
return self._postflight_epub_validation(skip=skip_validate, output_dir=output_dir)
|
|
if format_type == "pdf" and not skip_validate:
|
|
from ._pdf_checks import verify_pdf, format_failure_report
|
|
issues = verify_pdf(artifact, log_path=getattr(self, "_last_build_log", None))
|
|
if issues:
|
|
console.print(format_failure_report("Chapter PDF", artifact, issues), markup=False)
|
|
console.print("[yellow]References to omitted chapters may be unresolved in this isolated build. "
|
|
"Use --skip-validate for layout iteration; validate the full volume before release.[/yellow]")
|
|
return False
|
|
return True
|
|
except KeyboardInterrupt:
|
|
self.interrupted = True
|
|
console.print("[yellow]Build interrupted; restoring generated entry points.[/yellow]")
|
|
return False
|
|
except (ValueError, OSError, AmbiguousChapterError) as error:
|
|
# Expected request problems: an unknown chapter, a missing config.
|
|
console.print(f"[red]Build failed: {error}[/red]")
|
|
return False
|
|
except Exception as error:
|
|
console.print(f"[red]Build failed: {describe_exception(error)}[/red]")
|
|
return False
|
|
finally:
|
|
for path, (link, content) in snapshots.items():
|
|
if path.is_symlink():
|
|
path.unlink()
|
|
if link is not None:
|
|
if path.exists():
|
|
path.unlink()
|
|
path.symlink_to(link)
|
|
elif content is not None:
|
|
path.write_bytes(content)
|
|
elif path.exists():
|
|
path.unlink()
|
|
|
|
def build_volume(
|
|
self,
|
|
volume: str,
|
|
format_type: str = "pdf",
|
|
skip_hygiene: bool = False,
|
|
skip_validate: bool = False,
|
|
no_cover: bool = False,
|
|
print_marks: bool = False,
|
|
) -> bool:
|
|
"""Build one whole volume using its dedicated configuration.
|
|
|
|
Uses the volume-specific config (for example ``_quarto-pdf-vol1.yml``),
|
|
which lists every chapter of that volume in order.
|
|
|
|
Args:
|
|
volume: Volume to build ('vol1' through 'vol4')
|
|
format_type: Format to build ('html', 'pdf', 'epub')
|
|
skip_hygiene: EPUB-only; skip the pre-render hygiene check.
|
|
skip_validate: Skip post-render validation (EPUB smoke/epubcheck;
|
|
PDF unresolved-ref scan).
|
|
no_cover: PDF-only; omit the designed cover. The switch is set in
|
|
the generated ``_quarto.yml``, so the source config never changes.
|
|
print_marks: PDF-only; enable printer camera/trim marks in the
|
|
shared TeX header for this build and restore it afterwards.
|
|
|
|
Returns:
|
|
True if build and post-build validation succeeded, False otherwise
|
|
"""
|
|
volume_name = format_volume_display_name(volume)
|
|
console.print(f"[magenta]📖 Building {volume_name} ({format_type.upper()})...[/magenta]")
|
|
|
|
if format_type == "epub" and not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
if (no_cover or print_marks) and format_type != "pdf":
|
|
console.print("[red]❌ --no-cover and --print-marks apply to PDF builds only[/red]")
|
|
return False
|
|
|
|
config_file = self.config_manager.book_dir / "config" / f"_quarto-{format_type}-{volume}.yml"
|
|
if not config_file.exists():
|
|
console.print(f"[red]❌ Volume config not found: {config_file}[/red]")
|
|
return False
|
|
console.print(f"[dim]Using config: {config_file.name}[/dim]")
|
|
|
|
output_dir = self.config_manager.get_output_dir(format_type, volume)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
# Every PDF includes the shared TeX header, so crop marks are switched
|
|
# there for the duration of the build and restored afterwards.
|
|
header_file = self.config_manager.book_dir / "shared" / "tex" / "header-includes.tex"
|
|
header_original: Optional[str] = None
|
|
|
|
try:
|
|
with interrupts_as_keyboard_interrupt():
|
|
config_name = self.config_manager.activate_config(format_type, volume)
|
|
console.print(f"[dim]📄 Wrote _quarto.yml from {config_name}[/dim]")
|
|
if no_cover:
|
|
if self._set_pdf_cover(self.config_manager.active_config, enabled=False):
|
|
console.print("[yellow]📘 Omitting the designed cover for this build[/yellow]")
|
|
else:
|
|
console.print("[dim]📘 Designed cover already omitted[/dim]")
|
|
if print_marks:
|
|
header_original = header_file.read_text(encoding="utf-8")
|
|
if self._set_pdf_print_marks(header_file, enabled=True):
|
|
console.print("[yellow]✂ Temporarily enabled printer camera/trim marks[/yellow]")
|
|
else:
|
|
console.print("[dim]✂ Printer camera/trim marks already enabled[/dim]")
|
|
render_cmd = render_command(format_type)
|
|
console.print(f"[blue]💻 Command: {' '.join(render_cmd)}[/blue]")
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description=f"Building {volume_name} ({format_type.upper()})",
|
|
)
|
|
except KeyboardInterrupt:
|
|
self.interrupted = True
|
|
console.print("[yellow]Build interrupted.[/yellow]")
|
|
return False
|
|
finally:
|
|
if header_original is not None:
|
|
header_file.write_text(header_original, encoding="utf-8")
|
|
console.print("[green]✅ Print-mark setting restored[/green]")
|
|
|
|
if not success:
|
|
console.print(f"[red]❌ {volume_name} {format_type.upper()} build failed[/red]")
|
|
return False
|
|
console.print(f"[green]✅ {volume_name} {format_type.upper()} build completed: {output_dir}/[/green]")
|
|
self._open_output(output_dir, format_type)
|
|
if format_type == "epub":
|
|
return self._postflight_epub_validation(skip=skip_validate, output_dir=output_dir)
|
|
if format_type == "pdf":
|
|
build_log = getattr(self, "_last_build_log", None)
|
|
return self._postflight_pdf_validation(
|
|
volume, skip=skip_validate,
|
|
log_path=build_log if build_log and build_log.is_file() else None,
|
|
)
|
|
return True
|
|
|
|
@staticmethod
|
|
def _set_pdf_cover(config_file: Path, enabled: bool) -> bool:
|
|
"""Set the single titlepage PDF cover switch in a build manifest.
|
|
|
|
``build_volume`` applies it to the generated ``_quarto.yml`` copy, so the
|
|
source configuration is never modified. Returning ``False`` means the
|
|
switch was already in the requested state.
|
|
"""
|
|
content = config_file.read_text(encoding="utf-8")
|
|
pattern = re.compile(
|
|
r"^(\s{4}coverpage:\s*)(false|true)(\s*(?:#.*)?)$",
|
|
flags=re.MULTILINE,
|
|
)
|
|
matches = list(pattern.finditer(content))
|
|
if len(matches) != 1:
|
|
raise ValueError(
|
|
f"Expected exactly one four-space `coverpage:` switch in "
|
|
f"{config_file}; found {len(matches)}"
|
|
)
|
|
|
|
match = matches[0]
|
|
desired = "true" if enabled else "false"
|
|
if match.group(2) == desired:
|
|
return False
|
|
|
|
updated = pattern.sub(rf"\g<1>{desired}\g<3>", content, count=1)
|
|
config_file.write_text(updated, encoding="utf-8")
|
|
return True
|
|
|
|
@staticmethod
|
|
def _set_pdf_print_marks(header_file: Path, enabled: bool) -> bool:
|
|
"""Set the one active crop-mark switch in the shared TeX header."""
|
|
content = header_file.read_text(encoding="utf-8")
|
|
pattern = re.compile(
|
|
r"^(\\CropMarks)(true|false)(\s*(?:%.*)?)$",
|
|
flags=re.MULTILINE,
|
|
)
|
|
matches = list(pattern.finditer(content))
|
|
if len(matches) != 1:
|
|
raise ValueError(
|
|
f"Expected exactly one active `\\CropMarks` switch in "
|
|
f"{header_file}; found {len(matches)}"
|
|
)
|
|
|
|
desired = "true" if enabled else "false"
|
|
match = matches[0]
|
|
if match.group(2) == desired:
|
|
return False
|
|
|
|
updated = pattern.sub(rf"\g<1>{desired}\g<3>", content, count=1)
|
|
header_file.write_text(updated, encoding="utf-8")
|
|
return True
|
|
|
|
def _run_command(self, cmd: List[str], cwd: Path, description: str) -> bool:
|
|
"""Run a command with progress indication.
|
|
|
|
Args:
|
|
cmd: Command to run
|
|
cwd: Working directory
|
|
description: Description for progress display
|
|
|
|
Returns:
|
|
True if command succeeded, False otherwise
|
|
"""
|
|
env = local_render_env(self.config_manager.root_dir)
|
|
process = None
|
|
try:
|
|
if self.verbose:
|
|
# Verbose mode: stream output in real time and keep it for analysis.
|
|
console.print(f"[dim]▶ {description}[/dim]")
|
|
process = start_process_group(
|
|
cmd, cwd=cwd, env=env, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True, bufsize=1,
|
|
)
|
|
lines_buf: list[str] = []
|
|
for line in iter(process.stdout.readline, ''):
|
|
if line:
|
|
console.print(line.rstrip())
|
|
lines_buf.append(line)
|
|
process.wait(timeout=1800)
|
|
self._save_build_log(cwd, "".join(lines_buf))
|
|
if process.returncode != 0:
|
|
console.print(f"[red]Command failed with exit code {process.returncode}[/red]")
|
|
return process.returncode == 0
|
|
|
|
# Quiet mode: show a spinner and print diagnostics on failure.
|
|
with Progress(
|
|
SpinnerColumn(),
|
|
TextColumn("[progress.description]{task.description}"),
|
|
console=console,
|
|
transient=False
|
|
) as progress:
|
|
task = progress.add_task(description, total=None)
|
|
process = start_process_group(
|
|
cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
)
|
|
stdout, stderr = process.communicate(timeout=1800)
|
|
progress.update(task, completed=True)
|
|
|
|
self._save_build_log(cwd, "".join(part for part in (stdout, stderr) if part))
|
|
if process.returncode == 0:
|
|
return True
|
|
console.print(f"[red]Command failed with exit code {process.returncode}[/red]")
|
|
if stderr and stderr.strip():
|
|
console.print(f"[red]Error output (stderr):[/red]\n{stderr.strip()}")
|
|
elif stdout and stdout.strip():
|
|
# Quarto often writes fatal Lua/TeX/render errors to stdout.
|
|
stdout_lines = stdout.strip().splitlines()
|
|
error_lines = [
|
|
line for line in stdout_lines
|
|
if any(k in line.lower() for k in ("error", "fatal", "failed", "undefined control sequence", "compilation error"))
|
|
]
|
|
if error_lines:
|
|
sample = "\n".join(error_lines[-15:])
|
|
console.print(f"[red]Error diagnostics detected in output:[/red]\n{sample}")
|
|
else:
|
|
tail = "\n".join(stdout_lines[-20:])
|
|
console.print(f"[yellow]Last output lines before failure:[/yellow]\n{tail}")
|
|
if cwd and getattr(self, "_last_build_log", None):
|
|
console.print(f"[dim]Full build log saved at: {self._last_build_log}[/dim]")
|
|
return False
|
|
|
|
except subprocess.TimeoutExpired:
|
|
stop_process_group(process)
|
|
console.print(f"[red]❌ Build timed out after 30 minutes: {' '.join(cmd)} (in {cwd})[/red]")
|
|
return False
|
|
except (KeyboardInterrupt, SystemExit):
|
|
# Stop Quarto's process group (including TeX children) before the
|
|
# caller restores shared generated files (2026-09-11).
|
|
stop_process_group(process)
|
|
raise
|
|
except FileNotFoundError as e:
|
|
stop_process_group(process)
|
|
console.print(f"[red]❌ Could not start {cmd[0]}: {e}[/red]")
|
|
console.print("[yellow]Check that it is installed and on PATH: ./binder/binder doctor[/yellow]")
|
|
return False
|
|
except Exception as e:
|
|
stop_process_group(process)
|
|
console.print(f"[red]❌ Running {' '.join(cmd)} failed: {describe_exception(e)}[/red]")
|
|
return False
|
|
|
|
def _save_build_log(self, cwd: Optional[Path], text: str) -> None:
|
|
"""Write renderer output to ``<cwd>/_build/last-build.log`` for post-build checks."""
|
|
if cwd and text:
|
|
build_log = Path(cwd) / "_build" / "last-build.log"
|
|
build_log.parent.mkdir(parents=True, exist_ok=True)
|
|
build_log.write_text(text, encoding="utf-8")
|
|
self._last_build_log = build_log
|
|
|
|
@staticmethod
|
|
def _reset_config_comments(content: str) -> str:
|
|
"""Reset a config file by uncommenting all .qmd chapter lines.
|
|
|
|
This ensures a clean starting state regardless of whether a previous
|
|
build was interrupted and left the config in a partially-commented state.
|
|
|
|
Handles patterns like:
|
|
# - vol1/chapter/chapter.qmd → - vol1/chapter/chapter.qmd
|
|
#- vol1/chapter/chapter.qmd → - vol1/chapter/chapter.qmd
|
|
|
|
Also uncomments structural lines (part declarations, chapters: keys)
|
|
that may have been commented out by a previous fast build.
|
|
|
|
Args:
|
|
content: Raw config file content
|
|
|
|
Returns:
|
|
Content with all .qmd lines and their structural containers uncommented
|
|
"""
|
|
lines = content.split('\n')
|
|
reset_lines = []
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
|
|
if (stripped.startswith('#') and '.qmd' in line and
|
|
(stripped.startswith('# - ') or stripped.startswith('#- '))):
|
|
# Uncomment a previously-commented chapter list item.
|
|
# The `# - ` / `#- ` prefix guard distinguishes list items
|
|
# from prose comments that merely mention `.qmd` (which
|
|
# must be left alone, otherwise we'd produce invalid YAML).
|
|
indent = len(line) - len(line.lstrip())
|
|
uncommented = stripped.lstrip('#').lstrip()
|
|
if not uncommented.startswith('-'):
|
|
uncommented = '- ' + uncommented
|
|
reset_lines.append(' ' * indent + uncommented)
|
|
elif stripped.startswith('#') and (
|
|
'part:' in stripped
|
|
or stripped.lstrip('#').strip().startswith('chapters:')
|
|
or stripped.lstrip('#').strip().startswith('appendices:')
|
|
):
|
|
# Uncomment structural lines (part declarations, chapters/appendices keys)
|
|
indent = len(line) - len(line.lstrip())
|
|
uncommented = stripped.lstrip('#').lstrip()
|
|
reset_lines.append(' ' * indent + uncommented)
|
|
else:
|
|
reset_lines.append(line)
|
|
|
|
return '\n'.join(reset_lines)
|
|
|
|
def reset_build_config(self, format_type: str, volume: Optional[str] = None) -> bool:
|
|
"""Reset build config by uncommenting all chapter entries.
|
|
|
|
Restores YAML manifests that may have been partially commented by a
|
|
chapter-selective build. PDF/EPUB use chapters:/appendices: lists;
|
|
HTML uses render: lists where needed.
|
|
|
|
Args:
|
|
format_type: Format to reset ('html', 'pdf', 'epub')
|
|
volume: Optional specific volume ('vol1' or 'vol2'). If omitted, reset both volumes.
|
|
|
|
Returns:
|
|
True if at least one config was reset successfully.
|
|
"""
|
|
format_type = format_type.lower()
|
|
if format_type not in {"html", "pdf", "epub"}:
|
|
console.print(f"[red]❌ Unsupported format for reset: {format_type}[/red]")
|
|
return False
|
|
|
|
def _candidate_configs() -> List[Path]:
|
|
"""Return the config for ``volume``, or the Volume I and II configs when unset."""
|
|
if volume:
|
|
return [self.config_manager.get_config_file(format_type, volume)]
|
|
|
|
# Reset all known volume configs for the selected format.
|
|
if format_type == "html":
|
|
return [self.config_manager.html_vol1_config, self.config_manager.html_vol2_config]
|
|
if format_type == "pdf":
|
|
return [self.config_manager.pdf_vol1_config, self.config_manager.pdf_vol2_config]
|
|
return [self.config_manager.epub_vol1_config, self.config_manager.epub_vol2_config]
|
|
|
|
targets = _candidate_configs()
|
|
reset_count = 0
|
|
|
|
for cfg in targets:
|
|
if not cfg.exists():
|
|
console.print(f"[yellow]⚠️ Skipping missing config: {cfg.name}[/yellow]")
|
|
continue
|
|
|
|
try:
|
|
original_content = cfg.read_text(encoding="utf-8")
|
|
reset_content = self._reset_config_comments(original_content)
|
|
|
|
# All formats: uncomment any chapter/appendix/render entries a
|
|
# chapter-selective build commented out, restoring the full
|
|
# default manifest. For HTML this restores the render: list
|
|
# (vol2) and is a no-op for the render-all volume (vol1). The
|
|
# render: section is the HTML scoping mechanism and is no longer
|
|
# stripped on reset — doing so would delete vol2's full manifest.
|
|
cfg.write_text(reset_content, encoding="utf-8")
|
|
|
|
backup_file = cfg.with_suffix(".backup")
|
|
if backup_file.exists():
|
|
backup_file.unlink()
|
|
|
|
reset_count += 1
|
|
try:
|
|
rel = cfg.relative_to(self.config_manager.book_dir)
|
|
except ValueError:
|
|
rel = cfg
|
|
console.print(f"[green]✓[/green] Reset config: {rel}")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to reset {cfg.name}: {e}[/red]")
|
|
|
|
if reset_count == 0:
|
|
console.print("[red]❌ No configs were reset.[/red]")
|
|
return False
|
|
|
|
scope = volume if volume else "vol1 and vol2"
|
|
console.print(f"[green]✅ Reset {format_type.upper()} build config for {scope}[/green]")
|
|
return True
|