jekyll-theme-zer0 0.22.0 → 0.22.19

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +236 -0
  3. data/README.md +66 -19
  4. data/_data/navigation/admin.yml +53 -0
  5. data/_data/theme_backgrounds.yml +121 -0
  6. data/_includes/components/admin-tabs.html +59 -0
  7. data/_includes/components/analytics-dashboard.html +232 -0
  8. data/_includes/components/background-customizer.html +159 -0
  9. data/_includes/components/background-settings.html +137 -0
  10. data/_includes/components/collection-manager.html +151 -0
  11. data/_includes/components/component-showcase.html +452 -0
  12. data/_includes/components/config-editor.html +207 -0
  13. data/_includes/components/config-viewer.html +479 -0
  14. data/_includes/components/env-dashboard.html +154 -0
  15. data/_includes/components/feature-card.html +94 -0
  16. data/_includes/components/info-section.html +172 -149
  17. data/_includes/components/js-cdn.html +4 -1
  18. data/_includes/components/nav-editor.html +99 -0
  19. data/_includes/components/setup-banner.html +28 -0
  20. data/_includes/components/setup-check.html +53 -0
  21. data/_includes/components/svg-background.html +42 -0
  22. data/_includes/components/theme-customizer.html +46 -0
  23. data/_includes/content/seo.html +68 -135
  24. data/_includes/core/footer.html +1 -1
  25. data/_includes/core/head.html +3 -2
  26. data/_includes/core/header.html +14 -7
  27. data/_includes/landing/landing-install-cards.html +18 -7
  28. data/_includes/navigation/admin-nav.html +95 -0
  29. data/_includes/navigation/navbar.html +43 -5
  30. data/_includes/navigation/sidebar-left.html +1 -1
  31. data/_includes/setup/wizard.html +330 -0
  32. data/_layouts/admin.html +166 -0
  33. data/_layouts/landing.html +23 -9
  34. data/_layouts/root.html +12 -6
  35. data/_layouts/setup.html +73 -0
  36. data/_plugins/preview_image_generator.rb +26 -12
  37. data/_sass/core/_navbar.scss +2 -2
  38. data/_sass/custom.scss +28 -6
  39. data/_sass/theme/_background-mixins.scss +95 -0
  40. data/_sass/theme/_backgrounds.scss +156 -0
  41. data/_sass/theme/_color-modes.scss +2 -1
  42. data/assets/backgrounds/gradients/air.svg +15 -0
  43. data/assets/backgrounds/gradients/aqua.svg +15 -0
  44. data/assets/backgrounds/gradients/contrast.svg +15 -0
  45. data/assets/backgrounds/gradients/dark.svg +15 -0
  46. data/assets/backgrounds/gradients/dirt.svg +15 -0
  47. data/assets/backgrounds/gradients/mint.svg +15 -0
  48. data/assets/backgrounds/gradients/neon.svg +15 -0
  49. data/assets/backgrounds/gradients/plum.svg +15 -0
  50. data/assets/backgrounds/gradients/sunrise.svg +15 -0
  51. data/assets/backgrounds/noise/air.svg +8 -0
  52. data/assets/backgrounds/noise/aqua.svg +8 -0
  53. data/assets/backgrounds/noise/contrast.svg +8 -0
  54. data/assets/backgrounds/noise/dark.svg +8 -0
  55. data/assets/backgrounds/noise/dirt.svg +8 -0
  56. data/assets/backgrounds/noise/mint.svg +8 -0
  57. data/assets/backgrounds/noise/neon.svg +8 -0
  58. data/assets/backgrounds/noise/plum.svg +8 -0
  59. data/assets/backgrounds/noise/sunrise.svg +8 -0
  60. data/assets/backgrounds/patterns/air.svg +7 -0
  61. data/assets/backgrounds/patterns/aqua.svg +7 -0
  62. data/assets/backgrounds/patterns/contrast.svg +4 -0
  63. data/assets/backgrounds/patterns/dark.svg +5 -0
  64. data/assets/backgrounds/patterns/dirt.svg +5 -0
  65. data/assets/backgrounds/patterns/mint.svg +6 -0
  66. data/assets/backgrounds/patterns/neon.svg +6 -0
  67. data/assets/backgrounds/patterns/plum.svg +6 -0
  68. data/assets/backgrounds/patterns/sunrise.svg +5 -0
  69. data/assets/js/background-customizer.js +73 -0
  70. data/assets/js/code-copy.js +18 -47
  71. data/assets/js/config-utility.js +307 -0
  72. data/assets/js/nav-editor.js +39 -0
  73. data/assets/js/palette-generator.js +415 -0
  74. data/assets/js/search-modal.js +31 -11
  75. data/assets/js/setup-wizard.js +306 -0
  76. data/assets/js/skin-editor.js +645 -0
  77. data/assets/js/theme-customizer.js +102 -0
  78. data/assets/js/ui-enhancements.js +15 -24
  79. data/assets/vendor/bootstrap/css/bootstrap.min.css +1 -0
  80. data/assets/vendor/bootstrap/js/bootstrap.bundle.min.js +1 -0
  81. data/scripts/README.md +45 -0
  82. data/scripts/features/generate-preview-images +297 -7
  83. data/scripts/features/install-preview-generator +51 -33
  84. data/scripts/fork-cleanup.sh +92 -19
  85. data/scripts/github-setup.sh +284 -0
  86. data/scripts/init_setup.sh +0 -1
  87. data/scripts/lib/frontmatter.sh +543 -0
  88. data/scripts/lib/migrate.sh +265 -0
  89. data/scripts/lib/preview_generator.py +607 -32
  90. data/scripts/lint-pages +505 -0
  91. data/scripts/migrate.sh +201 -0
  92. data/scripts/platform/setup-linux.sh +244 -0
  93. data/scripts/platform/setup-macos.sh +187 -0
  94. data/scripts/platform/setup-wsl.sh +196 -0
  95. metadata +71 -6
