Compare commits

..

14 Commits

Author SHA1 Message Date
mi
2b8f30ef82 Release version 2025.11.18
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:44:41 +10:00
mi
882eed90f9 embed support 2025-11-18 13:40:52 +10:00
mi
d374df6b0b 🎨 configurable social links 2025-11-18 13:22:56 +10:00
mi
9c566bcc3c customizable newsletter 2025-11-16 13:30:00 +10:00
mi
2580ea076d enable upstream updates 2025-11-16 13:17:07 +10:00
mi
b1f0f9f09b 📝 note on protecting ip 2025-11-15 21:26:31 +10:00
mi
0381908610 :lightning: CDN option 2025-11-15 21:24:44 +10:00
mi
6b3d446207 🐳 setup cache, build sitemap, rss feed 2025-11-15 21:09:14 +10:00
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
26 changed files with 2101 additions and 332 deletions

30
.gitignore vendored
View File

@@ -1,6 +1,34 @@
# ============================================================
# FORK-FRIENDLY GITIGNORE
# ============================================================
# This gitignore is designed for the fork-and-customize workflow.
# - User content (comics, images) IS tracked in git
# - User config (comics_data.py, variables.css) IS tracked in git
# - Only generated/temporary files are ignored
# ============================================================
# IDE and environment
.idea .idea
.venv .venv
# This should be generated on deploy # Python bytecode
__pycache__/
*.pyc
*.pyo
*.pyd
# macOS
.DS_Store
# User configuration (use the .example files as templates)
# Uncomment these lines if you want to gitignore user config:
# comics_data.py
# static/css/variables.css
# Environment variables
.env
# Generated files (regenerated on deploy/publish)
static/feed.rss static/feed.rss
static/sitemap.xml static/sitemap.xml
data/comics/.comics_cache.pkl

43
CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# 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.18] - 2025-11-18
### Added
- HTML embed support for comics (`html_embed` field in YAML allows custom HTML instead of images)
- Configurable social links in footer (`SOCIAL_LINKS` in `comics_data.py`)
- Customizable newsletter section (`NEWSLETTER_ENABLED` and `NEWSLETTER_HTML` in `comics_data.py`)
- CDN option for serving static assets
- Upstream update workflow (`UPSTREAM.md`) for fork-friendly development
- Automatic cache building, RSS feed, and sitemap generation in Docker container
### Changed
- Footer social links now fully customizable with any platform (Instagram, YouTube, Patreon, etc.)
## [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.18...HEAD
[2025.11.18]: https://git.puercito.net/mi/sunday/compare/v2025.11.15...v2025.11.18
[2025.11.15]: https://git.puercito.net/mi/sunday/releases/tag/v2025.11.15

185
CLAUDE.md
View File

