Compare commits

...

8 Commits

Author SHA1 Message Date
mi
631bca7923 📝 initial release docs 2025-11-15 20:53:31 +10:00
mi
6dad2194a5 🔖 initial release 2025-11-15 20:46:44 +10:00
mi
3153455355 📝 consolidate multiple list endpoints for comics 2025-11-15 20:40:18 +10:00
mi
52b80563ba 📝 catch the docs up 2025-11-15 20:21:19 +10:00
mi
61aa0aaba7 :lightning: lazy load archive 2025-11-15 20:01:06 +10:00
mi
bbd8e0a96d :lightning: comics cache 2025-11-15 19:37:52 +10:00
mi
13176c68d2 🎨 manage comics via yaml files 2025-11-15 19:20:21 +10:00
mi
91b6d4efeb multi-image comics 2025-11-15 18:52:14 +10:00
27 changed files with 1787 additions and 228 deletions

5
.gitignore vendored
View File

@@ -3,4 +3,7 @@
# This should be generated on deploy # This should be generated on deploy
static/feed.rss static/feed.rss
static/sitemap.xml static/sitemap.xml
# Comic data cache
data/comics/.comics_cache.pkl

29
CHANGELOG.md Normal file
View File

@@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project uses date-based versioning (YYYY.MM.DD).
## [Unreleased]
### Added
### Changed
### Deprecated
### Removed
### Fixed
### Security
## [2025.11.15] - 2025-11-15
### Added
- Initial version tracking system
- `version.py` module for source code version reference
- `VERSION` file at project root for easy access
- HTML meta tag (`generator`) displaying version in page source
- Version number injected into all template contexts
- This CHANGELOG.md file to track version history
- Version bump script (`scripts/bump_version.py`) to automate releases
[Unreleased]: https://git.puercito.net/mi/sunday/compare/v2025.11.15...HEAD
[2025.11.15]: https://git.puercito.net/mi/sunday/releases/tag/v2025.11.15

121
CLAUDE.md
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
@@ -38,14 +44,79 @@ python scripts/generate_sitemap.py
``` ```
Run this after adding/updating comics to regenerate `static/sitemap.xml` for search engines. Run this after adding/updating comics to regenerate `static/sitemap.xml` for search engines.
**Publish comics (rebuild cache + RSS + sitemap):**
```bash
python scripts/publish_comic.py
```
Convenience script that rebuilds the cache and regenerates all static files in one command.
**Rebuild comics cache:**
```bash
python scripts/rebuild_cache.py
```
Force rebuild the comics cache from YAML files. Normally not needed (cache auto-invalidates).
**Bump version:**
```bash
python scripts/bump_version.py
```
Updates the project version to today's date (YYYY.MM.DD format) in both `version.py` and `VERSION` files. Optionally opens CHANGELOG.md for editing.
**Bump version to specific date:**
```bash
python scripts/bump_version.py 2025.12.25
```
Sets version to a specific date instead of using today's date.
## Versioning
The project uses date-based versioning (YYYY.MM.DD format):
- **`version.py`**: Python module containing `__version__` variable (import with `from version import __version__`)
- **`VERSION`**: Plain text file at project root for easy access by scripts and CI/CD
- **`CHANGELOG.md`**: Tracks version history and changes following [Keep a Changelog](https://keepachangelog.com/) format
- **HTML meta tag**: Version appears in page source as `<meta name="generator" content="Sunday Comics X.Y.Z">`
When releasing a new version:
1. Run `python scripts/bump_version.py` to update version files
2. Edit `CHANGELOG.md` to document changes under the new version
3. Commit changes: `git commit -m "Release version YYYY.MM.DD"`
4. Tag the release: `git tag -a vYYYY.MM.DD -m "Version YYYY.MM.DD"`
5. Push with tags: `git push && git push --tags`
## 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.
**Caching:** The data loader uses automatic caching to speed up subsequent loads:
- First load: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
- Subsequent loads: Reads from cache (~100x faster)
- Auto-invalidation: Cache rebuilds automatically when any YAML file is modified
- Cache can be disabled via environment variable: `DISABLE_COMIC_CACHE=true`
Performance with caching (1000 comics):
- Initial load: ~2-3 seconds (builds cache)
- Subsequent loads: ~0.01 seconds (uses cache)
- Scripts (RSS, sitemap): All share the same cache file on disk
**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/` - `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
- `alt_text` (required): Accessibility text - `alt_text` (required): Accessibility text OR list of alt texts (one per image for multi-image comics)
- `title` (optional): Comic title (defaults to "#X" if absent) - `title` (optional): Comic title (defaults to "#X" if absent)
- `author_note` (optional): Plain text note - `author_note` (optional): Plain text note
- `author_note_md` (optional): Markdown file for author note (just filename like "2025-01-01.md" looks in `content/author_notes/`, or path like "special/note.md" relative to `content/`). Takes precedence over `author_note`. - `author_note_md` (optional): Markdown file for author note (just filename like "2025-01-01.md" looks in `content/author_notes/`, or path like "special/note.md" relative to `content/`). Takes precedence over `author_note`.
@@ -53,6 +124,24 @@ 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) 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
- No click-through navigation on multi-image comics (use navigation buttons instead)
Global configuration in `comics_data.py`: Global configuration in `comics_data.py`:
- `COMIC_NAME`: Your comic/website name - `COMIC_NAME`: Your comic/website name
- `COPYRIGHT_NAME`: Name to display in copyright notice (defaults to COMIC_NAME if not set) - `COPYRIGHT_NAME`: Name to display in copyright notice (defaults to COMIC_NAME if not set)
@@ -144,21 +233,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. Results are cached to `.comics_cache.pkl` for performance.
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

262
README.md
View File

