Skip to content

Commit 9893b4a

Browse files
committed
Add image optimization script
Also added dev requirements file that includes Pillow required for the script to run.
1 parent 3afc048 commit 9893b4a

5 files changed

Lines changed: 174 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Weekly Dev Chat website — MkDocs Material static site. Read `mkdocs.yml` and r
77
- Posts are for Tuesdays (the weekly chat day). Use next Tuesday's date unless told otherwise.
88
- End post body with this paragraph (before the image):
99
`Everyone and anyone is welcome to [join](https://weeklydevchat.com/join/) as long as you are kind, supportive, and respectful of others. Zoom link will be posted at 12pm MDT.`
10-
- Place images in the same directory as the post's `index.md`.
10+
- Place images in the same directory as the post's `index.md`. Run `python3 scripts/optimize_image.py <image>` to convert to WebP and resize for the web.
1111
- Use `./create_post.sh` to scaffold a new post (calculates next Tuesday automatically).
1212
- **Multiple posts on the same date:** If a date folder already has an `index.md`, prefix the filename with a number and dash (e.g., `0-index.md`). The newest/latest post should use the lowest number so it appears first on the homepage. The original `index.md` keeps its name.
1313

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,43 @@ These scripts will:
118118

119119
4. Add any images to the same directory as the post
120120