@@ -23,9 +23,14 @@ import json
23
23
  import os
24
24
  import re
25
25
  import sys
26
- from dataclasses import dataclass
26
+ import signal
27
+ import time
28
+ import threading
29
+ from concurrent.futures import ThreadPoolExecutor, as_completed
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timedelta
27
32
  from pathlib import Path
28
- from typing import Optional, List, Dict, Any
33
+ from typing import Optional, List, Dict, Any, TextIO, Tuple
29
34
  import yaml
30
35
 
31
36
  # Optional imports with fallback
@@ -42,6 +47,81 @@ except ImportError:
42
47
  HAS_OPENAI = False
43
48
 
44
49
 
50
+ def _load_dotenv():
51
+ """Load environment variables from .env file if present."""
52
+ # Search for .env in cwd and parent directories
53
+ search_dir = Path.cwd()
54
+ for _ in range(5): # limit search depth
55
+ env_file = search_dir / '.env'
56
+ if env_file.is_file():
57
+ with open(env_file) as f:
58
+ for line in f:
59
+ line = line.strip()
60
+ if not line or line.startswith('#'):
61
+ continue
62
+ if '=' in line:
63
+ key, _, value = line.partition('=')
64
+ key = key.strip()
65
+ value = value.strip().strip('"').strip("'")
66
+ if key and key not in os.environ:
67
+ os.environ[key] = value
68
+ return
69
+ parent = search_dir.parent
70
+ if parent == search_dir:
71
+ break
72
+ search_dir = parent
73
+
74
+ _load_dotenv()
75
+
76
+
77
+ # Global state for interrupt handling
78
+ _interrupted = False
79
+ _log_file: Optional[TextIO] = None
80
+
81
+
82
+ def _signal_handler(signum, frame):
83
+ """Handle interrupt signals gracefully."""
84
+ global _interrupted
85
+ _interrupted = True
86
+ print(f"\n{Colors.YELLOW}⚠️ Interrupt received. Finishing current tasks...{Colors.NC}")
87
+
88
+
89
+ class RateLimiter:
90
+ """Token bucket rate limiter for API calls."""
91
+
92
+ def __init__(self, requests_per_minute: int = 5):
93
+ self.requests_per_minute = requests_per_minute
94
+ self.min_interval = 60.0 / requests_per_minute
95
+ self.lock = threading.Lock()
96
+ self.last_request_time = 0.0
97
+ self.request_count = 0
98
+ self.window_start = time.time()
99
+
100
+ def acquire(self) -> float:
101
+ """Acquire permission to make a request. Returns time waited."""
102
+ with self.lock:
103
+ now = time.time()
104
+ if now - self.window_start >= 60.0:
105
+ self.window_start = now
106
+ self.request_count = 0
107
+ if self.request_count >= self.requests_per_minute:
108
+ wait_time = 60.0 - (now - self.window_start)
109
+ if wait_time > 0:
110
+ time.sleep(wait_time)
111
+ self.window_start = time.time()
112
+ self.request_count = 0
113
+ return wait_time
114
+ elapsed = now - self.last_request_time
115
+ if elapsed < self.min_interval:
116
+ wait_time = self.min_interval - elapsed
117
+ time.sleep(wait_time)
118
+ else:
119
+ wait_time = 0
120
+ self.last_request_time = time.time()
121
+ self.request_count += 1
122
+ return wait_time
123
+
124
+
45
125
  @dataclass
