From 13176c68d282f231665f579285b6192dbc0387d5 Mon Sep 17 00:00:00 2001 From: mi Date: Sat, 15 Nov 2025 19:20:21 +1000 Subject: [PATCH] :art: manage comics via yaml files --- CLAUDE.md | 67 +++++++++++++++------ comics_data.py | 45 +++++--------- data/comics/001.yaml | 23 ++++++++ data/comics/002.yaml | 9 +++ data/comics/003.yaml | 7 +++ data/comics/README.md | 89 ++++++++++++++++++++++++++++ data/comics/TEMPLATE.yaml | 51 ++++++++++++++++ data_loader.py | 121 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 +- scripts/add_comic.py | 84 ++++++++++++++++---------- 10 files changed, 419 insertions(+), 80 deletions(-) create mode 100644 data/comics/001.yaml create mode 100644 data/comics/002.yaml create mode 100644 data/comics/003.yaml create mode 100644 data/comics/README.md create mode 100644 data/comics/TEMPLATE.yaml create mode 100644 data_loader.py diff --git a/CLAUDE.md b/CLAUDE.md index 8e05294..28b9185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Sunday Comics is a Flask-based webcomic website with server-side rendering and client-side navigation. Comics are stored as simple Python dictionaries in `comics_data.py`, making the system easy to manage without a database. +Sunday Comics is a Flask-based webcomic website with server-side rendering and client-side navigation. Comics are stored as individual YAML files in `data/comics/`, making them easy to manage without a database. Each comic gets its own file for clean organization and version control. ## Development Commands @@ -24,7 +24,13 @@ python app.py ```bash python scripts/add_comic.py ``` -This creates a new entry in `comics_data.py` with defaults. Edit the file afterwards to customize title, alt_text, author_note, etc. +This creates a new YAML file in `data/comics/` with the next comic number and reasonable defaults. Edit the generated file to customize title, alt_text, author_note, etc. + +**Add a new comic with markdown author note:** +```bash +python scripts/add_comic.py -m +``` +This also creates a markdown file in `content/author_notes/` for the author note. **Generate RSS feed:** ```bash @@ -40,8 +46,23 @@ Run this after adding/updating comics to regenerate `static/sitemap.xml` for sea ## Architecture -### Data Layer: comics_data.py -Comics are stored as a Python list called `COMICS`. Each comic is a dictionary with: +### Data Layer: YAML Files in data/comics/ + +Comics are stored as individual YAML files in the `data/comics/` directory. The `data_loader.py` module automatically loads all `.yaml` files (except `TEMPLATE.yaml` and `README.yaml`), sorts them by comic number, and builds the `COMICS` list. + +**File structure:** +- `data/comics/001.yaml` - Comic #1 +- `data/comics/002.yaml` - Comic #2 +- `data/comics/003.yaml` - Comic #3 +- `data/comics/TEMPLATE.yaml` - Template for new comics (ignored by loader) +- `data/comics/README.md` - Documentation for comic files + +**Adding a new comic:** +1. Use `python scripts/add_comic.py` to auto-generate the next comic file +2. OR manually copy `TEMPLATE.yaml` and rename it +3. Edit the YAML file to set comic properties + +Each comic YAML file contains: - `number` (required): Sequential comic number - `filename` (required): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style) - `date` (required): Publication date in YYYY-MM-DD format @@ -53,11 +74,19 @@ Comics are stored as a Python list called `COMICS`. Each comic is a dictionary w - `plain` (optional): Override global PLAIN_DEFAULT setting (hides header/border) - `section` (optional): Section/chapter title (e.g., "Chapter 1: Origins"). Add to first comic of a new section. -**Multi-image comics (webtoon style):** -- Set `filename` to a list of image filenames: `['page1.png', 'page2.png', 'page3.png']` -- Set `alt_text` to either: - - A single string (applies to all images): `'A three-part vertical story'` - - A list matching each image: `['Description 1', 'Description 2', 'Description 3']` +**Multi-image comics (webtoon style) in YAML:** +```yaml +filename: + - page1.png + - page2.png + - page3.png +alt_text: + - "Description 1" + - "Description 2" + - "Description 3" +``` +- Set `filename` to a list of image filenames +- Set `alt_text` to either a single string (applies to all images) or a list matching each image - If `alt_text` is a list but doesn't match `filename` length, a warning is logged - Images display vertically with seamless stacking (no gaps) - First image loads immediately; subsequent images lazy-load as user scrolls @@ -154,21 +183,25 @@ Global context variables injected into all templates: ## Important Implementation Details -1. **Comic ordering**: COMICS list order determines comic sequence. Last item is the "latest" comic. +1. **Comic loading**: The `data_loader.py` module scans `data/comics/` for `.yaml` files, loads them, validates required fields, and sorts by comic number. TEMPLATE.yaml and README.yaml are automatically ignored. -2. **Enrichment pattern**: Always use `enrich_comic()` before passing comics to templates or APIs. This adds computed properties like `full_width`, `plain`, and `formatted_date`. +2. **Comic ordering**: COMICS list order (determined by the `number` field in each YAML file) determines comic sequence. Last item is the "latest" comic. -3. **Date formatting**: The `format_comic_date()` function uses `%d` with lstrip('0') for cross-platform compatibility (not all systems support `%-d`). +3. **Enrichment pattern**: Always use `enrich_comic()` before passing comics to templates or APIs. This adds computed properties like `full_width`, `plain`, and `formatted_date`. -4. **Author notes hierarchy**: If `author_note_md` field is specified, the markdown file is loaded and rendered as HTML, taking precedence over the plain text `author_note` field. When markdown is used, `author_note_is_html` is set to True. +4. **Date formatting**: The `format_comic_date()` function uses `%d` with lstrip('0') for cross-platform compatibility (not all systems support `%-d`). -5. **Settings cascade**: Global settings (FULL_WIDTH_DEFAULT, PLAIN_DEFAULT) apply unless overridden per-comic with `full_width` or `plain` keys. +5. **Author notes hierarchy**: If `author_note_md` field is specified, the markdown file is loaded and rendered as HTML, taking precedence over the plain text `author_note` field. When markdown is used, `author_note_is_html` is set to True. -6. **Navigation state**: Client-side navigation reads `data-total-comics` and `data-comic-number` from the `.comic-container` element to manage button states. +6. **Settings cascade**: Global settings (FULL_WIDTH_DEFAULT, PLAIN_DEFAULT) apply unless overridden per-comic with `full_width` or `plain` keys in the YAML file. -7. **Comic icon navigation**: When `USE_COMIC_NAV_ICONS` is True, templates use `.btn-icon-nav` class with icon images instead of text buttons. JavaScript automatically detects icon mode and applies appropriate classes. Disabled icons have reduced opacity (0.3). +7. **Navigation state**: Client-side navigation reads `data-total-comics` and `data-comic-number` from the `.comic-container` element to manage button states. -8. **Archive sections**: When `SECTIONS_ENABLED` is True, comics with a `section` field will start a new section on the archive page. Only add the `section` field to the first comic of each new section. All subsequent comics belong to that section until a new `section` field is encountered. +8. **Comic icon navigation**: When `USE_COMIC_NAV_ICONS` is True, templates use `.btn-icon-nav` class with icon images instead of text buttons. JavaScript automatically detects icon mode and applies appropriate classes. Disabled icons have reduced opacity (0.3). + +9. **Archive sections**: When `SECTIONS_ENABLED` is True, comics with a `section` field will start a new section on the archive page. Only add the `section` field to the first comic of each new section. All subsequent comics belong to that section until a new `section` field is encountered. + +10. **YAML validation**: The data loader validates each comic file and logs warnings for missing required fields (`number`, `filename`, `date`, `alt_text`). Invalid files are skipped. ## Production Deployment diff --git a/comics_data.py b/comics_data.py index 343b272..023e733 100644 --- a/comics_data.py +++ b/comics_data.py @@ -97,34 +97,17 @@ EMBED_ENABLED = True # When enabled, users can easily copy a direct link to the current comic PERMALINK_ENABLED = True -COMICS = [ - { - 'number': 1, - 'title': 'First Comic', - 'filename': 'comic-001.jpg', - 'mobile_filename': 'comic-001-mobile.jpg', # Optional: mobile version of the comic - 'date': '2025-01-01', - 'alt_text': 'The very first comic', - 'author_note': 'This is where your comic journey begins!', - 'author_note_md': '2025-01-01.md', # Optional: use markdown from content/author_notes/2025-01-01.md (overrides author_note) - 'full_width': True, # Optional: override FULL_WIDTH_DEFAULT for this comic - 'plain': True, # Optional: override PLAIN_DEFAULT for this comic - 'section': 'Chapter 1: The Beginning', # Optional: start a new section on archive page - }, - { - 'number': 2, - 'filename': 'comic-002.jpg', - 'date': '2025-01-08', - 'alt_text': 'The second comic', - 'full_width': True, - 'plain': True, - }, - { - 'number': 3, - 'title': 'Third Comic', - 'filename': 'comic-003.jpg', - 'date': '2025-01-15', - 'alt_text': 'The third comic', - 'author_note': 'Things are getting interesting!', - }, -] +# Load comics from YAML files +from data_loader import load_comics_from_yaml, validate_comics + +COMICS = load_comics_from_yaml('data/comics') + +# Validate loaded comics +if not validate_comics(COMICS): + print("Warning: Comic validation failed. Please check your YAML files.") + +# Show loaded comics count +if COMICS: + print(f"Loaded {len(COMICS)} comics from data/comics/") +else: + print("Warning: No comics loaded! Please add .yaml files to data/comics/") diff --git a/data/comics/001.yaml b/data/comics/001.yaml new file mode 100644 index 0000000..2b49cb3 --- /dev/null +++ b/data/comics/001.yaml @@ -0,0 +1,23 @@ +# Comic #1 +number: 1 +title: "First Comic" +filename: comic-001.jpg +mobile_filename: comic-001-mobile.jpg # Optional: mobile version of the comic +date: "2025-01-01" +alt_text: "The very first comic" + +# Author notes (choose one method): +# Option 1: Plain text note +author_note: "This is where your comic journey begins!" + +# Option 2: Markdown file (overrides author_note if present) +# Just a filename looks in content/author_notes/ +# Or use a path like "special/note.md" relative to content/ +author_note_md: "2025-01-01.md" + +# Display settings (override global defaults) +full_width: true +plain: true + +# Section header (optional - only add to first comic of a new section) +section: "Chapter 1: The Beginning" diff --git a/data/comics/002.yaml b/data/comics/002.yaml new file mode 100644 index 0000000..ede3d74 --- /dev/null +++ b/data/comics/002.yaml @@ -0,0 +1,9 @@ +# Comic #2 +number: 2 +filename: comic-002.jpg +date: "2025-01-08" +alt_text: "The second comic" + +# Display settings +full_width: true +plain: true diff --git a/data/comics/003.yaml b/data/comics/003.yaml new file mode 100644 index 0000000..fb1ab95 --- /dev/null +++ b/data/comics/003.yaml @@ -0,0 +1,7 @@ +# Comic #3 +number: 3 +title: "Third Comic" +filename: comic-003.jpg +date: "2025-01-15" +alt_text: "The third comic" +author_note: "Things are getting interesting!" diff --git a/data/comics/README.md b/data/comics/README.md new file mode 100644 index 0000000..f1f506a --- /dev/null +++ b/data/comics/README.md @@ -0,0 +1,89 @@ +# Comic Data Directory + +This directory contains YAML files for managing individual comics. Each comic gets its own `.yaml` file. + +## Quick Start + +### Adding a New Comic + +1. **Copy the template:** + ```bash + cp TEMPLATE.yaml 004.yaml + ``` + +2. **Edit the file** with your comic's information: + - Update `number`, `filename`, `date`, and `alt_text` (required) + - Add optional fields like `title`, `author_note`, etc. + +3. **Save the file** and restart your application + +The comics will be automatically loaded and sorted by comic number. + +## File Naming + +You can name files anything you want (e.g., `001.yaml`, `first-comic.yaml`, `2025-01-01.yaml`), but using the comic number is recommended for easy organization. + +## Required Fields + +Every comic MUST have: +- `number` - Sequential comic number (integer) +- `filename` - Image filename (string) or list of filenames for multi-image comics +- `date` - Publication date in YYYY-MM-DD format (string) +- `alt_text` - Accessibility description (string or list for multi-image) + +## Optional Fields + +- `title` - Comic title (defaults to "#X" if not provided) +- `mobile_filename` - Mobile-optimized version +- `author_note` - Plain text note below the comic +- `author_note_md` - Markdown file for author note (overrides `author_note`) +- `full_width` - Override global width setting (boolean) +- `plain` - Override global plain mode (boolean) +- `section` - Start a new section/chapter (string, add only to first comic of section) + +## Multi-Image Comics (Webtoon Style) + +For vertical scrolling comics with multiple images: + +```yaml +number: 42 +filename: + - page1.png + - page2.png + - page3.png +alt_text: + - "First panel description" + - "Second panel description" + - "Third panel description" +date: "2025-01-01" +``` + +## Example + +```yaml +number: 4 +title: "The Adventure Begins" +filename: comic-004.jpg +date: "2025-01-22" +alt_text: "A hero stands at the edge of a cliff, looking at the horizon" +author_note: "This is where things get interesting!" +full_width: true +section: "Chapter 2: The Journey" +``` + +## Validation + +The data loader will: +- Skip files with missing required fields (with warnings) +- Check for duplicate comic numbers +- Warn about gaps in numbering +- Sort comics by number automatically + +## Testing Your Changes + +Test the loader directly: +```bash +python data_loader.py +``` + +This will show you all loaded comics and any validation warnings. diff --git a/data/comics/TEMPLATE.yaml b/data/comics/TEMPLATE.yaml new file mode 100644 index 0000000..4078c80 --- /dev/null +++ b/data/comics/TEMPLATE.yaml @@ -0,0 +1,51 @@ +# Template for creating new comics +# Copy this file and rename it to match your comic number (e.g., 004.yaml, 005.yaml) +# Fields marked as REQUIRED must be included +# All other fields are optional + +# REQUIRED: Sequential comic number +number: 999 + +# REQUIRED: Image filename(s) in static/images/comics/ +# Single image: +filename: comic-999.jpg +# OR multi-image (webtoon style): +# filename: +# - page1.png +# - page2.png +# - page3.png + +# Optional: Mobile-optimized version of the comic +# mobile_filename: comic-999-mobile.jpg + +# REQUIRED: Publication date (YYYY-MM-DD format) +date: "2025-01-01" + +# REQUIRED: Accessibility text for screen readers +# Single alt text (for single or multi-image): +alt_text: "Description of what happens in this comic" +# OR individual alt texts for multi-image comics: +# alt_text: +# - "Description of first image" +# - "Description of second image" +# - "Description of third image" + +# Optional: Comic title (defaults to "#X" if not provided) +title: "Title of Your Comic" + +# Optional: Plain text author note +author_note: "Your thoughts about this comic." + +# Optional: Markdown author note file (overrides author_note if present) +# Just filename looks in content/author_notes/ +# Or use path like "special/note.md" relative to content/ +# author_note_md: "2025-01-01.md" + +# Optional: Override global FULL_WIDTH_DEFAULT setting +# full_width: true + +# Optional: Override global PLAIN_DEFAULT setting (hides header/border) +# plain: true + +# Optional: Section/chapter title (only add to first comic of a new section) +# section: "Chapter 2: New Adventures" diff --git a/data_loader.py b/data_loader.py new file mode 100644 index 0000000..5f337b6 --- /dev/null +++ b/data_loader.py @@ -0,0 +1,121 @@ +""" +Comic data loader for YAML-based comic management. + +This module scans the data/comics/ directory for .yaml files, +loads each comic's configuration, and builds the COMICS list. +""" + +import yaml +from pathlib import Path + + +def load_comics_from_yaml(comics_dir='data/comics'): + """ + Load all comic data from YAML files in the specified directory. + + Args: + comics_dir: Path to directory containing comic YAML files + + Returns: + List of comic dictionaries, sorted by comic number + """ + comics = [] + comics_path = Path(comics_dir) + + if not comics_path.exists(): + print(f"Warning: Comics directory '{comics_dir}' does not exist. Creating it...") + comics_path.mkdir(parents=True, exist_ok=True) + return [] + + # Find all .yaml and .yml files + yaml_files = list(comics_path.glob('*.yaml')) + list(comics_path.glob('*.yml')) + + # Filter out template and README files + yaml_files = [f for f in yaml_files if f.stem.upper() not in ('TEMPLATE', 'README')] + + if not yaml_files: + print(f"Warning: No YAML files found in '{comics_dir}'") + return [] + + for yaml_file in yaml_files: + try: + with open(yaml_file, 'r', encoding='utf-8') as f: + comic_data = yaml.safe_load(f) + + if comic_data is None: + print(f"Warning: '{yaml_file.name}' is empty, skipping") + continue + + if 'number' not in comic_data: + print(f"Warning: '{yaml_file.name}' missing required 'number' field, skipping") + continue + + if 'filename' not in comic_data: + print(f"Warning: '{yaml_file.name}' missing required 'filename' field, skipping") + continue + + if 'date' not in comic_data: + print(f"Warning: '{yaml_file.name}' missing required 'date' field, skipping") + continue + + if 'alt_text' not in comic_data: + print(f"Warning: '{yaml_file.name}' missing required 'alt_text' field, skipping") + continue + + comics.append(comic_data) + + except yaml.YAMLError as e: + print(f"Error parsing '{yaml_file.name}': {e}") + continue + except Exception as e: + print(f"Error loading '{yaml_file.name}': {e}") + continue + + # Sort by comic number + comics.sort(key=lambda c: c['number']) + + return comics + + +def validate_comics(comics): + """ + Validate the loaded comics for common issues. + + Args: + comics: List of comic dictionaries + + Returns: + True if validation passes, False otherwise + """ + if not comics: + return True + + numbers = [c['number'] for c in comics] + + # Check for duplicate comic numbers + if len(numbers) != len(set(numbers)): + duplicates = [n for n in numbers if numbers.count(n) > 1] + print(f"Warning: Duplicate comic numbers found: {set(duplicates)}") + return False + + # Check for gaps in comic numbering (optional warning) + for i in range(len(comics) - 1): + if comics[i+1]['number'] - comics[i]['number'] > 1: + print(f"Info: Gap in comic numbering between {comics[i]['number']} and {comics[i+1]['number']}") + + return True + + +if __name__ == '__main__': + # Test the loader + print("Loading comics from data/comics/...") + comics = load_comics_from_yaml() + print(f"Loaded {len(comics)} comics") + + if validate_comics(comics): + print("Validation passed!") + for comic in comics: + title = comic.get('title', f"#{comic['number']}") + print(f" - Comic {comic['number']}: {title} ({comic['date']})") + else: + print("Validation failed!") diff --git a/requirements.txt b/requirements.txt index 8faa1fb..f96ce19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask==3.0.0 -markdown==3.5.1 \ No newline at end of file +markdown==3.5.1 +PyYAML==6.0.3 \ No newline at end of file diff --git a/scripts/add_comic.py b/scripts/add_comic.py index 5f1382a..ffc6744 100755 --- a/scripts/add_comic.py +++ b/scripts/add_comic.py @@ -4,7 +4,7 @@ # Licensed under the MIT License - see LICENSE file for details """ -Script to add a new comic entry to comics_data.py with reasonable defaults +Script to add a new comic entry as a YAML file with reasonable defaults """ import sys import os @@ -51,7 +51,7 @@ Write your author note here using markdown formatting. def main(): """Add a new comic entry with defaults""" - parser = argparse.ArgumentParser(description='Add a new comic entry to comics_data.py') + parser = argparse.ArgumentParser(description='Add a new comic entry as a YAML file') parser.add_argument('-m', '--markdown', action='store_true', help='Generate a markdown file for author notes and add author_note_md field to comic entry') args = parser.parse_args() @@ -59,53 +59,75 @@ def main(): # Get next number number = max(comic['number'] for comic in COMICS) + 1 if COMICS else 1 + # Get today's date + date_str = datetime.now().strftime('%Y-%m-%d') + # Create entry with defaults - comic = { + comic_data = { 'number': number, 'filename': f'comic-{number:03d}.png', - 'date': datetime.now().strftime('%Y-%m-%d'), + 'date': date_str, 'alt_text': f'Comic #{number}', } - # Get path to comics_data.py + # Add markdown reference if requested + if args.markdown: + comic_data['author_note_md'] = f'{date_str}.md' + + # Get paths script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.dirname(script_dir) - comics_file = os.path.join(parent_dir, 'comics_data.py') + comics_dir = os.path.join(parent_dir, 'data', 'comics') + yaml_file = os.path.join(comics_dir, f'{number:03d}.yaml') - # Read file - with open(comics_file, 'r') as f: - content = f.read() + # Create comics directory if it doesn't exist + os.makedirs(comics_dir, exist_ok=True) + + # Check if file already exists + if os.path.exists(yaml_file): + print(f"Error: Comic file already exists: {yaml_file}") + sys.exit(1) + + # Create YAML file with comments + yaml_content = f"""# Comic #{number} +number: {number} +filename: {comic_data['filename']} +date: "{date_str}" +alt_text: "{comic_data['alt_text']}" +""" - # Format new entry if args.markdown: - entry_str = f""" {{ - 'number': {comic['number']}, - 'filename': {repr(comic['filename'])}, - 'date': {repr(comic['date'])}, - 'alt_text': {repr(comic['alt_text'])}, - 'author_note_md': {repr(comic['date'] + '.md')} - }}""" + yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n' else: - entry_str = f""" {{ - 'number': {comic['number']}, - 'filename': {repr(comic['filename'])}, - 'date': {repr(comic['date'])}, - 'alt_text': {repr(comic['alt_text'])} - }}""" + yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n' - # Insert before closing bracket - insert_pos = content.rfind(']') - new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:] + yaml_content += """ +# Optional: Add a title +# title: "Title of Your Comic" - # Write back - with open(comics_file, 'w') as f: - f.write(new_content) +# Optional: Override global settings +# full_width: true +# plain: true - print(f"Added comic #{number}") +# Optional: Start a new section (only add to first comic of section) +# section: "Chapter X: Title" +""" + + # Write YAML file + with open(yaml_file, 'w') as f: + f.write(yaml_content) + + print(f"Created comic #{number}: {yaml_file}") # Create markdown file if requested if args.markdown: - create_markdown_file(comic['date'], parent_dir) + create_markdown_file(date_str, parent_dir) + + print(f"\nNext steps:") + print(f"1. Add your comic image as: static/images/comics/{comic_data['filename']}") + print(f"2. Edit {yaml_file} to customize the comic metadata") + if args.markdown: + print(f"3. Edit content/author_notes/{date_str}.md to write your author note") if __name__ == '__main__':