diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1a404a7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Build and Release EPUB + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Build EPUB + run: uv run scripts/build_epub.py + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: claude-howto-guide.epub + generate_release_notes: true diff --git a/.gitignore b/.gitignore index f5d629a..5f5a63c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,7 @@ yarn-error.log* *.temp # Keep .claude/ clean for hand-on section .claude/ -blog-posts/ \ No newline at end of file +blog-posts/ + +# EPUB files in root directory +/*.epub \ No newline at end of file diff --git a/scripts/build_epub.py b/scripts/build_epub.py new file mode 100755 index 0000000..49b39bd --- /dev/null +++ b/scripts/build_epub.py @@ -0,0 +1,545 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["ebooklib", "markdown", "beautifulsoup4", "httpx", "pillow"] +# /// +""" +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 + + 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 + - Generates a cover image from the project logo + - Converts internal markdown links to EPUB chapter references + - Handles SVG images by replacing with alt text (unsupported in EPUB) + +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 +""" + +import base64 +import re +import zlib +from pathlib import Path +import httpx +from io import BytesIO +from PIL import Image, ImageDraw, ImageFont +from ebooklib import epub +import markdown +from bs4 import BeautifulSoup + + +# Cache for mermaid images to avoid re-fetching (stores (image_data, filename) tuples) +_mermaid_cache: dict[str, tuple[bytes, str]] = {} +_mermaid_counter = 0 + +# Track which mermaid images have been added to the book +_mermaid_added_to_book: set[str] = set() + +# Mapping from source paths to EPUB chapter filenames +_path_to_chapter: dict[str, str] = {} + + +def mermaid_to_image(mermaid_code: str) -> tuple[bytes, str] | None: + """Convert mermaid code to PNG image using Kroki.io API.""" + global _mermaid_counter + + # Check cache - return cached image data and filename to avoid duplicates + cache_key = mermaid_code.strip() + if cache_key in _mermaid_cache: + return _mermaid_cache[cache_key] + + try: + # Use Kroki.io API - accepts deflate-compressed, base64-encoded diagrams + compressed = zlib.compress(mermaid_code.encode('utf-8'), level=9) + encoded = base64.urlsafe_b64encode(compressed).decode('ascii') + url = f"https://kroki.io/mermaid/png/{encoded}" + + response = httpx.get(url, timeout=30, follow_redirects=True) + if response.status_code == 200: + _mermaid_counter += 1 + img_data = response.content + img_name = f"mermaid_{_mermaid_counter}.png" + _mermaid_cache[cache_key] = (img_data, img_name) + return img_data, img_name + else: + print(f" Warning: Kroki API returned {response.status_code}") + return None + except Exception as e: + print(f" Warning: Failed to render mermaid diagram: {e}") + return None + + +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] + # Match patterns like [1. or ["1. inside node definitions + sanitized = re.sub(r'\[(["\']?)(\d+)\.(\s)', r'[\1\2\\.\3', mermaid_code) + return sanitized + + +def process_mermaid_blocks(md_content: str, book: epub.EpubBook) -> str: + """Find mermaid code blocks and replace with image references.""" + # Pattern to match ```mermaid ... ``` + pattern = r'```mermaid\n(.*?)```' + + def replace_mermaid(match): + mermaid_code = sanitize_mermaid(match.group(1)) + result = mermaid_to_image(mermaid_code) + if result: + img_data, img_name = result + # Only add image to book if not already added + if img_name not in _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) + _mermaid_added_to_book.add(img_name) + # Return markdown image reference + return f'\n\n' + else: + # Fallback: show as code block with a note + return f'\n**[Diagram]**\n```\n{mermaid_code}```\n' + + return re.sub(pattern, replace_mermaid, md_content, flags=re.DOTALL) + + +def create_cover_image(logo_path: Path, title: str = "Claude Code\nHow-To Guide") -> bytes: + """Create a cover image by composing the logo with title text on top. + + Args: + logo_path: Path to the PNG logo file + title: Title text to overlay on the cover + + Returns: + PNG image data as bytes + """ + # Target cover dimensions (standard ebook cover ratio ~1.6:1 height:width) + cover_width = 600 + cover_height = 900 + + # Background color matching the logo gradient + bg_color = (26, 26, 46) # #1a1a2e from the logo + + # Create the cover canvas + cover = Image.new('RGB', (cover_width, cover_height), bg_color) + draw = ImageDraw.Draw(cover) + + # Load and scale the logo + with Image.open(logo_path) as logo: + # Scale logo to fit cover width with some padding + target_width = cover_width - 60 # 30px padding on each side + scale_factor = target_width / logo.width + new_height = int(logo.height * scale_factor) + logo_scaled = logo.resize((target_width, new_height), Image.Resampling.LANCZOS) + + # Handle transparency + if logo_scaled.mode == 'RGBA': + # Composite onto a background matching cover color + logo_bg = Image.new('RGB', logo_scaled.size, 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') + + # Position the logo in the lower portion of the cover + logo_x = (cover_width - logo_scaled.width) // 2 + logo_y = cover_height - logo_scaled.height - 80 # 80px from bottom + cover.paste(logo_scaled, (logo_x, logo_y)) + + # Add title text at the top + # Try to use a nice font, fall back to default + font_size = 72 + font = ImageFont.load_default() + try: + # Try common system fonts on macOS + for font_name in [ + '/System/Library/Fonts/Supplemental/Arial Bold.ttf', + '/System/Library/Fonts/Helvetica.ttc', + 'Arial Bold', + 'Helvetica Bold', + ]: + try: + font = ImageFont.truetype(font_name, font_size) + break + except OSError: + continue + except Exception: + pass + + # Draw title text (centered, near top) + title_color = (78, 205, 196) # #4ecdc4 - teal from the logo gradient + + # Split title into lines and draw each centered + lines = title.split('\n') + y_offset = 120 + line_spacing = 90 + + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + text_width = bbox[2] - bbox[0] + x = (cover_width - text_width) // 2 + draw.text((x, y_offset), line, font=font, fill=title_color) + y_offset += line_spacing + + # Add a subtle subtitle + subtitle = "Complete Guide to Claude Code Features" + subtitle_font_size = 24 + subtitle_font = ImageFont.load_default() + try: + for font_name in [ + '/System/Library/Fonts/Supplemental/Arial.ttf', + '/System/Library/Fonts/Helvetica.ttc', + 'Arial', + 'Helvetica', + ]: + try: + subtitle_font = ImageFont.truetype(font_name, subtitle_font_size) + break + except OSError: + continue + except Exception: + pass + + subtitle_color = (168, 178, 209) # #a8b2d1 - light gray from logo + bbox = draw.textbbox((0, 0), subtitle, font=subtitle_font) + subtitle_width = bbox[2] - bbox[0] + subtitle_x = (cover_width - subtitle_width) // 2 + subtitle_y = y_offset + 20 + draw.text((subtitle_x, subtitle_y), subtitle, font=subtitle_font, fill=subtitle_color) + + # Save to bytes + buffer = BytesIO() + cover.save(buffer, format='PNG', optimize=True) + return buffer.getvalue() + + +def get_chapter_order(): + """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 convert_internal_links(html: str, current_file: Path, root_path: Path) -> str: + """Convert markdown links to internal EPUB chapter links.""" + soup = BeautifulSoup(html, '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 _path_to_chapter: + link['href'] = _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) -> str: + """Convert markdown to HTML with proper styling. + + Handles: + - Mermaid diagrams (rendered as PNG images) + - SVG images (replaced with alt text) + - 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) + + # Convert markdown to HTML + html = markdown.markdown( + md_content, + extensions=[ + 'tables', + 'fenced_code', + 'codehilite', + 'toc', + ] + ) + + # Clean up any SVG references (they won't work in EPUB) + soup = BeautifulSoup(html, 'html.parser') + for img in soup.find_all('img'): + src = img.get('src', '') + if src.endswith('.svg'): + # Replace SVG with alt text + alt = img.get('alt', 'Image') + img.replace_with(f'[{alt}]') + + html = str(soup) + + # Convert internal links to EPUB chapter references + html = convert_internal_links(html, current_file, root_path) + + return html + + +def collect_folder_files(folder_path: Path) -> list[tuple[Path, str]]: + """Collect all markdown files from a folder, README first.""" + files = [] + + # 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: + # Prefix with subfolder name + 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 + + +def create_epub(root_path: Path, output_path: Path): + """Create the EPUB from markdown files.""" + book = epub.EpubBook() + + # Set metadata + book.set_identifier('claude-howto-guide') + book.set_title('Claude Code How-To Guide') + book.set_language('en') + book.add_author('Claude Code Community') + + # Add cover image from PNG logo + logo_path = root_path / "claude-howto-logo.png" + if logo_path.exists(): + print("Adding cover image...") + cover_data = create_cover_image(logo_path) + book.set_cover("cover.png", cover_data) + + # Add CSS + 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; } + ''' + nav_css = epub.EpubItem( + uid="style_nav", + file_name="style/nav.css", + media_type="text/css", + content=style + ) + book.add_item(nav_css) + + chapters = [] + toc = [] + chapter_order = get_chapter_order() + + # First pass: build path-to-chapter mapping + chapter_num = 0 + print("Building chapter mapping...") + for item, display_name in chapter_order: + item_path = root_path / item + + if item_path.is_file() and item_path.suffix == '.md': + chapter_num += 1 + chapter_file = f'chap_{chapter_num:02d}.xhtml' + _path_to_chapter[item] = chapter_file + + elif item_path.is_dir(): + folder_files = collect_folder_files(item_path) + if not folder_files: + continue + + chapter_num += 1 + # Map folder itself to first file (README) + _path_to_chapter[item] = f'chap_{chapter_num:02d}_00.xhtml' + _path_to_chapter[item.rstrip('/')] = f'chap_{chapter_num:02d}_00.xhtml' + + for i, (file_path, _) in enumerate(folder_files): + rel_path = str(file_path.relative_to(root_path)) + _path_to_chapter[rel_path] = f'chap_{chapter_num:02d}_{i:02d}.xhtml' + + # Second pass: generate chapters with link conversion + chapter_num = 0 + for item, display_name in chapter_order: + item_path = root_path / item + + if item_path.is_file() and item_path.suffix == '.md': + # Single file chapter + chapter_num += 1 + content = item_path.read_text(encoding='utf-8') + print(f"Processing: {item_path.name}") + html_content = md_to_html(content, item_path, root_path, book) + + chapter = epub.EpubHtml( + title=display_name, + file_name=f'chap_{chapter_num:02d}.xhtml', + lang='en' + ) + chapter.content = f''' + +