Compare commits

...

6 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
15 changed files with 1078 additions and 148 deletions

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@
# 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

View File

@@ -44,12 +44,62 @@ 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: YAML Files in data/comics/ ### Data Layer: YAML Files in data/comics/
Comics are stored as individual YAML files in the `data/comics/` directory. The `data_loader.py` module automatically loads all `.yaml` files (except `TEMPLATE.yaml` and `README.yaml`), sorts them by comic number, and builds the `COMICS` list. 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:** **File structure:**
- `data/comics/001.yaml` - Comic #1 - `data/comics/001.yaml` - Comic #1
- `data/comics/002.yaml` - Comic #2 - `data/comics/002.yaml` - Comic #2
@@ -183,7 +233,7 @@ Global context variables injected into all templates:
## Important Implementation Details ## Important Implementation Details
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. 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. **Comic ordering**: COMICS list order (determined by the `number` field in each YAML file) determines comic sequence. Last item is the "latest" comic. 2. **Comic ordering**: COMICS list order (determined by the `number` field in each YAML file) determines comic sequence. Last item is the "latest" comic.

260
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
@@ -233,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**
@@ -632,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
@@ -659,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
@@ -683,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
@@ -697,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
@@ -716,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)
@@ -728,78 +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) OR list for multi-image date: '2025-01-01' # Publication date (required)
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version (single-image only) 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) OR list for multi-image 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):** **For multi-image comics (webtoon style):**
```python ```yaml
{ number: 2
'number': 2, filename:
'filename': ['page1.png', 'page2.png', 'page3.png'], # List of images - page1.png
'alt_text': ['Panel 1 description', 'Panel 2', 'Panel 3'], # Individual alt texts - page2.png
'date': '2025-01-08', - page3.png
'full_width': True # Recommended for webtoons 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
@@ -823,31 +873,34 @@ Sunday Comics supports vertical scrolling comics with multiple images stacked se
- No click-through navigation on multi-image comics (use navigation buttons instead) - No click-through navigation on multi-image comics (use navigation buttons instead)
**Basic Example:** **Basic Example:**
```python ```yaml
# In comics_data.py # In data/comics/004.yaml
{ number: 4
'number': 4, title: Webtoon Episode 1
'title': 'Webtoon Episode 1', filename:
'filename': ['page1.jpg', 'page2.jpg', 'page3.jpg'], # List of images - page1.jpg
'alt_text': 'A three-part vertical story', # Single alt text for all images - page2.jpg
'date': '2025-01-22', - 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):** **Individual Alt Text (Recommended for Accessibility):**
```python ```yaml
{ # In data/comics/005.yaml
'number': 5, number: 5
'title': 'Long Scroll Episode', title: Long Scroll Episode
'filename': ['scene1.png', 'scene2.png', 'scene3.png', 'scene4.png'], filename:
'alt_text': [ - scene1.png
'Opening scene showing the city at dawn', - scene2.png
'Character walking through the marketplace', - scene3.png
'Close-up of the mysterious artifact', - scene4.png
'Dramatic reveal of the antagonist' alt_text:
], # List must match number of images (or use single string for all) - Opening scene showing the city at dawn
'date': '2025-01-29', - 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. **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.
@@ -866,29 +919,26 @@ Sunday Comics supports vertical scrolling comics with multiple images stacked se
4. **Image optimization** - Compress images appropriately since readers will load multiple images per comic 4. **Image optimization** - Compress images appropriately since readers will load multiple images per comic
**Example with all options:** **Example with all options:**
```python ```yaml
{ # In data/comics/006.yaml
'number': 6, number: 6
'title': 'Chapter 2: The Journey Begins', title: 'Chapter 2: The Journey Begins'
'filename': [ filename:
'ch2-001.png', - ch2-001.png
'ch2-002.png', - ch2-002.png
'ch2-003.png', - ch2-003.png
'ch2-004.png', - ch2-004.png
'ch2-005.png' - ch2-005.png
], alt_text:
'alt_text': [ - 'Panel 1: Hero packs their bag at sunrise'
'Panel 1: Hero packs their bag at sunrise', - 'Panel 2: Saying goodbye to the village elder'
'Panel 2: Saying goodbye to the village elder', - 'Panel 3: Walking along the forest path'
'Panel 3: Walking along the forest path', - 'Panel 4: Encountering a mysterious stranger'
'Panel 4: Encountering a mysterious stranger', - 'Panel 5: Accepting a map to the ancient ruins'
'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.
'date': '2025-02-05', full_width: true # Recommended for webtoon-style comics
'author_note': 'This was so much fun to draw! The journey arc begins.', section: Chapter 2 # Optional: mark the start of a new chapter
'full_width': True, # Recommended for webtoon-style comics
'section': 'Chapter 2', # Optional: mark the start of a new chapter
}
``` ```
**Technical Details:** **Technical Details:**

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2025.11.15

83
app.py
View File

@@ -13,6 +13,7 @@ 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 # Configure logging
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
@@ -57,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__
} }
@@ -262,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')
@@ -310,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

@@ -1,25 +1,28 @@
""" """
Comic data loader for YAML-based comic management. Comic data loader for YAML-based comic management with caching.
This module scans the data/comics/ directory for .yaml files, This module scans the data/comics/ directory for .yaml files,
loads each comic's configuration, and builds the COMICS list. loads each comic's configuration, and builds the COMICS list.
Caching is used to speed up subsequent loads.
""" """
import os
import pickle
import yaml import yaml
from pathlib import Path from pathlib import Path
def load_comics_from_yaml(comics_dir='data/comics'): def load_comics_from_yaml(comics_dir='data/comics', use_cache=True):
""" """
Load all comic data from YAML files in the specified directory. Load all comic data from YAML files with optional caching.
Args: Args:
comics_dir: Path to directory containing comic YAML files comics_dir: Path to directory containing comic YAML files
use_cache: Whether to use cache (set to False to force reload)
Returns: Returns:
List of comic dictionaries, sorted by comic number List of comic dictionaries, sorted by comic number
""" """
comics = []
comics_path = Path(comics_dir) comics_path = Path(comics_dir)
if not comics_path.exists(): if not comics_path.exists():
@@ -27,6 +30,13 @@ def load_comics_from_yaml(comics_dir='data/comics'):
comics_path.mkdir(parents=True, exist_ok=True) comics_path.mkdir(parents=True, exist_ok=True)
return [] 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 # Find all .yaml and .yml files
yaml_files = list(comics_path.glob('*.yaml')) + list(comics_path.glob('*.yml')) yaml_files = list(comics_path.glob('*.yaml')) + list(comics_path.glob('*.yml'))
@@ -37,6 +47,28 @@ def load_comics_from_yaml(comics_dir='data/comics'):
print(f"Warning: No YAML files found in '{comics_dir}'") print(f"Warning: No YAML files found in '{comics_dir}'")
return [] 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: for yaml_file in yaml_files:
try: try:
with open(yaml_file, 'r', encoding='utf-8') as f: with open(yaml_file, 'r', encoding='utf-8') as f:
@@ -74,9 +106,35 @@ def load_comics_from_yaml(comics_dir='data/comics'):
# Sort by comic number # Sort by comic number
comics.sort(key=lambda c: c['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 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): def validate_comics(comics):
""" """
Validate the loaded comics for common issues. Validate the loaded comics for common issues.

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

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

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

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"