121+
5. **Optimize images** for the web before committing (see [Optimizing Images](#optimizing-images) below)
122+
123+
### Optimizing Images
124+
125+
After adding an image to a post, run the optimization script to convert it to WebP and resize it for the web:
126+
127+
```bash
128+
python3 scripts/optimize_image.py docs/posts/YYYY/MM/DD/your_image.png
129+
```
130+
131+
This will create an optimized `.webp` file alongside the original. Update your post's markdown to reference the new `.webp` file, then delete the original if no longer needed.
132+
133+
**Options:**
134+
135+
| Flag | Default | Description |
136+
|------|---------|-------------|
137+
| `--quality` | 80 | WebP quality (1–100) |
138+
| `--max-width` | 1200 | Max width in pixels (won't upscale) |
139+
140+
**Examples:**
141+
142+
```bash
143+
# Optimize a single image with defaults
144+
python3 scripts/optimize_image.py docs/posts/2026/04/14/photo.png
145+
146+
# Batch optimize multiple images
147+
python3 scripts/optimize_image.py docs/posts/2026/04/14/*.png docs/posts/2026/04/14/*.jpg
148+
149+
# Custom quality and max width
150+
python3 scripts/optimize_image.py --quality 90 --max-width 1600 image.jpeg
151+
```
152+
153+
> **Note:** This script requires [Pillow](https://pillow.readthedocs.io/). Install dev dependencies with:
154+
> ```bash
155+
> pip install -r requirements-dev.txt
156+
> ```
157+
121158
### Categories and Tags
122159
123160
Categories are broad topic groupings and tags are specific topic labels for filtering. **Please use existing categories and tags when possible** to keep the taxonomy consistent. New ones can be added when truly needed.
@@ -145,8 +182,11 @@ This script requires `pyyaml`, which is included in `requirements.txt`. It is al
145182
├── docker-compose.yml # Docker development environment
146183
├── create_post.sh # Bash script to create blog posts
147184
├── create_post.ps1 # PowerShell script to create blog posts
185+
├── requirements-dev.in # Dev-only dependency pins (e.g. Pillow)
186+
├── requirements-dev.txt # Compiled dev dependencies
148187
├── scripts/
149-
│ └── find_tags_categories.py # List all existing tags and categories
188+
│ ├── find_tags_categories.py # List all existing tags and categories
189+
│ └── optimize_image.py # Optimize images for the web (PNG/JPEG → WebP)
150190
├── .github/
151191
│ ├── dependabot.yml # Dependabot configuration
152192
│ └── workflows/
@@ -176,6 +216,9 @@ mkdocs serve -a 0.0.0.0:8000 # Start server accessible on network
176216
mkdocs build # Build static site to site/ directory
177217
mkdocs build --clean # Clean build
178218

219+
# Optimize an image for the web
220+
python3 scripts/optimize_image.py docs/posts/YYYY/MM/DD/image.png
221+
179222
# Help
180223
mkdocs -h # Show help
181224

requirements-dev.in

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Dev-only dependencies (not needed for production builds)
2+
# uv pip compile requirements-dev.in --output-file requirements-dev.txt
3+
4+
Pillow>=10.0.0

requirements-dev.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.9
3+
# by the following command:
4+
#
5+
# pip-compile --output-file=requirements-dev.txt requirements-dev.in
6+
#
7+
pillow==11.3.0
8+
# via -r requirements-dev.in

scripts/optimize_image.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Optimize images for the Weekly Dev Chat website.
4+
5+
Converts images to WebP format, resizes to a max width, and reports savings.
6+
The original file is preserved; the optimized WebP is written alongside it.
7+
8+
Usage:
9+
python scripts/optimize_image.py image1.png image2.jpg
10+
python scripts/optimize_image.py --quality 85 --max-width 1600 image.png
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import sys
17+
from pathlib import Path
18+
19+
try:
20+
from PIL import Image
21+
except ImportError:
22+
print(
23+
"Error: Pillow is not installed.\n"
24+
"Install dev dependencies with:\n"
25+
" pip install -r requirements-dev.txt\n"
26+
"Or install Pillow directly:\n"
27+
" pip install Pillow",
28+
file=sys.stderr,
29+
)
30+
sys.exit(1)
31+
32+
SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"}
33+
34+
35+
def optimize_image(input_path: Path, *, quality: int, max_width: int) -> Path | None:
36+
"""Optimize a single image. Returns the output path, or None on error."""
37+
if input_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
38+
print(f" Skipping {input_path.name}: unsupported format")
39+
return None
40+
41+
try:
42+
img = Image.open(input_path)
43+
except Exception as e:
44+
print(f" Error opening {input_path.name}: {e}", file=sys.stderr)
45+
return None
46+
47+
# Convert palette/RGBA images appropriately for WebP
48+
if img.mode in ("P", "PA"):
49+
img = img.convert("RGBA")
50+
elif img.mode not in ("RGB", "RGBA"):
51+
img = img.convert("RGB")
52+
53+
# Resize if wider than max_width, preserving aspect ratio
54+
if img.width > max_width:
55+
ratio = max_width / img.width
56+
new_height = round(img.height * ratio)
57+
img = img.resize((max_width, new_height), Image.LANCZOS)
58+
59+
output_path = input_path.with_suffix(".webp")
60+
img.save(output_path, "WEBP", quality=quality)
61+
62+
original_size = input_path.stat().st_size
63+
optimized_size = output_path.stat().st_size
64+
change_pct = (optimized_size / original_size - 1) * 100 if original_size > 0 else 0
65+
66+
print(f" {input_path.name}")
67+
print(f" {original_size:,} bytes → {optimized_size:,} bytes ({change_pct:+.1f}%)")
68+
print(f" Saved to {output_path.name} ({img.width}x{img.height})")
69+
70+
return output_path
71+
72+
73+
def main() -> None:
74+
parser = argparse.ArgumentParser(
75+
description="Optimize images for the Weekly Dev Chat website.",
76+
)
77+
parser.add_argument(
78+
"images",
79+
nargs="+",
80+
type=Path,
81+
help="One or more image file paths to optimize.",
82+
)
83+
parser.add_argument(
84+
"--quality",
85+
type=int,
86+
default=80,
87+
help="WebP quality (1-100, default: 80).",
88+
)
89+
parser.add_argument(
90+
"--max-width",
91+
type=int,
92+
default=1200,
93+
help="Max image width in pixels (default: 1200). Images smaller than this are not upscaled.",
94+
)
95+
args = parser.parse_args()
96+
97+
successes = 0
98+
failures = 0
99+
100+
for image_path in args.images:
101+
if not image_path.is_file():
102+
print(f" Warning: {image_path} not found, skipping.", file=sys.stderr)
103+
failures += 1
104+
continue
105+
106+
result = optimize_image(image_path, quality=args.quality, max_width=args.max_width)
107+
if result:
108+
successes += 1
109+
else:
110+
failures += 1
111+
112+
print(f"\nDone: {successes} optimized, {failures} skipped/failed.")
113+
sys.exit(1 if failures > 0 and successes == 0 else 0)
114+
115+
116+
if __name__ == "__main__":
117+
main()

0 commit comments

Comments
 (0)