@@ -72,7 +72,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
- An archive page where readers can browse all your comics - An archive page where readers can browse all your comics
- RSS feed so readers can subscribe to updates - RSS feed so readers can subscribe to updates
- Mobile-friendly design that works on phones and tablets - Mobile-friendly design that works on phones and tablets
- No database required - just upload images and edit a simple text file - No database required - just upload images and edit simple YAML files
**Perfect for:** **Perfect for:**
- Independent comic artists starting their first webcomic - Independent comic artists starting their first webcomic
@@ -81,7 +81,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
- Anyone looking for a lightweight, customizable comic platform - Anyone looking for a lightweight, customizable comic platform
**How it works:** **How it works:**
You add your comics by uploading image files and adding basic information (title, date, description) to a configuration file. The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media. You add your comics by uploading image files and creating individual YAML files with basic information (title, date, description). The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
No coding knowledge required for basic use - just follow the instructions below to add comics and customize your site's appearance. No coding knowledge required for basic use - just follow the instructions below to add comics and customize your site's appearance.
@@ -91,7 +91,7 @@ No coding knowledge required for basic use - just follow the instructions below
**Sunday Comics:** **Sunday Comics:**
- Server-side application (Flask/Python) that runs on a web server - Server-side application (Flask/Python) that runs on a web server
- Comics stored in a Python file - edit text to add comics - Comics stored as individual YAML files - easy version control and management
- Includes an RSS feed generator and helper scripts - Includes an RSS feed generator and helper scripts
- API endpoints for programmatic access - API endpoints for programmatic access
- Markdown support for rich-formatted content - Markdown support for rich-formatted content
@@ -100,7 +100,7 @@ No coding knowledge required for basic use - just follow the instructions below
**Rarebit:** **Rarebit:**
- Purely static HTML/CSS/JavaScript files - Purely static HTML/CSS/JavaScript files
- Comics are inferred from static images upload - edit a JS to customize - Comics are inferred from static images upload - edit a JS file to customize
- Can be hosted for free on GitHub Pages, Neocities, etc. - Can be hosted for free on GitHub Pages, Neocities, etc.
- No server or programming language required - No server or programming language required
- Simpler deployment - just upload files - Simpler deployment - just upload files
@@ -158,6 +158,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
## Features ## Features
- Comic viewer with navigation (First, Previous, Next, Latest) - Comic viewer with navigation (First, Previous, Next, Latest)
- **Multi-image comics** with vertical scrolling (webtoon style) and lazy loading
- Client-side navigation using JSON API (no page reloads) - Client-side navigation using JSON API (no page reloads)
- Keyboard navigation support (arrow keys, Home/End) - Keyboard navigation support (arrow keys, Home/End)
- Archive page with thumbnail grid - Archive page with thumbnail grid
@@ -232,13 +233,11 @@ To test keyboard navigation on your site:
When adding comics to your site, follow these guidelines to maintain accessibility: When adding comics to your site, follow these guidelines to maintain accessibility:
1. **Always provide alt text** 1. **Always provide alt text**
```python ```yaml
{ number: 1
'number': 1, filename: comic-001.png
'filename': 'comic-001.png', alt_text: A descriptive summary of what happens in the comic # Required!
'alt_text': 'A descriptive summary of what happens in the comic', # Required! date: '2025-01-01'
# ...
}
``` ```
2. **Write meaningful alt text** 2. **Write meaningful alt text**
@@ -631,16 +630,27 @@ Resources:
``` ```
sunday/ sunday/
├── app.py # Main Flask application ├── app.py # Main Flask application
├── comics_data.py # Comic data and configuration ├── comics_data.py # Global configuration (not comic data)
├── data_loader.py # YAML comic loader with caching
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── Dockerfile # Production Docker image ├── Dockerfile # Production Docker image
├── docker-compose.yml # Docker Compose configuration ├── docker-compose.yml # Docker Compose configuration
├── .dockerignore # Docker build exclusions ├── .dockerignore # Docker build exclusions
├── data/ # Comic data directory
│ └── comics/ # Individual comic YAML files
│ ├── 001.yaml # Comic #1
│ ├── 002.yaml # Comic #2
│ ├── TEMPLATE.yaml # Template for new comics
│ └── .comics_cache.pkl # Auto-generated cache file
├── scripts/ # Utility scripts ├── scripts/ # Utility scripts
│ ├── add_comic.py # Script to add new comic entries │ ├── add_comic.py # Create new comic YAML files
── generate_rss.py # Script to generate RSS feed ── generate_rss.py # Generate RSS feed
│ ├── generate_sitemap.py # Generate sitemap.xml
│ ├── rebuild_cache.py # Force rebuild comics cache
│ └── publish_comic.py # Rebuild cache + RSS + sitemap
├── content/ # Markdown content ├── content/ # Markdown content
│ ├── about.md # About page content │ ├── about.md # About page content
│ ├── terms.md # Terms of Service
│ └── author_notes/ # Author notes for comics (by date) │ └── author_notes/ # Author notes for comics (by date)
├── templates/ # Jinja2 templates ├── templates/ # Jinja2 templates
│ ├── base.html # Base template with navigation │ ├── base.html # Base template with navigation
@@ -658,7 +668,8 @@ sunday/
│ ├── comics/ # Comic images │ ├── comics/ # Comic images
│ ├── thumbs/ # Thumbnail images for archive │ ├── thumbs/ # Thumbnail images for archive
│ └── icons/ # Navigation and social icons (optional) │ └── icons/ # Navigation and social icons (optional)
── feed.rss # RSS feed (generated) ── feed.rss # RSS feed (generated)
└── sitemap.xml # Sitemap (generated)
``` ```
## Setup ## Setup
@@ -682,6 +693,7 @@ The app can be configured via environment variables:
- `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key') - `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key')
- `PORT` - Port to run on (defaults to 3000) - `PORT` - Port to run on (defaults to 3000)
- `DEBUG` - Enable debug mode (defaults to False) - `DEBUG` - Enable debug mode (defaults to False)
- `DISABLE_COMIC_CACHE` - Set to 'true' to disable comic caching (useful for debugging)
**Development:** **Development:**
```bash ```bash
@@ -696,9 +708,15 @@ export PORT=3000
python app.py python app.py
``` ```
**Disable caching (debugging):**
```bash
export DISABLE_COMIC_CACHE=true
python app.py
```
## Configuration ## Configuration
The `comics_data.py` file contains both comic data and global configuration options: The `comics_data.py` file contains global configuration options for your comic site. Comic data itself is stored in individual YAML files in the `data/comics/` directory.
### Global Settings ### Global Settings
@@ -715,10 +733,14 @@ FOOTER_IMAGE = None # Optional footer image path
BANNER_IMAGE = 'banner.jpg' # Shareable banner for "Link to Me" section BANNER_IMAGE = 'banner.jpg' # Shareable banner for "Link to Me" section
COMPACT_FOOTER = False # Display footer in compact mode COMPACT_FOOTER = False # Display footer in compact mode
ARCHIVE_FULL_WIDTH = True # Full-width archive with 4 columns ARCHIVE_FULL_WIDTH = True # Full-width archive with 4 columns
SECTIONS_ENABLED = True # Enable section headers on archive page
USE_COMIC_NAV_ICONS = True # Use icons for comic navigation buttons USE_COMIC_NAV_ICONS = True # Use icons for comic navigation buttons
USE_HEADER_NAV_ICONS = True # Show icons in main header navigation USE_HEADER_NAV_ICONS = True # Show icons in main header navigation
USE_FOOTER_SOCIAL_ICONS = True # Use icons for social links USE_FOOTER_SOCIAL_ICONS = True # Use icons for social links
USE_SHARE_ICONS = True # Use icons in share buttons (permalink/embed)
NEWSLETTER_ENABLED = False # Show newsletter section in footer NEWSLETTER_ENABLED = False # Show newsletter section in footer
EMBED_ENABLED = True # Enable comic embed functionality
PERMALINK_ENABLED = True # Enable permalink copy button
SOCIAL_INSTAGRAM = None # Instagram URL (or None) SOCIAL_INSTAGRAM = None # Instagram URL (or None)
SOCIAL_YOUTUBE = None # YouTube URL (or None) SOCIAL_YOUTUBE = None # YouTube URL (or None)
SOCIAL_EMAIL = None # Email mailto link (or None) SOCIAL_EMAIL = None # Email mailto link (or None)
@@ -727,67 +749,107 @@ API_SPEC_LINK = None # API documentation link (or None)
## Adding Comics ## Adding Comics
Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry: Comics are stored as individual YAML files in the `data/comics/` directory. Each comic file contains:
```python ```yaml
{ number: 1 # Comic number (required, sequential)
'number': 1, # Comic number (required, sequential) filename: comic-001.png # Image filename (required) OR list for multi-image
'filename': 'comic-001.png', # Image filename (required) date: '2025-01-01' # Publication date (required)
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version alt_text: Alt text for comic # Accessibility text (required) OR list for multi-image
'date': '2025-01-01', # Publication date (required) title: Comic Title # Title (optional, shows #X if absent)
'alt_text': 'Alt text for comic', # Accessibility text (required) author_note: Optional note # Author note (optional, plain text)
'title': 'Comic Title', # Title (optional, shows #X if absent) author_note_md: 2025-01-01.md # Optional markdown file for author note
'author_note': 'Optional note', # Author note (optional, plain text) full_width: true # Optional: override FULL_WIDTH_DEFAULT
'author_note_md': '2025-01-01.md', # Optional markdown file for author note plain: true # Optional: override PLAIN_DEFAULT
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT ```
'plain': True # Optional: override PLAIN_DEFAULT
} **For multi-image comics (webtoon style):**
```yaml
number: 2
filename:
- page1.png
- page2.png
- page3.png
alt_text:
- Panel 1 description
- Panel 2 description
- Panel 3 description
date: '2025-01-08'
full_width: true # Recommended for webtoons
``` ```
### Adding a New Comic ### Adding a New Comic
**Option 1: Use the script (recommended)** **Option 1: Use the script (recommended)**
```bash ```bash
# Add comic entry only # Add comic YAML file with defaults
python scripts/add_comic.py python scripts/add_comic.py
# Add comic entry AND create markdown file for author notes # Add comic YAML file AND create markdown file for author notes
python scripts/add_comic.py -m python scripts/add_comic.py -m
``` ```
This will automatically add a new entry with defaults. The `-m` flag creates a markdown file in `content/author_notes/{date}.md` with a template and adds the `author_note_md` field to the comic entry. Then edit `comics_data.py` to customize. This will create a new YAML file in `data/comics/` with the next comic number and reasonable defaults. The `-m` flag also creates a markdown file in `content/author_notes/{date}.md` with a template. Then:
1. Edit the generated YAML file to customize title, alt_text, author_note, etc.
2. Upload your comic image to `static/images/comics/`
3. Optionally create a thumbnail in `static/images/thumbs/` with the same filename
**Option 2: Manual** **Option 2: Manual**
1. Save your comic image in `static/images/comics/` (e.g., `comic-001.png`) 1. Copy `data/comics/TEMPLATE.yaml` and rename it (e.g., `003.yaml`)
2. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename 2. Edit the YAML file to set comic properties
3. Add the comic entry to the `COMICS` list in `comics_data.py` 3. Save your comic image in `static/images/comics/`
4. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
### Generating RSS Feed ### Publishing Comics
After adding comics, regenerate the RSS feed: After adding or updating comics, use the publish script to update all generated files:
```bash ```bash
python scripts/generate_rss.py python scripts/publish_comic.py
``` ```
This creates/updates `static/feed.rss` This convenience script rebuilds the cache and regenerates both RSS feed and sitemap in one command.
### Comic Caching System
Sunday Comics uses an automatic caching system to speed up comic loading:
**How it works:**
- **First load**: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
- **Subsequent loads**: Reads from cache (~100x faster)
- **Auto-invalidation**: Cache rebuilds automatically when any YAML file is modified
**Performance (1000 comics):**
- Initial load: ~2-3 seconds (builds cache)
- Subsequent loads: ~0.01 seconds (uses cache)
- Scripts share the same cache file on disk
**Manual cache management:**
```bash
# Force rebuild the cache (normally not needed)
python scripts/rebuild_cache.py
# Disable caching (for debugging)
export DISABLE_COMIC_CACHE=true
python app.py
```
The cache file is automatically excluded from git (listed in `.gitignore`).
### Markdown Support ### Markdown Support
**Author Notes:** **Author Notes:**
Add the `author_note_md` field to your comic entry in `comics_data.py` to use markdown-formatted author notes. The field can be: Add the `author_note_md` field to your comic YAML file to use markdown-formatted author notes. The field can be:
- Just a filename (e.g., `"2025-01-01.md"`) - looked up in `content/author_notes/` - Just a filename (e.g., `2025-01-01.md`) - looked up in `content/author_notes/`
- A path relative to `content/` (e.g., `"special/intro.md"`) - A path relative to `content/` (e.g., `special/intro.md`)
Markdown author notes take precedence over the plain text `author_note` field and render as HTML. Markdown author notes take precedence over the plain text `author_note` field and render as HTML.
Example: Example:
```python ```yaml
# In comics_data.py # In data/comics/001.yaml
{ number: 1
'number': 1, filename: comic-001.png
'filename': 'comic-001.png', date: '2025-01-01'
'date': '2025-01-01', alt_text: First comic
'alt_text': 'First comic', author_note_md: 2025-01-01.md # References content/author_notes/2025-01-01.md
'author_note_md': '2025-01-01.md' # References content/author_notes/2025-01-01.md
}
``` ```
```bash ```bash
@@ -800,6 +862,102 @@ The `/about` route renders `content/about.md` as HTML. Edit this file to customi
**Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL. **Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL.
### Multi-Image Comics (Webtoon Style)
Sunday Comics supports vertical scrolling comics with multiple images stacked seamlessly, perfect for webtoon-style storytelling.
**How it works:**
- Set `filename` to a list of image filenames instead of a single string
- Images display vertically with no gaps between them
- First image loads immediately, subsequent images lazy-load as readers scroll
- No click-through navigation on multi-image comics (use navigation buttons instead)
**Basic Example:**
```yaml
# In data/comics/004.yaml
number: 4
title: Webtoon Episode 1
filename:
- page1.jpg
- page2.jpg
- page3.jpg
alt_text: A three-part vertical story # Single alt text for all images
date: '2025-01-22'
```
**Individual Alt Text (Recommended for Accessibility):**
```yaml
# In data/comics/005.yaml
number: 5
title: Long Scroll Episode
filename:
- scene1.png
- scene2.png
- scene3.png
- scene4.png
alt_text:
- Opening scene showing the city at dawn
- Character walking through the marketplace
- Close-up of the mysterious artifact
- Dramatic reveal of the antagonist
date: '2025-01-29'
```
**Important:** If you provide `alt_text` as a list, it should match the number of images in `filename`. If the counts don't match, you'll see a warning in the logs. To use the same alt text for all images, just provide a single string instead of a list.
**Features:**
- ✅ **Seamless vertical layout** - Images stack with no visible gaps
- ✅ **Lazy loading** - Only loads images as they scroll into view (performance optimization)
- ✅ **Responsive** - Works on desktop and mobile devices
- ✅ **Accessible** - Supports individual alt text for each image panel
- ✅ **Backward compatible** - Single-image comics continue to work as before
**Best Practices:**
1. **Image consistency** - Use the same width for all images in a multi-image comic for best results
2. **Alt text per panel** - Provide individual alt text for each image to describe what's happening in that section
3. **File naming** - Use descriptive, sequential names like `comic-004-panel-1.png`, `comic-004-panel-2.png`
4. **Image optimization** - Compress images appropriately since readers will load multiple images per comic
**Example with all options:**
```yaml
# In data/comics/006.yaml
number: 6
title: 'Chapter 2: The Journey Begins'
filename:
- ch2-001.png
- ch2-002.png
- ch2-003.png
- ch2-004.png
- ch2-005.png
alt_text:
- 'Panel 1: Hero packs their bag at sunrise'
- 'Panel 2: Saying goodbye to the village elder'
- 'Panel 3: Walking along the forest path'
- 'Panel 4: Encountering a mysterious stranger'
- 'Panel 5: Accepting a map to the ancient ruins'
date: '2025-02-05'
author_note: This was so much fun to draw! The journey arc begins.
full_width: true # Recommended for webtoon-style comics
section: Chapter 2 # Optional: mark the start of a new chapter
```
**Technical Details:**
- Images appear in the order listed in the `filename` array
- If `alt_text` is a single string, it applies to all images
- If `alt_text` is a list, it must match the length of `filename` (or it will pad with empty strings)
- The first image in the array is used as the thumbnail in the archive page
- Lazy loading uses Intersection Observer API with 50px margin for smooth loading
**When to use multi-image:**
- Long-form vertical scrolling stories (webtoons, manhwa style)
- Comics with natural panel breaks across multiple images
- Stories that benefit from vertical pacing and reveals
**When to stick with single images:**
- Traditional comic strip or page layouts
- Self-contained single-panel comics
- When you want click-through navigation on the comic image
## Production Deployment ## Production Deployment
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods: For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2025.11.15

129
app.py
View File

@@ -3,6 +3,7 @@
# Licensed under the MIT License - see LICENSE file for details # Licensed under the MIT License - see LICENSE file for details
import os import os
import logging
from datetime import datetime from datetime import datetime
from flask import Flask, render_template, abort, jsonify, request from flask import Flask, render_template, abort, jsonify, request
from comics_data import ( from comics_data import (
@@ -12,6 +13,11 @@ from comics_data import (
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED
) )
import markdown import markdown
from version import __version__
# Configure logging
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
@@ -52,7 +58,8 @@ def inject_global_settings():
'social_email': SOCIAL_EMAIL, 'social_email': SOCIAL_EMAIL,
'api_spec_link': API_SPEC_LINK, 'api_spec_link': API_SPEC_LINK,
'embed_enabled': EMBED_ENABLED, 'embed_enabled': EMBED_ENABLED,
'permalink_enabled': PERMALINK_ENABLED 'permalink_enabled': PERMALINK_ENABLED,
'version': __version__
} }
@@ -117,6 +124,47 @@ def enrich_comic(comic):
enriched['plain'] = is_plain(comic) enriched['plain'] = is_plain(comic)
enriched['formatted_date'] = format_comic_date(comic['date']) enriched['formatted_date'] = format_comic_date(comic['date'])
# Normalize filename to list for multi-image support
if isinstance(comic.get('filename'), list):
enriched['filenames'] = comic['filename']
enriched['is_multi_image'] = True
else:
enriched['filenames'] = [comic['filename']] if 'filename' in comic else []
enriched['is_multi_image'] = False
# Normalize alt_text to list matching filenames
if isinstance(comic.get('alt_text'), list):
enriched['alt_texts'] = comic['alt_text']
# Warn if alt_text list doesn't match filenames length
if len(enriched['alt_texts']) != len(enriched['filenames']):
logger.warning(
f"Comic #{comic['number']}: alt_text list length ({len(enriched['alt_texts'])}) "
f"doesn't match filenames length ({len(enriched['filenames'])}). "
f"Tip: Use a single string for alt_text to apply the same text to all images, "
f"or provide a list matching the number of images."
)
else:
# If single alt_text string, use it for all images (this is intentional and valid)
alt_text = comic.get('alt_text', '')
enriched['alt_texts'] = [alt_text] * len(enriched['filenames'])
# Ensure alt_texts list matches filenames length (pad with empty strings if too short)
while len(enriched['alt_texts']) < len(enriched['filenames']):
enriched['alt_texts'].append('')
# Trim alt_texts if too long (extra ones won't be used anyway)
if len(enriched['alt_texts']) > len(enriched['filenames']):
enriched['alt_texts'] = enriched['alt_texts'][:len(enriched['filenames'])]
# Keep original filename and alt_text for backward compatibility (first image)
if enriched['filenames']:
enriched['filename'] = enriched['filenames'][0]
# Ensure alt_text is always a string (use first one if it's a list)
if enriched['alt_texts']:
enriched['alt_text'] = enriched['alt_texts'][0]
# Check for explicitly specified markdown author note file # Check for explicitly specified markdown author note file
if 'author_note_md' in comic and comic['author_note_md']: if 'author_note_md' in comic and comic['author_note_md']:
markdown_note = get_author_note_from_file(comic['author_note_md']) markdown_note = get_author_note_from_file(comic['author_note_md'])
@@ -216,14 +264,22 @@ def group_comics_by_section(comics_list):
@app.route('/archive') @app.route('/archive')
def archive(): def archive():
"""Archive page showing all comics""" """Archive page showing all comics"""
# Initial batch size for server-side rendering
initial_batch = 24
# Reverse order to show newest first # Reverse order to show newest first
comics = [enrich_comic(comic) for comic in reversed(COMICS)] all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
# Only take the first batch for initial render
initial_comics = all_comics[:initial_batch]
# Group by section if enabled # Group by section if enabled
sections = group_comics_by_section(comics) sections = group_comics_by_section(initial_comics)
return render_template('archive.html', title='Archive', return render_template('archive.html', title='Archive',
sections=sections) sections=sections,
total_comics=len(COMICS),
initial_batch=initial_batch)
@app.route('/about') @app.route('/about')
@@ -264,8 +320,69 @@ def terms():
@app.route('/api/comics') @app.route('/api/comics')
def api_comics(): def api_comics():
"""API endpoint - returns all comics as JSON""" """API endpoint - returns all comics as JSON (optionally paginated with sections)"""
return jsonify([enrich_comic(comic) for comic in COMICS]) # Check for pagination parameters
page = request.args.get('page', type=int)
per_page = request.args.get('per_page', type=int)
group_by_section = request.args.get('group_by_section', 'false').lower() in ('true', '1', 'yes')
# If no pagination requested, return simple array (backward compatible)
if page is None and per_page is None and not group_by_section:
return jsonify([enrich_comic(comic) for comic in COMICS])
# Pagination requested - return paginated response
page = page or 1
per_page = per_page or 24
# Limit per_page to reasonable values
per_page = min(max(per_page, 1), 100)
# Reverse order to show newest first
all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
# Group by section if enabled globally or requested via parameter
sections = group_comics_by_section(all_comics) if (SECTIONS_ENABLED or group_by_section) else [(None, all_comics)]
# Calculate pagination
total_comics = len(all_comics)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
# Handle section-aware pagination
result_sections = []
current_idx = 0
for section_title, section_comics in sections:
section_start = current_idx
section_end = current_idx + len(section_comics)
# Check if this section overlaps with our requested page
if section_end > start_idx and section_start < end_idx:
# Calculate which comics from this section to include
comics_start = max(0, start_idx - section_start)
comics_end = min(len(section_comics), end_idx - section_start)
paginated_comics = section_comics[comics_start:comics_end]
if paginated_comics:
result_sections.append({
'section_title': section_title,
'comics': paginated_comics
})
current_idx = section_end
# Stop if we've gone past the requested range
if current_idx >= end_idx:
break
return jsonify({
'sections': result_sections,
'page': page,
'per_page': per_page,
'total_comics': total_comics,
'has_more': end_idx < total_comics
})
@app.route('/api/comics/<int:comic_id>') @app.route('/api/comics/<int:comic_id>')

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"

