mirror of
https://github.com/nicholaswilde/recipes.git
synced 2026-08-18 02:54:22 +00:00
240 lines
7.0 KiB
Python
Executable File
240 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
|
||
################################################################################
|
||
#
|
||
# check_missing_images.py
|
||
# ----------------
|
||
# Check for recipes with missing image files
|
||
#
|
||
# @author nιcнolaѕ wιlde, 0x08b7d7a3
|
||
# @date 14 Jun 2026
|
||
# @version 0.1.0
|
||
#
|
||
################################################################################
|
||
|
||
"""Check recipe markdown files for missing images.
|
||
|
||
Detects three types of image issues:
|
||
1. NO_HERO - Recipe has no `hero:` field in frontmatter
|
||
2. NO_EMBED - Recipe has no image embed (![...]) in the body
|
||
3. HERO_MISSING - The `hero:` field references an image file that doesn't exist
|
||
4. HERO_CORRUPT - The `hero:` field contains markdown links (broken by hyperlink scripts)
|
||
|
||
Usage:
|
||
uv run python scripts/check_missing_images.py [--json] [--category CATEGORY]
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
DOCS_DIR = os.path.join(REPO_ROOT, "docs")
|
||
|
||
# Directories and files to skip
|
||
SKIP_DIRS = {"css", "javascripts", "stylesheets", "assets", "reference"}
|
||
SKIP_FILES = {"index.md", "README.md", "tags.md"}
|
||
|
||
|
||
def is_recipe(filepath):
|
||
"""Check if a markdown file is a recipe (has Ingredients or Instructions)."""
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
return "## :salt: Ingredients" in content or "## :pencil: Instructions" in content
|
||
|
||
|
||
def parse_frontmatter(filepath):
|
||
"""Extract frontmatter fields from a markdown file."""
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
|
||
if not lines or lines[0].strip() != "---":
|
||
return {}
|
||
|
||
frontmatter = {}
|
||
for line in lines[1:]:
|
||
if line.strip() == "---":
|
||
break
|
||
m = re.match(r"^(\w+):\s*(.+)$", line)
|
||
if m:
|
||
frontmatter[m.group(1)] = m.group(2).strip()
|
||
|
||
return frontmatter
|
||
|
||
|
||
def has_image_embed(filepath):
|
||
"""Check if a markdown file has an image embed ![...]."""
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
if line.strip().startswith("
|
||
if os.path.isfile(from_docs):
|
||
return True, from_docs, False
|
||
|
||
# Try resolving relative to the markdown file
|
||
from_file = os.path.join(os.path.dirname(filepath), hero_path)
|
||
if os.path.isfile(from_file):
|
||
return True, from_file, False
|
||
|
||
return False, hero_path, False
|
||
|
||
|
||
def find_recipe_files(category=None):
|
||
"""Find all recipe markdown files, optionally filtered by category."""
|
||
recipe_files = []
|
||
for root, dirs, files in os.walk(DOCS_DIR):
|
||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||
for fname in files:
|
||
if fname in SKIP_FILES or not fname.endswith(".md"):
|
||
continue
|
||
|
||
filepath = os.path.join(root, fname)
|
||
relpath = os.path.relpath(filepath, DOCS_DIR)
|
||
|
||
if category:
|
||
top_dir = relpath.split(os.sep)[0]
|
||
if top_dir != category:
|
||
continue
|
||
|
||
if is_recipe(filepath):
|
||
recipe_files.append(filepath)
|
||
|
||
return sorted(recipe_files)
|
||
|
||
|
||
def check_images(recipe_files):
|
||
"""Check all recipe files for image issues.
|
||
|
||
Returns a list of dicts with issue details.
|
||
"""
|
||
issues = []
|
||
|
||
for filepath in recipe_files:
|
||
relpath = os.path.relpath(filepath, REPO_ROOT)
|
||
frontmatter = parse_frontmatter(filepath)
|
||
hero = frontmatter.get("hero", "")
|
||
embed = has_image_embed(filepath)
|
||
|
||
if not hero:
|
||
issues.append({
|
||
"file": relpath,
|
||
"type": "NO_HERO",
|
||
"detail": "No hero image in frontmatter",
|
||
})
|
||
|
||
if not embed:
|
||
issues.append({
|
||
"file": relpath,
|
||
"type": "NO_EMBED",
|
||
"detail": "No image embed (![...]) in body",
|
||
})
|
||
|
||
if hero:
|
||
exists, resolved, is_corrupt = resolve_hero_image(filepath, hero)
|
||
if is_corrupt:
|
||
issues.append({
|
||
"file": relpath,
|
||
"type": "HERO_CORRUPT",
|
||
"detail": f"Hero path contains markdown links: {hero}",
|
||
})
|
||
elif not exists:
|
||
issues.append({
|
||
"file": relpath,
|
||
"type": "HERO_MISSING",
|
||
"detail": f"Hero image file not found: {hero}",
|
||
})
|
||
|
||
return issues
|
||
|
||
|
||
def print_report(issues):
|
||
"""Print a human-readable report of image issues."""
|
||
if not issues:
|
||
print("✅ All recipe files have proper images!")
|
||
return
|
||
|
||
# Group by type
|
||
by_type = {}
|
||
for issue in issues:
|
||
by_type.setdefault(issue["type"], []).append(issue)
|
||
|
||
type_labels = {
|
||
"NO_HERO": "Missing hero frontmatter",
|
||
"NO_EMBED": "Missing image embed",
|
||
"HERO_MISSING": "Hero image file not found",
|
||
"HERO_CORRUPT": "Hero path corrupted (contains markdown links)",
|
||
}
|
||
|
||
print(f"Found {len(issues)} image issues across recipe files:\n")
|
||
|
||
for issue_type in ["HERO_CORRUPT", "HERO_MISSING", "NO_HERO", "NO_EMBED"]:
|
||
type_issues = by_type.get(issue_type, [])
|
||
if not type_issues:
|
||
continue
|
||
|
||
label = type_labels.get(issue_type, issue_type)
|
||
print(f"{'=' * 60}")
|
||
print(f" {label} ({len(type_issues)} files)")
|
||
print(f"{'=' * 60}")
|
||
|
||
for issue in type_issues:
|
||
print(f" {issue['file']}")
|
||
if issue_type in ("HERO_MISSING", "HERO_CORRUPT"):
|
||
print(f" → {issue['detail']}")
|
||
print()
|
||
|
||
# Summary
|
||
print(f"{'=' * 60}")
|
||
print("Summary:")
|
||
for issue_type, label in type_labels.items():
|
||
count = len(by_type.get(issue_type, []))
|
||
if count > 0:
|
||
print(f" {label}: {count}")
|
||
print(f" Total issues: {len(issues)}")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Check recipe markdown files for missing images"
|
||
)
|
||
parser.add_argument(
|
||
"--json", action="store_true", help="Output results as JSON"
|
||
)
|
||
parser.add_argument(
|
||
"--category",
|
||
type=str,
|
||
default=None,
|
||
help="Filter by category directory (e.g., breakfast, desserts)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
recipe_files = find_recipe_files(category=args.category)
|
||
issues = check_images(recipe_files)
|
||
|
||
if args.json:
|
||
print(json.dumps(issues, indent=2))
|
||
else:
|
||
print_report(issues)
|
||
|
||
sys.exit(1 if issues else 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|