jekyll-theme-zer0 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +172 -1
  3. data/README.md +10 -27
  4. data/_data/authors.yml +4 -3
  5. data/_data/backlog.yml +28 -0
  6. data/_data/features.yml +65 -18
  7. data/_data/i18n/languages.yml +36 -0
  8. data/_data/theme-manifest.yml +0 -2
  9. data/_data/ui-text.yml +36 -246
  10. data/_includes/README.md +4 -0
  11. data/_includes/components/author-avatar-url.html +4 -2
  12. data/_includes/components/env-switcher.html +3 -1
  13. data/_includes/components/language-toggle.html +81 -0
  14. data/_includes/components/search-modal.html +2 -2
  15. data/_includes/components/shortcuts-modal.html +1 -1
  16. data/_includes/components/translation-notice.html +27 -0
  17. data/_includes/content/intro.html +16 -15
  18. data/_includes/core/footer.html +9 -4
  19. data/_includes/core/head.html +9 -0
  20. data/_includes/core/header.html +9 -4
  21. data/_includes/core/hreflang.html +33 -0
  22. data/_includes/core/i18n.html +36 -0
  23. data/_includes/navigation/breadcrumbs.html +1 -1
  24. data/_includes/navigation/navbar.html +5 -4
  25. data/_includes/navigation/sidebar-right.html +3 -2
  26. data/_includes/navigation/unified-drawer.html +1 -1
  27. data/_layouts/article.html +7 -1
  28. data/_layouts/default.html +5 -3
  29. data/_layouts/news.html +4 -2
  30. data/_layouts/root.html +14 -8
  31. data/_layouts/section.html +4 -2
  32. data/_sass/core/_obsidian.scss +9 -1
  33. data/_sass/layouts/_navbar-extras.scss +6 -1
  34. data/assets/js/obsidian-graph.js +5 -1
  35. data/scripts/README.md +20 -26
  36. data/scripts/bin/audit-consumer +1 -1
  37. data/scripts/bin/manifest +0 -1
  38. data/scripts/bin/sync-plugins +0 -1
  39. data/scripts/dev/rasterize-svg.js +65 -0
  40. data/scripts/features/generate-preview-images +49 -1390
  41. data/scripts/features/install-preview-generator +55 -33
  42. data/scripts/install/README.md +9 -20
  43. data/scripts/install/ai/prompts/wizard.system.md +8 -17
  44. data/scripts/lib/README.md +1 -5
  45. data/scripts/lib/install/deploy/README.md +3 -9
  46. data/scripts/lib/preview_generator.py +2261 -1341
  47. data/scripts/translate.rb +1114 -0
  48. metadata +9 -3
  49. data/_plugins/preview_image_generator.rb +0 -351
@@ -1,1404 +1,63 @@
1
- #!/bin/bash
1
+ #!/usr/bin/env bash
2
+ # Features: ZER0-004, ZER0-028
2
3
  #
3
4
  # Script Name: generate-preview-images
4
- # Description: AI-powered preview image generator for Jekyll posts/articles/quests
5
- # Scans content files, detects missing preview images, and generates
6
- # images using AI (OpenAI DALL-E, Stable Diffusion, or other providers)
5
+ # Description: AI-powered preview image generator for Jekyll posts/articles.
6
+ # Thin wrapper — ALL logic lives in the consolidated Python engine
7
+ # at scripts/lib/preview_generator.py. Claude ORCHESTRATES
8
+ # (analyzes the article into an art brief, reviews the render);
9
+ # a raster model RENDERS (openai [default], xai, stability,
10
+ # gemini, or the offline local template).
7
11
  #
8
12
  # Usage: ./scripts/features/generate-preview-images [options]
13
+ # Run with --help for the full option list (rendered by the engine).
9
14
  #
10
- # Options:
11
- # -h, --help Show this help message
12
- # -d, --dry-run Preview what would be generated (no actual changes)
13
- # -v, --verbose Enable verbose output
14
- # -f, --file FILE Process a specific file only
15
- # -c, --collection NAME Process specific collection (posts, quickstart, docs)
16
- # -p, --provider PROVIDER AI provider (openai, stability, local)
17
- # --output-dir DIR Output directory for images (default: assets/images/previews)
18
- # --force Regenerate images even if preview exists
19
- # --list-missing Only list files with missing previews
15
+ # Common examples:
16
+ # ./scripts/generate-preview-images.sh --list-missing
17
+ # ./scripts/generate-preview-images.sh --dry-run --verbose
18
+ # ./scripts/generate-preview-images.sh --collection posts
19
+ # ./scripts/generate-preview-images.sh -f pages/_posts/my-post.md --force
20
+ # ./scripts/generate-preview-images.sh --provider openai --enhance -f <file>
20
21
  #
21
22
  # Dependencies:
22
- # - bash 4.0+
23
- # - curl (for API calls)
24
- # - jq (for JSON processing)
25
- # - yq or python (for YAML parsing)
26
- #
27
- # Environment Variables:
28
- # OPENAI_API_KEY OpenAI API key for DALL-E image generation
29
- # STABILITY_API_KEY Stability AI API key for Stable Diffusion
30
- # IMAGE_STYLE Default image style (default: "digital art, professional")
31
- # IMAGE_SIZE Image dimensions (default: "1024x1024")
32
- #
33
- # Examples:
34
- # ./scripts/generate-preview-images.sh --dry-run
35
- # ./scripts/generate-preview-images.sh --collection posts
36
- # ./scripts/generate-preview-images.sh --file pages/_posts/my-post.md
37
- # ./scripts/generate-preview-images.sh --provider openai --verbose
23
+ # - python3 (3.9+) with PyYAML
24
+ # - Optional SVG rasterizers for the local template provider:
25
+ # rsvg-convert | inkscape | magick | Playwright (scripts/dev/rasterize-svg.js)
38
26
  #
27
+ # Environment — renderer key (default openai): OPENAI_API_KEY (or XAI_API_KEY /
28
+ # STABILITY_API_KEY / GEMINI_API_KEY for the matching --provider). Claude
29
+ # orchestration additionally uses any ONE of (optional; degrades to template):
30
+ # CLAUDE_CODE_OAUTH_TOKEN `claude setup-token` (Claude Pro/Max)
31
+ # ANTHROPIC_AUTH_TOKEN short-lived Bearer token
32
+ # ANTHROPIC_API_KEY console.anthropic.com API key
33
+ # (or a logged-in `claude` CLI — used automatically)
34
+ # .env in the project root is loaded by the engine (exported vars win).
39
35
 
40
36
  set -euo pipefail