46
126
  class ContentFile:
47
127
  """Represents a Jekyll content file with its metadata."""
@@ -63,6 +143,192 @@ class GenerationResult:
63
143
  preview_url: Optional[str]
64
144
  error: Optional[str]
65
145
  prompt_used: Optional[str]
146
+ duration: float = 0.0
147
+ file_path: Optional[Path] = None
148
+
149
+
150
+ class ThreadSafeStats:
151
+ """Thread-safe progress statistics."""
152
+
153
+ def __init__(self):
154
+ self.lock = threading.Lock()
155
+ self._total_files: int = 0
156
+ self._current_index: int = 0
157
+ self._processed: int = 0
158
+ self._generated: int = 0
159
+ self._skipped: int = 0
160
+ self._errors: int = 0
161
+ self._start_time: float = time.time()
162
+ self._generation_times: List[float] = []
163
+ self._active_workers: int = 0
164
+ self._pending_files: List[str] = []
165
+
166
+ @property
167
+ def total_files(self) -> int:
168
+ with self.lock:
169
+ return self._total_files
170
+
171
+ @total_files.setter
172
+ def total_files(self, value: int):
173
+ with self.lock:
174
+ self._total_files = value
175
+
176
+ @property
177
+ def current_index(self) -> int:
178
+ with self.lock:
179
+ return self._current_index
180
+
181
+ @current_index.setter
182
+ def current_index(self, value: int):
183
+ with self.lock:
184
+ self._current_index = value
185
+
186
+ @property
187
+ def processed(self) -> int:
188
+ with self.lock:
189
+ return self._processed
190
+
191
+ @property
192
+ def generated(self) -> int:
193
+ with self.lock:
194
+ return self._generated
195
+
196
+ @property
197
+ def skipped(self) -> int:
198
+ with self.lock:
199
+ return self._skipped
200
+
201
+ @property
202
+ def errors(self) -> int:
203
+ with self.lock:
204
+ return self._errors
205
+
206
+ @property
207
+ def active_workers(self) -> int:
208
+ with self.lock:
209
+ return self._active_workers
210
+
211
+ def increment_processed(self):
212
+ with self.lock:
213
+ self._processed += 1
214
+ self._current_index += 1
215
+
216
+ def increment_generated(self):
217
+ with self.lock:
218
+ self._generated += 1
219
+
220
+ def increment_skipped(self):
221
+ with self.lock:
222
+ self._skipped += 1
223
+
224
+ def increment_errors(self):
225
+ with self.lock:
226
+ self._errors += 1
227
+
228
+ def add_generation_time(self, duration: float):
229
+ with self.lock:
230
+ self._generation_times.append(duration)
231
+
232
+ def set_active_workers(self, count: int):
233
+ with self.lock:
234
+ self._active_workers = count
235
+
236
+ def add_pending_file(self, filename: str):
237
+ with self.lock:
238
+ self._pending_files.append(filename)
239
+
240
+ def remove_pending_file(self, filename: str):
241
+ with self.lock:
242
+ if filename in self._pending_files:
243
+ self._pending_files.remove(filename)
244
+
245
+ def get_pending_files(self) -> List[str]:
246
+ with self.lock:
247
+ return self._pending_files.copy()
248
+
249
+ @property
250
+ def elapsed(self) -> float:
251
+ return time.time() - self._start_time
252
+
253
+ @property
254
+ def elapsed_str(self) -> str:
255
+ return str(timedelta(seconds=int(self.elapsed)))
256
+
257
+ @property
258
+ def avg_generation_time(self) -> float:
259
+ with self.lock:
260
+ if not self._generation_times:
261
+ return 25.0
262
+ return sum(self._generation_times) / len(self._generation_times)
263
+
264
+ @property
265
+ def generation_times(self) -> List[float]:
266
+ with self.lock:
267
+ return self._generation_times.copy()
268
+
269
+ @property
270
+ def estimated_remaining(self) -> float:
271
+ with self.lock:
272
+ remaining = self._total_files - self._current_index
273
+ return remaining * self.avg_generation_time
274
+
275
+ @property
276
+ def eta_str(self) -> str:
277
+ with self.lock:
278
+ if self._total_files == 0:
279
+ return "unknown"
280
+ return str(timedelta(seconds=int(self.estimated_remaining)))
281
+
282
+ @property
283
+ def percentage(self) -> float:
284
+ with self.lock:
285
+ if self._total_files == 0:
286
+ return 0.0
287
+ return (self._current_index / self._total_files) * 100
288
+
289
+
290
+ @dataclass
291
+ class ProgressStats:
292
+ """Track progress statistics (legacy, non-thread-safe)."""
293
+ total_files: int = 0
294
+ current_index: int = 0
295
+ processed: int = 0
296
+ generated: int = 0
297
+ skipped: int = 0
298
+ errors: int = 0
299
+ start_time: float = field(default_factory=time.time)
300
+ generation_times: List[float] = field(default_factory=list)
301
+
302
+ @property
303
+ def elapsed(self) -> float:
304
+ return time.time() - self.start_time
305
+
306
+ @property
307
+ def elapsed_str(self) -> str:
308
+ return str(timedelta(seconds=int(self.elapsed)))
309
+
310
+ @property
311
+ def avg_generation_time(self) -> float:
312
+ if not self.generation_times:
313
+ return 25.0
314
+ return sum(self.generation_times) / len(self.generation_times)
315
+
316
+ @property
317
+ def estimated_remaining(self) -> float:
318
+ remaining = self.total_files - self.current_index
319
+ return remaining * self.avg_generation_time
320
+
321
+ @property
322
+ def eta_str(self) -> str:
323
+ if self.total_files == 0:
324
+ return "unknown"
325
+ return str(timedelta(seconds=int(self.estimated_remaining)))
326
+
327
+ @property
328
+ def percentage(self) -> float:
329
+ if self.total_files == 0:
330
+ return 0.0
331
+ return (self.current_index / self.total_files) * 100
66
332
 
