fosscat-site/scripts/optimize-images.sh
2026-03-08 00:08:39 -07:00

887 lines
30 KiB
Bash
Executable File

#!/usr/bin/env bash
# optimize-images.sh — Image auditor, metadata stripper, and WebP optimizer for fosscat.com
#
# Usage:
# ./scripts/optimize-images.sh # Interactive mode
# ./scripts/optimize-images.sh --dry-run # Show what would happen without changing anything
# ./scripts/optimize-images.sh --yes # Skip all confirmation prompts
# ./scripts/optimize-images.sh --audit-only # Only run the audit phase (no changes)
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
IMAGES_DIR="static/images"
CONTENT_DIR="content"
CONFIG_FILE="config.toml"
MAX_WIDTH=2000
MAX_HEIGHT=2000
WEBP_QUALITY=82
# ---------------------------------------------------------------------------
# CLI flags
# ---------------------------------------------------------------------------
DRY_RUN=false
AUTO_YES=false
AUDIT_ONLY=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--yes|-y) AUTO_YES=true ;;
--audit-only) AUDIT_ONLY=true ;;
--help|-h)
echo "Usage: $0 [--dry-run] [--yes] [--audit-only]"
echo ""
echo " --dry-run Show what would happen without making changes"
echo " --yes, -y Skip confirmation prompts"
echo " --audit-only Only run the audit (no modifications)"
echo " --help, -h Show this help"
exit 0
;;
*)
echo "Unknown option: $arg"
echo "Run $0 --help for usage"
exit 1
;;
esac
done
# ---------------------------------------------------------------------------
# Colors and formatting
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m' # No Color
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
header() { echo -e "\n${BOLD}${CYAN}═══ $* ═══${NC}\n"; }
# ---------------------------------------------------------------------------
# Dependency checks
# ---------------------------------------------------------------------------
check_deps() {
local missing=()
for cmd in exiftool convert identify cwebp; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
error "Missing required tools: ${missing[*]}"
echo " These are provided by the Nix dev shell. Run:"
echo " nix develop # or let direnv load the flake"
echo ""
echo " Required nix packages:"
echo " perl538Packages.ImageExifTool (exiftool)"
echo " imagemagick (convert, identify)"
echo " libwebp (cwebp)"
exit 1
fi
}
# ---------------------------------------------------------------------------
# Ensure we're in the project root
# ---------------------------------------------------------------------------
if [[ ! -f "$CONFIG_FILE" ]] || [[ ! -d "$IMAGES_DIR" ]]; then
error "Must be run from the project root (where $CONFIG_FILE and $IMAGES_DIR exist)"
exit 1
fi
check_deps
# ---------------------------------------------------------------------------
# Utility: human-readable file size
# ---------------------------------------------------------------------------
human_size() {
local bytes=$1
if (( bytes >= 1048576 )); then
local mb_whole=$(( bytes / 1048576 ))
local mb_frac=$(( (bytes % 1048576) * 10 / 1048576 ))
echo "${mb_whole}.${mb_frac} MB"
elif (( bytes >= 1024 )); then
echo "$(( bytes / 1024 )) KB"
else
echo "${bytes} B"
fi
}
# ---------------------------------------------------------------------------
# Manifest: track which images have been optimized (skip re-processing)
# ---------------------------------------------------------------------------
MANIFEST_FILE=".image-manifest"
manifest_hash() {
sha256sum "$1" | cut -d' ' -f1
}
# Look up a file's stored hash; prints it or empty string if not found
manifest_lookup() {
local file="$1"
if [[ -f "$MANIFEST_FILE" ]]; then
grep -F " $file" "$MANIFEST_FILE" 2>/dev/null | head -1 | awk '{print $1}' || true
fi
}
# Update or add a file's hash in the manifest
manifest_update() {
local file="$1"
local hash
hash=$(manifest_hash "$file")
if [[ ! -f "$MANIFEST_FILE" ]]; then
echo "# fosscat.com image optimization manifest — do not edit manually" > "$MANIFEST_FILE"
fi
# Remove existing entry for this file (if any)
if grep -qF " $file" "$MANIFEST_FILE" 2>/dev/null; then
grep -vF " $file" "$MANIFEST_FILE" > "${MANIFEST_FILE}.tmp"
mv "${MANIFEST_FILE}.tmp" "$MANIFEST_FILE"
fi
# Append new entry
echo "$hash $file" >> "$MANIFEST_FILE"
}
# Remove a file's entry from the manifest
manifest_remove() {
local file="$1"
if [[ -f "$MANIFEST_FILE" ]] && grep -qF " $file" "$MANIFEST_FILE" 2>/dev/null; then
grep -vF " $file" "$MANIFEST_FILE" > "${MANIFEST_FILE}.tmp"
mv "${MANIFEST_FILE}.tmp" "$MANIFEST_FILE"
fi
}
# ---------------------------------------------------------------------------
# Utility: confirm prompt (respects --yes and --dry-run)
# ---------------------------------------------------------------------------
confirm() {
local prompt="$1"
if $AUTO_YES; then
return 0
fi
if $DRY_RUN; then
echo -e " ${DIM}(dry-run: would ask) $prompt${NC}"
return 0
fi
echo -en " $prompt ${BOLD}[y/N]${NC} "
read -r answer
[[ "$answer" =~ ^[Yy]$ ]]
}
# ---------------------------------------------------------------------------
# PHASE 1: AUDIT
# ---------------------------------------------------------------------------
phase_audit() {
header "PHASE 1: IMAGE AUDIT"
# Collect all image files
local -a image_files=()
while IFS= read -r -d '' f; do
image_files+=("$f")
done < <(find "$IMAGES_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' \) -print0 | sort -z)
if [[ ${#image_files[@]} -eq 0 ]]; then
warn "No images found in $IMAGES_DIR"
return
fi
# --- Image inventory table ---
echo -e "${BOLD}Image Inventory${NC}"
printf " %-40s %-6s %-12s %s\n" "FILENAME" "FORMAT" "DIMENSIONS" "SIZE"
printf " %-40s %-6s %-12s %s\n" "--------" "------" "----------" "----"
local total_size=0
for img in "${image_files[@]}"; do
local fname
fname=$(basename "$img")
local ext="${fname##*.}"
local fsize
fsize=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null)
total_size=$((total_size + fsize))
local dims
dims=$(identify -format "%wx%h" "$img" 2>/dev/null || echo "unknown")
printf " %-40s %-6s %-12s %s\n" "$fname" "$ext" "$dims" "$(human_size "$fsize")"
done
echo ""
info "Total: ${#image_files[@]} images, $(human_size $total_size)"
# --- EXIF / Metadata scan ---
echo ""
echo -e "${BOLD}Metadata / Privacy Scan${NC}"
local privacy_issues=0
# Sensitive tag names to check (extracted in a single exiftool call per image)
local sensitive_tag_args=(
-GPSLatitude -GPSLongitude -GPSPosition
-SerialNumber -CameraSerialNumber -BodySerialNumber -LensSerialNumber
-OwnerName -Artist -Copyright -Creator -Rights
-By-line -Contact
-Make -Model -LensModel -Software
-DateTime -DateTimeOriginal -CreateDate
-CreatorTool -ImageDescription -UserComment
)
for img in "${image_files[@]}"; do
local fname
fname=$(basename "$img")
local has_metadata=false
local metadata_lines=()
# Single exiftool call to extract all sensitive tags at once
local exif_output
exif_output=$(exiftool -s -f "${sensitive_tag_args[@]}" "$img" 2>/dev/null || true)
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# exiftool -s output format: "TagName : value"
local tagname value
tagname=$(echo "$line" | sed 's/\s*:.*//' | xargs)
value=$(echo "$line" | sed 's/^[^:]*:\s*//')
# Skip tags with no value (exiftool -f shows "-" for missing tags)
[[ "$value" == "-" ]] && continue
[[ -z "$value" ]] && continue
has_metadata=true
# Highlight GPS data in red
if [[ "$tagname" == *GPS* ]] || [[ "$tagname" == *Latitude* ]] || [[ "$tagname" == *Longitude* ]]; then
metadata_lines+=("${RED}!!${NC} $tagname: $value")
elif [[ "$tagname" == *Serial* ]] || [[ "$tagname" == *Owner* ]] || [[ "$tagname" == *Artist* ]] || [[ "$tagname" == *Creator* ]]; then
metadata_lines+=("${YELLOW}!${NC} $tagname: $value")
else
metadata_lines+=("${DIM}-${NC} $tagname: $value")
fi
done <<< "$exif_output"
if $has_metadata; then
privacy_issues=$((privacy_issues + 1))
echo -e " ${YELLOW}$fname${NC} — metadata found:"
for line in "${metadata_lines[@]}"; do
echo -e " $line"
done
else
echo -e " ${GREEN}$fname${NC} — clean"
fi
done
echo ""
if [[ $privacy_issues -gt 0 ]]; then
warn "$privacy_issues image(s) contain metadata that should be stripped"
else
success "All images are clean of sensitive metadata"
fi
# --- Cross-reference with content ---
echo ""
echo -e "${BOLD}Content Reference Check${NC}"
# Collect all image references from content files
local -a referenced_images=()
local -a broken_refs=()
local -a inconsistent_paths=()
while IFS= read -r -d '' mdfile; do
# Front matter image field (handles both `image: "..."` and ` image: "..."` under cover:)
while IFS= read -r fm_image; do
[[ -z "$fm_image" ]] && continue
# Clean up: remove surrounding quotes and whitespace
fm_image=$(echo "$fm_image" | sed 's/^[[:space:]]*image:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']\s*$//')
if [[ -n "$fm_image" ]] && [[ "$fm_image" != '""' ]] && [[ "$fm_image" != http* ]]; then
# Normalize: Hugo serves /images/... from static/images/...
local fs_path="static/${fm_image#/}"
# Check if it's a broken reference
if [[ ! -f "$fs_path" ]]; then
broken_refs+=("$mdfile|$fm_image")
else
referenced_images+=("$fs_path")
fi
# Check for inconsistent path (missing leading /)
if [[ "$fm_image" != /* ]]; then
inconsistent_paths+=("$mdfile|$fm_image")
fi
fi
done < <(grep -E '^\s*image:\s' "$mdfile" 2>/dev/null || true)
# Inline markdown images: ![alt](/images/foo.jpg#center)
while IFS= read -r inline_ref; do
[[ -z "$inline_ref" ]] && continue
# Strip #fragment
local clean_ref="${inline_ref%%#*}"
local fs_ref="static/${clean_ref#/}"
if [[ ! -f "$fs_ref" ]] && [[ "$clean_ref" != http* ]]; then
broken_refs+=("$mdfile|$inline_ref")
else
referenced_images+=("$fs_ref")
fi
done < <(grep -oP '!\[[^\]]*\]\(\K[^)]+' "$mdfile" 2>/dev/null || true)
done < <(find "$CONTENT_DIR" -name '*.md' -print0)
# Also check config.toml for avatarUrl
local avatar_path
avatar_path=$(grep 'avatarUrl' "$CONFIG_FILE" | sed 's/.*=\s*["'\'']\(.*\)["'\'']/\1/' || true)
if [[ -n "$avatar_path" ]]; then
referenced_images+=("static/${avatar_path#/}")
fi
# Find unreferenced images (compare using static/images/... paths)
local -a unreferenced=()
for img in "${image_files[@]}"; do
local found=false
for ref in "${referenced_images[@]}"; do
if [[ "$ref" == "$img" ]]; then
found=true
break
fi
done
if ! $found; then
unreferenced+=("$img")
fi
done
# Report broken references
if [[ ${#broken_refs[@]} -gt 0 ]]; then
warn "${#broken_refs[@]} broken image reference(s):"
for entry in "${broken_refs[@]}"; do
local file="${entry%%|*}"
local ref="${entry##*|}"
echo -e " ${RED}$ref${NC} in ${DIM}$file${NC}"
done
else
success "No broken image references"
fi
# Report unreferenced images
echo ""
if [[ ${#unreferenced[@]} -gt 0 ]]; then
warn "${#unreferenced[@]} unreferenced image(s) (not used in any content):"
for img in "${unreferenced[@]}"; do
local fsize
fsize=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null)
echo -e " ${YELLOW}$(basename "$img")${NC} ($(human_size "$fsize"))"
done
else
success "All images are referenced in content"
fi
# Report inconsistent paths
if [[ ${#inconsistent_paths[@]} -gt 0 ]]; then
echo ""
warn "${#inconsistent_paths[@]} image path(s) missing leading '/':"
for entry in "${inconsistent_paths[@]}"; do
local file="${entry%%|*}"
local ref="${entry##*|}"
echo -e " ${YELLOW}$ref${NC} in ${DIM}$file${NC}"
done
fi
# Export arrays for later phases (bash 4+ trick: print to temp files)
printf '%s\n' "${image_files[@]}" > /tmp/optimg_files.txt
printf '%s\n' "${unreferenced[@]+"${unreferenced[@]}"}" > /tmp/optimg_unreferenced.txt
printf '%s\n' "${broken_refs[@]+"${broken_refs[@]}"}" > /tmp/optimg_broken.txt
echo "$total_size" > /tmp/optimg_total_size.txt
}
# ---------------------------------------------------------------------------
# PHASE 2: METADATA STRIPPING
# ---------------------------------------------------------------------------
phase_strip_metadata() {
header "PHASE 2: METADATA STRIPPING"
if $DRY_RUN; then
info "(dry-run) Would strip all EXIF/IPTC/XMP metadata from images"
echo ""
return
fi
local -a image_files=()
mapfile -t image_files < /tmp/optimg_files.txt
local stripped=0
for img in "${image_files[@]}"; do
[[ -z "$img" ]] && continue
local fname
fname=$(basename "$img")
# Check if image has strippable EXIF/XMP/IPTC metadata (not just file properties)
# Use -EXIF:All -XMP:All -IPTC:All to only check real metadata groups
local meta_check
meta_check=$(exiftool -s -s -s -EXIF:All -XMP:All -IPTC:All "$img" 2>/dev/null || true)
if [[ -z "$meta_check" ]]; then
echo -e " ${DIM}$fname — already clean, skipping${NC}"
continue
fi
# Auto-orient JPEG/PNG before stripping (applies EXIF rotation to pixels)
local ext="${fname##*.}"
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
if [[ "$ext" == "jpg" ]] || [[ "$ext" == "jpeg" ]] || [[ "$ext" == "png" ]]; then
magick "$img" -auto-orient "$img" 2>/dev/null || true
fi
# Strip all metadata
exiftool -all= -overwrite_original "$img" 2>/dev/null
stripped=$((stripped + 1))
echo -e " ${GREEN}$fname${NC} — metadata stripped"
# Update manifest since file content changed
manifest_update "$img"
done
echo ""
success "Stripped metadata from $stripped image(s)"
}
# ---------------------------------------------------------------------------
# PHASE 3: CONVERT & COMPRESS
# ---------------------------------------------------------------------------
phase_convert() {
header "PHASE 3: CONVERT TO WEBP & COMPRESS"
local -a image_files=()
mapfile -t image_files < /tmp/optimg_files.txt
# Delete unreferenced images first
local -a unreferenced=()
mapfile -t unreferenced < /tmp/optimg_unreferenced.txt
if [[ ${#unreferenced[@]} -gt 0 ]] && [[ -n "${unreferenced[0]}" ]]; then
echo -e "${BOLD}Removing unreferenced images${NC}"
for img in "${unreferenced[@]}"; do
[[ -z "$img" ]] && continue
local fsize
fsize=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null)
if $DRY_RUN; then
echo -e " ${DIM}(dry-run) Would delete: $(basename "$img") ($(human_size "$fsize"))${NC}"
else
rm -f "$img"
manifest_remove "$img"
echo -e " ${RED}Deleted:${NC} $(basename "$img") ($(human_size "$fsize"))"
fi
done
echo ""
fi
echo -e "${BOLD}Converting images to WebP (quality $WEBP_QUALITY, max ${MAX_WIDTH}x${MAX_HEIGHT})${NC}"
printf " %-40s %-12s %-12s %s\n" "FILENAME" "BEFORE" "AFTER" "STATUS"
printf " %-40s %-12s %-12s %s\n" "--------" "------" "-----" "------"
local total_before=0
local total_after=0
local converted=0
local skipped=0
local resized_only=0
for img in "${image_files[@]}"; do
[[ -z "$img" ]] && continue
# Skip if this was an unreferenced file we just deleted
[[ ! -f "$img" ]] && continue
local fname
fname=$(basename "$img")
local ext="${fname##*.}"
local base="${fname%.*}"
local ext_lower
ext_lower=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
local webp_path="$IMAGES_DIR/${base}.webp"
local before_size
before_size=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null)
total_before=$((total_before + before_size))
# --- Manifest-based skip logic ---
local current_hash stored_hash
current_hash=$(manifest_hash "$img")
stored_hash=$(manifest_lookup "$img")
if [[ "$ext_lower" == "webp" ]] && [[ "$current_hash" == "$stored_hash" ]] && [[ -n "$stored_hash" ]]; then
# Already optimized, hash unchanged — skip entirely
total_after=$((total_after + before_size))
skipped=$((skipped + 1))
printf " %-40s %-12s %-12s ${DIM}%s${NC}\n" \
"$fname" "$(human_size "$before_size")" "—" "already optimized"
continue
fi
if $DRY_RUN; then
if [[ "$ext_lower" == "webp" ]] && [[ -n "$stored_hash" ]] && [[ "$current_hash" != "$stored_hash" ]]; then
echo -e " ${DIM}(dry-run) $fname — edited, would check dimensions only${NC}"
elif [[ "$ext_lower" == "webp" ]] && [[ -z "$stored_hash" ]]; then
echo -e " ${DIM}(dry-run) $fname — new webp, would record in manifest${NC}"
else
echo -e " ${DIM}(dry-run) Would convert: $fname -> ${base}.webp${NC}"
fi
local est_after=$before_size
case "$ext_lower" in
jpg|jpeg) est_after=$((before_size / 5)) ;;
png) est_after=$((before_size / 3)) ;;
webp) est_after=$before_size ;;
esac
total_after=$((total_after + est_after))
converted=$((converted + 1))
continue
fi
# Get current dimensions
local cur_width cur_height
read -r cur_width cur_height < <(identify -format "%w %h\n" "$img" 2>/dev/null || echo "0 0")
local needs_resize=false
if (( cur_width > MAX_WIDTH )) || (( cur_height > MAX_HEIGHT )); then
needs_resize=true
fi
# --- Already WebP (edited or new): skip lossy re-compression ---
if [[ "$ext_lower" == "webp" ]]; then
if $needs_resize; then
# Resize oversized WebP via ImageMagick (lossless resize, no re-encoding)
local tmp_resized
tmp_resized=$(mktemp /tmp/optimg_XXXXXX.webp)
magick "$img" -resize "${MAX_WIDTH}x${MAX_HEIGHT}>" "$tmp_resized"
local new_dims
new_dims=$(magick identify -format '%wx%h' "$tmp_resized")
mv "$tmp_resized" "$img"
info " Resized $fname: ${cur_width}x${cur_height} -> $new_dims"
resized_only=$((resized_only + 1))
fi
# Record in manifest (whether resized or just newly tracked)
manifest_update "$img"
local after_size
after_size=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null)
total_after=$((total_after + after_size))
if $needs_resize; then
local savings=0
if (( before_size > 0 )); then
savings=$(( (before_size - after_size) * 100 / before_size ))
fi
printf " %-40s %-12s %-12s ${CYAN}%s${NC}\n" \
"$fname" "$(human_size "$before_size")" "$(human_size "$after_size")" "resized (${savings}%)"
elif [[ -z "$stored_hash" ]]; then
printf " %-40s %-12s %-12s ${GREEN}%s${NC}\n" \
"$fname" "$(human_size "$before_size")" "—" "recorded"
else
printf " %-40s %-12s %-12s ${CYAN}%s${NC}\n" \
"$fname" "$(human_size "$before_size")" "—" "updated (edited)"
fi
converted=$((converted + 1))
continue
fi
# --- Non-WebP image: full convert + compress pipeline ---
local cwebp_input="$img"
local tmp_resized=""
if $needs_resize; then
# Resize via ImageMagick, output to temp PNG for cwebp
tmp_resized=$(mktemp /tmp/optimg_XXXXXX.png)
magick "$img" -resize "${MAX_WIDTH}x${MAX_HEIGHT}>" -quality 100 "$tmp_resized"
info " Resized $fname: ${cur_width}x${cur_height} -> $(magick identify -format '%wx%h' "$tmp_resized")"
cwebp_input="$tmp_resized"
fi
# Convert to WebP via cwebp
cwebp -q "$WEBP_QUALITY" "$cwebp_input" -o "$webp_path" 2>/dev/null
# Cleanup temp file if we resized
[[ -n "$tmp_resized" ]] && rm -f "$tmp_resized"
# Delete original non-WebP source
rm -f "$img"
# Record the new WebP in the manifest
manifest_update "$webp_path"
local after_size
after_size=$(stat -c%s "$webp_path" 2>/dev/null || stat -f%z "$webp_path" 2>/dev/null)
total_after=$((total_after + after_size))
local savings=0
if (( before_size > 0 )); then
savings=$(( (before_size - after_size) * 100 / before_size ))
fi
local savings_color="$GREEN"
if (( savings < 10 )); then
savings_color="$YELLOW"
fi
printf " %-40s %-12s %-12s ${savings_color}%s%%${NC}\n" \
"${base}.webp" "$(human_size "$before_size")" "$(human_size "$after_size")" "$savings"
converted=$((converted + 1))
done
echo ""
local total_savings=0
if (( total_before > 0 )) && (( total_after > 0 )); then
total_savings=$(( (total_before - total_after) * 100 / total_before ))
fi
if (( skipped > 0 )); then
info "Skipped $skipped already-optimized image(s)"
fi
if (( resized_only > 0 )); then
info "Resized $resized_only edited image(s) (no re-compression)"
fi
info "Processed $converted image(s)"
if (( total_before > total_after )); then
info "Total: $(human_size $total_before) -> $(human_size $total_after) (${total_savings}% reduction)"
else
info "Total size: $(human_size $total_after)"
fi
# Save totals for summary
echo "$total_before" > /tmp/optimg_total_before.txt
echo "$total_after" > /tmp/optimg_total_after.txt
echo "$converted" > /tmp/optimg_converted.txt
echo "$skipped" > /tmp/optimg_skipped.txt
}
# ---------------------------------------------------------------------------
# PHASE 4: UPDATE CONTENT REFERENCES
# ---------------------------------------------------------------------------
phase_update_refs() {
header "PHASE 4: UPDATE CONTENT REFERENCES"
local updated_files=0
# --- Step 1: Update image extensions in content files ---
# This must happen BEFORE broken ref clearing, since .jpg/.png files are now .webp
echo -e "${BOLD}Updating image references (.jpg/.jpeg/.png -> .webp)${NC}"
while IFS= read -r -d '' mdfile; do
local changed=false
# Normalize front matter paths first: change image: "images/... to image: "/images/...
if grep -qE '^\s*image:\s*"images/' "$mdfile" 2>/dev/null; then
if ! $DRY_RUN; then
sed -i -E 's@^(\s*image:\s*)"images/@\1"/images/@' "$mdfile"
fi
changed=true
fi
# Update front matter image field (only local paths, not http URLs)
# Handles both `image: "/images/..."` and ` image: "/images/..."` (indented under cover:)
if grep -qE '^\s*image:\s*"/images/.*\.(jpg|jpeg|JPG|JPEG|png|PNG)"' "$mdfile" 2>/dev/null; then
if ! $DRY_RUN; then
sed -i -E 's@^(\s*image:\s*"/images/[^"]*)\.(jpg|jpeg|JPG|JPEG|png|PNG)"@\1.webp"@' "$mdfile"
fi
changed=true
fi
# Update inline markdown images: ![alt](/images/foo.jpg#center)
# Only match local /images/ paths, not external URLs
if grep -qP '!\[[^\]]*\]\(/images/[^)]*\.(jpg|jpeg|JPG|JPEG|png|PNG)(#[^)]*)?\)' "$mdfile" 2>/dev/null; then
if ! $DRY_RUN; then
sed -i -E 's@(!\[[^]]*\]\(/images/[^.)]*)\.(jpg|jpeg|JPG|JPEG|png|PNG)([#][^)]*)?(\))@\1.webp\3\4@g' "$mdfile"
fi
changed=true
fi
if $changed; then
local relpath="${mdfile}"
if $DRY_RUN; then
echo -e " ${DIM}(dry-run) Would update refs in: $relpath${NC}"
else
echo -e " ${GREEN}Updated${NC} $relpath"
fi
updated_files=$((updated_files + 1))
fi
done < <(find "$CONTENT_DIR" -name '*.md' -print0)
# --- Step 2: Update config.toml avatar ---
if grep -q 'avatarUrl.*\.png' "$CONFIG_FILE" 2>/dev/null; then
if $DRY_RUN; then
echo -e " ${DIM}(dry-run) Would update avatarUrl in $CONFIG_FILE${NC}"
else
sed -i 's@avatarUrl = "/images/fosscat_icon\.png"@avatarUrl = "/images/fosscat_icon.webp"@' "$CONFIG_FILE"
echo -e " ${GREEN}Updated${NC} avatarUrl in $CONFIG_FILE"
fi
updated_files=$((updated_files + 1))
fi
# --- Step 3: Clear genuinely broken image references ---
# Only clear refs that still don't resolve after extension updates
# (e.g., placeholder /images/img.jpg that was never a real image)
echo ""
echo -e "${BOLD}Checking for remaining broken image references${NC}"
local cleared=0
while IFS= read -r -d '' mdfile; do
# Check front matter image fields
while IFS= read -r fm_line; do
[[ -z "$fm_line" ]] && continue
local fm_image
fm_image=$(echo "$fm_line" | sed 's/^[[:space:]]*image:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']\s*$//')
[[ -z "$fm_image" ]] && continue
[[ "$fm_image" == '""' ]] && continue
[[ "$fm_image" == http* ]] && continue
local fs_path="static/${fm_image#/}"
if [[ ! -f "$fs_path" ]]; then
if $DRY_RUN; then
echo -e " ${DIM}(dry-run) Would clear broken ref in: $mdfile (was: $fm_image)${NC}"
else
local escaped_image
escaped_image=$(echo "$fm_image" | sed 's/[.[\/*^$]/\\&/g')
sed -i -E "s@^(\s*image:\s*).*${escaped_image}.*@\1\"\"@" "$mdfile"
echo -e " ${GREEN}Cleared${NC} broken ref ${DIM}$fm_image${NC} in ${DIM}$mdfile${NC}"
cleared=$((cleared + 1))
fi
fi
done < <(grep -E '^\s*image:\s' "$mdfile" 2>/dev/null || true)
done < <(find "$CONTENT_DIR" -name '*.md' -print0)
if [[ $cleared -eq 0 ]] && ! $DRY_RUN; then
success "No broken image references remaining"
fi
echo ""
info "Updated $updated_files file(s)"
}
# ---------------------------------------------------------------------------
# PHASE 5: SUMMARY
# ---------------------------------------------------------------------------
phase_summary() {
header "PHASE 5: SUMMARY"
if $DRY_RUN; then
echo -e "${BOLD}${YELLOW}DRY RUN — no changes were made${NC}"
echo ""
fi
local total_before total_after converted skipped
total_before=$(cat /tmp/optimg_total_before.txt 2>/dev/null || cat /tmp/optimg_total_size.txt 2>/dev/null || echo 0)
total_after=$(cat /tmp/optimg_total_after.txt 2>/dev/null || echo 0)
converted=$(cat /tmp/optimg_converted.txt 2>/dev/null || echo 0)
skipped=$(cat /tmp/optimg_skipped.txt 2>/dev/null || echo 0)
local savings=0
if (( total_before > 0 )) && (( total_after > 0 )); then
savings=$(( (total_before - total_after) * 100 / total_before ))
fi
echo -e " Images processed: ${BOLD}$converted${NC}"
if (( skipped > 0 )); then
echo -e " Images skipped: ${BOLD}$skipped${NC} (already optimized)"
fi
if (( total_after > 0 )) && (( total_before > total_after )); then
echo -e " Size before: ${BOLD}$(human_size "$total_before")${NC}"
echo -e " Size after: ${BOLD}$(human_size "$total_after")${NC}"
echo -e " Total reduction: ${BOLD}${GREEN}${savings}%${NC}"
elif (( total_after > 0 )); then
echo -e " Total size: ${BOLD}$(human_size "$total_after")${NC}"
fi
# Clean up stale manifest entries (files that no longer exist)
if [[ -f "$MANIFEST_FILE" ]] && ! $DRY_RUN; then
local stale=0
local manifest_tmp
manifest_tmp=$(mktemp /tmp/optimg_manifest_XXXXXX.txt)
while IFS= read -r line; do
# Keep comment lines
if [[ "$line" == \#* ]]; then
echo "$line" >> "$manifest_tmp"
continue
fi
# Extract filename (second field, after hash + two spaces)
local entry_file
entry_file=$(echo "$line" | awk '{print $2}')
if [[ -z "$entry_file" ]] || [[ -f "$entry_file" ]]; then
echo "$line" >> "$manifest_tmp"
else
stale=$((stale + 1))
fi
done < "$MANIFEST_FILE"
mv "$manifest_tmp" "$MANIFEST_FILE"
if (( stale > 0 )); then
info "Removed $stale stale manifest entry/entries"
fi
fi
echo ""
echo -e " ${BOLD}Next steps:${NC}"
echo -e " 1. Run ${CYAN}hugo server${NC} and verify images look correct"
echo -e " 2. Check the browser dev tools Network tab for proper WebP delivery"
echo -e " 3. Commit when satisfied: ${CYAN}git add -A && git commit -m \"optimize: convert images to webp, strip metadata\"${NC}"
# Cleanup temp files
rm -f /tmp/optimg_*.txt
}
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
main() {
echo -e "${BOLD}${CYAN}"
echo " ┌─────────────────────────────────────────┐"
echo " │ fosscat.com Image Optimizer │"
echo " │ Strip metadata · Convert to WebP │"
echo " │ Resize · Audit references │"
echo " └─────────────────────────────────────────┘"
echo -e "${NC}"
if $DRY_RUN; then
echo -e " ${YELLOW}Running in DRY RUN mode — no files will be modified${NC}"
echo ""
fi
# Phase 1: Audit (always runs)
phase_audit
if $AUDIT_ONLY; then
echo ""
info "Audit complete. Run without --audit-only to process images."
rm -f /tmp/optimg_*.txt
return
fi
# Confirm before proceeding
echo ""
if ! $AUTO_YES && ! $DRY_RUN; then
echo -en " ${BOLD}Proceed with optimization? [y/N]${NC} "
read -r answer
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
info "Aborted."
rm -f /tmp/optimg_*.txt
exit 0
fi
fi
# Phase 2: Strip metadata
phase_strip_metadata
# Phase 3: Convert & compress
phase_convert
# Phase 4: Update references
phase_update_refs
# Phase 5: Summary
phase_summary
}
main