41
-
42
- # Get script directory and source common utilities
43
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
- PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
45
-
46
- # Load environment variables from .env file if it exists
47
- if [[ -f "$PROJECT_ROOT/.env" ]]; then
48
- # Export variables from .env file, overriding any existing values
49
- while IFS='=' read -r key value; do
50
- # Skip comments and empty lines
51
- [[ -z "$key" || "$key" =~ ^# ]] && continue
52
- # Remove surrounding quotes from value if present
53
- value="${value%\"}"
54
- value="${value#\"}"
55
- value="${value%\'}"
56
- value="${value#\'}"
57
- # Export the variable
58
- export "$key=$value"
59
- done < "$PROJECT_ROOT/.env"
37
+ IFS=$'\n\t'
38
+
39
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
40
+
41
+ # Engine location — theme layout (scripts/features/ → ../lib/) first, then the
42
+ # consumer-install layout (wrapper at scripts/ → ./lib/).
43
+ ENGINE=""
44
+ for candidate in "$SCRIPT_DIR/../lib/preview_generator.py" "$SCRIPT_DIR/lib/preview_generator.py"; do
45
+ if [[ -f "$candidate" ]]; then
46
+ ENGINE="$candidate"
47
+ break
48
+ fi
49
+ done
50
+ if [[ -z "$ENGINE" ]]; then
51
+ echo "[ERROR] preview_generator.py not found next to $SCRIPT_DIR" >&2
52
+ echo " Expected at scripts/lib/preview_generator.py" >&2
53
+ exit 1
60
54
  fi
61
55
 
62
- # Source common library if available
63
- if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then
64
- source "$SCRIPT_DIR/lib/common.sh"
65
- else
66
- # Fallback logging functions
67
- RED='\033[0;31m'
68
- GREEN='\033[0;32m'
69
- YELLOW='\033[1;33m'
70
- BLUE='\033[0;34m'
71
- CYAN='\033[0;36m'
72
- PURPLE='\033[0;35m'
73
- NC='\033[0m'
74
-
75
- log() { echo -e "${GREEN}[LOG]${NC} $1"; }
76
- info() { echo -e "${BLUE}[INFO]${NC} $1"; }
77
- step() { echo -e "${CYAN}[STEP]${NC} $1"; }
78
- success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
79
- warn() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
80
- error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; }
81
- debug() { [[ "${VERBOSE:-false}" == "true" ]] && echo -e "${PURPLE}[DEBUG]${NC} $1" >&2 || true; }
82
- print_header() {
83
- echo ""
84
- echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
85
- echo -e " ${GREEN}$1${NC}"
86
- echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
87
- echo ""
88
- }
56
+ if ! command -v python3 &>/dev/null; then
57
+ echo "[ERROR] python3 is required. Install it (macOS: brew install python3; Debian/Ubuntu: apt-get install python3)." >&2
58
+ exit 1
89
59
  fi
90
60
 
91
- # =============================================================================
92
- # Configuration Loading
93
- # =============================================================================
94
- # Priority (per file): author preview overrides (_data/authors.yml) >
95
- # CLI args > Environment variables > _config.yml > Defaults
96
- # A post's `author:` may point at an entry in _data/authors.yml that carries a
97
- # `preview:` block (style / style_modifiers / size / quality / model). Those win
98
- # over the site-wide preview_images config for that post's banner, giving each
99
- # AI author persona a distinct, recognisable art style.
100
-
101
- CONFIG_FILE="$PROJECT_ROOT/_config.yml"
102
- AUTHORS_FILE="$PROJECT_ROOT/_data/authors.yml"
103
-
104
- # Function to read config value from _config.yml using grep (handles YAML anchors)
105
- read_config() {
106
- local key="$1"
107
- local default="$2"
108
-
109
- if [[ -f "$CONFIG_FILE" ]]; then
110
- # Use grep to find the key under preview_images section
111
- # This is more robust than yq for files with anchors
112
- local in_section=false
113
- local value=""
114
-
115
- while IFS= read -r line; do
116
- # Check if we're entering the preview_images section
117
- if [[ "$line" =~ ^preview_images: ]]; then
118
- in_section=true
119
- continue
120
- fi
121
-
122
- # Check if we're leaving the section (new top-level key)
123
- if [[ "$in_section" == true && "$line" =~ ^[a-zA-Z_]+: && ! "$line" =~ ^[[:space:]] ]]; then
124
- break
125
- fi
126
-
127
- # Look for the key within the section
128
- if [[ "$in_section" == true && "$line" =~ ^[[:space:]]+${key}[[:space:]]*:[[:space:]]*(.*) ]]; then
129
- value="${BASH_REMATCH[1]}"
130
- # First, remove inline comments (only if not inside quotes)
131
- # Simple approach: if value starts with quote, find closing quote
132
- if [[ "$value" =~ ^\'([^\']*)\' ]]; then
133
- value="${BASH_REMATCH[1]}"
134
- elif [[ "$value" =~ ^\"([^\"]*)\" ]]; then
135
- value="${BASH_REMATCH[1]}"
136
- else
137
- # No quotes, just trim and remove comment
138
- value="${value%%#*}"
139
- # Trim whitespace
140
- value="${value%"${value##*[![:space:]]}"}"
141
- value="${value#"${value%%[![:space:]]*}"}"
142
- fi
143
- if [[ -n "$value" ]]; then
144
- echo "$value"
145
- return
146
- fi
147
- fi
148
- done < "$CONFIG_FILE"
149
- fi
150
- echo "$default"
151
- }
152
-
153
- # Load defaults from _config.yml, with fallbacks
154
- CONFIG_PROVIDER=$(read_config "provider" "openai")
155
- CONFIG_MODEL=$(read_config "model" "gpt-image-2")
156
- CONFIG_SIZE=$(read_config "size" "1536x1024")
157
- CONFIG_QUALITY=$(read_config "quality" "auto")
158
- CONFIG_STYLE=$(read_config "style" "retro pixel art, 8-bit video game aesthetic, vibrant colors, nostalgic, clean pixel graphics")
159
- CONFIG_STYLE_MODIFIERS=$(read_config "style_modifiers" "pixelated, retro gaming style, CRT screen glow effect, limited color palette")
160
- CONFIG_OUTPUT_DIR=$(read_config "output_dir" "assets/images/previews")
161
-
162
- # Default configuration (env vars override config file)
163
- DRY_RUN="${DRY_RUN:-false}"
164
- VERBOSE="${VERBOSE:-false}"
165
- FORCE="${FORCE:-false}"
166
- LIST_ONLY="${LIST_ONLY:-false}"
167
- SPECIFIC_FILE=""
168
- COLLECTION=""
169
- AI_PROVIDER="${AI_PROVIDER:-$CONFIG_PROVIDER}"
170
- OUTPUT_DIR="${OUTPUT_DIR:-$CONFIG_OUTPUT_DIR}"
171
- IMAGE_STYLE="${IMAGE_STYLE:-$CONFIG_STYLE}"
172
- IMAGE_STYLE_MODIFIERS="${IMAGE_STYLE_MODIFIERS:-$CONFIG_STYLE_MODIFIERS}"
173
- IMAGE_SIZE="${IMAGE_SIZE:-$CONFIG_SIZE}"
174
- IMAGE_QUALITY="${IMAGE_QUALITY:-$CONFIG_QUALITY}"
175
- IMAGE_MODEL="${IMAGE_MODEL:-$CONFIG_MODEL}"
176
-
177
- # Enhancement configuration
178
- ENHANCE="${ENHANCE:-false}"
179
- ENHANCE_PROMPT=""
180
- ENHANCE_MODEL="${ENHANCE_MODEL:-gpt-image-2}"
181
- ENHANCE_QUALITY="${ENHANCE_QUALITY:-auto}"
182
- ENHANCE_FIDELITY="${ENHANCE_FIDELITY:-high}"
183
- ENHANCE_FORMAT="${ENHANCE_FORMAT:-png}"
184
- DEFAULT_ENHANCE_PROMPT="Improve this preview banner image: fix any misspelled, garbled, or incorrect text so it reads clearly and accurately. Sharpen visual details and improve composition while preserving the original art style, color palette, and theme. Ensure the image is clean and professional."
185
-
186
- # Counters (used in serial mode; replaced by file-based counters in parallel mode)
187
- PROCESSED=0
188
- GENERATED=0
189
- ENHANCED=0
190
- SKIPPED=0
191
- ERRORS=0
192
-
193
- # Parallel processing — number of concurrent API workers (1 = serial)
194
- MAX_PARALLEL="${MAX_PARALLEL:-4}"
195
- COUNTER_DIR="" # Initialised in main() for parallel generation
196
-
197
- # Temporary files may contain request payloads or auth headers. Keep a registry so
198
- # interrupted runs clean them up before exiting.
199
- TEMP_FILES=()
200
-
201
- cleanup_temp_files() {
202
- if [[ ${#TEMP_FILES[@]} -gt 0 ]]; then
203
- rm -f "${TEMP_FILES[@]}" 2>/dev/null || true
204
- fi
205
- # Clean up counter directory if we own it
206
- if [[ -n "$COUNTER_DIR" && -d "$COUNTER_DIR" ]]; then
207
- rm -rf "$COUNTER_DIR" 2>/dev/null || true
208
- fi
209
- }
210
-
211
- trap cleanup_temp_files EXIT HUP INT TERM
212
-
213
- # ---------------------------------------------------------------------------
214
- # Parallel-safe counter helpers
215
- # Each background worker subshell writes a unique sentinel file; the parent
216
- # aggregates the counts with ls | wc -l (no shared variable races).
217
- # ---------------------------------------------------------------------------
218
- init_counters() {
219
- COUNTER_DIR=$(mktemp -d)
220
- mkdir -p "$COUNTER_DIR"/{processed,generated,enhanced,skipped,errors}
221
- }
222
-
223
- inc_counter() {
224
- local name="$1"
225
- if [[ -n "$COUNTER_DIR" && -d "$COUNTER_DIR/$name" ]]; then
226
- mktemp "$COUNTER_DIR/$name/XXXXXX" >/dev/null 2>&1 || true
227
- else
228
- # Serial fallback — update parent variables directly
229
- case "$name" in
230
- processed) PROCESSED=$((PROCESSED + 1)) ;;
231
- generated) GENERATED=$((GENERATED + 1)) ;;
232
- enhanced) ENHANCED=$((ENHANCED + 1)) ;;
233
- skipped) SKIPPED=$((SKIPPED + 1)) ;;
234
- errors) ERRORS=$((ERRORS + 1)) ;;
235
- esac
236
- fi
237
- }
238
-
239
- get_counter() {
240
- local name="$1"
241
- if [[ -n "$COUNTER_DIR" && -d "$COUNTER_DIR/$name" ]]; then
242
- ls "$COUNTER_DIR/$name/" 2>/dev/null | wc -l | tr -d ' '
243
- else
244
- case "$name" in
245
- processed) echo "$PROCESSED" ;;
246
- generated) echo "$GENERATED" ;;
247
- enhanced) echo "$ENHANCED" ;;
248
- skipped) echo "$SKIPPED" ;;
249
- errors) echo "$ERRORS" ;;
250
- esac
251
- fi
252
- }
253
-
254
- # Print usage
255
- show_help() {
256
- cat << 'EOF'
257
- Usage: generate-preview-images.sh [OPTIONS]
258
-
259
- AI-powered preview image generator for Jekyll posts and content.
260
-
261
- OPTIONS:
262
- -h, --help Show this help message
263
- -d, --dry-run Preview what would be generated (no changes)
264
- -v, --verbose Enable verbose output
265
- -f, --file FILE Process a specific file only
266
- -c, --collection NAME Process specific collection (posts, quickstart, docs)
267
- -p, --provider PROVIDER AI provider: openai, stability, local (default: openai)
268
- -e, --enhance Enhance/improve existing preview images using AI
269
- --enhance-prompt TEXT Custom enhancement prompt (implies --enhance)
270
- --enhance-model MODEL Model for enhancement (default: gpt-image-2)
271
- --enhance-fidelity VAL Input fidelity: high or low (default: high)
272
- --enhance-format FMT Output format: png, jpeg, webp (default: png)
273
- --output-dir DIR Output directory for images (default: assets/images/previews)
274
- --force Regenerate images even if preview exists
275
- --list-missing Only list files with missing previews
276
-
277
- ENVIRONMENT VARIABLES:
278
- OPENAI_API_KEY Required for OpenAI DALL-E provider
279
- STABILITY_API_KEY Required for Stability AI provider
280
- IMAGE_STYLE Override style from _config.yml
281
- IMAGE_SIZE Override size (default: 1536x1024 landscape)
282
- IMAGE_MODEL OpenAI model (default: gpt-image-2)
283
- ENHANCE_MODEL Model for enhancement (default: gpt-image-2)
284
- ENHANCE_QUALITY Quality: low, medium, high, auto (default: auto)
285
- ENHANCE_FIDELITY Input fidelity: high or low (default: high)
286
- ENHANCE_FORMAT Output format: png, jpeg, webp (default: png)
287
-
288
- CONFIGURATION:
289
- Default settings are loaded from _config.yml under 'preview_images' section.
290
- Environment variables override config file settings.
291
-
292
- PER-AUTHOR ART STYLE:
293
- A post's `author:` may reference an entry in _data/authors.yml that defines a
294
- `preview:` block (style, style_modifiers, size, quality, model). When present,
295
- those values override the site-wide preview_images settings for that post's
296
- banner only — giving each AI author persona (e.g. cassandra, vega) a distinct
297
- look. Other posts keep the default style.
298
-
299
- EXAMPLES:
300
- # List all files missing preview images
301
- ./scripts/generate-preview-images.sh --list-missing
302
-
303
- # Dry run to see what would be generated
304
- ./scripts/generate-preview-images.sh --dry-run --verbose
305
-
306
- # Generate images for posts collection
307
- ./scripts/generate-preview-images.sh --collection posts
308
-
309
- # Generate image for a specific file
310
- ./scripts/generate-preview-images.sh -f pages/_posts/my-article.md
311
-
312
- # Force regenerate all images
313
- ./scripts/generate-preview-images.sh --force
314
-
315
- # Enhance an existing preview image with default improvements
316
- ./scripts/generate-preview-images.sh --enhance -f pages/_posts/my-post.md
317
-
318
- # Enhance with a custom prompt
319
- ./scripts/generate-preview-images.sh --enhance-prompt "Fix the text to read 'CI/CD Pipeline' and sharpen the icons" -f my-post.md
320
-
321
- # Enhance with high fidelity and JPEG output
322
- ./scripts/generate-preview-images.sh --enhance --enhance-fidelity high --enhance-format jpeg -f my-post.md
323
-
324
- # Enhance all images in a collection
325
- ./scripts/generate-preview-images.sh --enhance --collection posts
326
-
327
- EOF
328
- }
329
-
330
- # Parse command line arguments
331
- parse_args() {
332
- while [[ $# -gt 0 ]]; do
333
- case $1 in
334
- -h|--help)
335
- show_help
336
- exit 0
337
- ;;
338
- -d|--dry-run)
339
- DRY_RUN="true"
340
- ;;
341
- -v|--verbose)
342
- VERBOSE="true"
343
- ;;
344
- -f|--file)
345
- SPECIFIC_FILE="$2"
346
- shift
347
- ;;
348
- -c|--collection)
349
- COLLECTION="$2"
350
- shift
351
- ;;
352
- -p|--provider)
353
- AI_PROVIDER="$2"
354
- shift
355
- ;;
356
- --output-dir)
357
- OUTPUT_DIR="$2"
358
- shift
359
- ;;
360
- --force)
361
- FORCE="true"
362
- ;;
363
- --list-missing)
364
- LIST_ONLY="true"
365
- ;;
366
- -j|--parallel)
367
- MAX_PARALLEL="$2"
368
- shift
369
- ;;
370
- -e|--enhance)
371
- ENHANCE="true"
372
- ;;
373
- --enhance-prompt)
374
- ENHANCE_PROMPT="$2"
375
- ENHANCE="true"
376
- shift
377
- ;;
378
- --enhance-model)
379
- ENHANCE_MODEL="$2"
380
- shift
381
- ;;
382
- --enhance-fidelity)
383
- ENHANCE_FIDELITY="$2"
384
- ENHANCE="true"
385
- shift
386
- ;;
387
- --enhance-format)
388
- ENHANCE_FORMAT="$2"
389
- ENHANCE="true"
390
- shift
391
- ;;
392
- *)
393
- error "Unknown option: $1. Use --help for usage."
394
- ;;
395
- esac
396
- shift
397
- done
398
- }
399
-
400
- # Validate environment and dependencies
401
- validate_environment() {
402
- step "Validating environment..."
403
-
404
- # Check for required commands
405
- local required_cmds=("curl" "jq")
406
- for cmd in "${required_cmds[@]}"; do
407
- if ! command -v "$cmd" &> /dev/null; then
408
- error "Required command not found: $cmd"
409
- fi
410
- done
411
-
412
- # Check for YAML parser (prefer yq, fallback to python)
413
- if command -v yq &> /dev/null; then
414
- YAML_PARSER="yq"
415
- debug "Using yq for YAML parsing"
416
- elif command -v python3 &> /dev/null; then
417
- YAML_PARSER="python"
418
- debug "Using python for YAML parsing"
419
- else
420
- error "No YAML parser found. Install yq or python3."
421
- fi
422
-
423
- # Validate AI provider credentials (unless list-only or dry-run)
424
- if [[ "$LIST_ONLY" != "true" && "$DRY_RUN" != "true" ]]; then
425
- case "$AI_PROVIDER" in
426
- openai)
427
- if [[ -z "${OPENAI_API_KEY:-}" ]]; then
428
- error "OPENAI_API_KEY environment variable is required for OpenAI provider"
429
- fi
430
- ;;
431
- stability)
432
- if [[ -z "${STABILITY_API_KEY:-}" ]]; then
433
- error "STABILITY_API_KEY environment variable is required for Stability AI provider"
434
- fi
435
- ;;
436
- local)
437
- info "Using local provider - no API key required"
438
- ;;
439
- *)
440
- error "Unknown AI provider: $AI_PROVIDER"
441
- ;;
442
- esac
443
- fi
444
-
445
- # Ensure output directory exists
446
- local full_output_dir="$PROJECT_ROOT/$OUTPUT_DIR"
447
- if [[ ! -d "$full_output_dir" ]]; then
448
- if [[ "$DRY_RUN" != "true" ]]; then
449
- mkdir -p "$full_output_dir"
450
- debug "Created output directory: $full_output_dir"
451
- else
452
- debug "Would create output directory: $full_output_dir"
453
- fi
454
- fi
455
-
456
- success "Environment validation passed"
457
- }
458
-
459
- # Extract front matter from a markdown file
460
- extract_front_matter() {
461
- local file="$1"
462
-
463
- # Extract content between first pair of --- markers only
464
- awk 'BEGIN{found=0} /^---$/{found++; next} found==1{print} found==2{exit}' "$file"
465
- }
466
-
467
- # Get YAML value using available parser
468
- get_yaml_value() {
469
- local yaml="$1"
470
- local key="$2"
471
- local result=""
472
-
473
- if [[ "$YAML_PARSER" == "yq" ]]; then
474
- # yq v4 syntax - read from stdin and get specific key
475
- result=$(echo "$yaml" | yq eval ".$key" - 2>/dev/null | head -1)
476
- # Filter out null values
477
- if [[ "$result" == "null" || -z "$result" ]]; then
478
- result=""
479
- fi
480
- else
481
- # Python fallback - use stdin to avoid quote-escaping issues
482
- result=$(echo "$yaml" | python3 -c "
483
- import yaml, sys
484
- try:
485
- data = yaml.safe_load(sys.stdin.read())
486
- if data and '$key' in data:
487
- val = data['$key']
488
- if val is not None:
489
- print(val)
490
- except:
491
- pass
492
- " 2>/dev/null || echo "")
493
- fi
494
-
495
- echo "$result"
496
- }
497
-
498
- # Read a per-author preview override from _data/authors.yml
499
- # Usage: get_author_preview_value <author_key> <setting>
500
- # e.g. get_author_preview_value cassandra style
501
- # Returns the value (or empty string when the author / setting is absent).
502
- get_author_preview_value() {
503
- local author_key="$1"
504
- local setting="$2"
505
-
506
- [[ -z "$author_key" || ! -f "$AUTHORS_FILE" ]] && return 0
507
-
508
- local result=""
509
- if [[ "${YAML_PARSER:-python}" == "yq" ]]; then
510
- # Capture the whole value (do NOT `head -1`) so a multi-line literal/folded
511
- # scalar survives intact, matching the python3 branch below; trim a single
512
- # trailing newline that yq appends.
513
- result=$(yq eval ".\"${author_key}\".preview.${setting} // \"\"" "$AUTHORS_FILE" 2>/dev/null)
514
- result="${result%$'\n'}"
515
- [[ "$result" == "null" ]] && result=""
516
- else
517
- result=$(AUTHOR_KEY="$author_key" SETTING="$setting" AUTHORS_FILE="$AUTHORS_FILE" python3 -c '
518
- import os, yaml
519
- try:
520
- data = yaml.safe_load(open(os.environ["AUTHORS_FILE"])) or {}
521
- author = data.get(os.environ["AUTHOR_KEY"]) or {}
522
- preview = author.get("preview") or {}
523
- val = preview.get(os.environ["SETTING"])
524
- if val is not None:
525
- print(str(val).strip())
526
- except Exception:
527
- pass
528
- ' 2>/dev/null)
529
- fi
530
-
531
- echo "$result"
532
- }
533
-
534
- # Apply a post author's per-author preview overrides (from _data/authors.yml) on
535
- # top of the current image settings. MUST be called from a function that has
536
- # already declared `local IMAGE_STYLE` (+ IMAGE_STYLE_MODIFIERS / IMAGE_SIZE /
537
- # IMAGE_QUALITY / IMAGE_MODEL) — bash dynamic scoping lets this helper reassign
538
- # those caller locals, scoping the override to the current file. Call it only at
539
- # the point an image/prompt is actually built, so --list-missing stays clean and
540
- # already-satisfied files don't pay for the lookup.
541
- apply_author_preview_overrides() {
542
- local author="$1"
543
- [[ -z "$author" ]] && return 0
544
-
545
- local ov_style ov_mods ov_size ov_quality ov_model
546
- ov_style=$(get_author_preview_value "$author" "style")
547
- ov_mods=$(get_author_preview_value "$author" "style_modifiers")
548
- ov_size=$(get_author_preview_value "$author" "size")
549
- ov_quality=$(get_author_preview_value "$author" "quality")
550
- ov_model=$(get_author_preview_value "$author" "model")
551
-
552
- [[ -n "$ov_style" ]] && IMAGE_STYLE="$ov_style"
553
- [[ -n "$ov_mods" ]] && IMAGE_STYLE_MODIFIERS="$ov_mods"
554
- [[ -n "$ov_size" ]] && IMAGE_SIZE="$ov_size"
555
- [[ -n "$ov_quality" ]] && IMAGE_QUALITY="$ov_quality"
556
- [[ -n "$ov_model" ]] && IMAGE_MODEL="$ov_model"
557
-
558
- if [[ -n "$ov_style" || -n "$ov_mods" || -n "$ov_size" || -n "$ov_quality" || -n "$ov_model" ]]; then
559
- info " ↳ Author '$author' preview overrides applied (style from _data/authors.yml)"
560
- debug "Author override style: ${IMAGE_STYLE:0:120}..."
561
- fi
562
- return 0
563
- }
564
-
565
- # Extract post content (without front matter)
566
- extract_content() {
567
- local file="$1"
568
-
569
- # Skip front matter and get content
570
- awk '/^---$/ { if (++count == 2) found=1; next } found { print }' "$file"
571
- }
572
-
573
- # Check if preview image exists
574
- check_preview_exists() {
575
- local preview_path="$1"
576
-
577
- if [[ -z "$preview_path" ]]; then
578
- return 1
579
- fi
580
-
581
- # Handle paths starting with /
582
- local clean_path="${preview_path#/}"
583
- local full_path="$PROJECT_ROOT/$clean_path"
584
-
585
- # Also check in assets/images
586
- if [[ ! -f "$full_path" ]]; then
587
- full_path="$PROJECT_ROOT/assets/$clean_path"
588
- fi
589
-
590
- [[ -f "$full_path" ]]
591
- }
592
-
593
- # Generate image prompt from content
594
- generate_prompt() {
595
- local title="$1"
596
- local description="$2"
597
- local categories="$3"
598
- local content="$4"
599
-
600
- # Build a meaningful prompt
601
- local prompt="Create a blog preview banner image for an article titled '$title'."
602
-
603
- if [[ -n "$description" ]]; then
604
- prompt="$prompt The article is about: $description."
605
- fi
606
-
607
- if [[ -n "$categories" ]]; then
608
- prompt="$prompt Categories: $categories."
609
- fi
610
-
611
- # Extract key themes from content (first 500 chars)
612
- local content_excerpt="${content:0:500}"
613
- if [[ -n "$content_excerpt" ]]; then
614
- prompt="$prompt Key themes from content: $content_excerpt"
615
- fi
616
-
617
- # Add style instructions and modifiers
618
- prompt="$prompt Art style: $IMAGE_STYLE."
619
- if [[ -n "$IMAGE_STYLE_MODIFIERS" ]]; then
620
- prompt="$prompt Additional style: $IMAGE_STYLE_MODIFIERS."
621
- fi
622
- prompt="$prompt The image should be suitable as a wide blog header/banner image with clean composition. No text or words in the image."
623
-
624
- echo "$prompt"
625
- }
626
-
627
- # Generate image using OpenAI DALL-E
628
- generate_image_openai() {
629
- local prompt="$1"
630
- local output_file="$2"
631
-
632
- debug "Generating image with OpenAI model: $IMAGE_MODEL"
633
- debug "Prompt: ${prompt:0:200}..."
634
-
635
- local request_size="$IMAGE_SIZE"
636
- local request_quality="$IMAGE_QUALITY"
637
- if [[ "$IMAGE_MODEL" == gpt-image-* && "$request_size" == "1792x1024" ]]; then
638
- request_size="1536x1024"
639
- debug "Using GPT Image landscape size: $request_size"
640
- elif [[ "$IMAGE_MODEL" == dall-e-* && "$request_quality" == "auto" ]]; then
641
- request_quality="standard"
642
- debug "Using DALL-E quality: $request_quality"
643
- fi
644
-
645
- local payload
646
- payload=$(jq -n \
647
- --arg model "$IMAGE_MODEL" \
648
- --arg prompt "$prompt" \
649
- --arg size "$request_size" \
650
- --arg quality "$request_quality" \
651
- '{model: $model, prompt: $prompt, n: 1, size: $size, quality: $quality}')
652
- local auth_config payload_file
653
- auth_config=$(mktemp)
654
- payload_file=$(mktemp)
655
- TEMP_FILES+=("$auth_config" "$payload_file")
656
- chmod 600 "$auth_config" "$payload_file"
657
- printf 'header = "Authorization: Bearer %s"\n' "$OPENAI_API_KEY" > "$auth_config"
658
- printf '%s' "$payload" > "$payload_file"
659
-
660
- local response
661
- local max_retries=3
662
- local attempt=0
663
- local api_success=false
664
- while [[ $attempt -lt $max_retries ]]; do
665
- attempt=$((attempt + 1))
666
- if [[ $attempt -gt 1 ]]; then
667
- local backoff=$(( (attempt - 1) * 15 ))
668
- warn "Retrying API call (attempt $attempt/$max_retries) after ${backoff}s..."
669
- sleep "$backoff"
670
- fi
671
- if response=$(curl -s --max-time 120 -X POST "https://api.openai.com/v1/images/generations" \
672
- --config "$auth_config" \
673
- -H "Content-Type: application/json" \
674
- --data-binary "@$payload_file"); then
675
- api_success=true
676
- break
677
- fi
678
- warn "OpenAI API request failed (attempt $attempt/$max_retries)"
679
- done
680
- rm -f "$auth_config" "$payload_file"
681
- if [[ "$api_success" != "true" ]]; then
682
- warn "OpenAI API request failed after $max_retries attempts"
683
- return 1
684
- fi
685
-
686
- # Check for errors
687
- local error_msg
688
- error_msg=$(echo "$response" | jq -r '.error.message // empty')
689
- if [[ -n "$error_msg" ]]; then
690
- warn "OpenAI API error: $error_msg"
691
- debug "Full response: $response"
692
- return 1
693
- fi
694
-
695
- # GPT Image models may return b64_json; DALL-E models usually return a URL.
696
- local b64_data
697
- b64_data=$(echo "$response" | jq -r '.data[0].b64_json // empty')
698
- if [[ -n "$b64_data" ]]; then
699
- debug "Decoding base64 image data..."
700
- echo "$b64_data" | base64 -d > "$output_file"
701
- else
702
- # Extract image URL
703
- local image_url
704
- image_url=$(echo "$response" | jq -r '.data[0].url // empty')
705
-
706
- if [[ -z "$image_url" ]]; then
707
- warn "No image URL or base64 data in response"
708
- debug "Response: $response"
709
- return 1
710
- fi
711
-
712
- # Download image
713
- debug "Downloading image from: $image_url"
714
- curl -s -o "$output_file" "$image_url"
715
- fi
716
-
717
- if [[ -f "$output_file" && -s "$output_file" ]]; then
718
- local file_size
719
- file_size=$(du -h "$output_file" | cut -f1)
720
- success "Image saved to: $output_file ($file_size)"
721
- return 0
722
- else
723
- warn "Failed to save image"
724
- return 1
725
- fi
726
- }
727
-
728
- # Generate image using Stability AI
729
- generate_image_stability() {
730
- local prompt="$1"
731
- local output_file="$2"
732
-
733
- debug "Generating image with Stability AI..."
734
-
735
- local response
736
- response=$(curl -s -X POST "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image" \
737
- -H "Authorization: Bearer $STABILITY_API_KEY" \
738
- -H "Content-Type: application/json" \
739
- -d "{
740
- \"text_prompts\": [{\"text\": $(echo "$prompt" | jq -Rs .)}],
741
- \"cfg_scale\": 7,
742
- \"height\": 1024,
743
- \"width\": 1024,
744
- \"samples\": 1,
745
- \"steps\": 30
746
- }")
747
-
748
- # Check for errors
749
- local error_msg
750
- error_msg=$(echo "$response" | jq -r '.message // empty')
751
- if [[ -n "$error_msg" ]]; then
752
- warn "Stability API error: $error_msg"
753
- return 1
754
- fi
755
-
756
- # Extract and decode base64 image
757
- local base64_image
758
- base64_image=$(echo "$response" | jq -r '.artifacts[0].base64 // empty')
759
-
760
- if [[ -z "$base64_image" ]]; then
761
- warn "No image data in response"
762
- return 1
763
- fi
764
-
765
- echo "$base64_image" | base64 -d > "$output_file"
766
-
767
- if [[ -f "$output_file" ]]; then
768
- success "Image saved to: $output_file"
769
- return 0
770
- else
771
- warn "Failed to save image"
772
- return 1
773
- fi
774
- }
775
-
776
- # Generate placeholder for local provider (for testing)
777
- generate_image_local() {
778
- local prompt="$1"
779
- local output_file="$2"
780
-
781
- warn "Local provider: No actual image generation. Creating placeholder..."
782
- debug "Would generate image with prompt: ${prompt:0:200}..."
783
-
784
- # Create a simple placeholder file
785
- echo "PLACEHOLDER: $prompt" > "$output_file.txt"
786
-
787
- info "Placeholder created: $output_file.txt"
788
- return 0
789
- }
790
-
791
- # Generate image using selected provider
792
- generate_image() {
793
- local prompt="$1"
794
- local output_file="$2"
795
-
796
- case "$AI_PROVIDER" in
797
- openai)
798
- generate_image_openai "$prompt" "$output_file"
799
- ;;
800
- stability)
801
- generate_image_stability "$prompt" "$output_file"
802
- ;;
803
- local)
804
- generate_image_local "$prompt" "$output_file"
805
- ;;
806
- *)
807
- error "Unknown provider: $AI_PROVIDER"
808
- ;;
809
- esac
810
- }
811
-
812
- # =============================================================================
813
- # Enhancement Functions (improve existing preview images)
814
- # =============================================================================
815
-
816
- # Build the enhancement prompt from title, description, and optional custom prompt
817
- build_enhance_prompt() {
818
- local title="$1"
819
- local description="$2"
820
- local custom_prompt="$3"
821
-
822
- local prompt=""
823
-
824
- if [[ -n "$custom_prompt" ]]; then
825
- prompt="$custom_prompt"
826
- else
827
- prompt="$DEFAULT_ENHANCE_PROMPT"
828
- fi
829
-
830
- # Add article context
831
- if [[ -n "$title" ]]; then
832
- prompt="$prompt Context: This is a preview banner for an article titled '$title'."
833
- fi
834
-
835
- if [[ -n "$description" ]]; then
836
- prompt="$prompt Article topic: $description."
837
- fi
838
-
839
- # Add style consistency instruction
840
- prompt="$prompt Maintain the $IMAGE_STYLE artistic style."
841
-
842
- echo "$prompt"
843
- }
844
-
845
- # Enhance an existing image using OpenAI's /v1/images/edits endpoint
846
- # API Reference: https://developers.openai.com/api/reference/resources/images/methods/edit
847
- enhance_image_openai() {
848
- local image_file="$1"
849
- local prompt="$2"
850
- local output_file="$3"
851
- local model="${ENHANCE_MODEL}"
852
-
853
- debug "Enhancing image with OpenAI ($model)..."
854
- debug "Source image: $image_file"
855
- debug "Enhancement prompt: ${prompt:0:200}..."
856
- debug "Input fidelity: $ENHANCE_FIDELITY"
857
- debug "Output format: $ENHANCE_FORMAT"
858
- debug "Quality: $ENHANCE_QUALITY"
859
-
860
- local auth_config
861
- auth_config=$(mktemp)
862
- TEMP_FILES+=("$auth_config")
863
- chmod 600 "$auth_config"
864
- printf 'header = "Authorization: Bearer %s"\n' "$OPENAI_API_KEY" > "$auth_config"
865
- local curl_args=(
866
- -s --max-time 900 -X POST "https://api.openai.com/v1/images/edits"
867
- --config "$auth_config"
868
- -F "image[]=@$image_file"
869
- -F "prompt=$prompt"
870
- -F "model=$model"
871
- -F "n=1"
872
- -F "size=auto"
873
- -F "quality=$ENHANCE_QUALITY"
874
- -F "output_format=$ENHANCE_FORMAT"
875
- )
876
- if [[ "$model" != "gpt-image-2" ]]; then
877
- curl_args+=(-F "input_fidelity=$ENHANCE_FIDELITY")
878
- else
879
- debug "Skipping input_fidelity: $model does not support it"
880
- fi
881
-
882
- local response
883
- if ! response=$(curl "${curl_args[@]}"); then
884
- rm -f "$auth_config"
885
- warn "OpenAI enhance API request failed"
886
- return 1
887
- fi
888
- rm -f "$auth_config"
889
-
890
- # Check for errors
891
- local error_msg
892
- error_msg=$(echo "$response" | jq -r '.error.message // empty')
893
- if [[ -n "$error_msg" ]]; then
894
- warn "OpenAI enhance API error: $error_msg"
895
- debug "Full response: $response"
896
- return 1
897
- fi
898
-
899
- # Log token usage if available
900
- local total_tokens
901
- total_tokens=$(echo "$response" | jq -r '.usage.total_tokens // empty')
902
- if [[ -n "$total_tokens" ]]; then
903
- debug "Token usage: $total_tokens total (input: $(echo "$response" | jq -r '.usage.input_tokens // "?"'), output: $(echo "$response" | jq -r '.usage.output_tokens // "?"'))"
904
- fi
905
-
906
- # Response returns b64_json by default for GPT Image models
907
- local b64_data
908
- b64_data=$(echo "$response" | jq -r '.data[0].b64_json // empty')
909
-
910
- if [[ -n "$b64_data" ]]; then
911
- debug "Decoding base64 enhanced image..."
912
- echo "$b64_data" | base64 -d > "$output_file"
913
- else
914
- # Fallback: try URL (returned by DALL-E 2)
915
- local image_url
916
- image_url=$(echo "$response" | jq -r '.data[0].url // empty')
917
- if [[ -n "$image_url" ]]; then
918
- debug "Downloading enhanced image from URL..."
919
- curl -s -o "$output_file" "$image_url"
920
- else
921
- warn "No image data in enhance response"
922
- debug "Response: $(echo "$response" | jq -c '.' 2>/dev/null || echo "$response")"
923
- return 1
924
- fi
925
- fi
926
-
927
- # Log revised prompt if available
928
- local revised_prompt
929
- revised_prompt=$(echo "$response" | jq -r '.data[0].revised_prompt // empty')
930
- if [[ -n "$revised_prompt" ]]; then
931
- debug "Revised prompt: ${revised_prompt:0:200}..."
932
- fi
933
-
934
- if [[ -f "$output_file" && -s "$output_file" ]]; then
935
- local file_size
936
- file_size=$(du -h "$output_file" | cut -f1)
937
- success "Enhanced image saved to: $output_file ($file_size)"
938
- return 0
939
- else
940
- warn "Failed to save enhanced image"
941
- return 1
942
- fi
943
- }
944
-
945
- # Find the existing preview image file on disk
946
- find_preview_image() {
947
- local preview_path="$1"
948
-
949
- if [[ -z "$preview_path" ]]; then
950
- return 1
951
- fi
952
-
953
- # Try various path resolutions
954
- local clean_path="${preview_path#/}"
955
- local candidates=(
956
- "$PROJECT_ROOT/$clean_path"
957
- "$PROJECT_ROOT/assets/$clean_path"
958
- "$PROJECT_ROOT/$OUTPUT_DIR/$(basename "$clean_path")"
959
- )
960
-
961
- for candidate in "${candidates[@]}"; do
962
- if [[ -f "$candidate" ]]; then
963
- echo "$candidate"
964
- return 0
965
- fi
966
- done
967
-
968
- return 1
969
- }
970
-
971
- # Enhance image using selected provider
972
- enhance_image() {
973
- local image_file="$1"
974
- local prompt="$2"
975
- local output_file="$3"
976
-
977
- case "$AI_PROVIDER" in
978
- openai)
979
- enhance_image_openai "$image_file" "$prompt" "$output_file"
980
- ;;
981
- local)
982
- warn "Local provider: No actual enhancement. Logging prompt..."
983
- debug "Enhancement prompt: ${prompt:0:400}..."
984
- info "Placeholder: would enhance $image_file"
985
- cp "$image_file" "$output_file" 2>/dev/null || true
986
- return 0
987
- ;;
988
- *)
989
- warn "Enhancement not supported for provider: $AI_PROVIDER (falling back to OpenAI)"
990
- enhance_image_openai "$image_file" "$prompt" "$output_file"
991
- ;;
992
- esac
993
- }
994
-
995
- # Update front matter with new preview path
996
- update_front_matter() {
997
- local file="$1"
998
- local preview_path="$2"
999
-
1000
- debug "Updating front matter in: $file"
1001
-
1002
- if [[ "$DRY_RUN" == "true" ]]; then
1003
- info "[DRY RUN] Would update preview in $file to: $preview_path"
1004
- return 0
1005
- fi
1006
-
1007
- # Create backup
1008
- cp "$file" "$file.bak"
1009
-
1010
- # Always use sed for reliability (yq can fail on complex YAML)
1011
- # Check if preview field exists
1012
- if grep -q "^preview:" "$file"; then
1013
- # Update existing preview field using sed
1014
- if [[ "$(uname)" == "Darwin" ]]; then
1015
- # macOS sed requires empty string for -i
1016
- sed -i '' "s|^preview:.*|preview: $preview_path|" "$file"
1017
- else
1018
- sed -i "s|^preview:.*|preview: $preview_path|" "$file"
1019
- fi
1020
- else
1021
- # Add preview field after description or title
1022
- if grep -q "^description:" "$file"; then
1023
- if [[ "$(uname)" == "Darwin" ]]; then
1024
- sed -i '' "/^description:/a\\
1025
- preview: $preview_path" "$file"
1026
- else
1027
- sed -i "/^description:/a\\
1028
- preview: $preview_path" "$file"
1029
- fi
1030
- else
1031
- if [[ "$(uname)" == "Darwin" ]]; then
1032
- sed -i '' "/^title:/a\\
1033
- preview: $preview_path" "$file"
1034
- else
1035
- sed -i "/^title:/a\\
1036
- preview: $preview_path" "$file"
1037
- fi
1038
- fi
1039
- fi
1040
-
1041
- # Remove backup on success
1042
- rm -f "$file.bak"
1043
-
1044
- success "Updated front matter with preview: $preview_path"
1045
- }
1046
-
1047
- # Process a single file
1048
- process_file() {
1049
- local file="$1"
1050
-
1051
- inc_counter processed
1052
-
1053
- debug "Processing file: $file"
1054
-
1055
- # Extract front matter
1056
- local front_matter
1057
- front_matter=$(extract_front_matter "$file")
1058
-
1059
- if [[ -z "$front_matter" ]]; then
1060
- debug "No front matter found in: $file"
1061
- inc_counter skipped
1062
- return 0
1063
- fi
1064
-
1065
- # Get metadata
1066
- local title description categories preview author
1067
- title=$(get_yaml_value "$front_matter" "title")
1068
- description=$(get_yaml_value "$front_matter" "description")
1069
- categories=$(get_yaml_value "$front_matter" "categories")
1070
- preview=$(get_yaml_value "$front_matter" "preview")
1071
- author=$(get_yaml_value "$front_matter" "author")
1072
-
1073
- debug "Title: $title"
1074
- debug "Preview: $preview"
1075
-
1076
- # -------------------------------------------------------------------------
1077
- # Per-author art-style override (e.g. AI personas Cassandra / Vega).
1078
- # These `local`s shadow the global image settings for the duration of this
1079
- # function and everything it calls (generate_prompt, generate_image,
1080
- # build_enhance_prompt) via bash dynamic scoping — so each worker/file gets
1081
- # its author's style without leaking to other files, in serial or parallel.
1082
- # The actual lookup runs later, via apply_author_preview_overrides, only
1083
- # once an image/prompt is really built — never in --list-missing mode and
1084
- # not for files whose preview already exists (keeps listing output clean and
1085
- # skips wasted YAML-parser subprocesses).
1086
- # -------------------------------------------------------------------------
1087
- local IMAGE_STYLE="$IMAGE_STYLE"
1088
- local IMAGE_STYLE_MODIFIERS="$IMAGE_STYLE_MODIFIERS"
1089
- local IMAGE_SIZE="$IMAGE_SIZE"
1090
- local IMAGE_QUALITY="$IMAGE_QUALITY"
1091
- local IMAGE_MODEL="$IMAGE_MODEL"
1092
-
1093
- # =========================================================================
1094
- # ENHANCE MODE: improve an existing preview image
1095
- # =========================================================================
1096
- if [[ "$ENHANCE" == "true" ]]; then
1097
- # Find the existing preview image on disk
1098
- local existing_image
1099
- existing_image=$(find_preview_image "$preview" 2>/dev/null || echo "")
1100
-
1101
- if [[ -z "$existing_image" || ! -f "$existing_image" ]]; then
1102
- warn "No existing preview image found for: $title"
1103
- warn " Expected at: $preview"
1104
- warn " Use without --enhance to generate a new image first."
1105
- inc_counter skipped
1106
- return 0
1107
- fi
1108
-
1109
- info "Enhancing preview for: $title"
1110
- debug "Source image: $existing_image ($(du -h "$existing_image" | cut -f1))"
1111
-
1112
- # Apply the author's art style (if any) before building the prompt
1113
- apply_author_preview_overrides "$author"
1114
-
1115
- # Build enhancement prompt
1116
- local enhance_prompt
1117
- enhance_prompt=$(build_enhance_prompt "$title" "$description" "$ENHANCE_PROMPT")
1118
-
1119
- debug "Enhancement prompt: ${enhance_prompt:0:400}..."
1120
-
1121
- if [[ "$DRY_RUN" == "true" ]]; then
1122
- info "[DRY RUN] Would enhance image:"
1123
- echo " Source: $existing_image"
1124
- echo " Model: $ENHANCE_MODEL"
1125
- echo " Prompt: ${enhance_prompt:0:400}..."
1126
- echo ""
1127
- ENHANCED=$((ENHANCED + 1))
1128
- return 0
1129
- fi
1130
-
1131
- # Backup original before overwriting
1132
- local backup_file="${existing_image%.png}_pre-enhance.png"
1133
- if [[ ! -f "$backup_file" ]]; then
1134
- cp "$existing_image" "$backup_file"
1135
- info "Original backed up to: $(basename "$backup_file")"
1136
- else
1137
- debug "Backup already exists: $backup_file"
1138
- fi
1139
-
1140
- # Enhance the image (output overwrites original)
1141
- if enhance_image "$existing_image" "$enhance_prompt" "$existing_image"; then
1142
- inc_counter enhanced
1143
- else
1144
- warn "Failed to enhance image for: $title"
1145
- info "Original preserved at: $backup_file"
1146
- inc_counter errors
1147
- fi
1148
-
1149
- return 0
1150
- fi
1151
-
1152
- # =========================================================================
1153
- # GENERATE MODE: create new preview image (default)
1154
- # =========================================================================
1155
-
1156
- # Check if preview exists and is valid
1157
- if [[ -n "$preview" ]] && check_preview_exists "$preview"; then
1158
- if [[ "$FORCE" != "true" ]]; then
1159
- debug "Preview already exists and is valid: $preview"
1160
- inc_counter skipped
1161
- return 0
1162
- else
1163
- info "Force mode: regenerating preview for $title"
1164
- fi
1165
- fi
1166
-
1167
- # Report missing preview
1168
- if [[ "$LIST_ONLY" == "true" ]]; then
1169
- echo -e "${YELLOW}Missing preview:${NC} $file"
1170
- echo -e " Title: $title"
1171
- if [[ -n "$preview" ]]; then
1172
- echo -e " Current preview (not found): $preview"
1173
- fi
1174
- echo ""
1175
- return 0
1176
- fi
1177
-
1178
- info "Generating preview for: $title"
1179
-
1180
- # Generate filename from title
1181
- local safe_filename
1182
- safe_filename=$(echo "$title" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
1183
- safe_filename="${safe_filename:0:50}" # Limit length
1184
-
1185
- local output_file="$PROJECT_ROOT/$OUTPUT_DIR/${safe_filename}.png"
1186
- # Preview path should NOT include /assets/ prefix since the template adds it
1187
- local preview_path="/images/previews/${safe_filename}.png"
1188
-
1189
- # Apply the author's art style (if any) before building the prompt
1190
- apply_author_preview_overrides "$author"
1191
-
1192
- # Extract content for prompt generation
1193
- local content
1194
- content=$(extract_content "$file")
1195
-
1196
- # Generate prompt
1197
- local prompt
1198
- prompt=$(generate_prompt "$title" "$description" "$categories" "$content")
1199
-
1200
- debug "Generated prompt: ${prompt:0:500}..."
1201
-
1202
- if [[ "$DRY_RUN" == "true" ]]; then
1203
- info "[DRY RUN] Would generate image:"
1204
- echo " Output: $output_file"
1205
- echo " Preview path: $preview_path"
1206
- echo " Prompt: ${prompt:0:400}..."
1207
- echo ""
1208
- inc_counter generated
1209
- return 0
1210
- fi
1211
-
1212
- # Generate image
1213
- if generate_image "$prompt" "$output_file"; then
1214
- # Update front matter with new preview path
1215
- update_front_matter "$file" "$preview_path"
1216
- inc_counter generated
1217
- # Brief pause between successful generations to stay within API rate limits
1218
- sleep 2
1219
- else
1220
- warn "Failed to generate image for: $title"
1221
- inc_counter errors
1222
- fi
1223
- }
1224
-
1225
- # Find and process content files
1226
- process_collection() {
1227
- local collection_path="$1"
1228
- local pattern="${2:-*.md}"
1229
-
1230
- debug "Processing collection: $collection_path with pattern: $pattern"
1231
-
1232
- if [[ ! -d "$collection_path" ]]; then
1233
- warn "Collection directory not found: $collection_path"
1234
- return 1
1235
- fi
1236
-
1237
- # Serial mode for dry-run / list-only (clean, ordered output) or when parallelism=1
1238
- if [[ "$LIST_ONLY" == "true" || "$DRY_RUN" == "true" || "$MAX_PARALLEL" -le 1 ]]; then
1239
- while IFS= read -r -d '' file; do
1240
- process_file "$file"
1241
- done < <(find "$collection_path" -name "$pattern" -type f -print0 | sort -z)
1242
- return 0
1243
- fi
1244
-
1245
- # Parallel mode: dispatch up to MAX_PARALLEL concurrent workers
1246
- # Uses a busy-wait throttle compatible with bash 3.2+
1247
- while IFS= read -r -d '' file; do
1248
- # Block until a worker slot opens
1249
- while true; do
1250
- local active
1251
- active=$(jobs -rp 2>/dev/null | wc -l | tr -d ' ')
1252
- [[ "$active" -lt "$MAX_PARALLEL" ]] && break
1253
- sleep 0.2
1254
- done
1255
- process_file "$file" &
1256
- done < <(find "$collection_path" -name "$pattern" -type f -print0 | sort -z)
1257
-
1258
- # Drain all remaining workers before returning
1259
- wait
1260
- }
1261
-
1262
- # Main function
1263
- main() {
1264
- print_header "🎨 Preview Image Generator"
1265
-
1266
- # Validate environment
1267
- validate_environment
1268
-
1269
- # Initialise file-based counters for parallel-safe counting
1270
- if [[ "$LIST_ONLY" != "true" && "$DRY_RUN" != "true" ]]; then
1271
- init_counters
1272
- fi
1273
-
1274
- # Display configuration
1275
- info "Configuration:"
1276
- echo " AI Provider: $AI_PROVIDER"
1277
- echo " Image Model: $IMAGE_MODEL"
1278
- echo " Output Dir: $OUTPUT_DIR"
1279
- echo " Image Size: $IMAGE_SIZE"
1280
- echo " Parallel Workers: $MAX_PARALLEL"
1281
- echo " Dry Run: $DRY_RUN"
1282
- echo " Force: $FORCE"
1283
- echo " List Only: $LIST_ONLY"
1284
- if [[ "$ENHANCE" == "true" ]]; then
1285
- echo " Mode: ENHANCE (improve existing images)"
1286
- echo " Enhance Model: $ENHANCE_MODEL"
1287
- echo " Enhance Quality: $ENHANCE_QUALITY"
1288
- echo " Input Fidelity: $ENHANCE_FIDELITY"
1289
- echo " Output Format: $ENHANCE_FORMAT"
1290
- if [[ -n "$ENHANCE_PROMPT" ]]; then
1291
- echo " Custom Prompt: ${ENHANCE_PROMPT:0:80}..."
1292
- else
1293
- echo " Prompt: (default improvement prompt)"
1294
- fi
1295
- fi
1296
- echo ""
1297
-
1298
- # Get configured collections from _config.yml
1299
- get_configured_collections() {
1300
- local collections=()
1301
- local in_preview_images=false
1302
- local in_collections=false
1303
-
1304
- while IFS= read -r line; do
1305
- if [[ "$line" =~ ^preview_images: ]]; then
1306
- in_preview_images=true
1307
- continue
1308
- fi
1309
- if [[ "$in_preview_images" == true && "$line" =~ ^[[:space:]]+collections: ]]; then
1310
- in_collections=true
1311
- continue
1312
- fi
1313
- if [[ "$in_collections" == true && "$line" =~ ^[[:space:]]+- ]]; then
1314
- local collection="${line#*- }"
1315
- collection="${collection%%#*}"
1316
- collection="${collection%"${collection##*[![:space:]]}"}"
1317
- collections+=("$collection")
1318
- elif [[ "$in_collections" == true && ! "$line" =~ ^[[:space:]]+- && ! "$line" =~ ^[[:space:]]*$ ]]; then
1319
- break
1320
- fi
1321
- if [[ "$in_preview_images" == true && "$line" =~ ^[a-zA-Z_]+: && ! "$line" =~ ^[[:space:]] ]]; then
1322
- break
1323
- fi
1324
- done < "$CONFIG_FILE"
1325
-
1326
- if [[ ${#collections[@]} -eq 0 ]]; then
1327
- collections=("posts" "quickstart" "docs")
1328
- fi
1329
-
1330
- echo "${collections[@]}"
1331
- }
1332
-
1333
- # Process a collection by name
1334
- process_collection_by_name() {
1335
- local name="$1"
1336
- local path="$PROJECT_ROOT/pages/_${name}"
1337
-
1338
- if [[ -d "$path" ]]; then
1339
- step "Processing ${name} collection..."
1340
- process_collection "$path"
1341
- else
1342
- warn "Collection directory not found: $path"
1343
- fi
1344
- }
1345
-
1346
- # Process files
1347
- if [[ -n "$SPECIFIC_FILE" ]]; then
1348
- # Process single file
1349
- if [[ ! -f "$PROJECT_ROOT/$SPECIFIC_FILE" ]]; then
1350
- error "File not found: $SPECIFIC_FILE"
1351
- fi
1352
- process_file "$PROJECT_ROOT/$SPECIFIC_FILE"
1353
- elif [[ -n "$COLLECTION" ]]; then
1354
- # Process specific collection
1355
- if [[ "$COLLECTION" == "all" ]]; then
1356
- step "Processing all configured collections..."
1357
- for col in $(get_configured_collections); do
1358
- process_collection_by_name "$col"
1359
- done
1360
- else
1361
- # Check if collection directory exists
1362
- local collection_path="$PROJECT_ROOT/pages/_${COLLECTION}"
1363
- if [[ -d "$collection_path" ]]; then
1364
- process_collection_by_name "$COLLECTION"
1365
- else
1366
- local available=$(get_configured_collections | tr ' ' ', ')
1367
- error "Unknown collection: $COLLECTION. Available: $available, all"
1368
- fi
1369
- fi
1370
- else
1371
- # Process all configured collections by default
1372
- step "Processing all configured collections..."
1373
- for col in $(get_configured_collections); do
1374
- process_collection_by_name "$col"
1375
- done
1376
- fi
1377
-
1378
- # Print summary
1379
- echo ""
1380
- print_header "📊 Summary"
1381
- echo " Files processed: $(get_counter processed)"
1382
- echo " Images generated: $(get_counter generated)"
1383
- echo " Images enhanced: $(get_counter enhanced)"
1384
- echo " Files skipped: $(get_counter skipped)"
1385
- echo " Errors: $(get_counter errors)"
1386
- echo ""
1387
-
1388
- if [[ "$DRY_RUN" == "true" ]]; then
1389
- info "This was a dry run. No actual changes were made."
1390
- fi
1391
-
1392
- local err_count
1393
- err_count=$(get_counter errors)
1394
- if [[ "$err_count" -gt 0 ]]; then
1395
- warn "Some files had errors. Check the output above."
1396
- exit 1
1397
- fi
1398
-
1399
- success "Preview image generation complete!"
1400
- }
1401
-
1402
- # Parse arguments and run
1403
- parse_args "$@"
1404
- main
61
+ # PyYAML availability is checked by the engine itself (ensure_yaml) with an
62
+ # actionable message — no duplicate probe here.
63
+ exec python3 "$ENGINE" "$@"