67
333
 
68
334
  class Colors:
@@ -73,11 +339,56 @@ class Colors:
73
339
  BLUE = '\033[0;34m'
74
340
  CYAN = '\033[0;36m'
75
341
  PURPLE = '\033[0;35m'
342
+ BOLD = '\033[1m'
343
+ DIM = '\033[2m'
76
344
  NC = '\033[0m' # No Color
77
345
 
78
346
 
79
- def log(msg: str, level: str = "info"):
347
+ class Spinner:
348
+ """Simple spinner for showing activity during long operations."""
349
+
350
+ FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
351
+
352
+ def __init__(self, message: str = ""):
353
+ self.message = message
354
+ self.running = False
355
+ self.thread: Optional[threading.Thread] = None
356
+ self.frame_idx = 0
357
+ self.start_time = 0.0
358
+
359
+ def _spin(self):
360
+ while self.running:
361
+ elapsed = int(time.time() - self.start_time)
362
+ frame = self.FRAMES[self.frame_idx % len(self.FRAMES)]
363
+ sys.stdout.write(f"\r{Colors.CYAN}{frame}{Colors.NC} {self.message} ({elapsed}s)...")
364
+ sys.stdout.flush()
365
+ self.frame_idx += 1
366
+ time.sleep(0.1)
367
+
368
+ def start(self, message: str = None):
369
+ if message:
370
+ self.message = message
371
+ self.running = True
372
+ self.start_time = time.time()
373
+ self.thread = threading.Thread(target=self._spin, daemon=True)
374
+ self.thread.start()
375
+
376
+ def stop(self, success: bool = True):
377
+ self.running = False
378
+ if self.thread:
379
+ self.thread.join(timeout=0.2)
380
+ elapsed = time.time() - self.start_time
381
+ sys.stdout.write('\r' + ' ' * 80 + '\r')
382
+ sys.stdout.flush()
383
+ return elapsed
384
+
385
+
386
+ def log(msg: str, level: str = "info", to_file: bool = True):
80
387
  """Print formatted log message."""
388
+ global _log_file
389
+
390
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
391
+
81
392
  colors = {
82
393
  "info": Colors.BLUE,
83
394
  "success": Colors.GREEN,
@@ -85,10 +396,32 @@ def log(msg: str, level: str = "info"):
85
396
  "error": Colors.RED,
86
397
  "debug": Colors.PURPLE,
87
398
  "step": Colors.CYAN,
399
+ "progress": Colors.BOLD,
88
400
  }
89
401
  color = colors.get(level, Colors.NC)
90
402
  prefix = f"[{level.upper()}]"
91
403
  print(f"{color}{prefix}{Colors.NC} {msg}")
