mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-05 17:18:48 -05:00
Moves the mlsysim package from book/quarto/mlsysim/ to the repo root so it is importable as a proper top-level package across the codebase. Key changes: - mlsysim/fmt.py: new top-level module for all formatting helpers (fmt, sci, check, md_math, fmt_full, fmt_split, etc.), moved out of viz/ - mlsysim/viz/__init__.py: now exports only plot utilities; dashboard.py (marimo-only) is no longer wildcard-exported and must be imported explicitly by marimo labs - mlsysim/__init__.py: added `from . import fmt` and `from .core import constants`; removed broken `from .viz import plots as viz` alias - execute-env.yml: fixed PYTHONPATH from "../../.." to "../.." so chapters resolve to repo root, not parent of repo - 51 QMD files: updated `from mlsysim.viz import <fmt-fns>` to `from mlsysim.fmt import <fmt-fns>` - book/quarto/mlsys/: legacy shadow package contents cleaned up; stub __init__.py remains for backward compat - All Vol1 and Vol2 chapters verified to build with `binder build pdf`
1457 lines
64 KiB
Python
1457 lines
64 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 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 _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") -> bool:
|
|
"""Build full book in specified format.
|
|
|
|
Args:
|
|
format_type: Format to build ('html', 'pdf', 'epub')
|
|
|
|
Returns:
|
|
True if build 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()
|
|
|
|
# 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)
|
|
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") -> bool:
|
|
"""Build specific chapters.
|
|
|
|
Args:
|
|
chapter_names: List of chapter names to build
|
|
format_type: Format to build ('html', 'pdf', 'epub')
|
|
|
|
Returns:
|
|
True if build 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]")
|
|
|
|
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)
|
|
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) -> 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')
|
|
|
|
Returns:
|
|
True if build 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]")
|
|
|
|
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)
|
|
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") -> 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')
|
|
|
|
Returns:
|
|
True if build succeeded, False otherwise
|
|
"""
|
|
volume_name = "Volume I" if volume == "vol1" else "Volume II"
|
|
console.print(f"[magenta]📖 Building {volume_name} ({format_type.upper()})...[/magenta]")
|
|
|
|
# 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]")
|
|
|
|
# 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)
|
|
else:
|
|
console.print(f"[red]❌ {volume_name} {format_type.upper()} build failed[/red]")
|
|
|
|
return success
|
|
|
|
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 PYTHONPATH including the project root
|
|
env = os.environ.copy()
|
|
root_dir = str(self.config_manager.root_dir.resolve())
|
|
current_pythonpath = env.get("PYTHONPATH", "")
|
|
if current_pythonpath:
|
|
env["PYTHONPATH"] = f"{root_dir}:{current_pythonpath}"
|
|
else:
|
|
env["PYTHONPATH"] = root_dir
|
|
|
|
try:
|
|
if self.verbose:
|
|
# Verbose mode: stream output in real-time
|
|
console.print(f"[dim]▶ {description}[/dim]")
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
cwd=cwd,
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
# Stream output line by line
|
|
for line in iter(process.stdout.readline, ''):
|
|
if line:
|
|
console.print(line.rstrip())
|
|
|
|
process.wait(timeout=1800)
|
|
|
|
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 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]")
|
|
|
|
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_name, chapter_file in all_chapters.items():
|
|
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")
|
|
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 remove render section from config
|
|
try:
|
|
config_file = self.config_manager.get_config_file("html")
|
|
self._remove_render_section(config_file)
|
|
except:
|
|
pass
|
|
|
|
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')
|
|
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:
|
|
# Uncomment .qmd lines: preserve leading whitespace
|
|
indent = len(line) - len(line.lstrip())
|
|
# Strip the comment prefix from the stripped content
|
|
uncommented = stripped.lstrip('#').lstrip()
|
|
# Ensure it starts with '- ' (YAML list item)
|
|
if not uncommented.startswith('-'):
|
|
uncommented = '- ' + uncommented
|
|
reset_lines.append(' ' * indent + uncommented)
|
|
elif stripped.startswith('#') and ('part:' in stripped or stripped.lstrip('#').strip().startswith('chapters:')):
|
|
# Uncomment structural lines (part declarations, chapters 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:
|
|
# This is a chapter file - check if it should be included
|
|
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:
|
|
# This is a standalone .qmd file (not in a part)
|
|
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:
|
|
# This is a chapter file - check if it should be included
|
|
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:
|
|
# This is a standalone .qmd file (not in a part)
|
|
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()
|
|
|
|
# Check if this is a commented line with a .qmd file or a commented structural key
|
|
if stripped.startswith('#') and '.qmd' in line:
|
|
# 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.
|
|
|
|
For PDF/EPUB this restores commented chapter lists.
|
|
For HTML this also removes temporary `render:` sections used for fast builds.
|
|
|
|
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)
|
|
|
|
if format_type == "html":
|
|
# HTML fast builds can leave temporary render sections in place.
|
|
cfg.write_text(reset_content, encoding="utf-8")
|
|
self._remove_render_section(cfg)
|
|
else:
|
|
cfg.write_text(reset_content, encoding="utf-8")
|
|
|
|
backup_file = cfg.with_suffix(".backup")
|
|
if backup_file.exists():
|
|
backup_file.unlink()
|
|
|
|
reset_count += 1
|
|
console.print(f"[green]✓[/green] Reset config: {cfg.name}")
|
|
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
|