@@ -6,6 +6,106 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
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. 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.
## Fork-and-Customize Architecture
**IMPORTANT:** Sunday Comics is designed for users to fork and customize for their own webcomics. When making changes, maintain the separation between framework code and user customization to avoid breaking upstream updates.
### File Categories
**Core Framework Files** (Updated by upstream - DO NOT modify unless fixing bugs):
- `app.py` - Flask application logic
- `data_loader.py` - YAML loading and caching
- `templates/*.html` - Jinja2 templates
- `static/css/style.css` - Core framework styles (references CSS variables)
- `static/js/*.js` - Client-side navigation and functionality
- `scripts/*.py` - Utility scripts
- `version.py`, `VERSION` - Version management
- `Dockerfile`, `docker-compose.yml` - Deployment configuration
**User Customization Files** (Safe for users to modify):
- `comics_data.py` - Global configuration settings
- `static/css/variables.css` - Design variables (colors, fonts, spacing, layout)
- `data/comics/*.yaml` - Comic metadata (except TEMPLATE.yaml)
- `content/*.md` - Markdown content (about page, author notes, terms)
- `static/images/*` - User's images and graphics
**Template Files** (Reference only):
- `comics_data.py.example` - Configuration template showing all options
- `data/comics/TEMPLATE.yaml` - Comic file template
**Generated Files** (Auto-created, gitignored):
- `static/feed.rss`, `static/sitemap.xml` - Generated by scripts
- `data/comics/.comics_cache.pkl` - Comic cache
- `__pycache__/`, `*.pyc` - Python bytecode
### CSS Architecture
**Two-file CSS system** to separate customization from framework:
1. **`static/css/variables.css`** (User customization)
- Contains all CSS custom properties
- Organized by category: Colors, Typography, Spacing, Borders, Layout, Transitions
- Users edit this file to customize their design
- Loaded first in templates
2. **`static/css/style.css`** (Core framework)
- References variables from variables.css
- Contains structural styles and layout logic
- Should not be modified by users (to allow upstream updates)
- Loaded after variables.css
**When modifying styles:**
- Add new design tokens to `variables.css` (e.g., `--color-accent: #ff0000;`)
- Reference those variables in `style.css` (e.g., `color: var(--color-accent);`)
- Never hardcode values in `style.css` that users might want to customize
### Configuration Pattern
**`comics_data.py.example`** serves as a reference:
- Shows all available configuration options with defaults
- Updated when new settings are added to the framework
- Users can check this file when merging upstream updates
- Never imported - purely documentation
**When adding new configuration options:**
1. Add the option to `comics_data.py` with a default value
2. Add the same option to `comics_data.py.example` with documentation
3. Update `CHANGELOG.md` with migration instructions
4. Consider backward compatibility (provide sensible defaults)
### Best Practices for Code Changes
**DO:**
- ✅ Add new features to core framework files (app.py, templates, scripts)
- ✅ Create new CSS variables in variables.css for customizable values
- ✅ Update comics_data.py.example when adding new config options
- ✅ Document breaking changes in CHANGELOG.md with migration steps
- ✅ Test that user customizations (comics_data.py, variables.css) still work
- ✅ Keep file structure consistent with the fork-friendly model
**DON'T:**
- ❌ Hardcode design values in style.css that users might want to change
- ❌ Modify user content files (data/comics/*.yaml, content/*.md)
- ❌ Change the purpose or structure of user customization files
- ❌ Remove configuration options without deprecation warnings
- ❌ Make changes that require users to edit core framework files
**When adding new features:**
1. Ask: "Will users want to customize this?"
2. If yes: Add a variable/config option
3. If no: Implement in framework code
4. Always maintain the separation
### Upstream Update Workflow
Users following [UPSTREAM.md](UPSTREAM.md) will:
1. Fork the repository
2. Customize `comics_data.py` and `variables.css`
3. Add their comics and content
4. Periodically merge upstream updates: `git merge upstream/main`
5. Resolve conflicts (usually only in .example files)
6. Benefit from framework improvements without losing customizations
## Development Commands ## Development Commands
**Run the development server:** **Run the development server:**
@@ -44,12 +144,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
@@ -64,7 +214,8 @@ Comics are stored as individual YAML files in the `data/comics/` directory. The
Each comic YAML file contains: Each comic YAML file contains:
- `number` (required): Sequential comic number - `number` (required): Sequential comic number
- `filename` (required): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style) - `filename` (required unless using html_embed): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style)
- `html_embed` (optional): Custom HTML to embed instead of an image (e.g., video player, widget). Takes precedence over `filename`.
- `date` (required): Publication date in YYYY-MM-DD format - `date` (required): Publication date in YYYY-MM-DD format
- `alt_text` (required): Accessibility text OR list of alt texts (one per image for multi-image comics) - `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)
@@ -74,6 +225,18 @@ Each comic YAML file contains:
- `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.
**HTML embeds in YAML:**
```yaml
html_embed: '<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" frameborder="0" allowfullscreen></iframe>'
alt_text: "Video description for accessibility"
```
- Use `html_embed` to display custom HTML content instead of an image
- Useful for embedding videos, interactive widgets, or special content
- When `html_embed` is present, it takes precedence over `filename` and `mobile_filename`
- The HTML is rendered as-is using the `| safe` filter in templates
- No click-through navigation on HTML embeds (use navigation buttons instead)
- Still provide `alt_text` for accessibility context
**Multi-image comics (webtoon style) in YAML:** **Multi-image comics (webtoon style) in YAML:**
```yaml ```yaml
filename: filename:
@@ -107,9 +270,8 @@ Global configuration in `comics_data.py`:
- `USE_HEADER_NAV_ICONS`: Set to True to display icons next to main header navigation text (uses alert.png, archive.png, info.png) - `USE_HEADER_NAV_ICONS`: Set to True to display icons next to main header navigation text (uses alert.png, archive.png, info.png)
- `USE_FOOTER_SOCIAL_ICONS`: Set to True to use icons instead of text for footer social links (uses instagram.png, youtube.png, mail.png, alert.png) - `USE_FOOTER_SOCIAL_ICONS`: Set to True to use icons instead of text for footer social links (uses instagram.png, youtube.png, mail.png, alert.png)
- `NEWSLETTER_ENABLED`: Set to True to show newsletter section in footer - `NEWSLETTER_ENABLED`: Set to True to show newsletter section in footer
- `SOCIAL_INSTAGRAM`: Instagram URL (set to None to hide) - `NEWSLETTER_HTML`: Custom HTML for newsletter form (user pastes their service's form code here)
- `SOCIAL_YOUTUBE`: YouTube URL (set to None to hide) - `SOCIAL_LINKS`: List of dicts for social media links. Each dict has 'label', 'url', and optional 'icon' (filename in static/images/icons/). Users can add any platform (Instagram, YouTube, Bluesky, Patreon, etc.)
- `SOCIAL_EMAIL`: Email mailto link (set to None to hide)
### Markdown Support ### Markdown Support
@@ -140,7 +302,8 @@ Global configuration in `comics_data.py`:
Provides SPA-like navigation without page reloads: Provides SPA-like navigation without page reloads:
- Fetches comics from `/api/comics/<id>` - Fetches comics from `/api/comics/<id>`
- Updates DOM with `displayComic(comic)` function - Updates DOM with `displayComic(comic)` function
- Handles navigation buttons and image click-through - Handles navigation buttons and image click-through (disabled for HTML embeds and multi-image comics)
- Renders HTML embeds, multi-image comics, and single-image comics dynamically
- Uses History API to maintain proper URLs and browser back/forward - Uses History API to maintain proper URLs and browser back/forward
- Shows/hides header based on plain mode - Shows/hides header based on plain mode
- Adjusts container for full_width mode - Adjusts container for full_width mode
@@ -168,12 +331,14 @@ Global context variables injected into all templates:
- `use_header_nav_icons`: Boolean for main header navigation icons from `comics_data.py` - `use_header_nav_icons`: Boolean for main header navigation icons from `comics_data.py`
- `use_footer_social_icons`: Boolean for footer social link icons from `comics_data.py` - `use_footer_social_icons`: Boolean for footer social link icons from `comics_data.py`
- `newsletter_enabled`: Boolean to show/hide newsletter section from `comics_data.py` - `newsletter_enabled`: Boolean to show/hide newsletter section from `comics_data.py`
- `social_instagram`: Instagram URL from `comics_data.py` - `newsletter_html`: Custom HTML for newsletter form from `comics_data.py` (rendered with `| safe` filter)
- `social_youtube`: YouTube URL from `comics_data.py` - `social_links`: List of social media link dicts from `comics_data.py` (each with 'label', 'url', 'icon')
- `social_email`: Email link from `comics_data.py`
## Static Assets ## Static Assets
- `static/css/variables.css`: Design variables for user customization (colors, fonts, spacing, etc.)
- `static/css/style.css`: Core framework styles (references variables.css)
- `static/js/comic-nav.js`: Client-side navigation
- `static/images/comics/`: Full-size comic images - `static/images/comics/`: Full-size comic images
- `static/images/thumbs/`: Thumbnails for archive page (optional, same filename as comic) - `static/images/thumbs/`: Thumbnails for archive page (optional, same filename as comic)
- `static/images/icons/`: Navigation icons (first.png, previous.png, next.png, latest.png) used when `USE_ICON_NAV` is True - `static/images/icons/`: Navigation icons (first.png, previous.png, next.png, latest.png) used when `USE_ICON_NAV` is True
@@ -183,7 +348,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.
@@ -230,7 +395,7 @@ Environment variables:
Sunday Comics follows WCAG 2.1 Level AA guidelines. When modifying the site, maintain these accessibility features: Sunday Comics follows WCAG 2.1 Level AA guidelines. When modifying the site, maintain these accessibility features:
### Keyboard Navigation ### Keyboard Navigation
- **Focus indicators**: All interactive elements have visible 3px outlines when focused (defined in `static/css/style.css`) - **Focus indicators**: All interactive elements have visible 3px outlines when focused (colors defined in `static/css/variables.css`, styles in `static/css/style.css`)
- **Skip to main content**: First focusable element on every page, appears at top when focused - **Skip to main content**: First focusable element on every page, appears at top when focused
- **Keyboard shortcuts**: Arrow keys (Left/Right), Home, End for comic navigation (handled in `static/js/comic-nav.js`) - **Keyboard shortcuts**: Arrow keys (Left/Right), Home, End for comic navigation (handled in `static/js/comic-nav.js`)
- **Focus management**: After navigation, focus programmatically moves to `#comic-image-focus` element - **Focus management**: After navigation, focus programmatically moves to `#comic-image-focus` element

View File

@@ -25,6 +25,9 @@ COPY --chown=appuser:appuser . .
# Switch to non-root user # Switch to non-root user
USER appuser USER appuser
# Generate cache, RSS feed, and sitemap during build
RUN python scripts/publish_comic.py
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000

366
README.md
View File

@@ -33,6 +33,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
- [Reporting Violations](#reporting-violations) - [Reporting Violations](#reporting-violations)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Setup](#setup) - [Setup](#setup)
- [Keeping Your Fork Up-to-Date](#keeping-your-fork-up-to-date)
- [Environment Variables](#environment-variables) - [Environment Variables](#environment-variables)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Global Settings](#global-settings) - [Global Settings](#global-settings)
@@ -44,6 +45,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
- [Option 1: Docker (Recommended)](#option-1-docker-recommended) - [Option 1: Docker (Recommended)](#option-1-docker-recommended)
- [Option 2: Manual Deployment with Gunicorn](#option-2-manual-deployment-with-gunicorn) - [Option 2: Manual Deployment with Gunicorn](#option-2-manual-deployment-with-gunicorn)
- [Using a Reverse Proxy (Recommended)](#using-a-reverse-proxy-recommended) - [Using a Reverse Proxy (Recommended)](#using-a-reverse-proxy-recommended)
- [Using a CDN for Static Assets](#using-a-cdn-for-static-assets)
- [Additional Production Considerations](#additional-production-considerations) - [Additional Production Considerations](#additional-production-considerations)
- [Upgrading to a Database](#upgrading-to-a-database) - [Upgrading to a Database](#upgrading-to-a-database)
- [Customization](#customization) - [Customization](#customization)
@@ -72,7 +74,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 +83,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 +93,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 +102,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
@@ -172,6 +174,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
- JSON API for programmatic access - JSON API for programmatic access
- Open Graph and Twitter Card metadata for social sharing - Open Graph and Twitter Card metadata for social sharing
- Server-side rendering with Jinja2 - Server-side rendering with Jinja2
- **Built-in CDN support** for static assets (images, CSS, JavaScript)
- **Comprehensive accessibility features** (WCAG compliant) - **Comprehensive accessibility features** (WCAG compliant)
- **Search engine optimized** (sitemap, robots.txt, meta tags, canonical URLs) - **Search engine optimized** (sitemap, robots.txt, meta tags, canonical URLs)
@@ -233,13 +236,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 +633,29 @@ 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 (your settings)
├── comics_data.py.example # Configuration template (reference for new options)
├── 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
├── UPSTREAM.md # Guide for keeping forks up-to-date
├── 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
@@ -652,14 +666,16 @@ sunday/
│ └── 404.html # Error page │ └── 404.html # Error page
└── static/ # Static files └── static/ # Static files
├── css/ ├── css/
── style.css # Main stylesheet ── variables.css # Design variables (your customization)
│ └── style.css # Core framework styles
├── js/ ├── js/
│ └── comic-nav.js # Client-side navigation │ └── comic-nav.js # Client-side navigation
├── images/ # Image directory ├── images/ # Image directory
│ ├── 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
@@ -676,6 +692,38 @@ python app.py
3. Visit http://127.0.0.1:3000 in your browser 3. Visit http://127.0.0.1:3000 in your browser
## Keeping Your Fork Up-to-Date
**If you forked Sunday Comics to create your own webcomic site**, you can stay up-to-date with framework improvements while keeping your customizations.
Sunday Comics is designed with a **fork-and-customize workflow**:
- ✅ **Core framework files** (app.py, templates, scripts) - updated by upstream
- ✅ **User customization** (comics_data.py, variables.css) - your settings
- ✅ **User content** (comics, images, markdown) - your creative work
When you pull upstream updates, you'll get new features and bug fixes without losing your content or design.
**📖 [Read the complete guide: UPSTREAM.md](UPSTREAM.md)**
The UPSTREAM.md guide covers:
- Setting up your fork with an upstream remote
- Pulling and merging updates
- Handling merge conflicts (with examples)
- Understanding which files to modify vs. leave alone
- Troubleshooting common issues
**Quick update workflow:**
```bash
# Every few weeks/months:
git fetch upstream # Get new commits
git merge upstream/main # Merge updates
# Resolve any conflicts if needed
git push origin main # Push to your fork
python scripts/publish_comic.py # Rebuild site
```
**First time?** See [UPSTREAM.md](UPSTREAM.md) for the initial setup steps.
## Environment Variables ## Environment Variables
The app can be configured via environment variables: The app can be configured via environment variables:
@@ -683,6 +731,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 +746,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
@@ -707,6 +762,7 @@ The `comics_data.py` file contains both comic data and global configuration opti
COMIC_NAME = 'Sunday Comics' # Your comic/website name COMIC_NAME = 'Sunday Comics' # Your comic/website name
COPYRIGHT_NAME = None # Name for copyright (defaults to COMIC_NAME) COPYRIGHT_NAME = None # Name for copyright (defaults to COMIC_NAME)
SITE_URL = 'http://localhost:3000' # Your domain (update for production) SITE_URL = 'http://localhost:3000' # Your domain (update for production)
CDN_URL = None # CDN URL for static assets (None = use local)
FULL_WIDTH_DEFAULT = False # Make all comics full-width by default FULL_WIDTH_DEFAULT = False # Make all comics full-width by default
PLAIN_DEFAULT = False # Hide header/remove borders by default PLAIN_DEFAULT = False # Hide header/remove borders by default
LOGO_IMAGE = 'logo.png' # Path to logo (relative to static/images/) LOGO_IMAGE = 'logo.png' # Path to logo (relative to static/images/)
@@ -716,10 +772,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 +788,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 +912,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 +958,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:**
@@ -985,12 +1074,67 @@ Set up Nginx or another reverse proxy in front of your app for:
- Load balancing - Load balancing
- Better security - Better security
### Using a CDN for Static Assets
For better performance, especially with high traffic or global audiences, you can serve static assets (images, CSS, JavaScript) from a Content Delivery Network (CDN).
**How it works:**
Sunday Comics includes built-in CDN support through the `CDN_URL` configuration option. When set, all static assets (comic images, CSS, JavaScript, icons, etc.) are served from your CDN instead of your application server.
**Setup Steps:**
1. **Upload your static files to a CDN:**
- Upload the entire `static/` directory to your CDN provider
- Popular CDN options: Cloudflare, AWS CloudFront, BunnyCDN, KeyCDN
- Maintain the same directory structure (e.g., `static/css/style.css` → `your-cdn.com/static/css/style.css`)
2. **Configure Sunday Comics:**
```python
# In comics_data.py
CDN_URL = 'https://cdn.example.com' # No trailing slash!
```
3. **Deploy and test:**
- Restart your application
- Verify images and CSS load from the CDN by checking network requests in browser DevTools
**Benefits:**
- ⚡ **Faster loading** - Static assets served from edge servers closer to your readers
- 📉 **Reduced server load** - Your application server only handles dynamic content
- 💰 **Lower bandwidth costs** - CDN bandwidth is often cheaper than server bandwidth
- 🌍 **Global performance** - Readers worldwide get fast load times
- 🛡️ **DDoS protection** - Many CDNs include built-in protection
**Example Configuration:**
```python
# Local development (no CDN)
CDN_URL = None
# Production with CDN
CDN_URL = 'https://d1abc123xyz.cloudfront.net' # AWS CloudFront example
CDN_URL = 'https://cdn.yourcomic.com' # Custom domain example
```
**Important Notes:**
- When using a CDN, update your static files on the CDN whenever you make changes
- Consider using cache-busting techniques for CSS/JS updates (version query parameters)
- Test thoroughly after enabling CDN to ensure all assets load correctly
- The `cdn_static` template filter automatically handles URL generation
- When `CDN_URL` is `None`, Sunday Comics falls back to local static file serving
**Free CDN Options:**
- **Cloudflare** - Free tier includes CDN and DDoS protection
- **jsDelivr** - Free CDN for GitHub repositories
- **BunnyCDN** - Low-cost pay-as-you-go pricing
### Additional Production Considerations ### Additional Production Considerations
- Use a process manager (systemd, supervisor) for non-Docker deployments - Use a process manager (systemd, supervisor) for non-Docker deployments
- Set appropriate file permissions - Set appropriate file permissions
- Enable HTTPS with Let's Encrypt - Enable HTTPS with Let's Encrypt
- Consider using a CDN for static assets - Use a CDN for static assets (see [Using a CDN](#using-a-cdn-for-static-assets) above)
- Monitor logs and performance - Monitor logs and performance
- Set up automated backups of `comics_data.py` - Set up automated backups of `comics_data.py`
@@ -1010,8 +1154,9 @@ For larger comic archives, consider replacing the `COMICS` list with a database:
- Customize logo by setting `LOGO_IMAGE` and `LOGO_MODE` - Customize logo by setting `LOGO_IMAGE` and `LOGO_MODE`
### Styling ### Styling
- Modify `static/css/style.css` to change colors, fonts, and layout - **Customize design:** Edit `static/css/variables.css` to change colors, fonts, spacing, and layout dimensions
- Current color scheme uses blue (#3498db) and dark blue-gray (#2c3e50) - **Framework styles:** `static/css/style.css` contains core styles that reference the variables (avoid modifying for easier upstream updates)
- Variables are organized by category: Colors, Typography, Spacing, Borders, Layout, Transitions
### About Page ### About Page
- Edit `content/about.md` to add your bio and comic information (supports markdown) - Edit `content/about.md` to add your bio and comic information (supports markdown)
@@ -1144,7 +1289,8 @@ this will improve over time. As with data sourcing, I also hope this will be reg
This project's code is written primarily by AI. The application itself *does not* and *will never* have This project's code is written primarily by AI. The application itself *does not* and *will never* have
any AI integration. When you start up the project, it is completely standalone just like any normal website. Your any AI integration. When you start up the project, it is completely standalone just like any normal website. Your
content remains yours. content remains yours. In fact, the website itself does its best to protect your work from AI training! See the
"Content Protection & AI Scraping Prevention" section above.
**So, why use AI?** **So, why use AI?**

356
UPSTREAM.md Normal file
View File

@@ -0,0 +1,356 @@
# Updating from Upstream
This guide explains how to keep your forked Sunday Comics site up-to-date with framework improvements while preserving your customizations.
## Fork-and-Customize Workflow
Sunday Comics is designed to be forked and customized for your own webcomic. The project separates:
- **Core framework files** (updated by upstream) - app logic, templates, scripts
- **User content files** (your comic data) - comics, images, markdown content
- **User configuration** (your settings) - config and design variables
When you pull updates from upstream, you'll get new features and bug fixes without losing your content or customizations.
---
## Initial Setup
### 1. Fork the Repository
On GitHub, click "Fork" to create your own copy of Sunday Comics.
### 2. Clone Your Fork
```bash
git clone https://github.com/YOUR-USERNAME/sunday-comics.git
cd sunday-comics
```
### 3. Add Upstream Remote
Add the original repository as an "upstream" remote:
```bash
git remote add upstream https://github.com/ORIGINAL-AUTHOR/sunday-comics.git
```
Verify your remotes:
```bash
git remote -v
# origin https://github.com/YOUR-USERNAME/sunday-comics.git (fetch)
# origin https://github.com/YOUR-USERNAME/sunday-comics.git (push)
# upstream https://github.com/ORIGINAL-AUTHOR/sunday-comics.git (fetch)
# upstream https://github.com/ORIGINAL-AUTHOR/sunday-comics.git (push)
```
### 4. Set Up Your Configuration
**Option A: Track your config in git (recommended)**
Your `comics_data.py` and `static/css/variables.css` files are already set up. Customize them and commit:
```bash
# Edit your configuration
nano comics_data.py
nano static/css/variables.css
# Commit your changes
git add comics_data.py static/css/variables.css
git commit -m "Configure site settings and design"
git push origin main
```
**Option B: Keep config out of git**
If you prefer to keep your config private:
```bash
# Uncomment the gitignore lines for config files
nano .gitignore
# Uncomment:
# comics_data.py
# static/css/variables.css
# Copy the example files
cp comics_data.py comics_data.py.backup
# Now comics_data.py won't be tracked
```
---
## Getting Updates from Upstream
### 1. Fetch Upstream Changes
```bash
git fetch upstream
```
This downloads new commits from the original repository without modifying your files.
### 2. Review What Changed
See what's new in upstream:
```bash
# View commit log
git log HEAD..upstream/main --oneline
# See which files changed
git diff HEAD..upstream/main --stat
# Review the CHANGELOG
git show upstream/main:CHANGELOG.md
```
### 3. Merge Upstream Changes
```bash
# Merge upstream into your current branch
git merge upstream/main
```
**If there are no conflicts:**
```bash
# Push the updates to your fork
git push origin main
```
**If there are conflicts** (see next section).
---
## Handling Merge Conflicts
Conflicts may occur if both you and upstream modified the same file. Common scenarios:
### Scenario 1: Framework Added New Config Options
**Example:** Upstream added a new setting to `comics_data.py.example`
**What to do:**
1. The merge conflict will only be in `comics_data.py.example` (not your `comics_data.py`)
2. Accept upstream's version:
```bash
git checkout --theirs comics_data.py.example
git add comics_data.py.example
```
3. Review `comics_data.py.example` for new settings
4. Manually add any new settings you want to your `comics_data.py`
### Scenario 2: CSS Variables Conflict
**Example:** Both you and upstream added/changed CSS variables
**What to do:**
1. Open the conflicted file:
```bash
nano static/css/variables.css
```
2. Look for conflict markers:
```
<<<<<<< HEAD
--your-custom-variable: #ff0000;
=======
--new-upstream-variable: #00ff00;
>>>>>>> upstream/main
```
3. Keep both variables:
```css
--your-custom-variable: #ff0000;
--new-upstream-variable: #00ff00;
```
4. Save and mark as resolved:
```bash
git add static/css/variables.css
git commit -m "Merge upstream CSS variables"
```
### Scenario 3: Rare - Core File Conflicts
If you modified a core framework file (app.py, templates, etc.), you may have conflicts.
**Best practice:** Avoid modifying core files. If you need custom behavior:
- Add new routes/functions instead of modifying existing ones
- Use template blocks for customization
- Open an issue/PR to suggest the feature for upstream
**If you must resolve:**
```bash
# Open the conflicted file
nano path/to/conflicted_file.py
# Resolve conflicts manually
# Then:
git add path/to/conflicted_file.py
git commit -m "Merge upstream changes with custom modifications"
```
---
## File Categories Reference
### Core Framework (Upstream Updates)
**DO NOT modify these files** - you'll get conflicts when merging:
- `app.py` - Flask application
- `data_loader.py` - YAML loading logic
- `scripts/*.py` - Utility scripts
- `templates/*.html` - Jinja templates (unless extending)
- `static/css/style.css` - Core CSS framework
- `static/js/*.js` - JavaScript functionality
- `version.py`, `VERSION` - Version info
- `Dockerfile`, `docker-compose.yml` - Deployment config
- Documentation files (README, CLAUDE, etc.)
### User Configuration (You Customize)
**Safe to modify** - tracked in git:
- `comics_data.py` - Your site settings
- `static/css/variables.css` - Your design variables
- `data/comics/*.yaml` - Your comic metadata
- `content/*.md` - Your markdown content (about page, author notes)
- `static/images/*` - Your images and graphics
### Template Files (Reference)
**Use as reference** - copy to create your versions:
- `comics_data.py.example` - Default configuration template
- `data/comics/TEMPLATE.yaml` - Comic file template
### Generated Files (Ignored by Git)
**Automatically regenerated:**
- `static/feed.rss` - RSS feed
- `static/sitemap.xml` - Sitemap
- `data/comics/.comics_cache.pkl` - Comic cache
- `__pycache__/` - Python bytecode
---
## Migration Notes
When upstream releases breaking changes, check `CHANGELOG.md` for migration instructions.
**Example migration scenario:**
```markdown
## [2025.03.15] - Breaking Changes
### Changed
- Renamed `SHOW_HEADER_IMAGE` to `HEADER_IMAGE` in comics_data.py
**Migration:** Update your `comics_data.py`:
- Old: `SHOW_HEADER_IMAGE = True`
- New: `HEADER_IMAGE = 'title.jpg'`
```
After merging, check the CHANGELOG and update your config accordingly.
---
## Best Practices
### ✅ DO:
- Keep your fork's main branch in sync with upstream
- Customize via `comics_data.py` and `variables.css`
- Add your comics to `data/comics/`
- Read the CHANGELOG before/after merging
- Test your site after merging updates
- Commit your changes regularly
### ❌ DON'T:
- Modify core framework files (app.py, templates, etc.)
- Delete or rename upstream files (you'll break updates)
- Force push over merged commits
- Ignore merge conflicts (resolve them properly)
---
## Troubleshooting
### "I modified a core file and now I have conflicts"
**Option 1: Keep your changes** (advanced)
```bash
# Resolve conflicts manually, testing thoroughly
git mergetool
git commit
```
**Option 2: Discard your changes** (start fresh)
```bash
# Reset to upstream version
git checkout upstream/main -- path/to/file.py
git add path/to/file.py
git commit -m "Reset to upstream version"
```
### "I want to see what changed before merging"
```bash
# Create a test branch
git checkout -b test-upstream-merge
# Merge in the test branch
git merge upstream/main
# If you like it, merge to main
git checkout main
git merge test-upstream-merge
# If not, delete the test branch
git checkout main
git branch -D test-upstream-merge
```
### "My site broke after merging"
```bash
# Check what changed
git log --oneline -10
# Test the previous version
git checkout HEAD~1
python app.py
# If it works, the issue is in the latest commit
git checkout main
git diff HEAD~1 HEAD
# Read CHANGELOG for migration notes
cat CHANGELOG.md
```
---
## Getting Help
- **Check the docs:** `README.md`, `CLAUDE.md`, `CHANGELOG.md`
- **Report issues:** [GitHub Issues](https://github.com/ORIGINAL-AUTHOR/sunday-comics/issues)
- **Ask questions:** Open a discussion or issue on GitHub
---
## Summary
**Regular update workflow:**
```bash
# Every few weeks/months:
git fetch upstream # Get new commits
git log HEAD..upstream/main --oneline # Review changes
git merge upstream/main # Merge updates
# Resolve any conflicts
git push origin main # Push to your fork
python scripts/publish_comic.py # Rebuild site
```
Your comics, images, and config stay safe - only the framework code updates!

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2025.11.18

112
app.py
View File

@@ -7,12 +7,14 @@ 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 (
COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE, COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, CDN_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
HEADER_IMAGE, FOOTER_IMAGE, BANNER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED, HEADER_IMAGE, FOOTER_IMAGE, BANNER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED,
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS, NEWSLETTER_ENABLED, USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS,
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED NEWSLETTER_ENABLED, NEWSLETTER_HTML,
SOCIAL_LINKS, 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)
@@ -31,6 +33,24 @@ def add_ai_blocking_headers(response):
return response return response
@app.template_filter('cdn_static')
def cdn_static(filename):
"""Generate URL for static assets with CDN support
When CDN_URL is set, returns CDN URL. Otherwise returns local static URL.
Args:
filename: Path to static file (e.g., 'css/style.css')
Returns:
Full URL to the static asset
"""
from flask import url_for
if CDN_URL:
return f"{CDN_URL}/static/{filename}"
return url_for('static', filename=filename)
@app.context_processor @app.context_processor
def inject_global_settings(): def inject_global_settings():
"""Make global settings available to all templates""" """Make global settings available to all templates"""
@@ -39,6 +59,7 @@ def inject_global_settings():
'copyright_name': COPYRIGHT_NAME if COPYRIGHT_NAME else COMIC_NAME, 'copyright_name': COPYRIGHT_NAME if COPYRIGHT_NAME else COMIC_NAME,
'current_year': datetime.now().year, 'current_year': datetime.now().year,
'site_url': SITE_URL, 'site_url': SITE_URL,
'cdn_url': CDN_URL,
'logo_image': LOGO_IMAGE, 'logo_image': LOGO_IMAGE,
'logo_mode': LOGO_MODE, 'logo_mode': LOGO_MODE,
'header_image': HEADER_IMAGE, 'header_image': HEADER_IMAGE,
@@ -52,12 +73,12 @@ def inject_global_settings():
'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS, 'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS,
'use_share_icons': USE_SHARE_ICONS, 'use_share_icons': USE_SHARE_ICONS,
'newsletter_enabled': NEWSLETTER_ENABLED, 'newsletter_enabled': NEWSLETTER_ENABLED,
'social_instagram': SOCIAL_INSTAGRAM, 'newsletter_html': NEWSLETTER_HTML,
'social_youtube': SOCIAL_YOUTUBE, 'social_links': SOCIAL_LINKS,
'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 +283,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,9 +339,70 @@ 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)"""
# 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]) 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>')
def api_comic(comic_id): def api_comic(comic_id):

View File

@@ -15,6 +15,12 @@ COPYRIGHT_NAME = None # e.g., 'Your Name' or 'Your Studio Name'
# Update this to your production domain when deploying # Update this to your production domain when deploying
SITE_URL = 'http://localhost:3000' SITE_URL = 'http://localhost:3000'
# Global setting: CDN URL for static assets (set to None to use local assets)
# When set, all static assets will be served from this CDN
# Example: CDN_URL = 'https://cdn.example.com' (no trailing slash)
# Leave as None for local development or if not using a CDN
CDN_URL = None
# Global setting: Set to True to make all comics full-width by default # Global setting: Set to True to make all comics full-width by default
# Individual comics can override this with 'full_width': False # Individual comics can override this with 'full_width': False
FULL_WIDTH_DEFAULT = False FULL_WIDTH_DEFAULT = False
@@ -80,10 +86,30 @@ USE_SHARE_ICONS = True
# Global setting: Set to True to show newsletter section in footer # Global setting: Set to True to show newsletter section in footer
NEWSLETTER_ENABLED = False NEWSLETTER_ENABLED = False
# Social media links - set to None to hide the link # Global setting: Custom HTML for newsletter form (only used if NEWSLETTER_ENABLED is True)
SOCIAL_INSTAGRAM = None # e.g., 'https://instagram.com/yourhandle' # Paste your newsletter service's form HTML here (Mailchimp, ConvertKit, Buttondown, etc.)
SOCIAL_YOUTUBE = None # e.g., 'https://youtube.com/@yourchannel' # Example:
SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com' # NEWSLETTER_HTML = '''
# <form action="https://yourservice.com/subscribe" method="post" class="newsletter-form">
# <input type="email" name="email" placeholder="Enter your email" required>
# <button type="submit">Subscribe</button>
# </form>
# '''
NEWSLETTER_HTML = '<p class="newsletter-placeholder">Newsletter coming soon!</p>'
# Social media links - add/remove/reorder as needed
# Each link should have 'label', 'url', and optionally 'icon' (filename in static/images/icons/)
# Set to empty list [] to show no social links
SOCIAL_LINKS = [
# Example links (uncomment and customize):
# {'label': 'Instagram', 'url': 'https://instagram.com/yourhandle', 'icon': 'instagram.png'},
# {'label': 'YouTube', 'url': 'https://youtube.com/@yourchannel', 'icon': 'youtube.png'},
# {'label': 'Email', 'url': 'mailto:your@email.com', 'icon': 'mail.png'},
# {'label': 'Bluesky', 'url': 'https://bsky.app/profile/yourhandle', 'icon': 'bluesky.png'},
# {'label': 'Patreon', 'url': 'https://patreon.com/yourname', 'icon': 'patreon.png'},
# {'label': 'Ko-fi', 'url': 'https://ko-fi.com/yourname', 'icon': 'kofi.png'},
# {'label': 'Mastodon', 'url': 'https://mastodon.social/@yourhandle', 'icon': 'mastodon.png'},
]
# API documentation link - set to None to hide the link # API documentation link - set to None to hide the link
# Path is relative to static/ directory # Path is relative to static/ directory

139
comics_data.py.example Normal file
View File

@@ -0,0 +1,139 @@
# Sunday Comics - Comic data configuration
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
#
# Edit this file to add, remove, or modify comics
# Global setting: The name of your comic/website
COMIC_NAME = 'Sunday Comics'
# Global setting: The name to display in the copyright notice
# If not set (None), defaults to COMIC_NAME
COPYRIGHT_NAME = None # e.g., 'Your Name' or 'Your Studio Name'
# Global setting: Your website's domain (used for RSS feed, Open Graph tags, etc.)
# Update this to your production domain when deploying
SITE_URL = 'http://localhost:3000'
# Global setting: CDN URL for static assets (set to None to use local assets)
# When set, all static assets will be served from this CDN
# Example: CDN_URL = 'https://cdn.example.com' (no trailing slash)
# Leave as None for local development or if not using a CDN
CDN_URL = None
# Global setting: Set to True to make all comics full-width by default
# Individual comics can override this with 'full_width': False
FULL_WIDTH_DEFAULT = False
# Global setting: Set to True to hide header and remove nav border by default
# Individual comics can override this with 'plain': False
PLAIN_DEFAULT = False
# Global setting: Path to site logo (relative to static/images/)
# Set to None to disable logo
# Example: LOGO_IMAGE = 'logo.png' will use static/images/logo.png
LOGO_IMAGE = 'logo.png'
# Global setting: Logo display mode
# 'beside' - Display logo next to the site title
# 'replace' - Replace the site title with the logo
# Only applies when LOGO_IMAGE is set
LOGO_MODE = 'beside'
# Global setting: Path to header image (relative to static/images/)
# Set to None to disable header image
# Example: HEADER_IMAGE = 'title.jpg' will use static/images/title.jpg
HEADER_IMAGE = None
# Global setting: Path to footer image (relative to static/images/)
# Set to None to disable footer image
# Example: FOOTER_IMAGE = 'footer.jpg' will use static/images/footer.jpg
FOOTER_IMAGE = None
# Global setting: Path to shareable banner image (relative to static/images/)
# Set to None to disable "Link to Us" section in footer
# Example: BANNER_IMAGE = 'banner.jpg' will use static/images/banner.jpg
BANNER_IMAGE = 'banner.jpg'
# Global setting: Set to True to display footer in compact mode
# Compact mode: single line, no border, horizontal layout
COMPACT_FOOTER = False
# Global setting: Set to True to make archive page full-width with 4 columns (2 on mobile)
# Full-width archive shows square thumbnails with only dates, no titles
ARCHIVE_FULL_WIDTH = True
# Global setting: Set to True to enable sections/chapters on the archive page
# Add 'section': 'Chapter Title' to comics where a new section starts
SECTIONS_ENABLED = True
# Global setting: Set to True to use icon images for comic navigation buttons
# Icons should be in static/images/icons/ (first.png, previous.png, next.png, latest.png)
USE_COMIC_NAV_ICONS = True
# Global setting: Set to True to show icons next to main header navigation text
# Uses alert.png for Latest, archive.png for Archive, info.png for About
USE_HEADER_NAV_ICONS = True
# Global setting: Set to True to use icons instead of text for footer social links
# Uses instagram.png, youtube.png, and mail.png from static/images/icons/
USE_FOOTER_SOCIAL_ICONS = True
# Global setting: Set to True to show icons in share buttons (permalink and embed)
# Uses link.png for permalink and embed.png for embed from static/images/icons/
USE_SHARE_ICONS = True
# Global setting: Set to True to show newsletter section in footer
NEWSLETTER_ENABLED = False
# Global setting: Custom HTML for newsletter form (only used if NEWSLETTER_ENABLED is True)
# Paste your newsletter service's form HTML here (Mailchimp, ConvertKit, Buttondown, etc.)
# Example:
# NEWSLETTER_HTML = '''
# <form action="https://yourservice.com/subscribe" method="post" class="newsletter-form">
# <input type="email" name="email" placeholder="Enter your email" required>
# <button type="submit">Subscribe</button>
# </form>
# '''
NEWSLETTER_HTML = '<p class="newsletter-placeholder">Newsletter coming soon!</p>'
# Social media links - add/remove/reorder as needed
# Each link should have 'label', 'url', and optionally 'icon' (filename in static/images/icons/)
# Set to empty list [] to show no social links
SOCIAL_LINKS = [
# Example links (uncomment and customize):
# {'label': 'Instagram', 'url': 'https://instagram.com/yourhandle', 'icon': 'instagram.png'},
# {'label': 'YouTube', 'url': 'https://youtube.com/@yourchannel', 'icon': 'youtube.png'},
# {'label': 'Email', 'url': 'mailto:your@email.com', 'icon': 'mail.png'},
# {'label': 'Bluesky', 'url': 'https://bsky.app/profile/yourhandle', 'icon': 'bluesky.png'},
# {'label': 'Patreon', 'url': 'https://patreon.com/yourname', 'icon': 'patreon.png'},
# {'label': 'Ko-fi', 'url': 'https://ko-fi.com/yourname', 'icon': 'kofi.png'},
# {'label': 'Mastodon', 'url': 'https://mastodon.social/@yourhandle', 'icon': 'mastodon.png'},
]
# API documentation link - set to None to hide the link
# Path is relative to static/ directory
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
# Global setting: Set to True to enable comic embed functionality
# When enabled, users can get embed codes to display comics on other websites
EMBED_ENABLED = True
# Global setting: Set to True to enable permalink copy button
# When enabled, users can easily copy a direct link to the current comic
PERMALINK_ENABLED = True
# Load comics from YAML files
from data_loader import load_comics_from_yaml, validate_comics
COMICS = load_comics_from_yaml('data/comics')
# Validate loaded comics
if not validate_comics(COMICS):
print("Warning: Comic validation failed. Please check your YAML files.")
# Show loaded comics count
if COMICS:
print(f"Loaded {len(COMICS)} comics from data/comics/")
else:
print("Warning: No comics loaded! Please add .yaml files to data/comics/")

View File

@@ -6,7 +6,7 @@
# REQUIRED: Sequential comic number # REQUIRED: Sequential comic number
number: 999 number: 999
# REQUIRED: Image filename(s) in static/images/comics/ # REQUIRED (unless using html_embed): Image filename(s) in static/images/comics/
# Single image: # Single image:
filename: comic-999.jpg filename: comic-999.jpg
# OR multi-image (webtoon style): # OR multi-image (webtoon style):
@@ -18,6 +18,12 @@ filename: comic-999.jpg
# Optional: Mobile-optimized version of the comic # Optional: Mobile-optimized version of the comic
# mobile_filename: comic-999-mobile.jpg # mobile_filename: comic-999-mobile.jpg
# Optional: HTML embed instead of image
# Use this to embed videos, widgets, or other HTML content
# When set, this takes precedence over filename/mobile_filename
# Example: '<iframe src="https://www.youtube.com/embed/..." width="560" height="315"></iframe>'
# html_embed: '<div>Your custom HTML here</div>'
# REQUIRED: Publication date (YYYY-MM-DD format) # REQUIRED: Publication date (YYYY-MM-DD format)
date: "2025-01-01" date: "2025-01-01"

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

@@ -1,55 +1,13 @@
/* CSS Variables for easy customization */ /* ============================================================
:root { SUNDAY COMICS - CORE STYLES
/* Colors */ ============================================================
--color-primary: #000;
--color-background: #fff;
--color-text: #000;
--color-text-muted: #666;
--color-disabled: #999;
--color-hover-bg: #f0f0f0;
/* Typography */ This file contains the framework CSS that references variables
--font-family: 'Courier New', Courier, monospace; defined in variables.css. When updating from upstream, this file
--font-size-base: 1rem; may change, but your customizations in variables.css will be preserved.
--font-size-xs: 0.7rem;
--font-size-sm: 0.75rem;
--font-size-md: 0.85rem;
--font-size-lg: 0.9rem;
--font-size-xl: 1.2rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 6rem;
--line-height-base: 1.5;
--line-height-tight: 1.3;
--line-height-relaxed: 1.6;
--letter-spacing-tight: 1px;
--letter-spacing-wide: 2px;
/* Spacing */ Do not edit this file directly for design changes - use variables.css instead.
--space-xs: 0.25rem; ============================================================ */
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* Borders */
--border-width-thin: 2px;
--border-width-thick: 3px;
--border-color: var(--color-primary);
--border-radius: 0; /* Can be changed for rounded corners */
/* Layout */
--container-max-width: 900px;
--content-max-width: 700px;
--archive-grid-min: 180px;
--archive-grid-min-mobile: 140px;
--archive-thumbnail-height: 120px;
/* Transitions (for future enhancements) */
--transition-speed: 0.2s;
}
/* Reset and base styles */ /* Reset and base styles */
* { * {

80
static/css/variables.css Normal file
View File

@@ -0,0 +1,80 @@
/* ============================================================
CSS VARIABLES FOR CUSTOMIZATION
============================================================
This file contains all customizable design tokens for your webcomic.
Edit these values to change colors, fonts, spacing, and layout dimensions.
The main style.css file references these variables, so you can update
your design without touching the core framework styles.
============================================================ */
:root {
/* ========================================
COLORS
======================================== */
--color-primary: #000;
--color-background: #fff;
--color-text: #000;
--color-text-muted: #666;
--color-disabled: #999;
--color-hover-bg: #f0f0f0;
/* ========================================
TYPOGRAPHY
======================================== */
--font-family: 'Courier New', Courier, monospace;
--font-size-base: 1rem;
--font-size-xs: 0.7rem;
--font-size-sm: 0.75rem;
--font-size-md: 0.85rem;
--font-size-lg: 0.9rem;
--font-size-xl: 1.2rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 6rem;
--line-height-base: 1.5;
--line-height-tight: 1.3;
--line-height-relaxed: 1.6;
--letter-spacing-tight: 1px;
--letter-spacing-wide: 2px;
/* ========================================
SPACING
======================================== */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* ========================================
BORDERS
======================================== */
--border-width-thin: 2px;
--border-width-thick: 3px;
--border-color: var(--color-primary);
--border-radius: 0; /* Can be changed for rounded corners */
/* ========================================
LAYOUT
======================================== */
--container-max-width: 900px;
--content-max-width: 700px;
--archive-grid-min: 180px;
--archive-grid-min-mobile: 140px;
--archive-thumbnail-height: 120px;
/* ========================================
TRANSITIONS
======================================== */
--transition-speed: 0.2s;
}

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

@@ -80,8 +80,8 @@
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 (only for single-image comics) // Update or create/remove the link wrapper (only for single-image comics, not HTML embeds)
if (!comic.is_multi_image) { if (!comic.is_multi_image && !comic.html_embed) {
updateComicImageLink(comic.number); updateComicImageLink(comic.number);
} }
@@ -181,15 +181,26 @@
// Clear all existing content // Clear all existing content
comicImageDiv.innerHTML = ''; comicImageDiv.innerHTML = '';
// Update container class for multi-image // Update container classes
if (comic.is_multi_image) { if (comic.html_embed) {
comicImageDiv.classList.add('comic-image-embed');
comicImageDiv.classList.remove('comic-image-multi');
} else if (comic.is_multi_image) {
comicImageDiv.classList.add('comic-image-multi'); comicImageDiv.classList.add('comic-image-multi');
comicImageDiv.classList.remove('comic-image-embed');
} else { } else {
comicImageDiv.classList.remove('comic-image-multi'); comicImageDiv.classList.remove('comic-image-multi');
comicImageDiv.classList.remove('comic-image-embed');
} }
// Create new image element(s) // Create new content
if (comic.is_multi_image) { if (comic.html_embed) {
// HTML embed (video, widget, etc.)
const embedWrapper = document.createElement('div');
embedWrapper.className = 'comic-embed-wrapper';
embedWrapper.innerHTML = comic.html_embed;
comicImageDiv.appendChild(embedWrapper);
} else if (comic.is_multi_image) {
// Multi-image comic (webtoon style) // Multi-image comic (webtoon style)
comic.filenames.forEach((filename, index) => { comic.filenames.forEach((filename, index) => {
const img = document.createElement('img'); const img = document.createElement('img');
@@ -447,13 +458,14 @@
const formattedDate = dateDisplay ? dateDisplay.textContent : null; const formattedDate = dateDisplay ? dateDisplay.textContent : null;
updateNavButtons(currentNumber, formattedDate); updateNavButtons(currentNumber, formattedDate);
// Check if current comic is multi-image // Check if current comic is multi-image or HTML embed
const comicImageDiv = document.querySelector('.comic-image'); const comicImageDiv = document.querySelector('.comic-image');
const isMultiImage = comicImageDiv && comicImageDiv.classList.contains('comic-image-multi'); const isMultiImage = comicImageDiv && comicImageDiv.classList.contains('comic-image-multi');
const isHtmlEmbed = comicImageDiv && comicImageDiv.classList.contains('comic-image-embed');
if (!isMultiImage) { if (!isMultiImage && !isHtmlEmbed) {
updateComicImageLink(currentNumber); updateComicImageLink(currentNumber);
} else { } else if (isMultiImage) {
// Initialize lazy loading for multi-image comics on page load // Initialize lazy loading for multi-image comics on page load
initLazyLoad(); initLazyLoad();
} }

View File

@@ -16,20 +16,59 @@ 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:
- type: array
description: Simple array response (when no pagination parameters provided)
items: items:
$ref: '#/components/schemas/Comic' $ref: '#/components/schemas/Comic'
example: - $ref: '#/components/schemas/PaginatedComicsResponse'
examples:
simpleArray:
summary: Simple array response (default)
value:
- number: 1 - number: 1
title: "First Comic" title: "First Comic"
filename: "comic-001.jpg" filename: "comic-001.jpg"
@@ -49,6 +88,35 @@ paths:
plain: true plain: true
formatted_date: "Wednesday, January 8, 2025" formatted_date: "Wednesday, January 8, 2025"
author_note_is_html: false 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:
oneOf:
- type: string
description: Single image filename in static/images/comics/
- type: array
description: Multiple image filenames for webtoon-style comics
items:
type: string type: string
description: Image filename in static/images/comics/
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:
oneOf:
- 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 type: string
description: Accessibility text for the comic image
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">
@@ -22,8 +24,8 @@
{% for comic in section_comics %} {% for comic in section_comics %}
<div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}"> <div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}">
<a href="{{ url_for('comic', comic_id=comic.number) }}"> <a href="{{ url_for('comic', comic_id=comic.number) }}">
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}" <img src="{{ ('images/thumbs/' + comic.filename) | cdn_static }}"
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';" onerror="this.onerror=null; this.src='{{ 'images/thumbs/default.jpg' | cdn_static }}';"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}" alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
loading="lazy"> loading="lazy">
<div class="archive-info"> <div class="archive-info">
@@ -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="{{ 'js/archive-lazy-load.js' | cdn_static }}"></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">
@@ -28,13 +31,16 @@
<meta property="twitter:image" content="{% block twitter_image %}{{ self.og_image() }}{% endblock %}"> <meta property="twitter:image" content="{% block twitter_image %}{{ self.og_image() }}{% endblock %}">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ 'favicon.ico' | cdn_static }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ 'favicon-32x32.png' | cdn_static }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}"> <link rel="icon" type="image/png" sizes="16x16" href="{{ 'favicon-16x16.png' | cdn_static }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}"> <link rel="apple-touch-icon" sizes="180x180" href="{{ 'apple-touch-icon.png' | cdn_static }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <!-- CSS Variables (user customization) loaded first -->
<link rel="alternate" type="application/rss+xml" title="{{ comic_name }} RSS Feed" href="{{ url_for('static', filename='feed.rss') }}"> <link rel="stylesheet" href="{{ 'css/variables.css' | cdn_static }}">
<!-- Core framework styles (references variables) -->
<link rel="stylesheet" href="{{ 'css/style.css' | cdn_static }}">
<link rel="alternate" type="application/rss+xml" title="{{ comic_name }} RSS Feed" href="{{ 'feed.rss' | cdn_static }}">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body data-site-url="{{ site_url }}"> <body data-site-url="{{ site_url }}">
@@ -43,7 +49,7 @@
{% if header_image %} {% if header_image %}
<div class="site-header-image"> <div class="site-header-image">
<img src="{{ url_for('static', filename='images/' + header_image) }}" alt="{{ comic_name }} Header"> <img src="{{ ('images/' + header_image) | cdn_static }}" alt="{{ comic_name }} Header">
</div> </div>
{% endif %} {% endif %}
@@ -54,10 +60,10 @@
<div class="nav-brand"> <div class="nav-brand">
<a href="{{ url_for('index') }}"> <a href="{{ url_for('index') }}">
{% if logo_image and logo_mode == 'beside' %} {% if logo_image and logo_mode == 'beside' %}
<img src="{{ url_for('static', filename='images/' + logo_image) }}" alt="{{ comic_name }} Logo" class="nav-logo nav-logo-beside"> <img src="{{ ('images/' + logo_image) | cdn_static }}" alt="{{ comic_name }} Logo" class="nav-logo nav-logo-beside">
<span class="nav-title">{{ comic_name }}</span> <span class="nav-title">{{ comic_name }}</span>
{% elif logo_image and logo_mode == 'replace' %} {% elif logo_image and logo_mode == 'replace' %}
<img src="{{ url_for('static', filename='images/' + logo_image) }}" alt="{{ comic_name }}" class="nav-logo nav-logo-replace"> <img src="{{ ('images/' + logo_image) | cdn_static }}" alt="{{ comic_name }}" class="nav-logo nav-logo-replace">
{% else %} {% else %}
{{ comic_name }} {{ comic_name }}
{% endif %} {% endif %}
@@ -67,17 +73,17 @@
<ul class="nav-links"> <ul class="nav-links">
<li> <li>
<a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}> <a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/alert.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Latest {% if use_header_nav_icons %}<img src="{{ 'images/icons/alert.png' | cdn_static }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Latest
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('archive') }}" {% if request.endpoint == 'archive' %}class="active"{% endif %}> <a href="{{ url_for('archive') }}" {% if request.endpoint == 'archive' %}class="active"{% endif %}>
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/archive.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Archive {% if use_header_nav_icons %}<img src="{{ 'images/icons/archive.png' | cdn_static }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Archive
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('about') }}" {% if request.endpoint == 'about' %}class="active"{% endif %}> <a href="{{ url_for('about') }}" {% if request.endpoint == 'about' %}class="active"{% endif %}>
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/info.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}About {% if use_header_nav_icons %}<img src="{{ 'images/icons/info.png' | cdn_static }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}About
</a> </a>
</li> </li>
</ul> </ul>
@@ -97,42 +103,24 @@
<div class="footer-section"> <div class="footer-section">
<h3>Follow</h3> <h3>Follow</h3>
<div class="social-links{% if use_footer_social_icons %} social-links-icons{% endif %}"> <div class="social-links{% if use_footer_social_icons %} social-links-icons{% endif %}">
{% if social_instagram %} {% for link in social_links %}
<a href="{{ social_instagram }}" target="_blank" rel="noopener noreferrer" aria-label="Instagram"> <a href="{{ link.url }}" {% if link.url.startswith('http') %}target="_blank" rel="noopener noreferrer"{% endif %} aria-label="{{ link.label }}">
{% if use_footer_social_icons %} {% if use_footer_social_icons and link.icon %}
<img src="{{ url_for('static', filename='images/icons/instagram.png') }}" alt="" class="social-icon"> <img src="{{ ('images/icons/' + link.icon) | cdn_static }}" alt="" class="social-icon">
{% else %} {% else %}
Instagram {{ link.label }}
{% endif %} {% endif %}
</a> </a>
{% endif %} {% endfor %}
{% if social_youtube %} <a href="{{ 'feed.rss' | cdn_static }}" aria-label="RSS Feed">
<a href="{{ social_youtube }}" target="_blank" rel="noopener noreferrer" aria-label="YouTube">
{% if use_footer_social_icons %} {% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/youtube.png') }}" alt="" class="social-icon"> <img src="{{ 'images/icons/rss.png' | cdn_static }}" alt="" class="social-icon">
{% else %}
YouTube
{% endif %}
</a>
{% endif %}
{% if social_email %}
<a href="{{ social_email }}" aria-label="Email">
{% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/mail .png') }}" alt="" class="social-icon">
{% else %}
Email
{% endif %}
</a>
{% endif %}
<a href="{{ url_for('static', filename='feed.rss') }}" aria-label="RSS Feed">
{% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/rss.png') }}" alt="" class="social-icon">
{% else %} {% else %}
RSS Feed RSS Feed
{% endif %} {% endif %}
</a> </a>
{% if api_spec_link %} {% if api_spec_link %}
<a href="{{ url_for('static', filename=api_spec_link) }}" aria-label="API">API</a> <a href="{{ api_spec_link | cdn_static }}" aria-label="API">API</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -140,12 +128,7 @@
{% if newsletter_enabled %} {% if newsletter_enabled %}
<div class="footer-section"> <div class="footer-section">
<h3>Newsletter</h3> <h3>Newsletter</h3>
<!-- Replace with your newsletter service form --> {{ newsletter_html | safe }}
<!-- <form class="newsletter-form" action="#" method="post">
<input type="email" name="email" placeholder="Enter your email" required>
<button type="submit">Subscribe</button>
</form> -->
<p class="newsletter-placeholder">Newsletter coming soon!</p>
</div> </div>
{% endif %} {% endif %}
@@ -154,11 +137,11 @@
<h3>Share A Link</h3> <h3>Share A Link</h3>
<div class="shareable-banner"> <div class="shareable-banner">
<a href="{{ site_url }}" target="_blank" rel="noopener noreferrer" aria-label="Link to {{ comic_name }} home page"> <a href="{{ site_url }}" target="_blank" rel="noopener noreferrer" aria-label="Link to {{ comic_name }} home page">
<img src="{{ url_for('static', filename='images/' + banner_image) }}" alt="{{ comic_name }}" class="banner-image"> <img src="{{ ('images/' + banner_image) | cdn_static }}" alt="{{ comic_name }}" class="banner-image">
</a> </a>
<details class="banner-code"> <details class="banner-code">
<summary>Get code</summary> <summary>Get code</summary>
<textarea readonly onfocus="this.select()" onclick="this.select()" aria-label="HTML code for linking to {{ comic_name }}"><a href="{{ site_url }}"><img src="{{ site_url }}/static/images/{{ banner_image }}" alt="{{ comic_name }}"></a></textarea> <textarea readonly onfocus="this.select()" onclick="this.select()" aria-label="HTML code for linking to {{ comic_name }}"><a href="{{ site_url }}"><img src="{% if cdn_url %}{{ cdn_url }}/static/images/{{ banner_image }}{% else %}{{ site_url }}/static/images/{{ banner_image }}{% endif %}" alt="{{ comic_name }}"></a></textarea>
</details> </details>
</div> </div>
</div> </div>
@@ -172,7 +155,7 @@
<span class="footer-divider" aria-hidden="true">|</span> <span class="footer-divider" aria-hidden="true">|</span>
<div class="site-credit"> <div class="site-credit">
<a href="https://git.puercito.net/mi/sunday" target="_blank" rel="noopener noreferrer" aria-label="Sunday Comics - Webcomic platform"> <a href="https://git.puercito.net/mi/sunday" target="_blank" rel="noopener noreferrer" aria-label="Sunday Comics - Webcomic platform">
<img src="{{ url_for('static', filename='images/sunday.jpg') }}" alt="Sunday Comics" class="credit-image"> <img src="{{ 'images/sunday.jpg' | cdn_static }}" alt="Sunday Comics" class="credit-image">
</a> </a>
</div> </div>
</div> </div>
@@ -181,7 +164,7 @@
{% if footer_image %} {% if footer_image %}
<div class="site-footer-image"> <div class="site-footer-image">
<img src="{{ url_for('static', filename='images/' + footer_image) }}" alt="{{ comic_name }} Footer"> <img src="{{ ('images/' + footer_image) | cdn_static }}" alt="{{ comic_name }} Footer">
</div> </div>
{% endif %} {% endif %}
@@ -203,12 +186,12 @@
</div> </div>
{% endif %} {% endif %}
<script src="{{ url_for('static', filename='js/comic-nav.js') }}"></script> <script src="{{ 'js/comic-nav.js' | cdn_static }}"></script>
{% if embed_enabled %} {% if embed_enabled %}
<script src="{{ url_for('static', filename='js/embed.js') }}"></script> <script src="{{ 'js/embed.js' | cdn_static }}"></script>
{% endif %} {% endif %}
{% if permalink_enabled %} {% if permalink_enabled %}
<script src="{{ url_for('static', filename='js/permalink.js') }}"></script> <script src="{{ 'js/permalink.js' | cdn_static }}"></script>
{% endif %} {% endif %}
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>

View File

@@ -2,7 +2,7 @@
{% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %} {% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %}
{% block og_image %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endblock %} {% block og_image %}{% if cdn_url %}{{ cdn_url }}/static/images/thumbs/{{ comic.filename }}{% else %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endif %}{% endblock %}
{% block extra_css %} {% block extra_css %}
<script type="application/ld+json"> <script type="application/ld+json">
@@ -11,8 +11,8 @@
"@type": "ComicStory", "@type": "ComicStory",
"name": "{{ comic.title if comic.title else '#' ~ comic.number }}", "name": "{{ comic.title if comic.title else '#' ~ comic.number }}",
"datePublished": "{{ comic.date }}", "datePublished": "{{ comic.date }}",
"image": "{{ site_url }}/static/images/comics/{{ comic.filename }}", "image": "{% if cdn_url %}{{ cdn_url }}/static/images/comics/{{ comic.filename }}{% else %}{{ site_url }}/static/images/comics/{{ comic.filename }}{% endif %}",
"thumbnailUrl": "{{ site_url }}/static/images/thumbs/{{ comic.filename }}", "thumbnailUrl": "{% if cdn_url %}{{ cdn_url }}/static/images/thumbs/{{ comic.filename }}{% else %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endif %}",
"description": "{{ comic.alt_text }}", "description": "{{ comic.alt_text }}",
"isPartOf": { "isPartOf": {
"@type": "ComicSeries", "@type": "ComicSeries",
@@ -37,12 +37,17 @@
</div> </div>
{% endif %} {% endif %}
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1"> <div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}{% if comic.html_embed %} comic-image-embed{% endif %}" id="comic-image-focus" tabindex="-1">
{% if comic.is_multi_image %} {% if comic.html_embed %}
{# HTML embed (video, widget, etc.) #}
<div class="comic-embed-wrapper">
{{ comic.html_embed | safe }}
</div>
{% elif comic.is_multi_image %}
{# Multi-image layout (webtoon style) - no click-through on individual images #} {# Multi-image layout (webtoon style) - no click-through on individual images #}
{% for i in range(comic.filenames|length) %} {% for i in range(comic.filenames|length) %}
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}" <img src="{% if loop.first %}{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}{% endif %}"
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %} {% if not loop.first %}data-src="{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}" class="lazy-load"{% endif %}
alt="{{ comic.alt_texts[i] }}" alt="{{ comic.alt_texts[i] }}"
title="{{ comic.alt_texts[i] }}" title="{{ comic.alt_texts[i] }}"
loading="{% if loop.first %}eager{% else %}lazy{% endif %}"> loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
@@ -53,13 +58,13 @@
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic"> <a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic">
{% if comic.mobile_filename %} {% if comic.mobile_filename %}
<picture> <picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> <source media="(max-width: 768px)" srcset="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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> </picture>
{% else %} {% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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 }}"
loading="eager"> loading="eager">
@@ -68,13 +73,13 @@
{% else %} {% else %}
{% if comic.mobile_filename %} {% if comic.mobile_filename %}
<picture> <picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> <source media="(max-width: 768px)" srcset="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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> </picture>
{% else %} {% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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 }}"
loading="eager"> loading="eager">
@@ -89,17 +94,17 @@
{# Icon-based navigation #} {# Icon-based navigation #}
{% if comic.number > 1 %} {% if comic.number > 1 %}
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First"> <a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt=""> <img src="{{ 'images/icons/first.png' | cdn_static }}" alt="">
</a> </a>
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous"> <a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt=""> <img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
</a> </a>
{% else %} {% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt=""> <img src="{{ 'images/icons/first.png' | cdn_static }}" alt="">
</span> </span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt=""> <img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
</span> </span>
{% endif %} {% endif %}
@@ -107,17 +112,17 @@
{% if comic.number < total_comics %} {% if comic.number < total_comics %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next"> <a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt=""> <img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
</a> </a>
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest"> <a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt=""> <img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
</a> </a>
{% else %} {% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt=""> <img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
</span> </span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt=""> <img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
</span> </span>
{% endif %} {% endif %}
{% else %} {% else %}
@@ -147,12 +152,12 @@
<div class="comic-share-section"> <div class="comic-share-section">
{% if permalink_enabled %} {% if permalink_enabled %}
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic"> <button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink {% if use_share_icons %}<img src="{{ 'images/icons/link.png' | cdn_static }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
</button> </button>
{% endif %} {% endif %}
{% if embed_enabled %} {% if embed_enabled %}
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic"> <button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed {% if use_share_icons %}<img src="{{ 'images/icons/embed.png' | cdn_static }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
</button> </button>
{% endif %} {% endif %}
</div> </div>

View File

@@ -127,21 +127,21 @@
<p class="embed-date">{{ comic.formatted_date }}</p> <p class="embed-date">{{ comic.formatted_date }}</p>
</div> </div>
{% if logo_image %} {% if logo_image %}
<img src="{{ site_url }}/static/images/{{ logo_image }}" alt="{{ comic_name }}" class="embed-logo"> <img src="{% if cdn_url %}{{ cdn_url }}/static/images/{{ logo_image }}{% else %}{{ site_url }}/static/images/{{ logo_image }}{% endif %}" alt="{{ comic_name }}" class="embed-logo">
{% endif %} {% endif %}
</div> </div>
<div class="embed-image-wrapper"> <div class="embed-image-wrapper">
<a href="{{ site_url }}/comic/{{ comic.number }}" class="embed-image-link" target="_blank" rel="noopener noreferrer" aria-label="View {{ comic.title if comic.title else 'comic #' ~ comic.number }} on {{ comic_name }}"> <a href="{{ site_url }}/comic/{{ comic.number }}" class="embed-image-link" target="_blank" rel="noopener noreferrer" aria-label="View {{ comic.title if comic.title else 'comic #' ~ comic.number }} on {{ comic_name }}">
{% if comic.mobile_filename %} {% if comic.mobile_filename %}
<picture> <picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> <source media="(max-width: 768px)" srcset="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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 }}"
class="embed-image"> class="embed-image">
</picture> </picture>
{% else %} {% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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 }}"
class="embed-image"> class="embed-image">

View File

@@ -3,7 +3,7 @@
{% if comic %} {% if comic %}
{% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %} {% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %}
{% block og_image %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endblock %} {% block og_image %}{% if cdn_url %}{{ cdn_url }}/static/images/thumbs/{{ comic.filename }}{% else %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endif %}{% endblock %}
{% endif %} {% endif %}
{% block content %} {% block content %}
@@ -16,12 +16,17 @@
<p class="comic-date">{{ comic.date }}</p> <p class="comic-date">{{ comic.date }}</p>
</div> </div>
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1"> <div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}{% if comic.html_embed %} comic-image-embed{% endif %}" id="comic-image-focus" tabindex="-1">
{% if comic.is_multi_image %} {% if comic.html_embed %}
{# HTML embed (video, widget, etc.) #}
<div class="comic-embed-wrapper">
{{ comic.html_embed | safe }}
</div>
{% elif comic.is_multi_image %}
{# Multi-image layout (webtoon style) - no click-through on individual images #} {# Multi-image layout (webtoon style) - no click-through on individual images #}
{% for i in range(comic.filenames|length) %} {% for i in range(comic.filenames|length) %}
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}" <img src="{% if loop.first %}{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}{% endif %}"
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %} {% if not loop.first %}data-src="{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}" class="lazy-load"{% endif %}
alt="{{ comic.alt_texts[i] }}" alt="{{ comic.alt_texts[i] }}"
title="{{ comic.alt_texts[i] }}" title="{{ comic.alt_texts[i] }}"
loading="{% if loop.first %}eager{% else %}lazy{% endif %}"> loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
@@ -29,13 +34,13 @@
{% else %} {% else %}
{% if comic.mobile_filename %} {% if comic.mobile_filename %}
<picture> <picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> <source media="(max-width: 768px)" srcset="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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> </picture>
{% else %} {% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}" <img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
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 }}">
{% endif %} {% endif %}
@@ -48,17 +53,17 @@
{# Icon-based navigation #} {# Icon-based navigation #}
{% if comic.number > 1 %} {% if comic.number > 1 %}
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First"> <a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt=""> <img src="{{ 'images/icons/first.png' | cdn_static }}" alt="">
</a> </a>
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous"> <a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt=""> <img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
</a> </a>
{% else %} {% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt=""> <img src="{{ 'images/icons/first.png' | cdn_static }}" alt="">
</span> </span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt=""> <img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
</span> </span>
{% endif %} {% endif %}
@@ -66,17 +71,17 @@
{% if comic.number < total_comics %} {% if comic.number < total_comics %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next"> <a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt=""> <img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
</a> </a>
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest"> <a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt=""> <img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
</a> </a>
{% else %} {% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt=""> <img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
</span> </span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true"> <span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt=""> <img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
</span> </span>
{% endif %} {% endif %}
{% else %} {% else %}
@@ -106,12 +111,12 @@
<div class="comic-share-section"> <div class="comic-share-section">
{% if permalink_enabled %} {% if permalink_enabled %}
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic"> <button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink {% if use_share_icons %}<img src="{{ 'images/icons/link.png' | cdn_static }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
</button> </button>
{% endif %} {% endif %}
{% if embed_enabled %} {% if embed_enabled %}
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic"> <button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed {% if use_share_icons %}<img src="{{ 'images/icons/embed.png' | cdn_static }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
</button> </button>
{% 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.18"