🎨 manage comics via yaml files

This commit is contained in:
mi
2025-11-15 19:20:21 +10:00
parent 91b6d4efeb
commit 13176c68d2
10 changed files with 419 additions and 80 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## Development Commands
@@ -24,7 +24,13 @@ python app.py
```bash ```bash
python scripts/add_comic.py 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:** **Generate RSS feed:**
```bash ```bash
@@ -40,8 +46,23 @@ Run this after adding/updating comics to regenerate `static/sitemap.xml` for sea
## Architecture ## Architecture
### Data Layer: comics_data.py ### Data Layer: YAML Files in data/comics/
Comics are stored as a Python list called `COMICS`. Each comic is a dictionary with:
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 - `number` (required): Sequential comic number
- `filename` (required): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style) - `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 - `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) - `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. - `section` (optional): Section/chapter title (e.g., "Chapter 1: Origins"). Add to first comic of a new section.
**Multi-image comics (webtoon style):** **Multi-image comics (webtoon style) in YAML:**
- Set `filename` to a list of image filenames: `['page1.png', 'page2.png', 'page3.png']` ```yaml
- Set `alt_text` to either: filename:
- A single string (applies to all images): `'A three-part vertical story'` - page1.png
- A list matching each image: `['Description 1', 'Description 2', 'Description 3']` - 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 - If `alt_text` is a list but doesn't match `filename` length, a warning is logged
- Images display vertically with seamless stacking (no gaps) - Images display vertically with seamless stacking (no gaps)
- First image loads immediately; subsequent images lazy-load as user scrolls - 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 ## 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 ## Production Deployment

View File

@@ -97,34 +97,17 @@ EMBED_ENABLED = True
# When enabled, users can easily copy a direct link to the current comic # When enabled, users can easily copy a direct link to the current comic
PERMALINK_ENABLED = True PERMALINK_ENABLED = True
COMICS = [ # Load comics from YAML files
{ from data_loader import load_comics_from_yaml, validate_comics
'number': 1,
'title': 'First Comic', COMICS = load_comics_from_yaml('data/comics')
'filename': 'comic-001.jpg',
'mobile_filename': 'comic-001-mobile.jpg', # Optional: mobile version of the comic # Validate loaded comics
'date': '2025-01-01', if not validate_comics(COMICS):
'alt_text': 'The very first comic', print("Warning: Comic validation failed. Please check your YAML files.")
'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) # Show loaded comics count
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT for this comic if COMICS:
'plain': True, # Optional: override PLAIN_DEFAULT for this comic print(f"Loaded {len(COMICS)} comics from data/comics/")
'section': 'Chapter 1: The Beginning', # Optional: start a new section on archive page else:
}, print("Warning: No comics loaded! Please add .yaml files to data/comics/")
{
'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!',
},
]

23
data/comics/001.yaml Normal file
View File

@@ -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"

9
data/comics/002.yaml Normal file
View File

@@ -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

7
data/comics/003.yaml Normal file
View File

@@ -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!"

89
data/comics/README.md Normal file
View File

@@ -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.

51
data/comics/TEMPLATE.yaml Normal file
View File

@@ -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"

121
data_loader.py Normal file
View File

@@ -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!")

View File

@@ -1,2 +1,3 @@
Flask==3.0.0 Flask==3.0.0
markdown==3.5.1 markdown==3.5.1
PyYAML==6.0.3

View File

@@ -4,7 +4,7 @@
# Licensed under the MIT License - see LICENSE file for details # 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 sys
import os import os
@@ -51,7 +51,7 @@ Write your author note here using markdown formatting.
def main(): def main():
"""Add a new comic entry with defaults""" """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', 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') help='Generate a markdown file for author notes and add author_note_md field to comic entry')
args = parser.parse_args() args = parser.parse_args()
@@ -59,53 +59,75 @@ def main():
# Get next number # Get next number
number = max(comic['number'] for comic in COMICS) + 1 if COMICS else 1 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 # Create entry with defaults
comic = { comic_data = {
'number': number, 'number': number,
'filename': f'comic-{number:03d}.png', 'filename': f'comic-{number:03d}.png',
'date': datetime.now().strftime('%Y-%m-%d'), 'date': date_str,
'alt_text': f'Comic #{number}', '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__)) script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(script_dir) 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 # Create comics directory if it doesn't exist
with open(comics_file, 'r') as f: os.makedirs(comics_dir, exist_ok=True)
content = f.read()
# 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: if args.markdown:
entry_str = f""" {{ yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n'
'number': {comic['number']},
'filename': {repr(comic['filename'])},
'date': {repr(comic['date'])},
'alt_text': {repr(comic['alt_text'])},
'author_note_md': {repr(comic['date'] + '.md')}
}}"""
else: else:
entry_str = f""" {{ yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n'
'number': {comic['number']},
'filename': {repr(comic['filename'])},
'date': {repr(comic['date'])},
'alt_text': {repr(comic['alt_text'])}
}}"""
# Insert before closing bracket yaml_content += """
insert_pos = content.rfind(']') # Optional: Add a title
new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:] # title: "Title of Your Comic"
# Write back # Optional: Override global settings
with open(comics_file, 'w') as f: # full_width: true
f.write(new_content) # 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 # Create markdown file if requested
if args.markdown: 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__': if __name__ == '__main__':