mirror of
https://github.com/luongnv89/claude-howto.git
synced 2026-04-26 09:56:01 +02:00
a70777e9bc
* feat(i18n): add Vietnamese localization infrastructure Add comprehensive infrastructure for Vietnamese translation of the claude-howto documentation: Features: - Parallel vi/ directory structure for Vietnamese content - Modified build_epub.py with --lang flag for Vietnamese EPUB - Vietnamese-specific pre-commit hooks for quality checks - Translation support files (glossary, style guide, queue tracker) - sync_translations.py script to detect outdated translations Infrastructure: - vi/ directory with all 10 module subdirectories - TRANSLATION_NOTES.md: comprehensive glossary and style guide - TRANSLATION_QUEUE.md: progress tracking for 66+ files - vi/README.md: Vietnamese landing page EPUB Build: - Added --lang CLI flag (en/vi) to build_epub.py - Language-specific metadata (title, subtitle) - Automatic root path adjustment for vi/ directory - Dynamic output filename (claude-howto-guide-vi.epub) Quality Assurance: - Vietnamese markdown linting - Vietnamese cross-reference checking - Vietnamese Mermaid syntax validation - Vietnamese link checking - Vietnamese EPUB build validation Ready for translation work to begin. * feat(vi): translate INDEX.md to Vietnamese Complete Vietnamese translation of INDEX.md - the comprehensive index of all example files in the repository. Translation follows vi/TRANSLATION_NOTES.md guidelines: - Technical terms kept per glossary (slash command → "lệnh slash") - File paths and code blocks preserved in English - Table structure and formatting maintained - All 882 lines translated Content includes: - Complete file tree (10 modules, 100+ files) - Feature coverage matrix - Quick start guides by use case - Learning path progression - Search by keyword sections Progress: 1/5 core documents completed * feat(vi): translate CATALOG.md to Vietnamese Complete Vietnamese translation of CATALOG.md - the quick reference guide to all Claude Code features. Translation follows vi/TRANSLATION_NOTES.md guidelines: - Technical terms kept per glossary (slash command → "lệnh slash") - All command names preserved (/help, /optimize, etc.) - Code blocks, JSON examples, and bash commands unchanged - All tables and formatting maintained - 517 lines translated with complete feature coverage Content includes: - Summary statistics (99 built-in + 43 examples = 117 total) - Complete built-in commands reference (55+ commands) - Custom commands examples (8 commands) - Permission modes (6 modes) - Subagents (6 built-in + 10 custom) - Skills (5 bundled + 4 custom) - Plugins (3 complete plugins) - MCP servers (1 built-in + 8 common servers) - Hooks (25 events + 7 example hooks) - Memory files (7 types) - New features (March 2026) - 17 new features - Installation priority guide - Complete one-command installation Progress: 2/5 core documents completed * docs(vi): translate learning roadmap to Vietnamese - Add complete Vietnamese translation of LEARNING-ROADMAP.md (747 lines) - Include self-assessment quiz for level determination - Translate three learning levels: Beginner, Intermediate, Advanced - Translate all milestones (1A, 1B, 2A, 2B, 3A, 3B) with hands-on exercises - Preserve Mermaid diagram for learning path visualization - Maintain progress tracking checklists and learning tips * docs(vi): translate quick reference guide to Vietnamese - Add complete Vietnamese translation of QUICK_REFERENCE.md (507 lines) - Include installation quick commands for all features - Translate feature cheat sheet with usage examples - Translate common use cases (code review, documentation, DevOps, etc.) - Preserve all code snippets, commands, and file paths in English - Maintain file locations reference diagram - Include learning path and new features (March 2026) sections - Keep tips & tricks, feature matrix, and checklist intact * docs(vi): translate module 01 - slash commands to Vietnamese - Add complete Vietnamese translation of 01-slash-commands/README.md (553 lines) - Translate slash commands overview and architecture - Include 55+ built-in commands reference table with Vietnamese descriptions - Translate bundled skills section (5 skills) - Document custom commands (now skills) migration path - Include frontmatter reference, arguments, and dynamic context sections - Preserve all command names, code examples, and Mermaid diagrams - Translate 8 available commands in folder with usage examples - Include installation, troubleshooting, and best practices sections * docs(vi): update translation queue progress - Module 01 README done - Mark 01-slash-commands/README.md as completed (2026-04-02) - Update Module 01 progress: 1/9 files (11%) - Update overall progress: 6/66 files (9%) - Update Priority 2 progress: 1/31 files (3%) - Add recent completion note to queue header * docs(vi): translate slash command - commit to Vietnamese - Translate commit.md slash command to Vietnamese - Keep all git commands and technical syntax intact - Translate instructions and descriptions - Maintain conventional commits format documentation * docs(vi): translate slash command - doc-refactor to Vietnamese - Translate doc-refactor.md slash command to Vietnamese - Keep all technical terms and file paths intact - Translate documentation refactoring instructions - Maintain structure and numbering * docs(vi): translate slash command - generate-api-docs to Vietnamese - Translate generate-api-docs.md slash command to Vietnamese - Keep all technical terms and file paths intact - Translate API documentation generation instructions - Maintain output format specifications * docs(vi): translate slash command - optimize to Vietnamese - Translate optimize.md slash command to Vietnamese - Keep all technical terms and code concepts intact - Translate code optimization instructions - Maintain issue priority and response format * docs(vi): translate slash command - pr to Vietnamese - Translate pr.md slash command to Vietnamese - Keep all commands and technical syntax intact - Translate PR preparation checklist instructions - Maintain conventional commits format documentation * docs(vi): translate slash command - push-all to Vietnamese - Translate push-all.md slash command to Vietnamese (153 lines) - Keep all git commands and technical syntax intact - Translate comprehensive workflow with safety checks - Maintain API key validation patterns and error handling - Preserve when to use/avoid sections and alternatives * docs(vi): translate slash command - setup-ci-cd to Vietnamese - Translate setup-ci-cd.md slash command to Vietnamese - Keep all tool names and technical terms intact - Translate CI/CD pipeline implementation instructions - Maintain pre-commit hooks and GitHub Actions sections * docs(vi): translate slash command - unit-test-expand to Vietnamese - Translate unit-test-expand.md slash command to Vietnamese - Keep all framework names and technical terms intact - Translate test coverage expansion instructions - Maintain testing frameworks and scenario sections * docs(vi): update translation queue - Module 01 complete (100%) - Mark all 9 Module 01 files as completed (2026-04-02) - Update Module 01 progress: 9/9 files (100%) ✅ - Update overall progress: 14/66 files (21%) - Update Priority 2 progress: 9/31 files (29%) - Update header with Module 01 completion milestone Module 01: Slash Commands - All files translated: ✅ README.md (overview, built-in commands, skills) ✅ commit.md (git commit with context) ✅ doc-refactor.md (documentation restructuring) ✅ generate-api-docs.md (API documentation generator) ✅ optimize.md (code optimization analysis) ✅ pr.md (pull request preparation) ✅ push-all.md (stage, commit, push with safety) ✅ setup-ci-cd.md (CI/CD pipeline implementation) ✅ unit-test-expand.md (test coverage expansion) * docs(vi): translate module 02 - memory guide to Vietnamese - Add complete Vietnamese translation of 02-memory/README.md (1162 lines) - Translate memory system overview and architecture - Include quick reference for memory commands (/init, /memory, #) - Document complete memory hierarchy (8 levels with precedence) - Translate auto memory section with architecture diagrams - Include practical examples (project, directory-specific, personal memory) - Add best practices, installation instructions, and troubleshooting - Preserve all code examples, file paths, and technical terms - Maintain Mermaid diagrams and tables * docs(vi): translate example project CLAUDE.md to Vietnamese - Translate project-CLAUDE.md example memory file - Keep all technical terms, commands, and code style intact - Translate descriptions and explanations - Maintain project configuration structure * docs(vi): translate example personal CLAUDE.md to Vietnamese - Translate personal-CLAUDE.md example memory file - Keep all technical terms, tools, and preferences intact - Translate descriptions and explanations - Maintain personal preferences structure * docs(vi): translate example directory-specific CLAUDE.md to Vietnamese - Translate directory-api-CLAUDE.md example memory file - Keep all technical terms, API standards, and code examples intact - Translate descriptions and explanations - Maintain API module standards structure * docs(vi): update translation queue - Module 02 complete (100%) - Mark all 4 Module 02 files as completed (2026-04-02) - Update Module 02 progress: 4/4 files (100%) ✅ - Update overall progress: 18/66 files (27%) - Update Priority 2 progress: 13/31 files (42%) - Update header with Module 02 completion milestone Module 02: Memory - All files translated: ✅ README.md (1162 lines - comprehensive memory guide) ✅ project-CLAUDE.md (example project memory) ✅ personal-CLAUDE.md (example personal preferences) ✅ directory-api-CLAUDE.md (example directory-specific rules) * docs(vi): translate module 03 - skills guide to Vietnamese - Add complete Vietnamese translation of 03-skills/README.md (805 lines) - Translate Agent Skills overview and architecture - Include progressive disclosure loading mechanism (3 levels) - Document skill types, locations, and automatic discovery - Translate skill creation process and SKILL.md format - Include required and optional frontmatter fields - Document skill content types (reference vs task) - Translate controlling skill invocation and string substitutions - Include dynamic context injection with shell commands - Document running skills in subagents with context: fork - Translate 6 practical examples with directory structures - Add supporting files, management, and best practices sections - Preserve all code examples, YAML frontmatter, and Mermaid diagrams - Maintain tables, troubleshooting guides, and security considerations * docs(vi): translate skill - blog-draft to Vietnamese - Translate blog-draft/SKILL.md to Vietnamese (275 lines) - Keep all workflow steps and technical structure intact - Translate instructions and descriptions - Maintain version tracking and file structure - Preserve all markdown formatting and code examples * docs(vi): translate skill - brand-voice to Vietnamese - Translate brand-voice/SKILL.md to Vietnamese (73 lines) - Keep brand identity, tone, and vocabulary intact - Translate writing guidelines and examples - Maintain preferred/avoided terms sections - Preserve good/bad examples with explanations * docs(vi): translate skill - claude-md to Vietnamese - Translate claude-md/SKILL.md to Vietnamese (213 lines) - Keep all golden rules and core principles intact - Translate execution flow and content strategy - Maintain WHAT/WHY/HOW structure - Preserve quality constraints and validation checklist - Keep anti-patterns and output format sections * docs(vi): translate skill - code-review to Vietnamese - Translate code-review/SKILL.md to Vietnamese (71 lines) - Keep all review categories and template intact - Translate security, performance, quality, maintainability sections - Maintain review template structure - Preserve version history * docs(vi): translate skill - refactor to Vietnamese - Translate refactor/SKILL.md to Vietnamese (427 lines) - Keep all Martin Fowler refactoring methodology intact - Translate 6-phase workflow with detailed steps - Maintain core principles and safety rules - Preserve code smell catalog and refactoring techniques - Keep quick start example with before/after code - Maintain version history and references sections * docs(vi): update translation queue - Module 03 complete (100%) - Mark all 6 Module 03 files as completed (2026-04-02) - Update Module 03 progress: 6/6 files (100%) ✅ - Update overall progress: 24/66 files (36%) - Update Priority 2 progress: 19/31 files (61%) - Update header with Module 03 completion milestone Module 03: Skills - All files translated: ✅ README.md (805 lines - comprehensive skills guide) ✅ blog-draft/SKILL.md (275 lines - blog post creation workflow) ✅ brand-voice/SKILL.md (73 lines - brand voice consistency) ✅ claude-md/SKILL.md (213 lines - CLAUDE.md best practices) ✅ code-review/SKILL.md (71 lines - comprehensive code review) ✅ refactor/SKILL.md (427 lines - Fowler refactoring methodology) * docs(vi): translate Module 04 Subagents README Complete Vietnamese translation of subagents reference guide: - Configuration and built-in subagents (general-purpose, Plan, Explore, Bash, statusline-setup, Claude Code Guide) - Agent management with /agents command - Automatic delegation vs explicit invocation - Resumable agents and chaining - Persistent memory across agent sessions - Background subagents for parallel work - Worktree isolation for safe experimentation - Agent Teams (experimental feature) - Best practices and usage patterns * docs(vi): complete Module 04 Subagents translation Translate all 6 subagent example definitions: - code-reviewer.md: Expert code review specialist - debugger.md: Root cause analysis and debugging - documentation-writer.md: Technical documentation creation - implementation-agent.md: Full-stack feature implementation - secure-reviewer.md: Security-focused code review - test-engineer.md: Comprehensive test coverage Update translation queue progress: - Module 04: 7/7 files (100%) ✅ - Overall: 31/66 files (47%) - Next: Module 05 (MCP) * docs(vi): complete Module 05 MCP translation Translate comprehensive MCP (Model Context Protocol) guide: - Overview and architecture diagrams - Installation methods (HTTP, Stdio, SSE, WebSocket) - OAuth 2.0 authentication support - MCP tool search and dynamic updates - Elicitation and prompt slash commands - Configuration management (local, project, user scopes) - Practical examples (GitHub, Database, Filesystem, Slack MCPs) - MCP vs Memory decision matrix - Code execution approach for solving context bloat - MCPorter runtime reference - Security best practices and troubleshooting Translate 4 MCP configuration JSON examples: - filesystem-mcp.json: File operations - github-mcp.json: GitHub integration - database-mcp.json: SQL queries - multi-mcp.json: Multiple servers configuration Update translation queue progress: - Module 05: 5/5 files (100%) ✅ - Overall: 36/66 files (55%) - Next: Module 06 (Hooks) * docs(vi): complete Module 06 Hooks translation Translate comprehensive Hooks guide: - Overview and architecture - Configuration structure (user, project, local scopes) - Four hook types: command, HTTP, prompt, agent - 25 hook events with matcher patterns - Component-scoped hooks and subagent frontmatter - JSON input/output format and exit codes - Environment variables and best practices - Security considerations and debugging - Installation instructions Copy 9 hook example files (code, no translation needed): - format-code.sh: Auto-format code after write/edit - log-bash.sh: Bash command logging - notify-team.sh: Team notifications - pre-commit.sh: Pre-commit validation - security-scan.sh: Security scanning - validate-prompt.sh: Prompt validation - context-tracker.py: Token usage tracking - context-tracker-tiktoken.py: Accurate token counting Update translation queue progress: - Module 06: 9/9 files (100%) ✅ - Overall: 45/67 files (67%) - Next: Module 07 (Plugins) * docs(vi): complete Module 07 Plugins translation Translate comprehensive Plugins guide: - Overview and architecture diagrams - Plugin loading process and lifecycle - Plugin types (Official, Community, Organization, Personal) - Plugin definition structure and manifest - Plugin structure example with all directories - LSP server configuration and examples - Plugin options (v2.1.83+) with userConfig - Persistent plugin data via CLAUDE_PLUGIN_DATA - Inline plugins via settings (v2.1.80+) - Standalone vs Plugin approach comparison - Practical examples (PR Review, DevOps, Documentation plugins) - Plugin marketplace and distribution - Marketplace configuration and definition schema - Plugin source types and distribution methods - Plugin CLI commands and installation methods - Plugin features comparison table - Testing, hot-reload, and managed settings - Plugin security restrictions - Publishing workflow and best practices - Complete troubleshooting guide Copy all plugin subdirectories (code, no translation needed): - devops-automation/: Deployment workflows, incident management - documentation/: API docs, README generation, templates - pr-review/: Pull request review automation Update translation queue progress: - Module 07: 10+/10+ files (100%) ✅ - Overall: 55+/67 files (82%) - Next: Module 08-10 (Checkpoints, Advanced Features, CLI) * docs(vi): complete Modules 08-10 translation Module 08: Checkpoints and Rewind (2 files) - Overview and architecture - Access methods (keyboard shortcut, slash command) - Five rewind options (restore code+conversation, conversation, code, summarize, cancel) - Automatic checkpoints and retention policies - Use cases and practical examples - Integration with git and best practices Module 09: Advanced Features (3 files) - Planning Mode: Two-phase implementation approach - Extended Thinking: Deep reasoning for complex problems - Auto Mode: Background safety classifier - Background Tasks, Scheduled Tasks - Permission Modes (default, acceptEdits, plan, auto, dontAsk, bypassPermissions) - Print Mode, Session Management - Voice Dictation, Channels, Remote Control - Web Sessions, Desktop App, Task List - Git Worktrees, Sandboxing, Managed Settings Module 10: CLI Reference (1 file) - CLI commands reference - Core flags and options - Interactive vs Print Mode - Model selection and configuration - System prompt customization - Tool & permission management - Output formats and scripting Update translation queue progress: - Modules 08-10: 6/6 files (100%) ✅ - All Modules 01-10: 61+/61+ files (100%) ✅ - Overall: 61+/67 files (91%) - Remaining: Supporting docs (6 files) * docs(vi): complete supporting documentation translation Translate all 5 supporting documents: - CONTRIBUTING.md: Contribution guidelines - SECURITY.md: Security policy and vulnerability reporting - CODE_OF_CONDUCT.md: Community standards and harassment policy - STYLE_GUIDE.md: Documentation formatting conventions - CHANGELOG.md: Version history (v2.2.0, v2.1.1, v2.1.0, v2.0.0) Update translation queue to 100% completion: - All Modules 01-10: 61+ files (100%) ✅ - Supporting Docs: 5/5 files (100%) ✅ - Overall: 67+/67 files (100%) ✅ - Status: HOÀN THÀNH ✅ * fix(vi): fix mermaid syntax and broken cross-references in Vietnamese translation Fix invalid `#` comment in mermaid block (use `%%` instead) and update cross-reference script to support Unicode/Vietnamese diacritics in anchor generation. Fix broken anchor links across vi/ documentation files. * fix(vi): remove inline mermaid comment causing parse error Inline %% comments on node definition lines are not supported by the mermaid parser. The surrounding HTML comment already explains this is an incorrect example.
1091 lines
36 KiB
Python
Executable File
1091 lines
36 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = ["ebooklib", "markdown", "beautifulsoup4", "httpx", "pillow", "tenacity"]
|
|
# ///
|
|
"""
|
|
Build an EPUB from the Claude How-To markdown files.
|
|
|
|
Usage:
|
|
Run from the repository root directory:
|
|
./scripts/build_epub.py
|
|
|
|
Or run directly with Python/uv:
|
|
uv run scripts/build_epub.py
|
|
python scripts/build_epub.py
|
|
|
|
Command-line options:
|
|
--root, -r Root directory containing markdown files (default: repo root)
|
|
--output, -o Output EPUB file path (default: <root>/claude-howto-guide.epub)
|
|
--verbose, -v Enable verbose logging
|
|
--timeout Timeout for API requests in seconds (default: 30)
|
|
--max-concurrent Maximum concurrent API requests (default: 10)
|
|
|
|
The script uses inline script dependencies (PEP 723), so uv will
|
|
automatically install required packages in an isolated environment.
|
|
|
|
Output:
|
|
Creates 'claude-howto-guide.epub' in the repository root directory.
|
|
|
|
Features:
|
|
- Organizes chapters by folder structure (01-slash-commands, etc.)
|
|
- Renders Mermaid diagrams as PNG images via Kroki.io API (async concurrent)
|
|
- Generates a cover image from the project logo
|
|
- Converts internal markdown links to EPUB chapter references
|
|
- Handles SVG images by replacing with styled placeholders
|
|
- Strict error mode: fails if any diagram cannot be rendered
|
|
|
|
Requirements:
|
|
- uv (recommended) or Python 3.10+ with dependencies installed
|
|
- Internet connection for Mermaid diagram rendering
|
|
- Repository structure with markdown files and claude-howto-logo.png
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import html
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import zlib
|
|
from dataclasses import dataclass, field
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import markdown
|
|
from bs4 import BeautifulSoup
|
|
from ebooklib import epub
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from tenacity import (
|
|
retry,
|
|
retry_if_exception_type,
|
|
stop_after_attempt,
|
|
wait_exponential,
|
|
)
|
|
|
|
# =============================================================================
|
|
# Custom Exceptions
|
|
# =============================================================================
|
|
|
|
|
|
class EPUBBuildError(Exception):
|
|
"""Base exception for EPUB build errors."""
|
|
|
|
pass
|
|
|
|
|
|
class MermaidRenderError(EPUBBuildError):
|
|
"""Error rendering Mermaid diagram."""
|
|
|
|
pass
|
|
|
|
|
|
class ValidationError(EPUBBuildError):
|
|
"""Error validating input or output."""
|
|
|
|
pass
|
|
|
|
|
|
class CoverGenerationError(EPUBBuildError):
|
|
"""Error generating cover image."""
|
|
|
|
pass
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration and State
|
|
# =============================================================================
|
|
|
|
|
|
@dataclass
|
|
class EPUBConfig:
|
|
"""Configuration for EPUB generation."""
|
|
|
|
# Paths
|
|
root_path: Path
|
|
output_path: Path
|
|
logo_path: Path | None = None
|
|
|
|
# EPUB Metadata
|
|
identifier: str = "claude-howto-guide"
|
|
title: str = "Claude Code How-To Guide"
|
|
language: str = "en"
|
|
author: str = "Claude Code Community"
|
|
|
|
# Language-specific metadata
|
|
vi_title: str = "Hướng Dẫn Claude Code"
|
|
vi_subtitle: str = "Làm chủ Claude Code trong một cuối tuần"
|
|
en_title: str = "Claude Code How-To Guide"
|
|
en_subtitle: str = "Master Claude Code in a Weekend"
|
|
|
|
# Cover Settings
|
|
cover_width: int = 600
|
|
cover_height: int = 900
|
|
cover_bg_color: tuple[int, int, int] = (26, 26, 46)
|
|
cover_title_color: tuple[int, int, int] = (78, 205, 196)
|
|
cover_subtitle_color: tuple[int, int, int] = (168, 178, 209)
|
|
|
|
# Network Settings
|
|
kroki_base_url: str = "https://kroki.io"
|
|
request_timeout: float = 30.0
|
|
max_retries: int = 3
|
|
max_concurrent_requests: int = 10
|
|
|
|
# Font paths (platform-specific)
|
|
title_font_paths: list[str] = field(
|
|
default_factory=lambda: [
|
|
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
|
"/System/Library/Fonts/Helvetica.ttc",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Linux
|
|
"C:\\Windows\\Fonts\\arialbd.ttf", # Windows
|
|
]
|
|
)
|
|
subtitle_font_paths: list[str] = field(
|
|
default_factory=lambda: [
|
|
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
"/System/Library/Fonts/Helvetica.ttc",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux
|
|
"C:\\Windows\\Fonts\\arial.ttf", # Windows
|
|
]
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class BuildState:
|
|
"""Mutable state for the build process."""
|
|
|
|
mermaid_cache: dict[str, tuple[bytes, str]] = field(default_factory=dict)
|
|
mermaid_counter: int = 0
|
|
mermaid_added_to_book: set[str] = field(default_factory=set)
|
|
path_to_chapter: dict[str, str] = field(default_factory=dict)
|
|
|
|
def reset(self) -> None:
|
|
"""Reset all state for a fresh build."""
|
|
self.mermaid_cache.clear()
|
|
self.mermaid_counter = 0
|
|
self.mermaid_added_to_book.clear()
|
|
self.path_to_chapter.clear()
|
|
|
|
|
|
@dataclass
|
|
class ChapterInfo:
|
|
"""Information about a chapter for processing."""
|
|
|
|
file_path: Path
|
|
display_name: str
|
|
file_title: str
|
|
chapter_filename: str
|
|
is_folder_overview: bool = False
|
|
folder_name: str | None = None
|
|
|
|
|
|
# =============================================================================
|
|
# Logging Setup
|
|
# =============================================================================
|
|
|
|
|
|
def setup_logging(verbose: bool = False) -> logging.Logger:
|
|
"""Configure logging for the build process."""
|
|
level = logging.DEBUG if verbose else logging.INFO
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
return logging.getLogger("epub_builder")
|
|
|
|
|
|
# =============================================================================
|
|
# Input Validation
|
|
# =============================================================================
|
|
|
|
|
|
def validate_inputs(config: EPUBConfig, logger: logging.Logger) -> None:
|
|
"""Validate all inputs before starting the build."""
|
|
errors = []
|
|
|
|
# Check root path exists
|
|
if not config.root_path.exists():
|
|
errors.append(f"Root path does not exist: {config.root_path}")
|
|
elif not config.root_path.is_dir():
|
|
errors.append(f"Root path is not a directory: {config.root_path}")
|
|
|
|
# Check output path is writable
|
|
output_dir = config.output_path.parent
|
|
if not output_dir.exists():
|
|
errors.append(f"Output directory does not exist: {output_dir}")
|
|
elif not os.access(output_dir, os.W_OK):
|
|
errors.append(f"Output directory is not writable: {output_dir}")
|
|
|
|
# Check logo if specified
|
|
logo_path = config.logo_path or (config.root_path / "claude-howto-logo.png")
|
|
if not logo_path.exists():
|
|
logger.warning(
|
|
f"Logo file not found: {logo_path}. Cover will be generated without logo."
|
|
)
|
|
|
|
# Verify at least some markdown files exist
|
|
md_files = list(config.root_path.glob("**/*.md"))
|
|
if not md_files:
|
|
errors.append(f"No markdown files found in {config.root_path}")
|
|
|
|
if errors:
|
|
for error in errors:
|
|
logger.error(error)
|
|
raise ValidationError("\n".join(errors))
|
|
|
|
|
|
# =============================================================================
|
|
# Mermaid Rendering (Async with Retry)
|
|
# =============================================================================
|
|
|
|
|
|
def sanitize_mermaid(mermaid_code: str) -> str:
|
|
"""Sanitize mermaid code to avoid markdown parsing issues.
|
|
|
|
Mermaid's markdown-in-nodes feature incorrectly interprets numbered
|
|
lists (e.g., "1. Item") inside node labels. This escapes the period
|
|
to prevent that.
|
|
"""
|
|
# Escape numbered list patterns inside brackets: [1. Text] -> [1\. Text]
|
|
sanitized = re.sub(r'\[(["\']?)(\d+)\.(\s)', r"[\1\2\\.\3", mermaid_code)
|
|
return sanitized
|
|
|
|
|
|
class MermaidRenderer:
|
|
"""Async renderer for Mermaid diagrams via Kroki.io API."""
|
|
|
|
def __init__(
|
|
self, config: EPUBConfig, state: BuildState, logger: logging.Logger
|
|
) -> None:
|
|
self.config = config
|
|
self.state = state
|
|
self.logger = logger
|
|
self._semaphore: asyncio.Semaphore | None = None
|
|
|
|
async def _fetch_single(
|
|
self, client: httpx.AsyncClient, mermaid_code: str, index: int
|
|
) -> tuple[str, tuple[bytes, str]]:
|
|
"""Fetch a single Mermaid diagram with retry logic."""
|
|
cache_key = mermaid_code.strip()
|
|
|
|
# Check cache first
|
|
if cache_key in self.state.mermaid_cache:
|
|
self.logger.debug(f"Cache hit for diagram {index}")
|
|
return cache_key, self.state.mermaid_cache[cache_key]
|
|
|
|
# Rate limit with semaphore
|
|
assert self._semaphore is not None
|
|
async with self._semaphore:
|
|
result = await self._fetch_with_retry(client, mermaid_code, index)
|
|
if result is None:
|
|
raise MermaidRenderError(
|
|
f"Failed to render Mermaid diagram {index} after {self.config.max_retries} attempts"
|
|
)
|
|
return cache_key, result
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
|
reraise=True,
|
|
)
|
|
async def _fetch_with_retry(
|
|
self, client: httpx.AsyncClient, mermaid_code: str, index: int
|
|
) -> tuple[bytes, str] | None:
|
|
"""Fetch diagram with retry logic."""
|
|
try:
|
|
compressed = zlib.compress(mermaid_code.encode("utf-8"), level=9)
|
|
encoded = base64.urlsafe_b64encode(compressed).decode("ascii")
|
|
url = f"{self.config.kroki_base_url}/mermaid/png/{encoded}"
|
|
|
|
self.logger.debug(f"Fetching diagram {index}...")
|
|
response = await client.get(url, timeout=self.config.request_timeout)
|
|
|
|
if response.status_code == 200:
|
|
self.state.mermaid_counter += 1
|
|
img_name = f"mermaid_{self.state.mermaid_counter}.png"
|
|
result = (response.content, img_name)
|
|
cache_key = mermaid_code.strip()
|
|
self.state.mermaid_cache[cache_key] = result
|
|
self.logger.info(f"Rendered diagram {index} -> {img_name}")
|
|
return result
|
|
else:
|
|
self.logger.warning(
|
|
f"Kroki API returned {response.status_code} for diagram {index}"
|
|
)
|
|
raise MermaidRenderError(
|
|
f"Kroki API returned {response.status_code} for diagram {index}"
|
|
)
|
|
|
|
except httpx.TimeoutException:
|
|
self.logger.warning(f"Timeout fetching diagram {index}, will retry...")
|
|
raise
|
|
except httpx.NetworkError as e:
|
|
self.logger.warning(
|
|
f"Network error for diagram {index}: {e}, will retry..."
|
|
)
|
|
raise
|
|
|
|
async def render_all(
|
|
self, diagrams: list[tuple[int, str]]
|
|
) -> dict[str, tuple[bytes, str]]:
|
|
"""Render all Mermaid diagrams concurrently."""
|
|
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_requests)
|
|
results: dict[str, tuple[bytes, str]] = {}
|
|
|
|
async with httpx.AsyncClient(
|
|
follow_redirects=True,
|
|
limits=httpx.Limits(max_connections=self.config.max_concurrent_requests),
|
|
timeout=httpx.Timeout(self.config.request_timeout),
|
|
) as client:
|
|
tasks = [
|
|
self._fetch_single(client, sanitize_mermaid(code), idx)
|
|
for idx, code in diagrams
|
|
]
|
|
|
|
self.logger.info(f"Fetching {len(tasks)} Mermaid diagrams concurrently...")
|
|
|
|
# Use gather with return_exceptions=False for strict mode
|
|
completed = await asyncio.gather(*tasks)
|
|
|
|
for cache_key, data in completed:
|
|
results[cache_key] = data
|
|
|
|
success_count = len(results)
|
|
self.logger.info(
|
|
f"Successfully rendered {success_count}/{len(diagrams)} diagrams"
|
|
)
|
|
return results
|
|
|
|
|
|
def extract_all_mermaid_blocks(
|
|
md_files: list[tuple[Path, str]], logger: logging.Logger
|
|
) -> list[tuple[int, str]]:
|
|
"""Extract all unique Mermaid code blocks from markdown files."""
|
|
pattern = r"```mermaid\n(.*?)```"
|
|
seen: set[str] = set()
|
|
diagrams: list[tuple[int, str]] = []
|
|
counter = 0
|
|
|
|
for file_path, _ in md_files:
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
for match in re.finditer(pattern, content, flags=re.DOTALL):
|
|
code = match.group(1).strip()
|
|
if code not in seen:
|
|
seen.add(code)
|
|
counter += 1
|
|
diagrams.append((counter, code))
|
|
except UnicodeDecodeError as e:
|
|
logger.warning(f"Failed to read {file_path}: {e}")
|
|
|
|
logger.info(f"Found {len(diagrams)} unique Mermaid diagrams")
|
|
return diagrams
|
|
|
|
|
|
# =============================================================================
|
|
# Chapter Collection (Single-Pass)
|
|
# =============================================================================
|
|
|
|
|
|
def get_chapter_order() -> list[tuple[str, str]]:
|
|
"""Define the order of chapters based on folder structure."""
|
|
return [
|
|
("README.md", "Introduction"),
|
|
("LEARNING-ROADMAP.md", "Learning Roadmap"),
|
|
("QUICK_REFERENCE.md", "Quick Reference"),
|
|
("claude_concepts_guide.md", "Claude Concepts Guide"),
|
|
("01-slash-commands", "Slash Commands"),
|
|
("02-memory", "Memory"),
|
|
("03-skills", "Skills"),
|
|
("04-subagents", "Subagents"),
|
|
("05-mcp", "MCP Protocol"),
|
|
("06-hooks", "Hooks"),
|
|
("07-plugins", "Plugins"),
|
|
("08-checkpoints", "Checkpoints"),
|
|
("09-advanced-features", "Advanced Features"),
|
|
("resources.md", "Resources"),
|
|
]
|
|
|
|
|
|
def collect_folder_files(folder_path: Path) -> list[tuple[Path, str]]:
|
|
"""Collect all markdown files from a folder, README first."""
|
|
files: list[tuple[Path, str]] = []
|
|
|
|
# Get README first if it exists
|
|
readme = folder_path / "README.md"
|
|
if readme.exists():
|
|
files.append((readme, "Overview"))
|
|
|
|
# Get all other markdown files
|
|
for md_file in sorted(folder_path.glob("*.md")):
|
|
if md_file.name != "README.md":
|
|
title = md_file.stem.replace("-", " ").replace("_", " ").title()
|
|
files.append((md_file, title))
|
|
|
|
# Recursively get subfolders
|
|
for subfolder in sorted(folder_path.iterdir()):
|
|
if subfolder.is_dir() and not subfolder.name.startswith("."):
|
|
subfiles = collect_folder_files(subfolder)
|
|
for sf, st in subfiles:
|
|
rel_path = sf.relative_to(folder_path)
|
|
if len(rel_path.parts) > 1:
|
|
prefix = (
|
|
rel_path.parts[0].replace("-", " ").replace("_", " ").title()
|
|
)
|
|
files.append((sf, f"{prefix}: {st}"))
|
|
else:
|
|
files.append((sf, st))
|
|
|
|
return files
|
|
|
|
|
|
class ChapterCollector:
|
|
"""Collects and organizes chapter information in a single pass."""
|
|
|
|
def __init__(self, root_path: Path, state: BuildState) -> None:
|
|
self.root_path = root_path
|
|
self.state = state
|
|
|
|
def collect_all_chapters(
|
|
self, chapter_order: list[tuple[str, str]]
|
|
) -> list[ChapterInfo]:
|
|
"""Collect all chapters and build path mapping in one pass."""
|
|
chapters: list[ChapterInfo] = []
|
|
chapter_num = 0
|
|
|
|
for item, display_name in chapter_order:
|
|
item_path = self.root_path / item
|
|
|
|
if item_path.is_file() and item_path.suffix == ".md":
|
|
chapter_num += 1
|
|
chapter_filename = f"chap_{chapter_num:02d}.xhtml"
|
|
self.state.path_to_chapter[item] = chapter_filename
|
|
|
|
chapters.append(
|
|
ChapterInfo(
|
|
file_path=item_path,
|
|
display_name=display_name,
|
|
file_title=display_name,
|
|
chapter_filename=chapter_filename,
|
|
)
|
|
)
|
|
|
|
elif item_path.is_dir():
|
|
folder_chapters = self._collect_folder(
|
|
item_path, item, display_name, chapter_num
|
|
)
|
|
if folder_chapters:
|
|
chapter_num += 1
|
|
chapters.extend(folder_chapters)
|
|
|
|
return chapters
|
|
|
|
def _collect_folder(
|
|
self, folder_path: Path, item: str, display_name: str, base_chapter_num: int
|
|
) -> list[ChapterInfo]:
|
|
"""Collect chapters from a folder."""
|
|
folder_files = collect_folder_files(folder_path)
|
|
if not folder_files:
|
|
return []
|
|
|
|
chapter_num = base_chapter_num + 1
|
|
chapters: list[ChapterInfo] = []
|
|
|
|
# Map folder itself
|
|
first_filename = f"chap_{chapter_num:02d}_00.xhtml"
|
|
self.state.path_to_chapter[item] = first_filename
|
|
self.state.path_to_chapter[item.rstrip("/")] = first_filename
|
|
|
|
for i, (file_path, file_title) in enumerate(folder_files):
|
|
chapter_filename = f"chap_{chapter_num:02d}_{i:02d}.xhtml"
|
|
rel_path = str(file_path.relative_to(self.root_path))
|
|
self.state.path_to_chapter[rel_path] = chapter_filename
|
|
|
|
chapters.append(
|
|
ChapterInfo(
|
|
file_path=file_path,
|
|
display_name=display_name if i == 0 else file_title,
|
|
file_title=file_title,
|
|
chapter_filename=chapter_filename,
|
|
is_folder_overview=(i == 0),
|
|
folder_name=display_name,
|
|
)
|
|
)
|
|
|
|
return chapters
|
|
|
|
|
|
# =============================================================================
|
|
# Cover Image Generation
|
|
# =============================================================================
|
|
|
|
|
|
def load_font(
|
|
font_paths: list[str], size: int, logger: logging.Logger
|
|
) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
"""Load a font from a list of paths, with fallback to default."""
|
|
for font_path in font_paths:
|
|
try:
|
|
font = ImageFont.truetype(font_path, size)
|
|
logger.debug(f"Loaded font: {font_path}")
|
|
return font
|
|
except OSError:
|
|
continue
|
|
|
|
logger.warning("No custom fonts found, using default font")
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _add_logo_to_cover(
|
|
cover: Image.Image, logo_path: Path, config: EPUBConfig, logger: logging.Logger
|
|
) -> None:
|
|
"""Add logo to cover image."""
|
|
with Image.open(logo_path) as logo:
|
|
target_width = config.cover_width - 60
|
|
scale_factor = target_width / logo.width
|
|
new_height = int(logo.height * scale_factor)
|
|
logo_scaled = logo.resize((target_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
if logo_scaled.mode == "RGBA":
|
|
logo_bg = Image.new("RGB", logo_scaled.size, config.cover_bg_color)
|
|
logo_bg.paste(logo_scaled, mask=logo_scaled.split()[3])
|
|
logo_scaled = logo_bg
|
|
elif logo_scaled.mode != "RGB":
|
|
logo_scaled = logo_scaled.convert("RGB")
|
|
|
|
logo_x = (config.cover_width - logo_scaled.width) // 2
|
|
logo_y = config.cover_height - logo_scaled.height - 80
|
|
cover.paste(logo_scaled, (logo_x, logo_y))
|
|
logger.debug(f"Added logo from {logo_path}")
|
|
|
|
|
|
def _draw_text_centered(
|
|
draw: ImageDraw.ImageDraw,
|
|
text: str,
|
|
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
|
color: tuple[int, int, int],
|
|
canvas_width: int,
|
|
y_start: int,
|
|
line_spacing: int,
|
|
) -> int:
|
|
"""Draw centered multi-line text, return final y position."""
|
|
y_offset = y_start
|
|
for line in text.split("\n"):
|
|
bbox = draw.textbbox((0, 0), line, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
x = (canvas_width - text_width) // 2
|
|
draw.text((x, y_offset), line, font=font, fill=color)
|
|
y_offset += line_spacing
|
|
return y_offset
|
|
|
|
|
|
def create_cover_image(
|
|
config: EPUBConfig,
|
|
logger: logging.Logger,
|
|
title: str = "Claude Code\nHow-To Guide",
|
|
subtitle: str = "Complete Guide to Claude Code Features",
|
|
) -> bytes:
|
|
"""Create a cover image with proper error handling."""
|
|
try:
|
|
cover = Image.new(
|
|
"RGB", (config.cover_width, config.cover_height), config.cover_bg_color
|
|
)
|
|
draw = ImageDraw.Draw(cover)
|
|
|
|
# Load fonts once
|
|
title_font = load_font(config.title_font_paths, 72, logger)
|
|
subtitle_font = load_font(config.subtitle_font_paths, 24, logger)
|
|
|
|
# Add logo if available
|
|
logo_path = config.logo_path or (config.root_path / "claude-howto-logo.png")
|
|
if logo_path.exists():
|
|
_add_logo_to_cover(cover, logo_path, config, logger)
|
|
else:
|
|
logger.warning("Logo not found, creating text-only cover")
|
|
|
|
# Draw title
|
|
y_after_title = _draw_text_centered(
|
|
draw,
|
|
title,
|
|
title_font,
|
|
config.cover_title_color,
|
|
config.cover_width,
|
|
y_start=120,
|
|
line_spacing=90,
|
|
)
|
|
|
|
# Draw subtitle
|
|
_draw_text_centered(
|
|
draw,
|
|
subtitle,
|
|
subtitle_font,
|
|
config.cover_subtitle_color,
|
|
config.cover_width,
|
|
y_start=y_after_title + 20,
|
|
line_spacing=30,
|
|
)
|
|
|
|
buffer = BytesIO()
|
|
cover.save(buffer, format="PNG", optimize=True)
|
|
logger.info("Cover image generated successfully")
|
|
return buffer.getvalue()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to create cover image: {e}")
|
|
raise CoverGenerationError(f"Cover generation failed: {e}") from e
|
|
|
|
|
|
# =============================================================================
|
|
# HTML Generation
|
|
# =============================================================================
|
|
|
|
|
|
def create_chapter_html(
|
|
display_name: str, file_title: str, html_content: str, is_overview: bool = False
|
|
) -> str:
|
|
"""Create chapter HTML with proper escaping."""
|
|
safe_display = html.escape(display_name)
|
|
safe_title = html.escape(file_title)
|
|
|
|
if is_overview:
|
|
return f"""<!DOCTYPE html>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>{safe_display}</title>
|
|
</head>
|
|
<body>
|
|
<h1>{safe_display}</h1>
|
|
{html_content}
|
|
</body>
|
|
</html>"""
|
|
else:
|
|
return f"""<!DOCTYPE html>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>{safe_title}</title>
|
|
</head>
|
|
<body>
|
|
<h2>{safe_title}</h2>
|
|
{html_content}
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def handle_svg_image(src: str, alt: str, logger: logging.Logger) -> str:
|
|
"""Handle SVG images with a styled placeholder."""
|
|
placeholder = f"""
|
|
<div class="svg-placeholder" style="
|
|
border: 1px dashed #ccc;
|
|
padding: 1em;
|
|
text-align: center;
|
|
background: #f9f9f9;
|
|
border-radius: 4px;
|
|
margin: 1em 0;
|
|
">
|
|
<p><em>[SVG Image: {html.escape(alt)}]</em></p>
|
|
<p style="font-size: 0.8em; color: #666;">
|
|
Original: {html.escape(src)}
|
|
</p>
|
|
</div>
|
|
"""
|
|
logger.debug(f"Replaced SVG image: {src}")
|
|
return placeholder
|
|
|
|
|
|
# =============================================================================
|
|
# Markdown Processing
|
|
# =============================================================================
|
|
|
|
|
|
def process_mermaid_blocks(
|
|
md_content: str, book: epub.EpubBook, state: BuildState, logger: logging.Logger
|
|
) -> str:
|
|
"""Find mermaid code blocks and replace with image references."""
|
|
pattern = r"```mermaid\n(.*?)```"
|
|
|
|
def replace_mermaid(match: re.Match[str]) -> str:
|
|
mermaid_code = sanitize_mermaid(match.group(1))
|
|
cache_key = mermaid_code.strip()
|
|
|
|
if cache_key in state.mermaid_cache:
|
|
img_data, img_name = state.mermaid_cache[cache_key]
|
|
# Only add image to book if not already added
|
|
if img_name not in state.mermaid_added_to_book:
|
|
img_item = epub.EpubItem(
|
|
uid=img_name.replace(".", "_"),
|
|
file_name=f"images/{img_name}",
|
|
media_type="image/png",
|
|
content=img_data,
|
|
)
|
|
book.add_item(img_item)
|
|
state.mermaid_added_to_book.add(img_name)
|
|
return f"\n\n"
|
|
else:
|
|
# This should not happen in strict mode since we pre-fetch all diagrams
|
|
logger.error("Mermaid diagram not found in cache")
|
|
raise MermaidRenderError("Mermaid diagram not found in cache")
|
|
|
|
return re.sub(pattern, replace_mermaid, md_content, flags=re.DOTALL)
|
|
|
|
|
|
def convert_internal_links(
|
|
html_content: str, current_file: Path, root_path: Path, state: BuildState
|
|
) -> str:
|
|
"""Convert markdown links to internal EPUB chapter links."""
|
|
soup = BeautifulSoup(html_content, "html.parser")
|
|
|
|
for link in soup.find_all("a"):
|
|
href = link.get("href", "")
|
|
if not href or href.startswith(("http://", "https://", "mailto:", "#")):
|
|
continue
|
|
|
|
# Remove anchor part for path resolution
|
|
anchor = ""
|
|
if "#" in href:
|
|
href, anchor = href.split("#", 1)
|
|
anchor = "#" + anchor
|
|
|
|
# Resolve relative path from current file's directory
|
|
if href:
|
|
resolved = (current_file.parent / href).resolve()
|
|
try:
|
|
rel_to_root = resolved.relative_to(root_path)
|
|
except ValueError:
|
|
# Link points outside the repo
|
|
continue
|
|
|
|
# Normalize the path for lookup
|
|
lookup_path = str(rel_to_root)
|
|
|
|
# Try various path forms for matching
|
|
paths_to_try = [
|
|
lookup_path,
|
|
lookup_path.rstrip("/"),
|
|
lookup_path + "/README.md"
|
|
if not lookup_path.endswith(".md")
|
|
else lookup_path,
|
|
]
|
|
|
|
for path in paths_to_try:
|
|
if path in state.path_to_chapter:
|
|
link["href"] = state.path_to_chapter[path] + anchor
|
|
break
|
|
|
|
return str(soup)
|
|
|
|
|
|
def md_to_html(
|
|
md_content: str,
|
|
current_file: Path,
|
|
root_path: Path,
|
|
book: epub.EpubBook,
|
|
state: BuildState,
|
|
logger: logging.Logger,
|
|
) -> str:
|
|
"""Convert markdown to HTML with proper styling.
|
|
|
|
Handles:
|
|
- Mermaid diagrams (rendered as PNG images)
|
|
- SVG images (replaced with styled placeholders)
|
|
- Internal links (converted to EPUB chapter references)
|
|
- Standard markdown features
|
|
"""
|
|
# Process mermaid blocks first (before markdown conversion)
|
|
md_content = process_mermaid_blocks(md_content, book, state, logger)
|
|
|
|
# Convert markdown to HTML
|
|
html_content = markdown.markdown(
|
|
md_content,
|
|
extensions=[
|
|
"tables",
|
|
"fenced_code",
|
|
"codehilite",
|
|
"toc",
|
|
],
|
|
)
|
|
|
|
# Clean up any SVG references (they won't work in EPUB)
|
|
soup = BeautifulSoup(html_content, "html.parser")
|
|
for img in soup.find_all("img"):
|
|
src = img.get("src", "")
|
|
if src.endswith(".svg"):
|
|
alt = img.get("alt", "Image")
|
|
placeholder = handle_svg_image(src, alt, logger)
|
|
img.replace_with(BeautifulSoup(placeholder, "html.parser"))
|
|
|
|
html_content = str(soup)
|
|
|
|
# Convert internal links to EPUB chapter references
|
|
html_content = convert_internal_links(html_content, current_file, root_path, state)
|
|
|
|
return html_content
|
|
|
|
|
|
# =============================================================================
|
|
# EPUB Generation
|
|
# =============================================================================
|
|
|
|
|
|
def create_stylesheet() -> epub.EpubItem:
|
|
"""Create the EPUB stylesheet."""
|
|
style = """
|
|
body { font-family: Georgia, serif; line-height: 1.6; padding: 1em; }
|
|
h1 { color: #333; border-bottom: 2px solid #e67e22; padding-bottom: 0.3em; }
|
|
h2 { color: #444; margin-top: 1.5em; }
|
|
h3 { color: #555; }
|
|
code { background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; }
|
|
pre { background: #f4f4f4; padding: 1em; overflow-x: auto; border-radius: 5px; }
|
|
pre code { background: none; padding: 0; }
|
|
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
|
th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; }
|
|
th { background: #f4f4f4; }
|
|
blockquote { border-left: 4px solid #e67e22; margin: 1em 0; padding-left: 1em; color: #666; }
|
|
a { color: #e67e22; }
|
|
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
|
|
.diagram { text-align: center; margin: 1.5em 0; }
|
|
.svg-placeholder { border: 1px dashed #ccc; padding: 1em; text-align: center; background: #f9f9f9; border-radius: 4px; margin: 1em 0; }
|
|
"""
|
|
return epub.EpubItem(
|
|
uid="style_nav",
|
|
file_name="style/nav.css",
|
|
media_type="text/css",
|
|
content=style,
|
|
)
|
|
|
|
|
|
async def build_epub_async(
|
|
config: EPUBConfig,
|
|
logger: logging.Logger,
|
|
state: BuildState | None = None,
|
|
) -> Path:
|
|
"""Build EPUB asynchronously with concurrent diagram fetching."""
|
|
state = state or BuildState()
|
|
state.reset() # Ensure clean state
|
|
|
|
# Validate inputs
|
|
validate_inputs(config, logger)
|
|
|
|
# Initialize book
|
|
book = epub.EpubBook()
|
|
book.set_identifier(config.identifier)
|
|
book.set_title(config.title)
|
|
book.set_language(config.language)
|
|
book.add_author(config.author)
|
|
|
|
# Add cover
|
|
logger.info("Generating cover image...")
|
|
cover_data = create_cover_image(config, logger)
|
|
book.set_cover("cover.png", cover_data)
|
|
|
|
# Add CSS
|
|
nav_css = create_stylesheet()
|
|
book.add_item(nav_css)
|
|
|
|
# Collect all chapters in single pass
|
|
logger.info("Collecting chapters...")
|
|
collector = ChapterCollector(config.root_path, state)
|
|
chapter_infos = collector.collect_all_chapters(get_chapter_order())
|
|
|
|
# Extract and pre-fetch all Mermaid diagrams
|
|
logger.info("Extracting Mermaid diagrams...")
|
|
md_files = [(ch.file_path, ch.file_title) for ch in chapter_infos]
|
|
all_diagrams = extract_all_mermaid_blocks(md_files, logger)
|
|
|
|
if all_diagrams:
|
|
renderer = MermaidRenderer(config, state, logger)
|
|
await renderer.render_all(all_diagrams)
|
|
|
|
# Process chapters
|
|
logger.info("Processing chapters...")
|
|
chapters: list[epub.EpubHtml] = []
|
|
toc: list[epub.EpubHtml | tuple[epub.Section, list[epub.EpubHtml]]] = []
|
|
|
|
current_folder: str | None = None
|
|
current_folder_chapters: list[epub.EpubHtml] = []
|
|
|
|
for chapter_info in chapter_infos:
|
|
try:
|
|
content = chapter_info.file_path.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError as e:
|
|
logger.error(f"Failed to read {chapter_info.file_path}: {e}")
|
|
raise ValidationError(
|
|
f"Failed to read {chapter_info.file_path}: {e}"
|
|
) from e
|
|
|
|
logger.debug(
|
|
f"Processing: {chapter_info.file_path.relative_to(config.root_path)}"
|
|
)
|
|
html_content = md_to_html(
|
|
content, chapter_info.file_path, config.root_path, book, state, logger
|
|
)
|
|
|
|
chapter = epub.EpubHtml(
|
|
title=chapter_info.file_title,
|
|
file_name=chapter_info.chapter_filename,
|
|
lang="en",
|
|
)
|
|
|
|
chapter.content = create_chapter_html(
|
|
chapter_info.display_name,
|
|
chapter_info.file_title,
|
|
html_content,
|
|
is_overview=chapter_info.is_folder_overview
|
|
or chapter_info.folder_name is None,
|
|
)
|
|
chapter.add_item(nav_css)
|
|
book.add_item(chapter)
|
|
chapters.append(chapter)
|
|
|
|
# Build TOC structure
|
|
if chapter_info.folder_name is None:
|
|
# Single file chapter
|
|
if current_folder is not None:
|
|
# Finish previous folder
|
|
toc.append(
|
|
(epub.Section(current_folder), current_folder_chapters.copy())
|
|
)
|
|
current_folder_chapters.clear()
|
|
current_folder = None
|
|
toc.append(chapter)
|
|
else:
|
|
# Part of a folder
|
|
if current_folder != chapter_info.folder_name:
|
|
if current_folder is not None:
|
|
# Finish previous folder
|
|
toc.append(
|
|
(epub.Section(current_folder), current_folder_chapters.copy())
|
|
)
|
|
current_folder_chapters.clear()
|
|
current_folder = chapter_info.folder_name
|
|
current_folder_chapters.append(chapter)
|
|
|
|
# Handle last folder
|
|
if current_folder is not None and current_folder_chapters:
|
|
toc.append((epub.Section(current_folder), current_folder_chapters))
|
|
|
|
# Set table of contents
|
|
book.toc = toc
|
|
|
|
# Add navigation files
|
|
book.add_item(epub.EpubNcx())
|
|
book.add_item(epub.EpubNav())
|
|
|
|
# Set spine
|
|
book.spine = ["nav"] + chapters
|
|
|
|
# Write EPUB
|
|
logger.info(f"Writing EPUB to {config.output_path}...")
|
|
epub.write_epub(str(config.output_path), book, {})
|
|
|
|
logger.info(f"EPUB created successfully: {config.output_path}")
|
|
return config.output_path
|
|
|
|
|
|
def create_epub(root_path: Path, output_path: Path, verbose: bool = False) -> Path:
|
|
"""Synchronous wrapper for backward compatibility."""
|
|
logger = setup_logging(verbose)
|
|
config = EPUBConfig(root_path=root_path, output_path=output_path)
|
|
return asyncio.run(build_epub_async(config, logger))
|
|
|
|
|
|
# =============================================================================
|
|
# CLI
|
|
# =============================================================================
|
|
|
|
|
|
def main() -> int:
|
|
"""Main entry point with CLI argument parsing."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Build an EPUB from Claude How-To markdown files."
|
|
)
|
|
parser.add_argument(
|
|
"--root",
|
|
"-r",
|
|
type=Path,
|
|
default=None,
|
|
help="Root directory containing markdown files (default: repo root)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
"-o",
|
|
type=Path,
|
|
default=None,
|
|
help="Output EPUB file path (default: <root>/claude-howto-guide.epub)",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v", action="store_true", help="Enable verbose logging"
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=30.0,
|
|
help="Timeout for API requests in seconds (default: 30)",
|
|
)
|
|
parser.add_argument(
|
|
"--max-concurrent",
|
|
type=int,
|
|
default=10,
|
|
help="Maximum concurrent API requests (default: 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--lang",
|
|
type=str,
|
|
default="en",
|
|
choices=["en", "vi"],
|
|
help="Language code: 'en' for English, 'vi' for Vietnamese (default: en)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Determine root path and language-specific settings
|
|
repo_root = args.root if args.root else Path(__file__).parent.parent
|
|
repo_root = repo_root.resolve()
|
|
|
|
# Set language-specific paths and metadata
|
|
if args.lang == "vi":
|
|
root = repo_root / "vi"
|
|
output = args.output or (repo_root / "claude-howto-guide-vi.epub")
|
|
title = EPUBConfig.vi_title
|
|
language = "vi"
|
|
else:
|
|
root = repo_root
|
|
output = args.output or (repo_root / "claude-howto-guide.epub")
|
|
title = EPUBConfig.en_title
|
|
language = "en"
|
|
|
|
root = root.resolve()
|
|
output = output.resolve()
|
|
|
|
logger = setup_logging(args.verbose)
|
|
config = EPUBConfig(
|
|
root_path=root,
|
|
output_path=output,
|
|
language=language,
|
|
title=title,
|
|
request_timeout=args.timeout,
|
|
max_concurrent_requests=args.max_concurrent,
|
|
)
|
|
|
|
try:
|
|
result = asyncio.run(build_epub_async(config, logger))
|
|
print(f"Successfully created: {result}")
|
|
return 0
|
|
except EPUBBuildError as e:
|
|
logger.error(f"Build failed: {e}")
|
|
return 1
|
|
except KeyboardInterrupt:
|
|
logger.warning("Build interrupted by user")
|
|
return 130
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|