179
data_loader.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Comic data loader for YAML-based comic management with caching.
This module scans the data/comics/ directory for .yaml files,
loads each comic's configuration, and builds the COMICS list.
Caching is used to speed up subsequent loads.
"""
import os
import pickle
import yaml
from pathlib import Path
def load_comics_from_yaml(comics_dir='data/comics', use_cache=True):
"""
Load all comic data from YAML files with optional caching.
Args:
comics_dir: Path to directory containing comic YAML files
use_cache: Whether to use cache (set to False to force reload)
Returns:
List of comic dictionaries, sorted by comic number
"""
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 []
# Cache file location
cache_file = comics_path / '.comics_cache.pkl'
# Check if caching is disabled via environment variable
if os.getenv('DISABLE_COMIC_CACHE') == 'true':
use_cache = False
# 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 []
# Check if we can use cache
if use_cache and cache_file.exists():
cache_mtime = cache_file.stat().st_mtime
# Get the newest YAML file modification time
newest_yaml_mtime = max(f.stat().st_mtime for f in yaml_files)
# If cache is newer than all YAML files, use it
if cache_mtime >= newest_yaml_mtime:
try:
with open(cache_file, 'rb') as f:
comics = pickle.load(f)
print(f"Loaded {len(comics)} comics from cache")
return comics
except Exception as e:
print(f"Warning: Failed to load cache: {e}")
# Fall through to reload from YAML
# Load from YAML files (cache miss or disabled)
print(f"Loading {len(yaml_files)} comic files from YAML...")
comics = []
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'])
# Save to cache
if use_cache:
try:
with open(cache_file, 'wb') as f:
pickle.dump(comics, f)
print(f"Saved {len(comics)} comics to cache")
except Exception as e:
print(f"Warning: Failed to save cache: {e}")
return comics
def clear_cache(comics_dir='data/comics'):
"""
Clear the comics cache file.
Args:
comics_dir: Path to directory containing comic YAML files
"""
cache_file = Path(comics_dir) / '.comics_cache.pkl'
if cache_file.exists():
cache_file.unlink()
print("Cache cleared")
return True
else:
print("No cache file found")
return False
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__':

154
scripts/bump_version.py Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# Sunday Comics - Version bump script
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
"""
Script to bump the project version number
Usage:
python scripts/bump_version.py # Use today's date
python scripts/bump_version.py 2025.12.25 # Use specific date
"""
import sys
import os
import re
from datetime import datetime
import argparse
def validate_version(version_str):
"""Validate version format (YYYY.MM.DD)"""
pattern = r'^\d{4}\.\d{2}\.\d{2}$'
if not re.match(pattern, version_str):
return False
# Try to parse as a date to ensure it's valid
try:
parts = version_str.split('.')
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
datetime(year, month, day)
return True
except ValueError:
return False
def get_current_version(parent_dir):
"""Read current version from version.py"""
version_file = os.path.join(parent_dir, 'version.py')
try:
with open(version_file, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
except FileNotFoundError:
pass
return None
def update_version_py(parent_dir, new_version):
"""Update version.py with new version"""
version_file = os.path.join(parent_dir, 'version.py')
content = f"""# Sunday Comics Version
# This file contains the version number for the project
# Format: YYYY.MM.DD (date-based versioning)
__version__ = "{new_version}"
"""
with open(version_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✓ Updated {version_file}")
def update_version_file(parent_dir, new_version):
"""Update VERSION file with new version"""
version_file = os.path.join(parent_dir, 'VERSION')
with open(version_file, 'w', encoding='utf-8') as f:
f.write(f"{new_version}\n")
print(f"✓ Updated {version_file}")
def remind_changelog(parent_dir, new_version, current_version):
"""Remind user to update CHANGELOG.md"""
changelog_file = os.path.join(parent_dir, 'CHANGELOG.md')
print(f"\n📝 Don't forget to update {changelog_file}!")
print(f"\nAdd your changes under the new version section:")
print(f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}")
print(f"\n### Added")
print(f"### Changed")
print(f"### Fixed")
if os.path.exists(changelog_file):
print(f"\n💡 Tip: Edit the file now with: nano {changelog_file}")
def main():
parser = argparse.ArgumentParser(
description='Bump project version number',
epilog='Examples:\n %(prog)s\n %(prog)s 2025.12.25',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'version',
nargs='?',
help='Version number in YYYY.MM.DD format (defaults to today\'s date)'
)
parser.add_argument(
'--no-changelog-reminder',
action='store_true',
help='Skip the changelog reminder'
)
args = parser.parse_args()
# Determine new version
if args.version:
new_version = args.version
if not validate_version(new_version):
print(f"Error: Invalid version format '{new_version}'")
print(f"Expected format: YYYY.MM.DD (e.g., 2025.12.25)")
sys.exit(1)
else:
# Use today's date
new_version = datetime.now().strftime('%Y.%m.%d')
# Get parent directory (project root)
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Get current version
current_version = get_current_version(parent_dir)
if current_version:
print(f"Current version: {current_version}")
print(f"New version: {new_version}")
# Check if version is the same
if current_version == new_version:
print(f"\n⚠️ Version is already {new_version}")
response = input("Continue anyway? [y/N]: ").lower().strip()
if response != 'y':
print("Aborted.")
sys.exit(0)
# Update files
print(f"\nUpdating version files...")
update_version_py(parent_dir, new_version)
update_version_file(parent_dir, new_version)
print(f"\n✅ Version bumped to {new_version}")
# Remind about changelog
if not args.no_changelog_reminder:
remind_changelog(parent_dir, new_version, current_version)
if __name__ == '__main__':
main()

84
scripts/publish_comic.py Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# Sunday Comics - Publish script
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
"""
Convenience script to rebuild cache and regenerate all static files.
Run this after adding or updating comics.
"""
import sys
import os
import subprocess
# Add parent directory to path so we can import data_loader
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data_loader import load_comics_from_yaml, clear_cache
def run_script(script_name, description):
"""Run a script and handle errors"""
script_dir = os.path.dirname(os.path.abspath(__file__))
script_path = os.path.join(script_dir, script_name)
print(f"{description}...")
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True
)
if result.returncode == 0:
# Print only the summary line (last non-empty line)
output_lines = [line for line in result.stdout.strip().split('\n') if line.strip()]
if output_lines:
print(f"{output_lines[-1]}")
else:
print(f" ✗ Failed!")
if result.stderr:
print(f" Error: {result.stderr}")
return False
return True
def main():
"""Rebuild cache and regenerate all static files"""
print("=" * 60)
print("Publishing Comics")
print("=" * 60)
print()
# Step 1: Rebuild cache
print("1. Rebuilding comics cache...")
clear_cache()
# Load with cache enabled - since we just cleared it, this will reload from YAML
# and automatically save the cache
comics = load_comics_from_yaml(use_cache=True)
if not comics:
print(" ✗ No comics found!")
sys.exit(1)
print(f" ✓ Cached {len(comics)} comics")
print()
# Step 2: Generate RSS feed
success = run_script('generate_rss.py', '2. Generating RSS feed')
if not success:
sys.exit(1)
print()
# Step 3: Generate sitemap
success = run_script('generate_sitemap.py', '3. Generating sitemap')
if not success:
sys.exit(1)
print()
print("=" * 60)
print("✓ All static files updated successfully!")
print("=" * 60)
if __name__ == '__main__':
main()

38
scripts/rebuild_cache.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
# Sunday Comics - Cache rebuild script
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
"""
Script to rebuild the comics cache from YAML files.
Useful for forcing a fresh cache build.
"""
import sys
import os
# Add parent directory to path so we can import data_loader
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data_loader import load_comics_from_yaml, clear_cache
def main():
"""Rebuild the comics cache"""
print("Clearing existing cache...")
clear_cache()
print()
print("Rebuilding cache from YAML files...")
# Load with cache enabled - since we just cleared it, this will reload from YAML
# and automatically save the cache
comics = load_comics_from_yaml(use_cache=True)
print()
if comics:
print(f"✓ Cache rebuilt successfully with {len(comics)} comics")
else:
print("✗ No comics found to cache")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -431,6 +431,30 @@ main {
margin: 0 auto; margin: 0 auto;
} }
/* Multi-image comics (webtoon style) */
.comic-image-multi {
padding: 0; /* Remove padding for seamless stacking */
}
.comic-image-multi img {
display: block;
width: 100%;
margin: 0;
padding: 0;
vertical-align: bottom; /* Removes tiny gap below images */
}
/* Lazy-loaded images - placeholder while loading */
.comic-image-multi img.lazy-load {
min-height: 200px;
background: var(--color-hover-bg);
}
.comic-image-multi img.lazy-load.loaded {
min-height: auto;
background: none;
}
/* Comic Navigation */ /* Comic Navigation */
.comic-navigation { .comic-navigation {
border-top: var(--border-width-thin) solid var(--border-color); border-top: var(--border-width-thin) solid var(--border-color);

View File

@@ -0,0 +1,221 @@
/**
* Sunday Comics - Archive Lazy Loading
* Implements infinite scroll for the archive page
*/
(function() {
'use strict';
let currentPage = 1;
let isLoading = false;
let hasMore = true;
const perPage = 24;
// Get elements
const archiveContent = document.querySelector('.archive-content');
if (!archiveContent) return; // Not on archive page
const totalComics = parseInt(archiveContent.dataset.totalComics || '0');
const initialBatch = parseInt(archiveContent.dataset.initialBatch || '24');
// Calculate if there are more comics to load
hasMore = totalComics > initialBatch;
// Create loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'archive-loading';
loadingIndicator.innerHTML = '<p>Loading more comics...</p>';
loadingIndicator.style.display = 'none';
loadingIndicator.style.textAlign = 'center';
loadingIndicator.style.padding = '2rem';
archiveContent.parentNode.insertBefore(loadingIndicator, archiveContent.nextSibling);
/**
* Load more comics from the API
*/
async function loadMoreComics() {
if (isLoading || !hasMore) return;
isLoading = true;
loadingIndicator.style.display = 'block';
try {
currentPage++;
const response = await fetch(`/api/comics?page=${currentPage}&per_page=${perPage}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Add new comics to the DOM
appendComics(data.sections);
// Update state
hasMore = data.has_more;
if (!hasMore) {
loadingIndicator.innerHTML = '<p>End of archive</p>';
setTimeout(() => {
loadingIndicator.style.display = 'none';
}, 2000);
}
} catch (error) {
console.error('Error loading more comics:', error);
loadingIndicator.innerHTML = '<p>Error loading comics. Please try again.</p>';
setTimeout(() => {
loadingIndicator.style.display = 'none';
isLoading = false;
}, 3000);
return;
}
isLoading = false;
loadingIndicator.style.display = 'none';
}
/**
* Append comics to the archive
* @param {Array} sections - Array of section objects with title and comics
*/
function appendComics(sections) {
const archiveFullWidth = document.querySelector('.archive-content-fullwidth') !== null;
const sectionsEnabled = document.querySelector('.section-header') !== null;
sections.forEach(section => {
const sectionTitle = section.section_title;
const comics = section.comics;
// Check if we need to create a new section or append to existing
let targetGrid;
if (sectionsEnabled && sectionTitle) {
// Check if section already exists
const existingSection = findSectionByTitle(sectionTitle);
if (existingSection) {
// Append to existing section grid
targetGrid = existingSection.querySelector('.archive-grid');
} else {
// Create new section
const sectionHeader = document.createElement('div');
sectionHeader.className = 'section-header';
sectionHeader.innerHTML = `<h2>${sectionTitle}</h2>`;
archiveContent.appendChild(sectionHeader);
targetGrid = document.createElement('div');
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
archiveContent.appendChild(targetGrid);
}
} else {
// No sections or no title - use the last grid or create one
targetGrid = archiveContent.querySelector('.archive-grid:last-of-type');
if (!targetGrid) {
targetGrid = document.createElement('div');
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
archiveContent.appendChild(targetGrid);
}
}
// Add each comic to the grid
comics.forEach(comic => {
const item = createArchiveItem(comic, archiveFullWidth);
targetGrid.appendChild(item);
});
});
}
/**
* Find an existing section by title
* @param {string} title - Section title to find
* @returns {Element|null} - The section element or null
*/
function findSectionByTitle(title) {
const sectionHeaders = archiveContent.querySelectorAll('.section-header h2');
for (const header of sectionHeaders) {
if (header.textContent.trim() === title) {
// Return the grid following this header
let nextEl = header.parentElement.nextElementSibling;
while (nextEl && !nextEl.classList.contains('archive-grid')) {
nextEl = nextEl.nextElementSibling;
}
return nextEl ? nextEl.parentElement : null;
}
}
return null;
}
/**
* Create an archive item element
* @param {Object} comic - Comic data
* @param {boolean} fullWidth - Whether using full width layout
* @returns {Element} - The archive item element
*/
function createArchiveItem(comic, fullWidth) {
const item = document.createElement('div');
item.className = 'archive-item' + (fullWidth ? ' archive-item-fullwidth' : '');
const link = document.createElement('a');
link.href = `/comic/${comic.number}`;
const img = document.createElement('img');
img.src = `/static/images/thumbs/${comic.filename}`;
img.alt = comic.title || `#${comic.number}`;
img.loading = 'lazy';
img.onerror = function() {
this.onerror = null;
this.src = '/static/images/thumbs/default.jpg';
};
const info = document.createElement('div');
info.className = 'archive-info';
if (!fullWidth) {
const title = document.createElement('h3');
title.textContent = `#${comic.number}${comic.title ? ': ' + comic.title : ''}`;
info.appendChild(title);
}
const date = document.createElement('p');
date.className = 'archive-date';
date.textContent = comic.date;
info.appendChild(date);
link.appendChild(img);
link.appendChild(info);
item.appendChild(link);
return item;
}
/**
* Check if user has scrolled near the bottom
*/
function checkScrollPosition() {
if (isLoading || !hasMore) return;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Trigger when user is within 1000px of the bottom
if (scrollTop + windowHeight >= documentHeight - 1000) {
loadMoreComics();
}
}
// Set up scroll listener
let scrollTimeout;
window.addEventListener('scroll', function() {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(checkScrollPosition, 100);
});
// Check initial scroll position (in case page is short)
setTimeout(checkScrollPosition, 500);
})();

View File

@@ -5,6 +5,7 @@
let totalComics = 0; let totalComics = 0;
let comicName = ''; // Will be extracted from initial page title let comicName = ''; // Will be extracted from initial page title
let currentComicNumber = 0; let currentComicNumber = 0;
let lazyLoadObserver = null;
// Fetch and display a comic // Fetch and display a comic
async function loadComic(comicId) { async function loadComic(comicId) {
@@ -79,8 +80,15 @@
const comicImageDiv = document.querySelector('.comic-image'); const comicImageDiv = document.querySelector('.comic-image');
updateComicImage(comicImageDiv, comic, title); updateComicImage(comicImageDiv, comic, title);
// Update or create/remove the link wrapper // Update or create/remove the link wrapper (only for single-image comics)
updateComicImageLink(comic.number); if (!comic.is_multi_image) {
updateComicImageLink(comic.number);
}
// Initialize lazy loading for multi-image comics
if (comic.is_multi_image) {
initLazyLoad();
}
// Update author note // Update author note
let transcriptDiv = document.querySelector('.comic-transcript'); let transcriptDiv = document.querySelector('.comic-transcript');
@@ -137,35 +145,96 @@
} }
} }
// Initialize lazy loading for multi-image comics
function initLazyLoad() {
// Disconnect existing observer if any
if (lazyLoadObserver) {
lazyLoadObserver.disconnect();
}
// Create Intersection Observer for lazy loading
lazyLoadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
img.classList.add('loaded');
lazyLoadObserver.unobserve(img);
}
}
});
}, {
rootMargin: '50px' // Start loading 50px before image enters viewport
});
// Observe all lazy-load images
document.querySelectorAll('.lazy-load').forEach(img => {
lazyLoadObserver.observe(img);
});
}
// Update or create comic image with optional mobile version // Update or create comic image with optional mobile version
function updateComicImage(comicImageDiv, comic, title) { function updateComicImage(comicImageDiv, comic, title) {
// Clear all existing content // Clear all existing content
comicImageDiv.innerHTML = ''; comicImageDiv.innerHTML = '';
// Create new image element(s) // Update container class for multi-image
if (comic.mobile_filename) { if (comic.is_multi_image) {
// Create picture element with mobile source comicImageDiv.classList.add('comic-image-multi');
const picture = document.createElement('picture');
const source = document.createElement('source');
source.media = '(max-width: 768px)';
source.srcset = `/static/images/comics/${comic.mobile_filename}`;
const img = document.createElement('img');
img.src = `/static/images/comics/${comic.filename}`;
img.alt = title;
img.title = comic.alt_text;
picture.appendChild(source);
picture.appendChild(img);
comicImageDiv.appendChild(picture);
} else { } else {
// Create regular img element comicImageDiv.classList.remove('comic-image-multi');
const img = document.createElement('img'); }
img.src = `/static/images/comics/${comic.filename}`;
img.alt = title; // Create new image element(s)
img.title = comic.alt_text; if (comic.is_multi_image) {
comicImageDiv.appendChild(img); // Multi-image comic (webtoon style)
comic.filenames.forEach((filename, index) => {
const img = document.createElement('img');
if (index === 0) {
// First image loads immediately
img.src = `/static/images/comics/${filename}`;
img.loading = 'eager';
} else {
// Subsequent images lazy load
img.setAttribute('data-src', `/static/images/comics/${filename}`);
img.classList.add('lazy-load');
img.loading = 'lazy';
}
img.alt = comic.alt_texts[index] || '';
img.title = comic.alt_texts[index] || '';
comicImageDiv.appendChild(img);
});
} else {
// Single image comic
if (comic.mobile_filename) {
// Create picture element with mobile source
const picture = document.createElement('picture');
const source = document.createElement('source');
source.media = '(max-width: 768px)';
source.srcset = `/static/images/comics/${comic.mobile_filename}`;
const img = document.createElement('img');
img.src = `/static/images/comics/${comic.filename}`;
img.alt = title;
img.title = comic.alt_text;
picture.appendChild(source);
picture.appendChild(img);
comicImageDiv.appendChild(picture);
} else {
// Create regular img element
const img = document.createElement('img');
img.src = `/static/images/comics/${comic.filename}`;
img.alt = title;
img.title = comic.alt_text;
comicImageDiv.appendChild(img);
}
} }
} }
@@ -377,7 +446,17 @@
const dateDisplay = document.querySelector('.comic-date-display'); const dateDisplay = document.querySelector('.comic-date-display');
const formattedDate = dateDisplay ? dateDisplay.textContent : null; const formattedDate = dateDisplay ? dateDisplay.textContent : null;
updateNavButtons(currentNumber, formattedDate); updateNavButtons(currentNumber, formattedDate);
updateComicImageLink(currentNumber);
// Check if current comic is multi-image
const comicImageDiv = document.querySelector('.comic-image');
const isMultiImage = comicImageDiv && comicImageDiv.classList.contains('comic-image-multi');
if (!isMultiImage) {
updateComicImageLink(currentNumber);
} else {
// Initialize lazy loading for multi-image comics on page load
initLazyLoad();
}
} }
// Handle browser back/forward // Handle browser back/forward

View File

@@ -16,39 +16,107 @@ paths:
/api/comics: /api/comics:
get: get:
summary: Get all comics summary: Get all comics
description: Returns a list of all comics with enriched metadata including formatted dates and author notes description: |
Returns all comics with enriched metadata. Supports optional pagination and section grouping.
**Without pagination parameters:** Returns a simple array of all comics (newest first when using pagination, original order otherwise).
**With pagination parameters:** Returns paginated response with section grouping (if enabled globally or via `group_by_section` parameter).
operationId: getAllComics operationId: getAllComics
tags: tags:
- Comics - Comics
parameters:
- name: page
in: query
description: Page number for pagination (1-indexed). When provided, triggers paginated response format.
required: false
schema:
type: integer
minimum: 1
default: 1
example: 1
- name: per_page
in: query
description: Number of comics per page (max 100). When provided, triggers paginated response format.
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 24
example: 24
- name: group_by_section
in: query
description: Force section grouping in response (even when SECTIONS_ENABLED is false). When true, triggers paginated response format.
required: false
schema:
type: boolean
default: false
example: false
responses: responses:
'200': '200':
description: Successful response with array of comics description: Successful response
content: content:
application/json: application/json:
schema: schema:
type: array oneOf:
items: - type: array
$ref: '#/components/schemas/Comic' description: Simple array response (when no pagination parameters provided)
example: items:
- number: 1 $ref: '#/components/schemas/Comic'
title: "First Comic" - $ref: '#/components/schemas/PaginatedComicsResponse'
filename: "comic-001.jpg" examples:
mobile_filename: "comic-001-mobile.jpg" simpleArray:
date: "2025-01-01" summary: Simple array response (default)
alt_text: "The very first comic" value:
author_note: "This is where your comic journey begins!" - number: 1
full_width: true title: "First Comic"
plain: true filename: "comic-001.jpg"
formatted_date: "Wednesday, January 1, 2025" mobile_filename: "comic-001-mobile.jpg"
author_note_is_html: false date: "2025-01-01"
- number: 2 alt_text: "The very first comic"
filename: "comic-002.jpg" author_note: "This is where your comic journey begins!"
date: "2025-01-08" full_width: true
alt_text: "The second comic" plain: true
full_width: true formatted_date: "Wednesday, January 1, 2025"
plain: true author_note_is_html: false
formatted_date: "Wednesday, January 8, 2025" - number: 2
author_note_is_html: false filename: "comic-002.jpg"
date: "2025-01-08"
alt_text: "The second comic"
full_width: true
plain: true
formatted_date: "Wednesday, January 8, 2025"
author_note_is_html: false
paginatedResponse:
summary: Paginated response (when using page/per_page parameters)
value:
sections:
- section_title: "Chapter 1"
comics:
- number: 2
filename: "comic-002.jpg"
date: "2025-01-08"
alt_text: "The second comic"
full_width: true
plain: true
formatted_date: "Wednesday, January 8, 2025"
author_note_is_html: false
- section_title: null
comics:
- number: 1
title: "First Comic"
filename: "comic-001.jpg"
date: "2025-01-01"
alt_text: "The very first comic"
full_width: true
plain: true
formatted_date: "Wednesday, January 1, 2025"
author_note_is_html: false
page: 1
per_page: 24
total_comics: 2
has_more: false
/api/comics/{comic_id}: /api/comics/{comic_id}:
get: get:
@@ -107,6 +175,9 @@ components:
- plain - plain
- formatted_date - formatted_date
- author_note_is_html - author_note_is_html
- filenames
- alt_texts
- is_multi_image
properties: properties:
number: number:
type: integer type: integer
@@ -118,9 +189,20 @@ components:
description: Comic title (optional, defaults to "#X" if not provided) description: Comic title (optional, defaults to "#X" if not provided)
example: "First Comic" example: "First Comic"
filename: filename:
type: string oneOf:
description: Image filename in static/images/comics/ - type: string
description: Single image filename in static/images/comics/
- type: array
description: Multiple image filenames for webtoon-style comics
items:
type: string
example: "comic-001.jpg" example: "comic-001.jpg"
filenames:
type: array
description: Normalized array of image filenames (computed field, always an array even for single images)
items:
type: string
example: ["comic-001.jpg"]
mobile_filename: mobile_filename:
type: string type: string
description: Optional mobile version of the comic image description: Optional mobile version of the comic image
@@ -131,13 +213,36 @@ components:
description: Publication date in YYYY-MM-DD format description: Publication date in YYYY-MM-DD format
example: "2025-01-01" example: "2025-01-01"
alt_text: alt_text:
type: string oneOf:
description: Accessibility text for the comic image - type: string
description: Accessibility text for single image or shared text for all images
- type: array
description: Individual accessibility text for each image in multi-image comics
items:
type: string
example: "The very first comic" example: "The very first comic"
alt_texts:
type: array
description: Normalized array of alt texts matching filenames (computed field)
items:
type: string
example: ["The very first comic"]
is_multi_image:
type: boolean
description: Indicates if this is a multi-image comic (computed field)
example: false
author_note: author_note:
type: string type: string
description: Author's note about the comic (plain text or HTML from markdown) description: Author's note about the comic (plain text or HTML from markdown)
example: "This is where your comic journey begins!" example: "This is where your comic journey begins!"
author_note_md:
type: string
description: Filename or path to markdown file for author note
example: "2025-01-01.md"
section:
type: string
description: Section/chapter title (appears on archive page when SECTIONS_ENABLED is true)
example: "Chapter 1: Origins"
full_width: full_width:
type: boolean type: boolean
description: Whether the comic should display in full-width mode (computed from global default and per-comic override) description: Whether the comic should display in full-width mode (computed from global default and per-comic override)
@@ -155,6 +260,58 @@ components:
description: Indicates whether author_note contains HTML (from markdown) or plain text (computed field) description: Indicates whether author_note contains HTML (from markdown) or plain text (computed field)
example: false example: false
ComicSection:
type: object
required:
- section_title
- comics
properties:
section_title:
type: string
nullable: true
description: Section/chapter title (null for comics without a section)
example: "Chapter 1"
comics:
type: array
description: Comics in this section
items:
$ref: '#/components/schemas/Comic'
PaginatedComicsResponse:
type: object
required:
- sections
- page
- per_page
- total_comics
- has_more
properties:
sections:
type: array
description: Comics grouped by section
items:
$ref: '#/components/schemas/ComicSection'
page:
type: integer
description: Current page number
minimum: 1
example: 1
per_page:
type: integer
description: Number of comics per page
minimum: 1
maximum: 100
example: 24
total_comics:
type: integer
description: Total number of comics across all pages
minimum: 0
example: 100
has_more:
type: boolean
description: Whether there are more pages available
example: true
Error: Error:
type: object type: object
required: required:

View File

@@ -7,10 +7,12 @@
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}"> <div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
<h1>Comic Archive</h1> <h1>Comic Archive</h1>
<p>Browse all {% set total = namespace(count=0) %}{% for section_title, section_comics in sections %}{% set total.count = total.count + section_comics|length %}{% endfor %}{{ total.count }} comics</p> <p>Browse all {{ total_comics }} comics</p>
</div> </div>
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"> <section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
data-total-comics="{{ total_comics }}"
data-initial-batch="{{ initial_batch }}">
{% for section_title, section_comics in sections %} {% for section_title, section_comics in sections %}
{% if section_title and sections_enabled %} {% if section_title and sections_enabled %}
<div class="section-header"> <div class="section-header">
@@ -43,3 +45,7 @@
<div class="container"> {# Reopen container for footer #} <div class="container"> {# Reopen container for footer #}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/archive-lazy-load.js') }}"></script>
{% endblock %}

View File

@@ -9,6 +9,9 @@
<meta name="description" content="{% block meta_description %}A webcomic about life, the universe, and everything{% endblock %}"> <meta name="description" content="{% block meta_description %}A webcomic about life, the universe, and everything{% endblock %}">
<link rel="canonical" href="{% block canonical %}{{ site_url }}{{ request.path }}{% endblock %}"> <link rel="canonical" href="{% block canonical %}{{ site_url }}{{ request.path }}{% endblock %}">
<!-- Version -->
<meta name="generator" content="Sunday Comics {{ version }}">
<!-- AI Scraping Prevention --> <!-- AI Scraping Prevention -->
<meta name="robots" content="noai, noimageai"> <meta name="robots" content="noai, noimageai">
<meta name="googlebot" content="noai, noimageai"> <meta name="googlebot" content="noai, noimageai">

View File

@@ -37,36 +37,48 @@
</div> </div>
{% endif %} {% endif %}
<div class="comic-image" id="comic-image-focus" tabindex="-1"> <div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
{% if comic.number < total_comics %} {% if comic.is_multi_image %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic"> {# Multi-image layout (webtoon style) - no click-through on individual images #}
{% if comic.mobile_filename %} {% for i in range(comic.filenames|length) %}
<picture> <img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> {% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" alt="{{ comic.alt_texts[i] }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}" title="{{ comic.alt_texts[i] }}"
title="{{ comic.alt_text }}"> loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
</picture> {% endfor %}
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"
loading="eager">
{% endif %}
</a>
{% else %} {% else %}
{% if comic.mobile_filename %} {# Single image with click-through to next comic #}
<picture> {% if comic.number < total_comics %}
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> <a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic">
{% if comic.mobile_filename %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}" alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"> title="{{ comic.alt_text }}"
</picture> loading="eager">
{% endif %}
</a>
{% else %} {% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" {% if comic.mobile_filename %}
alt="{{ comic.title if comic.title else '#' ~ comic.number }}" <picture>
title="{{ comic.alt_text }}" <source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
loading="eager"> <img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"
loading="eager">
{% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>

View File

@@ -16,18 +16,29 @@
<p class="comic-date">{{ comic.date }}</p> <p class="comic-date">{{ comic.date }}</p>
</div> </div>
<div class="comic-image" id="comic-image-focus" tabindex="-1"> <div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
{% if comic.mobile_filename %} {% if comic.is_multi_image %}
<picture> {# Multi-image layout (webtoon style) - no click-through on individual images #}
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> {% for i in range(comic.filenames|length) %}
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
alt="{{ comic.alt_texts[i] }}"
title="{{ comic.alt_texts[i] }}"
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
{% endfor %}
{% else %}
{% if comic.mobile_filename %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}" alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"> title="{{ comic.alt_text }}">
</picture> {% endif %}
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
{% endif %} {% endif %}
</div> </div>

5
version.py Normal file
View File

@@ -0,0 +1,5 @@
# Sunday Comics Version
# This file contains the version number for the project
# Format: YYYY.MM.DD (date-based versioning)
__version__ = "2025.11.15"