update images to webp, update pre-commit hooks
This commit is contained in:
Executable
+738
@@ -0,0 +1,738 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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: 
|
||||
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"
|
||||
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"
|
||||
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" "SAVINGS"
|
||||
printf " %-40s %-12s %-12s %s\n" "--------" "------" "-----" "-------"
|
||||
|
||||
local total_before=0
|
||||
local total_after=0
|
||||
local converted=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%.*}"
|
||||
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))
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo -e " ${DIM}(dry-run) Would convert: $fname -> ${base}.webp${NC}"
|
||||
# Estimate: assume 80% reduction for JPEGs, 70% for PNGs, 10% for existing WebP
|
||||
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 * 9 / 10)) ;;
|
||||
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
|
||||
|
||||
# Determine the input for cwebp
|
||||
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 (handles JPEG/PNG/WebP input natively)
|
||||
if [[ "$ext_lower" == "webp" ]] && [[ "$img" == "$webp_path" ]]; then
|
||||
# Same input and output: use temp output
|
||||
local tmp_webp
|
||||
tmp_webp=$(mktemp /tmp/optimg_XXXXXX.webp)
|
||||
cwebp -q "$WEBP_QUALITY" "$cwebp_input" -o "$tmp_webp" 2>/dev/null
|
||||
mv "$tmp_webp" "$webp_path"
|
||||
else
|
||||
cwebp -q "$WEBP_QUALITY" "$cwebp_input" -o "$webp_path" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Cleanup temp file if we resized
|
||||
[[ -n "$tmp_resized" ]] && rm -f "$tmp_resized"
|
||||
|
||||
# Step 3: Delete original if it's not already .webp
|
||||
if [[ "$ext_lower" != "webp" ]]; then
|
||||
rm -f "$img"
|
||||
fi
|
||||
|
||||
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 )); then
|
||||
total_savings=$(( (total_before - total_after) * 100 / total_before ))
|
||||
fi
|
||||
info "Converted $converted image(s)"
|
||||
info "Total: $(human_size $total_before) -> $(human_size $total_after) (${total_savings}% reduction)"
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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: 
|
||||
# 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
|
||||
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)
|
||||
|
||||
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 (( total_after > 0 )); 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}"
|
||||
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
|
||||
Reference in New Issue
Block a user