404
+
405
+ if to_file and _log_file:
406
+ _log_file.write(f"{timestamp} {prefix} {msg}\n")
407
+ _log_file.flush()
408
+
409
+
410
+ def log_progress(current: int, total: int, title: str, stats):
411
+ """Print progress bar and statistics."""
412
+ bar_width = 30
413
+ filled = int(bar_width * current / total) if total > 0 else 0
414
+ bar = '█' * filled + '░' * (bar_width - filled)
415
+
416
+ max_title_len = 40
417
+ display_title = title[:max_title_len-3] + "..." if len(title) > max_title_len else title
418
+
419
+ print(f"\n{Colors.CYAN}{'─' * 70}{Colors.NC}")
420
+ print(f"{Colors.BOLD}📊 Progress: [{bar}] {current}/{total} ({stats.percentage:.1f}%){Colors.NC}")
421
+ print(f" {Colors.DIM}Elapsed: {stats.elapsed_str} | ETA: {stats.eta_str} | Avg: {stats.avg_generation_time:.1f}s/image{Colors.NC}")
422
+ print(f" ✅ Generated: {stats.generated} | ⏭️ Skipped: {stats.skipped} | ❌ Errors: {stats.errors}")
423
+ print(f"{Colors.CYAN}{'─' * 70}{Colors.NC}")
424
+ print(f"📁 Processing: {display_title}")
92
425
 
93
426
 
94
427
  class PreviewGenerator:
@@ -106,6 +439,9 @@ class PreviewGenerator:
106
439
  dry_run: bool = False,
107
440
  verbose: bool = False,
108
441
  force: bool = False,
442
+ batch_limit: int = 0,
443
+ workers: int = 1,
444
+ rate_limit: int = 5,
109
445
  ):
110
446
  self.project_root = project_root
111
447
  self.provider = provider
@@ -117,12 +453,19 @@ class PreviewGenerator:
117
453
  self.dry_run = dry_run
118
454
  self.verbose = verbose
119
455
  self.force = force
456
+ self.batch_limit = batch_limit
457
+ self.workers = workers
458
+ self.rate_limit = rate_limit
459
+
460
+ # Progress tracking - use thread-safe stats for parallel processing
461
+ if workers > 1:
462
+ self.stats = ThreadSafeStats()
463
+ else:
464
+ self.stats = ProgressStats()
465
+ self.spinner = Spinner()
120
466
 
121
- # Statistics
122
- self.processed = 0
123
- self.generated = 0
124
- self.skipped = 0
125
- self.errors = 0
467
+ # Rate limiter for API calls
468
+ self.rate_limiter = RateLimiter(requests_per_minute=rate_limit)
126
469
 
127
470
  # Ensure output directory exists
128
471
  if not dry_run:
@@ -592,13 +935,26 @@ class PreviewGenerator:
592
935
  log(f"Failed to update front matter: {e}", "error")
593
936
  return False
594
937
 
938
+ def _increment_stat(self, stat_name: str):
939
+ """Increment a stat on either ThreadSafeStats or ProgressStats."""
940
+ if isinstance(self.stats, ThreadSafeStats):
941
+ method = getattr(self.stats, f"increment_{stat_name}", None)
942
+ if method:
943
+ method()
944
+ else:
945
+ setattr(self.stats, stat_name, getattr(self.stats, stat_name) + 1)
946
+
595
947
  def process_file(self, file_path: Path, list_only: bool = False) -> bool:
596
948
  """Process a single content file."""
597
- self.processed += 1
949
+ global _interrupted
950
+ if _interrupted:
951
+ return False
952
+
953
+ self._increment_stat("processed")
598
954
 
599
955
  content = self.parse_front_matter(file_path)
600
956
  if not content:
601
- self.skipped += 1
957
+ self._increment_stat("skipped")
602
958
  return False
603
959
 
604
960
  self.debug(f"Processing: {content.title}")
@@ -607,7 +963,7 @@ class PreviewGenerator:
607
963
  if content.preview and self.check_preview_exists(content.preview):
608
964
  if not self.force:
609
965
  self.debug(f"Preview exists: {content.preview}")
610
- self.skipped += 1
966
+ self._increment_stat("skipped")
611
967
  return True
612
968
  else:
613
969
  log(f"Force mode: regenerating preview for {content.title}", "info")
@@ -639,56 +995,233 @@ class PreviewGenerator:
639
995
  print(f" Preview URL: {preview_url}")
640
996
  print(f" Prompt: {prompt[:200]}...")
641
997
  print()
642
- self.generated += 1
998
+ self._increment_stat("generated")
643
999
  return True
644
1000
 
1001
+ # Rate limiting
1002
+ self.rate_limiter.acquire()
1003
+
1004
+ start_time = time.time()
1005
+
645
1006
  # Generate image
1007
+ self.spinner.start(f"Generating: {content.title[:50]}...")
646
1008
  result = self.generate_image(prompt, output_file)
