mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-16 14:42:29 -05:00
1903 lines
84 KiB
Python
1903 lines
84 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 subprocess
|
|
import signal
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple
|
|
from rich.console import Console
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
|
console = Console()
|
|
|
|
|
|
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
|
|
|
|
def _preflight_epub_hygiene(self, skip: bool = False) -> bool:
|
|
"""Run the `hygiene` scope of `./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 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.
|
|
try:
|
|
from cli.commands._epub_checks import find_hygiene_issues
|
|
except ImportError:
|
|
# Running from an older checkout without the check module —
|
|
# don't break the build, just note and continue.
|
|
console.print("[dim]ⓘ Hygiene preflight unavailable in this checkout; skipping.[/dim]")
|
|
return True
|
|
|
|
# book_dir is `book/quarto/`; repo root is two levels up.
|
|
repo_root = self.config_manager.book_dir.parent.parent
|
|
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 check epub --scope hygiene` for all.[/dim]")
|
|
console.print()
|
|
console.print("[yellow]Resolve with one of:[/yellow]")
|
|
console.print(" [cyan]./binder check epub --scope hygiene --fix[/cyan] "
|
|
"[dim]# auto-repair source files in place[/dim]")
|
|
console.print(" [cyan]./binder build epub --skip-hygiene[/cyan] "
|
|
"[dim]# build anyway (not recommended)[/dim]")
|
|
return False
|
|
|
|
def _postflight_epub_validation(self, skip: bool = False) -> 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 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
|
|
|
|
try:
|
|
from cli.commands._epub_checks import (
|
|
_discover_built_epubs,
|
|
emit_github_annotations,
|
|
run_epubcheck_on,
|
|
run_smoke_checks_on,
|
|
)
|
|
except ImportError:
|
|
console.print("[dim]ⓘ Post-build validation unavailable in this checkout; skipping.[/dim]")
|
|
return True
|
|
|
|
repo_root = self.config_manager.book_dir.parent.parent
|
|
epubs = _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 / "book" / "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 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 check epub --scope epubcheck --baseline "
|
|
"book/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 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
|
|
|
|
volume_name = "Volume I" if volume == "vol1" else "Volume II"
|
|
console.print(f"[cyan]🔍 Post-build PDF validation ({volume_name})[/cyan]")
|
|
|
|
from cli.commands._pdf_checks import format_checklist, verify_volume_pdf
|
|
|
|
quarto_dir = self.config_manager.book_dir
|
|
result = verify_volume_pdf(quarto_dir, volume, log_path=log_path)
|
|
console.print(format_checklist(result))
|
|
|
|
# Warn-only rendered geometry detail. The summary is computed inside
|
|
# verify_volume_pdf, so this reports the same Binder-native geometry
|
|
# check instead of running the older pdfplumber margin heuristic.
|
|
geom = getattr(result, "margin_geometry", None)
|
|
if geom is not None and getattr(geom, "findings", None):
|
|
console.print()
|
|
console.print(
|
|
f" [yellow]⚠ margin geometry[/yellow] [dim]("
|
|
f"{len(geom.findings)} rendered margin finding(s) — "
|
|
f"non-blocking in build validation)[/dim]"
|
|
)
|
|
issue_rank = {"overlap": 0, "overflow-bottom": 1, "overflow-top": 2}
|
|
for finding in sorted(
|
|
geom.findings,
|
|
key=lambda f: (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 full book in specified format.
|
|
|
|
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/PDF builds, skip post-render validation
|
|
(epubcheck/smoke for EPUB; pdftotext cross-ref scan for PDF).
|
|
|
|
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]")
|
|
|
|
# Handle special case for building both HTML and PDF
|
|
if format_type == "both":
|
|
return self._build_both_formats()
|
|
|
|
# EPUB preflight: catch source-level regressions before the
|
|
# ~2-minute render rather than after.
|
|
if format_type == "epub":
|
|
if not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
|
|
# Create build directory
|
|
output_dir = self.config_manager.get_output_dir(format_type)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup config
|
|
config_name = self.config_manager.setup_symlink(format_type)
|
|
|
|
# Get config file
|
|
config_file = self.config_manager.get_config_file(format_type)
|
|
|
|
# Uncomment all files for full build (PDF/EPUB only)
|
|
if format_type in ["pdf", "epub"]:
|
|
console.print("[yellow]📝 Uncommenting all chapter files for full book build...[/yellow]")
|
|
self._uncomment_all_chapters(config_file)
|
|
|
|
# Track if config has been restored to avoid double restoration
|
|
self._config_restored = False
|
|
|
|
# Setup signal handler to restore config on Ctrl+C
|
|
def signal_handler(signum, frame):
|
|
if not self._config_restored and format_type in ["pdf", "epub"]:
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config...[/yellow]")
|
|
self._restore_config(config_file)
|
|
self._config_restored = True
|
|
console.print("[green]✅ Config restored[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
try:
|
|
# Determine render target
|
|
render_targets = {
|
|
"html": "html",
|
|
"pdf": "titlepage-pdf",
|
|
"epub": "epub"
|
|
}
|
|
|
|
if format_type not in render_targets:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
render_to = render_targets[format_type]
|
|
render_cmd = ["quarto", "render", f"--to={render_to}"]
|
|
|
|
# Show the command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue]💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Execute build
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description=f"Building full {format_type.upper()} book"
|
|
)
|
|
|
|
if success:
|
|
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":
|
|
if not self._postflight_epub_validation(skip=skip_validate):
|
|
return False
|
|
else:
|
|
console.print(f"[red]❌ {format_type.upper()} build failed[/red]")
|
|
|
|
return success
|
|
finally:
|
|
# Always restore config for PDF/EPUB builds (unless already restored by signal handler)
|
|
if format_type in ["pdf", "epub"] and not self._config_restored:
|
|
self._restore_config(config_file)
|
|
|
|
def build_chapters(self, chapter_names: List[str], format_type: str = "html", skip_hygiene: bool = False, skip_validate: bool = False) -> bool:
|
|
"""Build specific chapters.
|
|
|
|
Args:
|
|
chapter_names: List of chapter names to build
|
|
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)
|
|
|
|
console.print(f"[green]🚀 Building {len(chapter_names)} chapters[/green] [dim]({format_type})[/dim]")
|
|
console.print(f"[dim]📋 Chapters: {', '.join(chapter_names)}[/dim]")
|
|
|
|
if format_type == "epub":
|
|
if not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
|
|
try:
|
|
# Validate chapters exist
|
|
chapter_files = self.chapter_discovery.validate_chapters(chapter_names)
|
|
|
|
# Show files that will be built
|
|
console.print("[dim]📄 Files to be rendered:[/dim]")
|
|
console.print(f"[dim] • index.qmd[/dim]")
|
|
for chapter_file in chapter_files:
|
|
rel_path = chapter_file.relative_to(self.config_manager.book_dir)
|
|
console.print(f"[dim] • {rel_path}[/dim]")
|
|
|
|
# Setup configuration
|
|
config_file = self.config_manager.get_config_file(format_type)
|
|
format_args = {
|
|
"html": "html",
|
|
"pdf": "titlepage-pdf",
|
|
"epub": "epub"
|
|
}
|
|
|
|
if format_type not in format_args:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
format_arg = format_args[format_type]
|
|
|
|
# Create build directory
|
|
output_dir = self.config_manager.get_output_dir(format_type)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup correct configuration symlink
|
|
self.config_manager.setup_symlink(format_type)
|
|
|
|
# Set up fast build mode for the target chapters
|
|
self._setup_fast_build_mode(config_file, chapter_files)
|
|
|
|
# Track if config has been restored to avoid double restoration
|
|
self._config_restored = False
|
|
|
|
# Setup signal handler to restore config on Ctrl+C
|
|
def signal_handler(signum, frame):
|
|
if not self._config_restored:
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config...[/yellow]")
|
|
self._restore_config(config_file)
|
|
self._config_restored = True
|
|
console.print("[green]✅ Config restored[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Build with project.render configuration
|
|
console.print("[yellow]🔨 Building with fast build configuration...[/yellow]")
|
|
|
|
render_cmd = ["quarto", "render", f"--to={format_arg}"]
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue]💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Execute build
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description=f"Building {len(chapter_names)} chapters ({format_type})"
|
|
)
|
|
|
|
if success:
|
|
console.print(f"[green]✅ Build complete: {output_dir}/[/green]")
|
|
self._open_output(output_dir, format_type)
|
|
if format_type == "epub":
|
|
if not self._postflight_epub_validation(skip=skip_validate):
|
|
return False
|
|
else:
|
|
console.print("[red]❌ Build failed[/red]")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Build error: {e}[/red]")
|
|
return False
|
|
finally:
|
|
# Always restore config (unless already restored by signal handler)
|
|
try:
|
|
if not self._config_restored:
|
|
self._restore_config(config_file)
|
|
except:
|
|
pass
|
|
|
|
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 specific chapters using volume-specific configuration.
|
|
|
|
Args:
|
|
chapter_names: List of chapter names to build
|
|
format_type: Format to build ('html', 'pdf', 'epub')
|
|
volume: Volume config to use ('vol1' or 'vol2')
|
|
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_ within the requested volume
|
|
chapter_names = self.chapter_discovery.expand_chapter_patterns(chapter_names, volume=volume)
|
|
|
|
volume_name = "Volume I" if volume == "vol1" else "Volume II"
|
|
console.print(f"[green]🚀 Building {len(chapter_names)} chapters[/green] [dim]({format_type}, {volume_name} config)[/dim]")
|
|
console.print(f"[dim]📋 Chapters: {', '.join(chapter_names)}[/dim]")
|
|
|
|
if format_type == "epub":
|
|
if not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
|
|
try:
|
|
# Auto-prefix chapter names with volume to disambiguate
|
|
prefixed_chapters = []
|
|
for ch in chapter_names:
|
|
# Normalize: strip .qmd extension so find_chapter_file gets a stem
|
|
ch = ch.removesuffix(".qmd")
|
|
# Only prefix if not already prefixed
|
|
if not ch.startswith(f"{volume}/"):
|
|
prefixed_chapters.append(f"{volume}/{ch}")
|
|
else:
|
|
prefixed_chapters.append(ch)
|
|
|
|
# Validate chapters exist
|
|
chapter_files = self.chapter_discovery.validate_chapters(prefixed_chapters)
|
|
|
|
# Show files that will be built
|
|
console.print("[dim]📄 Files to be rendered:[/dim]")
|
|
console.print(f"[dim] • index.qmd[/dim]")
|
|
for chapter_file in chapter_files:
|
|
rel_path = chapter_file.relative_to(self.config_manager.book_dir)
|
|
console.print(f"[dim] • {rel_path}[/dim]")
|
|
|
|
# Setup volume-specific configuration
|
|
config_file = self.config_manager.get_config_file(format_type, volume)
|
|
|
|
if not config_file.exists():
|
|
console.print(f"[yellow]⚠️ Volume config not found: {config_file}[/yellow]")
|
|
console.print(f"[yellow]Falling back to default config...[/yellow]")
|
|
return self.build_chapters(chapter_names, format_type)
|
|
|
|
format_args = {
|
|
"html": "html",
|
|
"pdf": "titlepage-pdf",
|
|
"epub": "epub"
|
|
}
|
|
|
|
if format_type not in format_args:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
format_arg = format_args[format_type]
|
|
|
|
# Create volume-specific build directory
|
|
output_dir = self.config_manager.get_output_dir(format_type, volume)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup volume-specific configuration symlink
|
|
config_name = self.config_manager.setup_symlink(format_type, volume)
|
|
console.print(f"[dim]🔗 Linked _quarto.yml → {config_name}[/dim]")
|
|
|
|
# Set up fast build mode for the target chapters
|
|
self._setup_fast_build_mode(config_file, chapter_files)
|
|
|
|
# Track if config has been restored to avoid double restoration
|
|
self._config_restored = False
|
|
|
|
# Setup signal handler to restore config on Ctrl+C
|
|
def signal_handler(signum, frame):
|
|
if not self._config_restored:
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config...[/yellow]")
|
|
self._restore_config(config_file)
|
|
self._config_restored = True
|
|
console.print("[green]✅ Config restored[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Build with project.render configuration
|
|
console.print("[yellow]🔨 Building with fast build configuration...[/yellow]")
|
|
|
|
render_cmd = ["quarto", "render", f"--to={format_arg}"]
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue]💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Execute build
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description=f"Building {len(chapter_names)} chapters ({format_type}, {volume_name})"
|
|
)
|
|
|
|
if success:
|
|
console.print(f"[green]✅ Build complete: {output_dir}/[/green]")
|
|
self._open_output(output_dir, format_type)
|
|
if format_type == "epub":
|
|
if not self._postflight_epub_validation(skip=skip_validate):
|
|
return False
|
|
else:
|
|
console.print("[red]❌ Build failed[/red]")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Build failed: {e}[/red]")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return False
|
|
finally:
|
|
# Restore configuration
|
|
try:
|
|
if not self._config_restored:
|
|
console.print("[yellow]🛡️ Restoring config...[/yellow]")
|
|
self._restore_config(config_file)
|
|
console.print("[green]✅ Configuration restored successfully[/green]")
|
|
except:
|
|
pass
|
|
|
|
def build_volume(self, volume: str, format_type: str = "pdf", skip_hygiene: bool = False, skip_validate: bool = False) -> bool:
|
|
"""Build a specific volume using its dedicated configuration.
|
|
|
|
This uses the volume-specific config files (e.g., _quarto-pdf-vol1.yml)
|
|
which are pre-configured with all the correct chapters and settings
|
|
for that volume.
|
|
|
|
Args:
|
|
volume: Volume to build ('vol1' or 'vol2')
|
|
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
|
|
"""
|
|
volume_name = "Volume I" if volume == "vol1" else "Volume II"
|
|
console.print(f"[magenta]📖 Building {volume_name} ({format_type.upper()})...[/magenta]")
|
|
|
|
if format_type == "epub":
|
|
if not self._preflight_epub_hygiene(skip=skip_hygiene):
|
|
return False
|
|
|
|
# Check if volume-specific config exists
|
|
config_file = self.config_manager.get_config_file(format_type, volume)
|
|
if not config_file.exists():
|
|
console.print(f"[yellow]⚠️ Volume-specific config not found: {config_file}[/yellow]")
|
|
console.print(f"[yellow]Falling back to chapter-based build...[/yellow]")
|
|
# Fallback to config-ordered chapter list
|
|
chapter_stems = self.chapter_discovery.get_chapters_from_config(volume)
|
|
if not chapter_stems:
|
|
console.print(f"[red]No chapters found in {volume}[/red]")
|
|
return False
|
|
console.print(f"[dim]Found {len(chapter_stems)} chapters in {volume}[/dim]")
|
|
return self.build_chapters(
|
|
[f"{volume}/{stem}" for stem in chapter_stems],
|
|
format_type
|
|
)
|
|
|
|
console.print(f"[dim]Using config: {config_file.name}[/dim]")
|
|
|
|
# Create build directory
|
|
output_dir = self.config_manager.get_output_dir(format_type, volume)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup config symlink to volume-specific config
|
|
config_name = self.config_manager.setup_symlink(format_type, volume)
|
|
console.print(f"[dim]🔗 Linked _quarto.yml → {config_name}[/dim]")
|
|
|
|
# Full volume PDF/EPUB builds must uncomment every chapter in the
|
|
# volume config (fast-build configs ship with most chapters commented).
|
|
if format_type in ("pdf", "epub"):
|
|
console.print(
|
|
"[yellow]📝 Uncommenting all chapter files for full volume build...[/yellow]"
|
|
)
|
|
self._uncomment_all_chapters(config_file)
|
|
|
|
self._config_restored = False
|
|
|
|
def signal_handler(signum, frame):
|
|
if not self._config_restored and format_type in ("pdf", "epub"):
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config...[/yellow]")
|
|
self._restore_config(config_file)
|
|
self._config_restored = True
|
|
console.print("[green]✅ Config restored[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
try:
|
|
# Determine render target
|
|
render_targets = {
|
|
"html": "html",
|
|
"pdf": "titlepage-pdf",
|
|
"epub": "epub"
|
|
}
|
|
|
|
if format_type not in render_targets:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
render_to = render_targets[format_type]
|
|
render_cmd = ["quarto", "render", f"--to={render_to}"]
|
|
|
|
# Show the command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue]💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Execute build
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description=f"Building {volume_name} ({format_type.upper()})"
|
|
)
|
|
|
|
if success:
|
|
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":
|
|
if not self._postflight_epub_validation(skip=skip_validate):
|
|
return False
|
|
elif format_type == "pdf":
|
|
build_log = getattr(self, '_last_build_log', None)
|
|
if not self._postflight_pdf_validation(
|
|
volume, skip=skip_validate,
|
|
log_path=build_log if build_log and build_log.is_file() else None,
|
|
):
|
|
return False
|
|
else:
|
|
console.print(f"[red]❌ {volume_name} {format_type.upper()} build failed[/red]")
|
|
|
|
return success
|
|
finally:
|
|
if format_type in ("pdf", "epub") and not self._config_restored:
|
|
self._restore_config(config_file)
|
|
|
|
def _build_both_formats(self) -> bool:
|
|
"""Build both HTML and PDF formats sequentially."""
|
|
console.print("[blue]📚 Building both HTML and PDF formats...[/blue]")
|
|
|
|
# Build HTML first
|
|
console.print("[blue]📄 Building HTML version...[/blue]")
|
|
html_success = self.build_full("html")
|
|
if not html_success:
|
|
console.print("[red]❌ HTML build failed![/red]")
|
|
return False
|
|
|
|
# Build PDF
|
|
console.print("[blue]📄 Building PDF version...[/blue]")
|
|
pdf_success = self.build_full("pdf")
|
|
if not pdf_success:
|
|
console.print("[red]❌ PDF build failed![/red]")
|
|
return False
|
|
|
|
console.print("[green]✅ Both HTML and PDF builds completed successfully![/green]")
|
|
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
|
|
"""
|
|
# Set up environment with local sources first. The mlsysim package
|
|
# uses a nested layout, so the package directory must precede any
|
|
# globally installed editable checkout.
|
|
env = os.environ.copy()
|
|
root_dir = str(self.config_manager.root_dir.resolve())
|
|
mlsysim_dir = str((self.config_manager.root_dir / "mlsysim").resolve())
|
|
current_pythonpath = env.get("PYTHONPATH", "")
|
|
local_pythonpath = f"{root_dir}:{mlsysim_dir}"
|
|
if current_pythonpath:
|
|
env["PYTHONPATH"] = f"{local_pythonpath}:{current_pythonpath}"
|
|
else:
|
|
env["PYTHONPATH"] = local_pythonpath
|
|
|
|
try:
|
|
if self.verbose:
|
|
# Verbose mode: stream output in real-time and save for analysis
|
|
console.print(f"[dim]▶ {description}[/dim]")
|
|
process = subprocess.Popen(
|
|
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)
|
|
|
|
# Save build log for post-build analysis (overfull hbox, etc.)
|
|
if cwd and lines_buf:
|
|
build_log = Path(cwd) / "_build" / "last-build.log"
|
|
build_log.parent.mkdir(parents=True, exist_ok=True)
|
|
build_log.write_text("".join(lines_buf), encoding="utf-8")
|
|
self._last_build_log = build_log
|
|
|
|
if process.returncode == 0:
|
|
return True
|
|
else:
|
|
console.print(f"[red]Command failed with exit code {process.returncode}[/red]")
|
|
return False
|
|
else:
|
|
# Quiet mode: show spinner
|
|
with Progress(
|
|
SpinnerColumn(),
|
|
TextColumn("[progress.description]{task.description}"),
|
|
console=console,
|
|
transient=False
|
|
) as progress:
|
|
task = progress.add_task(description, total=None)
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=1800 # 30 minute timeout
|
|
)
|
|
|
|
progress.update(task, completed=True)
|
|
|
|
if cwd:
|
|
build_output = "".join(part for part in (result.stdout, result.stderr) if part)
|
|
if build_output:
|
|
build_log = Path(cwd) / "_build" / "last-build.log"
|
|
build_log.parent.mkdir(parents=True, exist_ok=True)
|
|
build_log.write_text(build_output, encoding="utf-8")
|
|
self._last_build_log = build_log
|
|
|
|
if result.returncode == 0:
|
|
return True
|
|
else:
|
|
console.print(f"[red]Command failed with exit code {result.returncode}[/red]")
|
|
if result.stderr:
|
|
console.print(f"[red]Error: {result.stderr}[/red]")
|
|
return False
|
|
|
|
except subprocess.TimeoutExpired:
|
|
console.print("[red]❌ Build timed out after 30 minutes[/red]")
|
|
return False
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Command execution error: {e}[/red]")
|
|
return False
|
|
|
|
def build_html_only(self, chapter_names: List[str] = None) -> bool:
|
|
"""Build HTML-only version with index.qmd and specific files of interest.
|
|
|
|
Args:
|
|
chapter_names: List of chapter names to include (optional, if None builds all)
|
|
|
|
Returns:
|
|
True if build succeeded, False otherwise
|
|
"""
|
|
console.print("[green]🌐 Building HTML-only version...[/green]")
|
|
|
|
config_file: Optional[Path] = None
|
|
original_config: Optional[str] = None
|
|
|
|
try:
|
|
# Always include index.qmd
|
|
files_to_render = ["index.qmd"]
|
|
|
|
# Add specified chapters if provided, otherwise add ALL chapters
|
|
if chapter_names:
|
|
console.print(f"[dim]📋 Including chapters: {', '.join(chapter_names)}[/dim]")
|
|
chapter_files = self.chapter_discovery.validate_chapters(chapter_names)
|
|
|
|
# Convert to relative paths from book directory
|
|
for chapter_file in chapter_files:
|
|
rel_path = chapter_file.relative_to(self.config_manager.book_dir)
|
|
files_to_render.append(str(rel_path))
|
|
else:
|
|
console.print("[yellow]📝 Adding ALL available chapters to render list...[/yellow]")
|
|
# Get all available chapters
|
|
all_chapters = self.chapter_discovery.get_all_chapters()
|
|
console.print(f"[dim]📋 Found {len(all_chapters)} chapters[/dim]")
|
|
|
|
# Add all chapter files to render list
|
|
for chapter in all_chapters:
|
|
chapter_name = chapter.get("name", "")
|
|
chapter_file = chapter.get("path")
|
|
if not chapter_file:
|
|
continue
|
|
try:
|
|
rel_path = chapter_file.relative_to(self.config_manager.book_dir)
|
|
files_to_render.append(str(rel_path))
|
|
except ValueError:
|
|
# If relative path fails, try to construct it
|
|
files_to_render.append(f"contents/vol1/{chapter_name}/{chapter_name}.qmd")
|
|
|
|
# Show files that will be built
|
|
console.print("[dim]📄 Files to be rendered:[/dim]")
|
|
for file_path in files_to_render:
|
|
console.print(f"[dim] • {file_path}[/dim]")
|
|
|
|
# Use surgical approach - modify existing config file directly
|
|
config_file = self.config_manager.get_config_file("html")
|
|
original_config = config_file.read_text(encoding='utf-8')
|
|
self._add_render_section(config_file, files_to_render)
|
|
|
|
# Ensure symlink points to the HTML config
|
|
self.config_manager.setup_symlink("html")
|
|
|
|
# Build HTML
|
|
render_cmd = ["quarto", "render", "--to=html"]
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue]💻 Command: {cmd_str}[/blue]")
|
|
|
|
success = self._run_command(
|
|
render_cmd,
|
|
cwd=self.config_manager.book_dir,
|
|
description="Building HTML-only version"
|
|
)
|
|
|
|
if success:
|
|
output_dir = self.config_manager.get_output_dir("html")
|
|
console.print(f"[green]✅ HTML-only build completed: {output_dir}/[/green]")
|
|
self._open_output(output_dir, "html")
|
|
else:
|
|
console.print("[red]❌ HTML-only build failed[/red]")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ HTML-only build error: {e}[/red]")
|
|
return False
|
|
finally:
|
|
# Always restore the exact original config. HTML configs can carry
|
|
# load-bearing render lists, so deleting render blocks is unsafe.
|
|
try:
|
|
if config_file and original_config is not None:
|
|
config_file.write_text(original_config, encoding='utf-8')
|
|
console.print("[dim]🛡️ Restored original HTML config[/dim]")
|
|
else:
|
|
config_file = self.config_manager.get_config_file("html")
|
|
self._remove_render_section(config_file)
|
|
except Exception as restore_error:
|
|
console.print(f"[yellow]⚠️ Error restoring HTML config: {restore_error}[/yellow]")
|
|
|
|
def _add_render_section(self, config_file: Path, files_to_render: List[str]) -> None:
|
|
"""Add render section to existing config file.
|
|
|
|
Args:
|
|
config_file: Path to config file to modify
|
|
files_to_render: List of files to include in render section
|
|
"""
|
|
# Read current config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
lines = content.split('\n')
|
|
cleaned_lines = []
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if line.strip().startswith('render:'):
|
|
i += 1
|
|
while i < len(lines) and (lines[i].startswith(' -') or lines[i].strip() == ''):
|
|
i += 1
|
|
continue
|
|
cleaned_lines.append(line)
|
|
i += 1
|
|
|
|
lines = cleaned_lines
|
|
modified_lines = []
|
|
render_added = False
|
|
|
|
for i, line in enumerate(lines):
|
|
# If we find post-render and haven't added render yet, add it before
|
|
if not render_added and line.strip().startswith('post-render:'):
|
|
modified_lines.append(' render:')
|
|
for file in files_to_render:
|
|
modified_lines.append(f' - {file}')
|
|
modified_lines.append('')
|
|
render_added = True
|
|
|
|
modified_lines.append(line)
|
|
|
|
# Write modified config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(modified_lines))
|
|
|
|
console.print(f"[dim]⚡ Added render section with {len(files_to_render)} files[/dim]")
|
|
|
|
def _remove_render_section(self, config_file: Path) -> None:
|
|
"""Remove render section from config file.
|
|
|
|
Args:
|
|
config_file: Path to config file to modify
|
|
"""
|
|
try:
|
|
# Read current config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
lines = content.split('\n')
|
|
modified_lines = []
|
|
i = 0
|
|
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
# Skip render section entirely
|
|
if line.strip().startswith('render:'):
|
|
# Skip this line and all indented lines that follow
|
|
i += 1
|
|
while i < len(lines) and (lines[i].startswith(' -') or lines[i].strip() == ''):
|
|
i += 1
|
|
continue
|
|
|
|
modified_lines.append(line)
|
|
i += 1
|
|
|
|
# Write modified config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(modified_lines))
|
|
|
|
console.print("[dim]🛡️ Removed render section[/dim]")
|
|
except Exception as e:
|
|
console.print(f"[yellow]⚠️ Error removing render section: {e}[/yellow]")
|
|
|
|
@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:
|
|
# - contents/vol1/chapter/chapter.qmd → - contents/vol1/chapter/chapter.qmd
|
|
#- contents/vol1/chapter/chapter.qmd → - contents/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 _setup_fast_build_mode(self, config_file: Path, chapter_files: List[Path]) -> None:
|
|
"""Setup fast build mode by modifying config for selective chapter builds.
|
|
|
|
For HTML: Uses render field to specify which files to build
|
|
For PDF/EPUB: Comments out chapters not being built
|
|
|
|
Always resets the config to a clean state first (all chapters uncommented)
|
|
to handle cases where a previous build was interrupted.
|
|
"""
|
|
console.print("[dim]⚡ Setting up fast build mode...[/dim]")
|
|
|
|
# Create backup of original config
|
|
backup_file = config_file.with_suffix('.backup')
|
|
if backup_file.exists():
|
|
backup_file.unlink()
|
|
|
|
# Read original config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
original_content = f.read()
|
|
|
|
# Save backup
|
|
with open(backup_file, 'w', encoding='utf-8') as f:
|
|
f.write(original_content)
|
|
|
|
# Reset to clean state: uncomment all .qmd lines so we start fresh
|
|
clean_content = self._reset_config_comments(original_content)
|
|
|
|
# Determine format and call appropriate setup function
|
|
config_name = str(config_file).lower()
|
|
|
|
if 'html' in config_name:
|
|
self._setup_html_fast_build(config_file, chapter_files, clean_content)
|
|
elif 'pdf' in config_name:
|
|
self._setup_pdf_fast_build(config_file, chapter_files, clean_content)
|
|
elif 'epub' in config_name:
|
|
self._setup_epub_fast_build(config_file, chapter_files, clean_content)
|
|
else:
|
|
# Fallback to PDF/EPUB approach for unknown formats
|
|
console.print(f"[yellow]⚠️ Unknown config format, using PDF approach: {config_file}[/yellow]")
|
|
self._setup_pdf_fast_build(config_file, chapter_files, clean_content)
|
|
|
|
def _setup_html_fast_build(self, config_file: Path, chapter_files: List[Path], original_content: str) -> None:
|
|
"""Setup HTML fast build using render field."""
|
|
# Build list of files to render
|
|
files_to_render = ["index.qmd"]
|
|
|
|
for chapter_file in chapter_files:
|
|
try:
|
|
rel_path = chapter_file.relative_to(self.config_manager.book_dir)
|
|
files_to_render.append(str(rel_path))
|
|
except ValueError:
|
|
# Try to construct the path
|
|
chapter_name = chapter_file.stem
|
|
files_to_render.append(f"contents/vol1/{chapter_name}/{chapter_name}.qmd")
|
|
|
|
console.print(f"[dim]📋 Files to render: {len(files_to_render)} files[/dim]")
|
|
|
|
# Process config to update/add render field
|
|
lines = original_content.split('\n')
|
|
modified_lines = []
|
|
i = 0
|
|
render_added = False
|
|
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
# Check if this is the render: section
|
|
if line.strip().startswith('render:'):
|
|
# Skip the entire existing render section
|
|
while i < len(lines) and (lines[i].strip().startswith('render:') or
|
|
lines[i].strip().startswith('-') or
|
|
lines[i].strip().startswith('#') or
|
|
(lines[i].startswith(' ') and lines[i].strip())):
|
|
i += 1
|
|
|
|
# Add our new render section
|
|
modified_lines.append(' render:')
|
|
for file in files_to_render:
|
|
modified_lines.append(f' - {file}')
|
|
render_added = True
|
|
continue
|
|
|
|
# If we hit post-render and haven't added render yet, add it before
|
|
if not render_added and line.strip().startswith('post-render:'):
|
|
modified_lines.append(' render:')
|
|
for file in files_to_render:
|
|
modified_lines.append(f' - {file}')
|
|
modified_lines.append('')
|
|
render_added = True
|
|
|
|
modified_lines.append(line)
|
|
i += 1
|
|
|
|
# Write modified config
|
|
modified_content = '\n'.join(modified_lines)
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(modified_content)
|
|
|
|
console.print("[green]✓[/green] Fast build mode configured (HTML)")
|
|
|
|
def _setup_pdf_fast_build(self, config_file: Path, chapter_files: List[Path], original_content: str) -> None:
|
|
"""Setup PDF fast build by commenting out chapters not being built.
|
|
|
|
Note: render field doesn't work for PDF. We preserve the structure
|
|
but comment out files not in the selected list.
|
|
|
|
Handles multiple path patterns:
|
|
- Regular chapters: contents/vol1/chapter_name/chapter_name.qmd
|
|
- Backmatter/appendix: contents/vol1/backmatter/appendix_name.qmd
|
|
- Glossary: contents/vol1/backmatter/glossary/glossary.qmd
|
|
"""
|
|
# Get list of chapter names to keep
|
|
keep_chapters = set(['index']) # Always keep index.qmd
|
|
always_include = {'index.qmd'} # Only include index.qmd for selective builds
|
|
|
|
for chapter_file in chapter_files:
|
|
keep_chapters.add(chapter_file.stem)
|
|
|
|
# Track what we're building
|
|
files_being_built = []
|
|
|
|
# Process config - comment out chapters not being built
|
|
lines = original_content.split('\n')
|
|
modified_lines = []
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
# Check if this is a part declaration
|
|
if stripped.startswith('- part:') or (stripped.startswith('part:') and not '.qmd' in line):
|
|
# This is a part - look ahead to see if any chapters in this part should be included
|
|
part_has_active_chapters = False
|
|
part_lines = [line] # Start with the part line
|
|
j = i + 1
|
|
|
|
# Collect all lines that belong to this part
|
|
while j < len(lines):
|
|
next_line = lines[j]
|
|
next_stripped = next_line.strip()
|
|
|
|
# Stop if we hit another part or a non-indented line that indicates end of part
|
|
if ((next_stripped.startswith('- part:') or
|
|
(next_stripped.startswith('part:') and not '.qmd' in next_line)) or
|
|
(next_line and not next_line[0].isspace() and not next_line.startswith('\t') and
|
|
not next_stripped.startswith('#'))):
|
|
break
|
|
|
|
part_lines.append(next_line)
|
|
|
|
# Check if this line has a chapter we want to include
|
|
if '.qmd' in next_line:
|
|
for chapter_name in keep_chapters:
|
|
# Check multiple path patterns for PDF backmatter/appendix support
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in next_line or
|
|
f'{chapter_name}.qmd' in next_line or
|
|
f'backmatter/{chapter_name}.qmd' in next_line or
|
|
f'glossary/{chapter_name}.qmd' in next_line):
|
|
part_has_active_chapters = True
|
|
break
|
|
# Also check always_include
|
|
for always_file in always_include:
|
|
if always_file in next_line:
|
|
part_has_active_chapters = True
|
|
break
|
|
|
|
j += 1
|
|
|
|
# Process all lines in this part
|
|
for part_line in part_lines:
|
|
part_stripped = part_line.strip()
|
|
|
|
# Check structural lines FIRST (before .qmd check) to avoid treating part declarations as chapters
|
|
if part_has_active_chapters and ('part:' in part_line or part_stripped.startswith('chapters:')):
|
|
# This part has active chapters, so ensure structural lines are uncommented
|
|
# Always ensure part and chapters lines are uncommented when part has active chapters
|
|
if part_stripped.startswith('#'):
|
|
uncommented = part_line.replace('# ', '', 1).replace('#', '', 1)
|
|
modified_lines.append(uncommented)
|
|
else:
|
|
modified_lines.append(part_line)
|
|
elif '.qmd' in part_line and (
|
|
part_stripped.startswith('- ')
|
|
or part_stripped.startswith('# - ')
|
|
or part_stripped.startswith('#- ')
|
|
):
|
|
# Chapter file entry - guard against comment lines
|
|
# that merely mention `.qmd` in their text (which
|
|
# would otherwise be silently uncommented and break
|
|
# YAML).
|
|
should_include = False
|
|
|
|
# Check against always_include files
|
|
for always_file in always_include:
|
|
if always_file in part_line:
|
|
should_include = True
|
|
break
|
|
|
|
# Check against selected chapters using multiple path patterns
|
|
if not should_include:
|
|
for chapter_name in keep_chapters:
|
|
# Check multiple path patterns:
|
|
# 1. Regular: chapter_name/chapter_name.qmd
|
|
# 2. Direct: chapter_name.qmd (for backmatter files)
|
|
# 3. Backmatter: backmatter/chapter_name.qmd (explicit)
|
|
# 4. Glossary: backmatter/glossary/glossary.qmd
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in part_line or
|
|
f'{chapter_name}.qmd' in part_line or
|
|
f'backmatter/{chapter_name}.qmd' in part_line or
|
|
f'glossary/{chapter_name}.qmd' in part_line):
|
|
should_include = True
|
|
break
|
|
|
|
if should_include:
|
|
# Ensure line is not commented
|
|
if part_stripped.startswith('#'):
|
|
uncommented = part_line.replace('# ', '', 1).replace('#', '', 1)
|
|
modified_lines.append(uncommented)
|
|
files_being_built.append(part_stripped[2:] if part_stripped.startswith('# ') else part_stripped[1:])
|
|
else:
|
|
modified_lines.append(part_line)
|
|
files_being_built.append(part_stripped[2:] if part_stripped.startswith('- ') else part_stripped)
|
|
else:
|
|
# Comment out this chapter
|
|
if not part_stripped.startswith('#'):
|
|
indent = len(part_line) - len(part_line.lstrip())
|
|
commented = ' ' * indent + '# ' + part_line.lstrip()
|
|
modified_lines.append(commented)
|
|
else:
|
|
modified_lines.append(part_line)
|
|
elif part_has_active_chapters:
|
|
# Part has active chapters but this line is neither structural nor a chapter
|
|
# Keep as-is
|
|
modified_lines.append(part_line)
|
|
else:
|
|
# This part has no active chapters, comment out all lines in it
|
|
if not part_stripped.startswith('#') and part_stripped:
|
|
indent = len(part_line) - len(part_line.lstrip())
|
|
commented = ' ' * indent + '# ' + part_line.lstrip()
|
|
modified_lines.append(commented)
|
|
else:
|
|
modified_lines.append(part_line)
|
|
|
|
# Skip ahead since we've processed this whole part
|
|
i = j - 1
|
|
|
|
elif '.qmd' in line and (
|
|
stripped.startswith('- ')
|
|
or stripped.startswith('# - ')
|
|
or stripped.startswith('#- ')
|
|
):
|
|
# Standalone .qmd entry (not in a part). The list-item
|
|
# guard prevents prose comments that mention `.qmd` from
|
|
# being treated as chapter entries.
|
|
should_include = False
|
|
|
|
# Check against always_include files
|
|
for always_file in always_include:
|
|
if always_file in line:
|
|
should_include = True
|
|
break
|
|
|
|
# Check against selected chapters using multiple path patterns
|
|
if not should_include:
|
|
for chapter_name in keep_chapters:
|
|
# Check multiple path patterns:
|
|
# 1. Regular: chapter_name/chapter_name.qmd
|
|
# 2. Direct: chapter_name.qmd (for backmatter files)
|
|
# 3. Backmatter: backmatter/chapter_name.qmd (explicit)
|
|
# 4. Glossary: backmatter/glossary/glossary.qmd
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in line or
|
|
f'{chapter_name}.qmd' in line or
|
|
f'backmatter/{chapter_name}.qmd' in line or
|
|
f'glossary/{chapter_name}.qmd' in line):
|
|
should_include = True
|
|
break
|
|
|
|
if should_include:
|
|
# Ensure line is not commented
|
|
if stripped.startswith('#'):
|
|
uncommented = line.replace('# ', '', 1).replace('#', '', 1)
|
|
modified_lines.append(uncommented)
|
|
files_being_built.append(stripped[2:] if stripped.startswith('# ') else stripped)
|
|
else:
|
|
modified_lines.append(line)
|
|
files_being_built.append(stripped)
|
|
else:
|
|
# Comment out the line
|
|
if not stripped.startswith('#'):
|
|
indent = len(line) - len(line.lstrip())
|
|
commented = ' ' * indent + '# ' + line.lstrip()
|
|
modified_lines.append(commented)
|
|
else:
|
|
modified_lines.append(line)
|
|
elif stripped == 'appendices:':
|
|
# Handle appendices section: look ahead to see if any appendix entries will be kept.
|
|
# If all entries are commented out, we must also comment out the 'appendices:' key
|
|
# itself, otherwise Quarto sees appendices: null and fails validation.
|
|
has_active_appendix = False
|
|
j = i + 1
|
|
while j < len(lines):
|
|
next_line = lines[j]
|
|
next_stripped = next_line.strip()
|
|
# Stop at blank lines or non-indented lines (end of appendices block)
|
|
if not next_stripped or (next_line and not next_line[0].isspace() and not next_line.startswith('\t')):
|
|
break
|
|
if '.qmd' in next_line:
|
|
for chapter_name in keep_chapters:
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in next_line or
|
|
f'{chapter_name}.qmd' in next_line or
|
|
f'backmatter/{chapter_name}.qmd' in next_line or
|
|
f'glossary/{chapter_name}.qmd' in next_line):
|
|
has_active_appendix = True
|
|
break
|
|
for always_file in always_include:
|
|
if always_file in next_line:
|
|
has_active_appendix = True
|
|
break
|
|
if has_active_appendix:
|
|
break
|
|
j += 1
|
|
|
|
if has_active_appendix:
|
|
modified_lines.append(line)
|
|
else:
|
|
indent = len(line) - len(line.lstrip())
|
|
modified_lines.append(' ' * indent + '# ' + line.lstrip())
|
|
else:
|
|
# All other lines - copy as-is
|
|
modified_lines.append(line)
|
|
|
|
i += 1
|
|
|
|
# Write modified config
|
|
modified_content = '\n'.join(modified_lines)
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(modified_content)
|
|
|
|
console.print(f"[dim]📋 Files to build: {len(files_being_built)} files[/dim]")
|
|
for file in files_being_built:
|
|
console.print(f"[green]✓[/green] {file}")
|
|
|
|
console.print("[green]✓[/green] Fast build mode configured (PDF)")
|
|
|
|
def _setup_epub_fast_build(self, config_file: Path, chapter_files: List[Path], original_content: str) -> None:
|
|
"""Setup EPUB fast build by commenting out chapters not being built.
|
|
|
|
EPUB has specific requirements:
|
|
- Must preserve part structure (parts cannot be commented out if they contain active chapters)
|
|
- Must uncomment both part and chapters lines when building chapters in that part
|
|
- Uses same commenting approach as PDF but with stricter part preservation
|
|
|
|
Handles multiple path patterns:
|
|
- Regular chapters: contents/vol1/chapter_name/chapter_name.qmd
|
|
- Backmatter/appendix: contents/vol1/backmatter/appendix_name.qmd
|
|
- Glossary: contents/vol1/backmatter/glossary/glossary.qmd
|
|
- Handles "Appendices" part wrapper for backmatter in EPUB
|
|
"""
|
|
# Get list of chapter names to keep
|
|
keep_chapters = set(['index']) # Always keep index.qmd
|
|
always_include = {'index.qmd'} # Only include index.qmd for selective builds
|
|
|
|
for chapter_file in chapter_files:
|
|
keep_chapters.add(chapter_file.stem)
|
|
|
|
# Track what we're building
|
|
files_being_built = []
|
|
|
|
# Process config - comment out chapters not being built
|
|
lines = original_content.split('\n')
|
|
modified_lines = []
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
# Check if this is a part declaration
|
|
if stripped.startswith('- part:') or (stripped.startswith('part:') and not '.qmd' in line):
|
|
# This is a part - look ahead to see if any chapters in this part should be included
|
|
part_has_active_chapters = False
|
|
part_lines = [line] # Start with the part line
|
|
j = i + 1
|
|
|
|
# Collect all lines that belong to this part
|
|
while j < len(lines):
|
|
next_line = lines[j]
|
|
next_stripped = next_line.strip()
|
|
|
|
# Stop if we hit another part or a non-indented line that indicates end of part
|
|
if ((next_stripped.startswith('- part:') or
|
|
(next_stripped.startswith('part:') and not '.qmd' in next_line)) or
|
|
(next_line and not next_line[0].isspace() and not next_line.startswith('\t') and
|
|
not next_stripped.startswith('#'))):
|
|
break
|
|
|
|
part_lines.append(next_line)
|
|
|
|
# Check if this line has a chapter we want to include
|
|
if '.qmd' in next_line:
|
|
for chapter_name in keep_chapters:
|
|
# Check multiple path patterns for EPUB backmatter/appendix support
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in next_line or
|
|
f'{chapter_name}.qmd' in next_line or
|
|
f'backmatter/{chapter_name}.qmd' in next_line or
|
|
f'glossary/{chapter_name}.qmd' in next_line):
|
|
part_has_active_chapters = True
|
|
break
|
|
# Also check always_include
|
|
for always_file in always_include:
|
|
if always_file in next_line:
|
|
part_has_active_chapters = True
|
|
break
|
|
|
|
j += 1
|
|
|
|
# Process all lines in this part
|
|
for part_line in part_lines:
|
|
part_stripped = part_line.strip()
|
|
|
|
# Check structural lines FIRST (before .qmd check) to avoid treating part declarations as chapters
|
|
if part_has_active_chapters and ('part:' in part_line or part_stripped.startswith('chapters:')):
|
|
# EPUB CRITICAL: This part has active chapters, so ensure structural lines are uncommented
|
|
# Always ensure part and chapters lines are uncommented when part has active chapters
|
|
# This handles "Appendices" part wrapper for backmatter chapters
|
|
if part_stripped.startswith('#'):
|
|
uncommented = part_line.replace('# ', '', 1).replace('#', '', 1)
|
|
modified_lines.append(uncommented)
|
|
else:
|
|
modified_lines.append(part_line)
|
|
elif '.qmd' in part_line and (
|
|
part_stripped.startswith('- ')
|
|
or part_stripped.startswith('# - ')
|
|
or part_stripped.startswith('#- ')
|
|
):
|
|
# Chapter file entry - guard against comment lines
|
|
# that merely mention `.qmd` in their text (which
|
|
# would otherwise be silently uncommented and break
|
|
# YAML).
|
|
should_include = False
|
|
|
|
# Check against always_include files
|
|
for always_file in always_include:
|
|
if always_file in part_line:
|
|
should_include = True
|
|
break
|
|
|
|
# Check against selected chapters using multiple path patterns
|
|
if not should_include:
|
|
for chapter_name in keep_chapters:
|
|
# Check multiple path patterns:
|
|
# 1. Regular: chapter_name/chapter_name.qmd
|
|
# 2. Direct: chapter_name.qmd (for backmatter files)
|
|
# 3. Backmatter: backmatter/chapter_name.qmd (explicit)
|
|
# 4. Glossary: backmatter/glossary/glossary.qmd
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in part_line or
|
|
f'{chapter_name}.qmd' in part_line or
|
|
f'backmatter/{chapter_name}.qmd' in part_line or
|
|
f'glossary/{chapter_name}.qmd' in part_line):
|
|
should_include = True
|
|
break
|
|
|
|
if should_include:
|
|
# Ensure line is not commented
|
|
if part_stripped.startswith('#'):
|
|
uncommented = part_line.replace('# ', '', 1).replace('#', '', 1)
|
|
modified_lines.append(uncommented)
|
|
files_being_built.append(part_stripped[2:] if part_stripped.startswith('# ') else part_stripped[1:])
|
|
else:
|
|
modified_lines.append(part_line)
|
|
files_being_built.append(part_stripped[2:] if part_stripped.startswith('- ') else part_stripped)
|
|
else:
|
|
# Comment out this chapter
|
|
if not part_stripped.startswith('#'):
|
|
indent = len(part_line) - len(part_line.lstrip())
|
|
commented = ' ' * indent + '# ' + part_line.lstrip()
|
|
modified_lines.append(commented)
|
|
else:
|
|
modified_lines.append(part_line)
|
|
elif part_has_active_chapters:
|
|
# Part has active chapters but this line is neither structural nor a chapter
|
|
# Keep as-is
|
|
modified_lines.append(part_line)
|
|
else:
|
|
# This part has no active chapters, comment out all lines in it
|
|
if not part_stripped.startswith('#') and part_stripped:
|
|
indent = len(part_line) - len(part_line.lstrip())
|
|
commented = ' ' * indent + '# ' + part_line.lstrip()
|
|
modified_lines.append(commented)
|
|
else:
|
|
modified_lines.append(part_line)
|
|
|
|
# Skip ahead since we've processed this whole part
|
|
i = j - 1
|
|
|
|
elif '.qmd' in line and (
|
|
stripped.startswith('- ')
|
|
or stripped.startswith('# - ')
|
|
or stripped.startswith('#- ')
|
|
):
|
|
# Standalone .qmd entry (not in a part). The list-item
|
|
# guard prevents prose comments that mention `.qmd` from
|
|
# being treated as chapter entries.
|
|
should_include = False
|
|
|
|
# Check against always_include files
|
|
for always_file in always_include:
|
|
if always_file in line:
|
|
should_include = True
|
|
break
|
|
|
|
# Check against selected chapters using multiple path patterns
|
|
if not should_include:
|
|
for chapter_name in keep_chapters:
|
|
# Check multiple path patterns:
|
|
# 1. Regular: chapter_name/chapter_name.qmd
|
|
# 2. Direct: chapter_name.qmd (for backmatter files)
|
|
# 3. Backmatter: backmatter/chapter_name.qmd (explicit)
|
|
# 4. Glossary: backmatter/glossary/glossary.qmd
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in line or
|
|
f'{chapter_name}.qmd' in line or
|
|
f'backmatter/{chapter_name}.qmd' in line or
|
|
f'glossary/{chapter_name}.qmd' in line):
|
|
should_include = True
|
|
break
|
|
|
|
if should_include:
|
|
# Ensure line is not commented
|
|
if stripped.startswith('#'):
|
|
uncommented = line.replace('# ', '', 1).replace('#', '', 1)
|
|
modified_lines.append(uncommented)
|
|
files_being_built.append(stripped[2:] if stripped.startswith('# ') else stripped)
|
|
else:
|
|
modified_lines.append(line)
|
|
files_being_built.append(stripped)
|
|
else:
|
|
# Comment out the line
|
|
if not stripped.startswith('#'):
|
|
indent = len(line) - len(line.lstrip())
|
|
commented = ' ' * indent + '# ' + line.lstrip()
|
|
modified_lines.append(commented)
|
|
else:
|
|
modified_lines.append(line)
|
|
elif stripped == 'appendices:':
|
|
# Handle appendices section: look ahead to see if any appendix entries will be kept.
|
|
# If all entries are commented out, we must also comment out the 'appendices:' key
|
|
# itself, otherwise Quarto sees appendices: null and fails validation.
|
|
has_active_appendix = False
|
|
j = i + 1
|
|
while j < len(lines):
|
|
next_line = lines[j]
|
|
next_stripped = next_line.strip()
|
|
if not next_stripped or (next_line and not next_line[0].isspace() and not next_line.startswith('\t')):
|
|
break
|
|
if '.qmd' in next_line:
|
|
for chapter_name in keep_chapters:
|
|
if (f'{chapter_name}/{chapter_name}.qmd' in next_line or
|
|
f'{chapter_name}.qmd' in next_line or
|
|
f'backmatter/{chapter_name}.qmd' in next_line or
|
|
f'glossary/{chapter_name}.qmd' in next_line):
|
|
has_active_appendix = True
|
|
break
|
|
for always_file in always_include:
|
|
if always_file in next_line:
|
|
has_active_appendix = True
|
|
break
|
|
if has_active_appendix:
|
|
break
|
|
j += 1
|
|
|
|
if has_active_appendix:
|
|
modified_lines.append(line)
|
|
else:
|
|
indent = len(line) - len(line.lstrip())
|
|
modified_lines.append(' ' * indent + '# ' + line.lstrip())
|
|
else:
|
|
# All other lines - copy as-is
|
|
modified_lines.append(line)
|
|
|
|
i += 1
|
|
|
|
# Write modified config
|
|
modified_content = '\n'.join(modified_lines)
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(modified_content)
|
|
|
|
console.print(f"[dim]📋 Files to build: {len(files_being_built)} files[/dim]")
|
|
for file in files_being_built:
|
|
console.print(f"[green]✓[/green] {file}")
|
|
|
|
console.print("[green]✓[/green] Fast build mode configured (EPUB)")
|
|
|
|
def _uncomment_all_chapters(self, config_file: Path) -> None:
|
|
"""Uncomment all chapter files in the config for full book build.
|
|
|
|
Args:
|
|
config_file: Path to config file to modify
|
|
"""
|
|
# Create backup of original config
|
|
backup_file = config_file.with_suffix('.backup')
|
|
if backup_file.exists():
|
|
backup_file.unlink()
|
|
|
|
# Read original config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
original_content = f.read()
|
|
|
|
# Save backup
|
|
with open(backup_file, 'w', encoding='utf-8') as f:
|
|
f.write(original_content)
|
|
|
|
# Process config - uncomment all lines with .qmd files
|
|
lines = original_content.split('\n')
|
|
modified_lines = []
|
|
uncommented_count = 0
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
|
|
# Only uncomment chapter-list entries (`# - path.qmd`), not free-form
|
|
# comments that mention `.qmd` (e.g. index.qmd symlink notes).
|
|
is_commented_chapter_entry = (
|
|
stripped.startswith('#')
|
|
and '.qmd' in line
|
|
and ('# -' in line or '#-' in line)
|
|
)
|
|
if is_commented_chapter_entry:
|
|
# Uncomment the line while preserving indentation
|
|
# Handle both "# - " and "#- " patterns
|
|
if '# -' in line:
|
|
uncommented = line.replace('# -', '-', 1)
|
|
elif '#-' in line:
|
|
uncommented = line.replace('#-', '-', 1)
|
|
else:
|
|
# Just remove the first # and space
|
|
uncommented = line.replace('# ', '', 1).replace('#', '', 1)
|
|
|
|
modified_lines.append(uncommented)
|
|
uncommented_count += 1
|
|
elif stripped == '# appendices:':
|
|
# Uncomment the appendices key (may have been commented out by fast build)
|
|
uncommented = line.replace('# appendices:', 'appendices:', 1)
|
|
modified_lines.append(uncommented)
|
|
uncommented_count += 1
|
|
else:
|
|
# Keep line as-is
|
|
modified_lines.append(line)
|
|
|
|
# Write modified config
|
|
modified_content = '\n'.join(modified_lines)
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(modified_content)
|
|
|
|
console.print(f"[green]✓[/green] Uncommented {uncommented_count} chapter files")
|
|
|
|
def _restore_config(self, config_file: Path) -> None:
|
|
"""Restore configuration to pristine state."""
|
|
console.print("[dim]🛡️ Restoring config...[/dim]")
|
|
|
|
backup_file = config_file.with_suffix('.backup')
|
|
|
|
if backup_file.exists():
|
|
try:
|
|
# Read backup content
|
|
with open(backup_file, 'r', encoding='utf-8') as f:
|
|
original_content = f.read()
|
|
|
|
# Restore original config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(original_content)
|
|
|
|
# Clean up backup file
|
|
backup_file.unlink()
|
|
|
|
console.print("[green]✅ Configuration restored successfully[/green]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Error restoring config: {e}[/red]")
|
|
else:
|
|
console.print("[yellow]⚠️ No backup file found - config may already be restored[/yellow]")
|
|
|
|
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]:
|
|
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 "all volumes"
|
|
console.print(f"[green]✅ Reset {format_type.upper()} build config for {scope}[/green]")
|
|
return True
|