1009
+ self.spinner.stop()
1010
+
1011
+ duration = time.time() - start_time
1012
+ result.duration = duration
1013
+ result.file_path = file_path
647
1014
 
648
1015
  if result.success:
649
1016
  # Update front matter
650
- if self.update_front_matter(file_path, preview_url):
651
- log(f"Updated front matter with: {preview_url}", "success")
652
- self.generated += 1
1017
+ self.spinner.start("Updating front matter...")
1018
+ updated = self.update_front_matter(file_path, preview_url)
1019
+ self.spinner.stop()
1020
+
1021
+ if updated:
1022
+ log(f"Updated front matter with: {preview_url} ({duration:.1f}s)", "success")
1023
+ self._increment_stat("generated")
1024
+ if isinstance(self.stats, ThreadSafeStats):
1025
+ self.stats.add_generation_time(duration)
653
1026
  return True
654
1027
  else:
655
- self.errors += 1
1028
+ self._increment_stat("errors")
656
1029
  return False
657
1030
  else:
658
1031
  log(f"Failed to generate image: {result.error}", "warning")
659
- self.errors += 1
1032
+ self._increment_stat("errors")
660
1033
  return False
661
1034
 
1035
+ def process_file_parallel(self, file_path: Path) -> Tuple[Path, GenerationResult]:
1036
+ """Thread-safe version of process_file for parallel processing."""
1037
+ global _interrupted
1038
+ if _interrupted:
1039
+ return file_path, GenerationResult(success=False, error="Interrupted")
1040
+
1041
+ content = self.parse_front_matter(file_path)
1042
+ if not content:
1043
+ self.stats.increment_skipped()
1044
+ return file_path, GenerationResult(success=False, error="No front matter")
1045
+
1046
+ # Check if preview exists
1047
+ if content.preview and self.check_preview_exists(content.preview):
1048
+ if not self.force:
1049
+ self.stats.increment_skipped()
1050
+ return file_path, GenerationResult(success=True, file_path=file_path)
1051
+
1052
+ # Generate filename and paths
1053
+ safe_filename = self.generate_filename(content.title)
1054
+ output_file = self.output_dir / f"{safe_filename}.png"
1055
+ preview_url = f"/{self.output_dir.relative_to(self.project_root)}/{safe_filename}.png"
1056
+
1057
+ # Generate prompt
1058
+ prompt = self.generate_prompt(content)
1059
+
1060
+ # Rate limiting
1061
+ self.rate_limiter.acquire()
1062
+
1063
+ start_time = time.time()
1064
+ result = self.generate_image(prompt, output_file)
1065
+ duration = time.time() - start_time
1066
+ result.duration = duration
1067
+ result.file_path = file_path
1068
+
1069
+ if result.success:
1070
+ if self.update_front_matter(file_path, preview_url):
1071
+ self.stats.increment_generated()
1072
+ self.stats.add_generation_time(duration)
1073
+ return file_path, result
1074
+ else:
1075
+ self.stats.increment_errors()
1076
+ result.success = False
1077
+ result.error = "Failed to update front matter"
1078
+ return file_path, result
1079
+ else:
1080
+ self.stats.increment_errors()
1081
+ return file_path, result
1082
+
662
1083
  def process_collection(self, collection_path: Path, list_only: bool = False):
663
1084
  """Process all markdown files in a collection."""
664
1085
  if not collection_path.exists():
665
1086
  log(f"Collection not found: {collection_path}", "warning")
666
1087
  return
667
1088
 
668
- for md_file in collection_path.rglob("*.md"):
1089
+ files = sorted(collection_path.rglob("*.md"))
1090
+
1091
+ # Apply batch limit
1092
+ if self.batch_limit > 0:
1093
+ files = files[:self.batch_limit]
1094
+ log(f"Batch limit: processing {len(files)} files", "info")
1095
+
1096
+ if not files:
1097
+ log(f"No markdown files found in {collection_path}", "info")
1098
+ return
1099
+
1100
+ if self.workers > 1 and not list_only and not self.dry_run:
1101
+ self._process_collection_parallel(files)
1102
+ else:
1103
+ self._process_collection_sequential(files, list_only)
1104
+
1105
+ def _process_collection_sequential(self, files: List[Path], list_only: bool = False):
1106
+ """Process files sequentially with progress tracking."""
1107
+ global _interrupted
1108
+ total = len(files)
1109
+
1110
+ for i, md_file in enumerate(files):
1111
+ if _interrupted:
1112
+ log("Interrupted! Stopping...", "warning")
1113
+ break
1114
+
1115
+ if isinstance(self.stats, ProgressStats):
1116
+ log_progress(i + 1, total, "Processing", self.stats)
1117
+
669
1118
  self.process_file(md_file, list_only)
1119
+ self.stats.processed = i + 1
1120
+
1121
+ def _process_collection_parallel(self, files: List[Path]):
1122
+ """Process files in parallel using ThreadPoolExecutor."""
1123
+ global _interrupted
1124
+ total = len(files)
1125
+
1126
+ log(f"Processing {total} files with {self.workers} workers", "info")
1127
+
1128
+ if isinstance(self.stats, ThreadSafeStats):
1129
+ self.stats.set_active_workers(self.workers)
1130
+ for f in files:
1131
+ self.stats.add_pending_file(str(f))
1132
+
1133
+ completed = 0
1134
+
1135
+ with ThreadPoolExecutor(max_workers=self.workers) as executor:
1136
+ futures = {
1137
+ executor.submit(self.process_file_parallel, f): f
1138
+ for f in files
1139
+ }
1140
+
1141
+ for future in as_completed(futures):
1142
+ if _interrupted:
1143
+ log("Interrupted! Cancelling remaining tasks...", "warning")
1144
+ for f in futures:
1145
+ f.cancel()
1146
+ break
1147
+
1148
+ file_path, result = future.result()
1149
+ completed += 1
1150
+
1151
+ if isinstance(self.stats, ThreadSafeStats):
1152
+ self.stats.increment_processed()
1153
+ self.stats.remove_pending_file(str(file_path))
1154
+
1155
+ self._show_parallel_progress(completed, total, file_path, result)
1156
+
1157
+ def _show_parallel_progress(self, completed: int, total: int, file_path: Path, result: GenerationResult):
1158
+ """Show progress for parallel processing."""
1159
+ pct = (completed / total) * 100
1160
+ bar_len = 30
1161
+ filled = int(bar_len * completed / total)
1162
+ bar = "█" * filled + "░" * (bar_len - filled)
1163
+
1164
+ status = f"{Colors.GREEN}✓{Colors.NC}" if result.success else f"{Colors.RED}✗{Colors.NC}"
1165
+ name = file_path.name[:30]
1166
+ duration = f" ({result.duration:.1f}s)" if result.duration > 0 else ""
1167
+
1168
+ workers_info = ""
1169
+ if isinstance(self.stats, ThreadSafeStats):
1170
+ workers_info = f" [{self.stats.active_workers}w]"
1171
+
1172
+ print(f"\r {bar} {pct:5.1f}% ({completed}/{total}){workers_info} {status} {name}{duration} ", end="", flush=True)
1173
+
1174
+ if completed == total:
1175
+ print()
670
1176
 
671
1177
  def print_summary(self):
672
1178
  """Print processing summary."""
1179
+ stats = self.stats
1180
+
673
1181
  print()
674
- print(f"{Colors.CYAN}{'=' * 40}{Colors.NC}")
675
- print(f"{Colors.CYAN}📊 Summary{Colors.NC}")
676
- print(f"{Colors.CYAN}{'=' * 40}{Colors.NC}")
677
- print(f" Files processed: {self.processed}")
678
- print(f" Images generated: {self.generated}")
679
- print(f" Files skipped: {self.skipped}")
680
- print(f" Errors: {self.errors}")
1182
+ print(f"{Colors.CYAN}{'=' * 50}{Colors.NC}")
1183
+ print(f"{Colors.CYAN}📊 Generation Summary{Colors.NC}")
1184
+ print(f"{Colors.CYAN}{'=' * 50}{Colors.NC}")
1185
+
1186
+ if isinstance(stats, ThreadSafeStats):
1187
+ print(f" 📁 Files processed: {stats.processed}")
1188
+ print(f" 🎨 Images generated: {stats.generated}")
1189
+ print(f" ⏭️ Files skipped: {stats.skipped}")
1190
+ print(f" ❌ Errors: {stats.errors}")
1191
+
1192
+ if stats.elapsed > 0:
1193
+ print(f"\n ⏱️ Total time: {stats.elapsed_str}")
1194
+ if stats.avg_generation_time > 0:
1195
+ print(f" 📈 Avg time/image: {stats.avg_generation_time:.1f}s")
1196
+ if self.workers > 1:
1197
+ print(f" 👷 Workers used: {self.workers}")
1198
+ else:
1199
+ print(f" 📁 Files processed: {stats.processed}")
1200
+ print(f" 🎨 Images generated: {stats.generated}")
1201
+ print(f" ⏭️ Files skipped: {stats.skipped}")
1202
+ print(f" ❌ Errors: {stats.errors}")
1203
+
681
1204
  print()
682
1205
 
1206
+ if _interrupted:
1207
+ log("Processing was interrupted by user.", "warning")
1208
+
683
1209
  if self.dry_run:
684
1210
  log("This was a dry run. No actual changes were made.", "info")
685
1211
 
686
- if self.errors > 0:
1212
+ errors = stats.errors if isinstance(stats, ThreadSafeStats) else stats.errors
1213
+ if errors > 0:
687
1214
  log("Some files had errors. Check the output above.", "warning")
688
1215
 
689
1216
 
690
1217
  def main():
691
1218
  """Main entry point."""
1219
+ global _log_file
1220
+
1221
+ # Set up signal handlers for graceful shutdown
1222
+ signal.signal(signal.SIGINT, _signal_handler)
1223
+ signal.signal(signal.SIGTERM, _signal_handler)
1224
+
692
1225
  parser = argparse.ArgumentParser(
693
1226
  description="AI-powered preview image generator for Jekyll content"
694
1227
  )
@@ -747,9 +1280,39 @@ def main():
747
1280
  action='store_true',
748
1281
  help="Disable automatic assets prefix prepending"
749
1282
  )
1283
+ parser.add_argument(
1284
+ '--batch',
1285
+ type=int,
1286
+ default=0,
1287
+ help="Limit number of files to process (0 = no limit)"
1288
+ )
1289
+ parser.add_argument(
1290
+ '--log-file',
1291
+ help="Write log output to file"
1292
+ )
1293
+ parser.add_argument(
1294
+ '-w', '--workers',
1295
+ type=int,
1296
+ default=1,
1297
+ help="Number of parallel workers (default: 1 = sequential)"
1298
+ )
1299
+ parser.add_argument(
1300
+ '--rate-limit',
1301
+ type=int,
1302
+ default=5,
1303
+ help="Max API requests per minute (default: 5)"
1304
+ )
750
1305
 
751
1306
  args = parser.parse_args()
752
1307
 
1308
+ # Set up log file
1309
+ if args.log_file:
1310
+ try:
1311
+ _log_file = open(args.log_file, 'w')
1312
+ log(f"Logging to: {args.log_file}", "info")
1313
+ except IOError as e:
1314
+ log(f"Cannot open log file: {e}", "warning")
1315
+
753
1316
  # Determine project root
754
1317
  script_dir = Path(__file__).parent
755
1318
  project_root = script_dir.parent.parent
@@ -765,16 +1328,23 @@ def main():
765
1328
  dry_run=args.dry_run,
766
1329
  verbose=args.verbose,
767
1330
  force=args.force,
1331
+ batch_limit=args.batch,
1332
+ workers=args.workers,
1333
+ rate_limit=args.rate_limit,
768
1334
  )
769
1335
 
770
- print(f"{Colors.BLUE}{'=' * 40}{Colors.NC}")
1336
+ print(f"{Colors.BLUE}{'=' * 50}{Colors.NC}")
771
1337
  print(f"{Colors.BLUE}🎨 Preview Image Generator{Colors.NC}")
772
- print(f"{Colors.BLUE}{'=' * 40}{Colors.NC}")
1338
+ print(f"{Colors.BLUE}{'=' * 50}{Colors.NC}")
773
1339
  print()
774
1340
 
775
- log(f"Provider: {args.provider}", "info")
776
- log(f"Output Dir: {args.output_dir}", "info")
777
- log(f"Dry Run: {args.dry_run}", "info")
1341
+ log(f"Provider: {args.provider}", "info")
1342
+ log(f"Output Dir: {args.output_dir}", "info")
1343
+ log(f"Workers: {args.workers}", "info")
1344
+ log(f"Rate Limit: {args.rate_limit} req/min", "info")
1345
+ log(f"Dry Run: {args.dry_run}", "info")
1346
+ if args.batch > 0:
1347
+ log(f"Batch Limit: {args.batch}", "info")
778
1348
  print()
779
1349
 
780
1350
  # Process files
@@ -810,7 +1380,12 @@ def main():
810
1380
 
811
1381
  generator.print_summary()
812
1382
 
813
- return 0 if generator.errors == 0 else 1
1383
+ # Close log file
1384
+ if _log_file:
1385
+ _log_file.close()
1386
+
1387
+ errors = generator.stats.errors if isinstance(generator.stats, ThreadSafeStats) else generator.stats.errors
1388
+ return 0 if errors == 0 else 1
814
1389
 
815
1390
 
816
1391
  if __name__ == "__main__":