jekyll-theme-zer0 0.7.2 → 0.8.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +33 -4
- data/_plugins/preview_image_generator.rb +258 -0
- data/_plugins/theme_version.rb +88 -0
- data/assets/images/previews/git-workflow-best-practices-for-modern-teams.png +0 -0
- data/scripts/README.md +443 -0
- data/scripts/analyze-commits.sh +313 -0
- data/scripts/build +115 -0
- data/scripts/build.sh +33 -0
- data/scripts/build.sh.legacy +174 -0
- data/scripts/example-usage.sh +102 -0
- data/scripts/fix-markdown-format.sh +265 -0
- data/scripts/gem-publish.sh +42 -0
- data/scripts/gem-publish.sh.legacy +700 -0
- data/scripts/generate-preview-images.sh +846 -0
- data/scripts/install-preview-generator.sh +531 -0
- data/scripts/lib/README.md +263 -0
- data/scripts/lib/changelog.sh +313 -0
- data/scripts/lib/common.sh +154 -0
- data/scripts/lib/gem.sh +226 -0
- data/scripts/lib/git.sh +205 -0
- data/scripts/lib/preview_generator.py +646 -0
- data/scripts/lib/test/run_tests.sh +140 -0
- data/scripts/lib/test/test_changelog.sh +87 -0
- data/scripts/lib/test/test_gem.sh +68 -0
- data/scripts/lib/test/test_git.sh +82 -0
- data/scripts/lib/test/test_validation.sh +72 -0
- data/scripts/lib/test/test_version.sh +96 -0
- data/scripts/lib/validation.sh +139 -0
- data/scripts/lib/version.sh +178 -0
- data/scripts/release +240 -0
- data/scripts/release.sh +33 -0
- data/scripts/release.sh.legacy +342 -0
- data/scripts/setup.sh +155 -0
- data/scripts/test-auto-version.sh +260 -0
- data/scripts/test-mermaid.sh +251 -0
- data/scripts/test.sh +156 -0
- data/scripts/version.sh +152 -0
- metadata +37 -1
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Preview Image Generator - AI-powered preview image generation for Jekyll content.
|
|
4
|
+
|
|
5
|
+
This module provides a Python-based interface for generating preview images
|
|
6
|
+
using various AI providers (OpenAI DALL-E, Stability AI, etc.).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 preview_generator.py --file path/to/post.md
|
|
10
|
+
python3 preview_generator.py --collection posts --dry-run
|
|
11
|
+
python3 preview_generator.py --list-missing
|
|
12
|
+
|
|
13
|
+
Dependencies:
|
|
14
|
+
pip install openai pyyaml requests pillow
|
|
15
|
+
|
|
16
|
+
Environment Variables:
|
|
17
|
+
OPENAI_API_KEY - Required for OpenAI provider
|
|
18
|
+
STABILITY_API_KEY - Required for Stability AI provider
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional, List, Dict, Any
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
# Optional imports with fallback
|
|
32
|
+
try:
|
|
33
|
+
import requests
|
|
34
|
+
HAS_REQUESTS = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
HAS_REQUESTS = False
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
from openai import OpenAI
|
|
40
|
+
HAS_OPENAI = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
HAS_OPENAI = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ContentFile:
|
|
47
|
+
"""Represents a Jekyll content file with its metadata."""
|
|
48
|
+
path: Path
|
|
49
|
+
title: str
|
|
50
|
+
description: str
|
|
51
|
+
categories: List[str]
|
|
52
|
+
tags: List[str]
|
|
53
|
+
preview: Optional[str]
|
|
54
|
+
content: str
|
|
55
|
+
front_matter: Dict[str, Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class GenerationResult:
|
|
60
|
+
"""Result of an image generation attempt."""
|
|
61
|
+
success: bool
|
|
62
|
+
image_path: Optional[str]
|
|
63
|
+
preview_url: Optional[str]
|
|
64
|
+
error: Optional[str]
|
|
65
|
+
prompt_used: Optional[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Colors:
|
|
69
|
+
"""Terminal colors for output."""
|
|
70
|
+
RED = '\033[0;31m'
|
|
71
|
+
GREEN = '\033[0;32m'
|
|
72
|
+
YELLOW = '\033[1;33m'
|
|
73
|
+
BLUE = '\033[0;34m'
|
|
74
|
+
CYAN = '\033[0;36m'
|
|
75
|
+
PURPLE = '\033[0;35m'
|
|
76
|
+
NC = '\033[0m' # No Color
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def log(msg: str, level: str = "info"):
|
|
80
|
+
"""Print formatted log message."""
|
|
81
|
+
colors = {
|
|
82
|
+
"info": Colors.BLUE,
|
|
83
|
+
"success": Colors.GREEN,
|
|
84
|
+
"warning": Colors.YELLOW,
|
|
85
|
+
"error": Colors.RED,
|
|
86
|
+
"debug": Colors.PURPLE,
|
|
87
|
+
"step": Colors.CYAN,
|
|
88
|
+
}
|
|
89
|
+
color = colors.get(level, Colors.NC)
|
|
90
|
+
prefix = f"[{level.upper()}]"
|
|
91
|
+
print(f"{color}{prefix}{Colors.NC} {msg}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PreviewGenerator:
|
|
95
|
+
"""AI-powered preview image generator for Jekyll content."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
project_root: Path,
|
|
100
|
+
provider: str = "openai",
|
|
101
|
+
output_dir: str = "assets/images/previews",
|
|
102
|
+
image_style: str = "digital art, professional blog illustration",
|
|
103
|
+
image_size: str = "1024x1024",
|
|
104
|
+
dry_run: bool = False,
|
|
105
|
+
verbose: bool = False,
|
|
106
|
+
force: bool = False,
|
|
107
|
+
):
|
|
108
|
+
self.project_root = project_root
|
|
109
|
+
self.provider = provider
|
|
110
|
+
self.output_dir = project_root / output_dir
|
|
111
|
+
self.image_style = image_style
|
|
112
|
+
self.image_size = image_size
|
|
113
|
+
self.dry_run = dry_run
|
|
114
|
+
self.verbose = verbose
|
|
115
|
+
self.force = force
|
|
116
|
+
|
|
117
|
+
# Statistics
|
|
118
|
+
self.processed = 0
|
|
119
|
+
self.generated = 0
|
|
120
|
+
self.skipped = 0
|
|
121
|
+
self.errors = 0
|
|
122
|
+
|
|
123
|
+
# Ensure output directory exists
|
|
124
|
+
if not dry_run:
|
|
125
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
def debug(self, msg: str):
|
|
128
|
+
"""Print debug message if verbose mode is enabled."""
|
|
129
|
+
if self.verbose:
|
|
130
|
+
log(msg, "debug")
|
|
131
|
+
|
|
132
|
+
def parse_front_matter(self, file_path: Path) -> Optional[ContentFile]:
|
|
133
|
+
"""Parse front matter and content from a markdown file."""
|
|
134
|
+
try:
|
|
135
|
+
content = file_path.read_text(encoding='utf-8')
|
|
136
|
+
except Exception as e:
|
|
137
|
+
log(f"Failed to read {file_path}: {e}", "error")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
# Extract front matter
|
|
141
|
+
fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n(.*)$', content, re.DOTALL)
|
|
142
|
+
if not fm_match:
|
|
143
|
+
self.debug(f"No front matter found in: {file_path}")
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
front_matter = yaml.safe_load(fm_match.group(1))
|
|
148
|
+
post_content = fm_match.group(2)
|
|
149
|
+
except yaml.YAMLError as e:
|
|
150
|
+
log(f"Failed to parse YAML in {file_path}: {e}", "error")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
if not front_matter:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Extract fields with defaults
|
|
157
|
+
categories = front_matter.get('categories', [])
|
|
158
|
+
if isinstance(categories, str):
|
|
159
|
+
categories = [categories]
|
|
160
|
+
|
|
161
|
+
tags = front_matter.get('tags', [])
|
|
162
|
+
if isinstance(tags, str):
|
|
163
|
+
tags = [tags]
|
|
164
|
+
|
|
165
|
+
return ContentFile(
|
|
166
|
+
path=file_path,
|
|
167
|
+
title=front_matter.get('title', ''),
|
|
168
|
+
description=front_matter.get('description', ''),
|
|
169
|
+
categories=categories,
|
|
170
|
+
tags=tags,
|
|
171
|
+
preview=front_matter.get('preview'),
|
|
172
|
+
content=post_content,
|
|
173
|
+
front_matter=front_matter,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def check_preview_exists(self, preview_path: Optional[str]) -> bool:
|
|
177
|
+
"""Check if the preview image file exists."""
|
|
178
|
+
if not preview_path:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
# Handle absolute and relative paths
|
|
182
|
+
clean_path = preview_path.lstrip('/')
|
|
183
|
+
|
|
184
|
+
# Check direct path
|
|
185
|
+
full_path = self.project_root / clean_path
|
|
186
|
+
if full_path.exists():
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
# Check in assets directory
|
|
190
|
+
assets_path = self.project_root / 'assets' / clean_path
|
|
191
|
+
if assets_path.exists():
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def generate_prompt(self, content: ContentFile) -> str:
|
|
197
|
+
"""Generate an AI prompt from content metadata."""
|
|
198
|
+
prompt_parts = [
|
|
199
|
+
f"Create a professional blog preview image for an article titled '{content.title}'."
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
if content.description:
|
|
203
|
+
prompt_parts.append(f"The article is about: {content.description}.")
|
|
204
|
+
|
|
205
|
+
if content.categories:
|
|
206
|
+
prompt_parts.append(f"Categories: {', '.join(content.categories)}.")
|
|
207
|
+
|
|
208
|
+
if content.tags:
|
|
209
|
+
prompt_parts.append(f"Tags: {', '.join(content.tags[:5])}.") # Limit tags
|
|
210
|
+
|
|
211
|
+
# Add content excerpt (first 500 chars)
|
|
212
|
+
content_excerpt = content.content[:500].strip()
|
|
213
|
+
if content_excerpt:
|
|
214
|
+
# Remove markdown formatting
|
|
215
|
+
clean_content = re.sub(r'[#*`\[\]()]', '', content_excerpt)
|
|
216
|
+
clean_content = re.sub(r'\n+', ' ', clean_content)
|
|
217
|
+
prompt_parts.append(f"Key themes: {clean_content}")
|
|
218
|
+
|
|
219
|
+
# Add style instructions
|
|
220
|
+
prompt_parts.extend([
|
|
221
|
+
f"Style: {self.image_style}.",
|
|
222
|
+
"The image should be suitable as a blog header/preview image.",
|
|
223
|
+
"Clean composition, professional look, visually appealing.",
|
|
224
|
+
"No text or letters in the image.",
|
|
225
|
+
])
|
|
226
|
+
|
|
227
|
+
return ' '.join(prompt_parts)
|
|
228
|
+
|
|
229
|
+
def generate_filename(self, title: str) -> str:
|
|
230
|
+
"""Generate a safe filename from title."""
|
|
231
|
+
# Convert to lowercase and replace special chars
|
|
232
|
+
safe_name = re.sub(r'[^a-z0-9]+', '-', title.lower())
|
|
233
|
+
safe_name = re.sub(r'-+', '-', safe_name).strip('-')
|
|
234
|
+
return safe_name[:50] # Limit length
|
|
235
|
+
|
|
236
|
+
def generate_image_openai(self, prompt: str, output_path: Path) -> GenerationResult:
|
|
237
|
+
"""Generate image using OpenAI DALL-E."""
|
|
238
|
+
if not HAS_OPENAI:
|
|
239
|
+
return GenerationResult(
|
|
240
|
+
success=False,
|
|
241
|
+
image_path=None,
|
|
242
|
+
preview_url=None,
|
|
243
|
+
error="openai package not installed. Run: pip install openai",
|
|
244
|
+
prompt_used=prompt,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
api_key = os.environ.get('OPENAI_API_KEY')
|
|
248
|
+
if not api_key:
|
|
249
|
+
return GenerationResult(
|
|
250
|
+
success=False,
|
|
251
|
+
image_path=None,
|
|
252
|
+
preview_url=None,
|
|
253
|
+
error="OPENAI_API_KEY environment variable not set",
|
|
254
|
+
prompt_used=prompt,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
client = OpenAI(api_key=api_key)
|
|
259
|
+
|
|
260
|
+
self.debug(f"Generating with prompt: {prompt[:200]}...")
|
|
261
|
+
|
|
262
|
+
# Parse size
|
|
263
|
+
size_map = {
|
|
264
|
+
"1024x1024": "1024x1024",
|
|
265
|
+
"1792x1024": "1792x1024",
|
|
266
|
+
"1024x1792": "1024x1792",
|
|
267
|
+
}
|
|
268
|
+
size = size_map.get(self.image_size, "1024x1024")
|
|
269
|
+
|
|
270
|
+
response = client.images.generate(
|
|
271
|
+
model="dall-e-3",
|
|
272
|
+
prompt=prompt,
|
|
273
|
+
size=size,
|
|
274
|
+
quality="standard",
|
|
275
|
+
n=1,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
image_url = response.data[0].url
|
|
279
|
+
|
|
280
|
+
# Download image
|
|
281
|
+
if not HAS_REQUESTS:
|
|
282
|
+
return GenerationResult(
|
|
283
|
+
success=False,
|
|
284
|
+
image_path=None,
|
|
285
|
+
preview_url=image_url,
|
|
286
|
+
error="requests package not installed. Run: pip install requests",
|
|
287
|
+
prompt_used=prompt,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
img_response = requests.get(image_url)
|
|
291
|
+
img_response.raise_for_status()
|
|
292
|
+
|
|
293
|
+
output_path.write_bytes(img_response.content)
|
|
294
|
+
|
|
295
|
+
return GenerationResult(
|
|
296
|
+
success=True,
|
|
297
|
+
image_path=str(output_path),
|
|
298
|
+
preview_url=str(output_path.relative_to(self.project_root)),
|
|
299
|
+
error=None,
|
|
300
|
+
prompt_used=prompt,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return GenerationResult(
|
|
305
|
+
success=False,
|
|
306
|
+
image_path=None,
|
|
307
|
+
preview_url=None,
|
|
308
|
+
error=str(e),
|
|
309
|
+
prompt_used=prompt,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def generate_image_stability(self, prompt: str, output_path: Path) -> GenerationResult:
|
|
313
|
+
"""Generate image using Stability AI."""
|
|
314
|
+
if not HAS_REQUESTS:
|
|
315
|
+
return GenerationResult(
|
|
316
|
+
success=False,
|
|
317
|
+
image_path=None,
|
|
318
|
+
preview_url=None,
|
|
319
|
+
error="requests package not installed",
|
|
320
|
+
prompt_used=prompt,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
api_key = os.environ.get('STABILITY_API_KEY')
|
|
324
|
+
if not api_key:
|
|
325
|
+
return GenerationResult(
|
|
326
|
+
success=False,
|
|
327
|
+
image_path=None,
|
|
328
|
+
preview_url=None,
|
|
329
|
+
error="STABILITY_API_KEY environment variable not set",
|
|
330
|
+
prompt_used=prompt,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
response = requests.post(
|
|
335
|
+
"https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image",
|
|
336
|
+
headers={
|
|
337
|
+
"Authorization": f"Bearer {api_key}",
|
|
338
|
+
"Content-Type": "application/json",
|
|
339
|
+
},
|
|
340
|
+
json={
|
|
341
|
+
"text_prompts": [{"text": prompt}],
|
|
342
|
+
"cfg_scale": 7,
|
|
343
|
+
"height": 1024,
|
|
344
|
+
"width": 1024,
|
|
345
|
+
"samples": 1,
|
|
346
|
+
"steps": 30,
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
response.raise_for_status()
|
|
350
|
+
|
|
351
|
+
data = response.json()
|
|
352
|
+
|
|
353
|
+
if 'artifacts' not in data or not data['artifacts']:
|
|
354
|
+
return GenerationResult(
|
|
355
|
+
success=False,
|
|
356
|
+
image_path=None,
|
|
357
|
+
preview_url=None,
|
|
358
|
+
error="No image data in response",
|
|
359
|
+
prompt_used=prompt,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
import base64
|
|
363
|
+
image_data = base64.b64decode(data['artifacts'][0]['base64'])
|
|
364
|
+
output_path.write_bytes(image_data)
|
|
365
|
+
|
|
366
|
+
return GenerationResult(
|
|
367
|
+
success=True,
|
|
368
|
+
image_path=str(output_path),
|
|
369
|
+
preview_url=str(output_path.relative_to(self.project_root)),
|
|
370
|
+
error=None,
|
|
371
|
+
prompt_used=prompt,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
except Exception as e:
|
|
375
|
+
return GenerationResult(
|
|
376
|
+
success=False,
|
|
377
|
+
image_path=None,
|
|
378
|
+
preview_url=None,
|
|
379
|
+
error=str(e),
|
|
380
|
+
prompt_used=prompt,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def generate_image(self, prompt: str, output_path: Path) -> GenerationResult:
|
|
384
|
+
"""Generate image using configured provider."""
|
|
385
|
+
if self.provider == "openai":
|
|
386
|
+
return self.generate_image_openai(prompt, output_path)
|
|
387
|
+
elif self.provider == "stability":
|
|
388
|
+
return self.generate_image_stability(prompt, output_path)
|
|
389
|
+
else:
|
|
390
|
+
return GenerationResult(
|
|
391
|
+
success=False,
|
|
392
|
+
image_path=None,
|
|
393
|
+
preview_url=None,
|
|
394
|
+
error=f"Unknown provider: {self.provider}",
|
|
395
|
+
prompt_used=prompt,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def update_front_matter(self, file_path: Path, preview_path: str) -> bool:
|
|
399
|
+
"""Update the front matter with new preview path."""
|
|
400
|
+
try:
|
|
401
|
+
content = file_path.read_text(encoding='utf-8')
|
|
402
|
+
|
|
403
|
+
# Check if preview field exists
|
|
404
|
+
if re.search(r'^preview:', content, re.MULTILINE):
|
|
405
|
+
# Update existing preview
|
|
406
|
+
new_content = re.sub(
|
|
407
|
+
r'^preview:.*$',
|
|
408
|
+
f'preview: {preview_path}',
|
|
409
|
+
content,
|
|
410
|
+
flags=re.MULTILINE,
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
# Add preview after description or title
|
|
414
|
+
if 'description:' in content:
|
|
415
|
+
new_content = re.sub(
|
|
416
|
+
r'(^description:.*$)',
|
|
417
|
+
f'\\1\npreview: {preview_path}',
|
|
418
|
+
content,
|
|
419
|
+
flags=re.MULTILINE,
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
new_content = re.sub(
|
|
423
|
+
r'(^title:.*$)',
|
|
424
|
+
f'\\1\npreview: {preview_path}',
|
|
425
|
+
content,
|
|
426
|
+
flags=re.MULTILINE,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
file_path.write_text(new_content, encoding='utf-8')
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
log(f"Failed to update front matter: {e}", "error")
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
def process_file(self, file_path: Path, list_only: bool = False) -> bool:
|
|
437
|
+
"""Process a single content file."""
|
|
438
|
+
self.processed += 1
|
|
439
|
+
|
|
440
|
+
content = self.parse_front_matter(file_path)
|
|
441
|
+
if not content:
|
|
442
|
+
self.skipped += 1
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
self.debug(f"Processing: {content.title}")
|
|
446
|
+
|
|
447
|
+
# Check if preview exists
|
|
448
|
+
if content.preview and self.check_preview_exists(content.preview):
|
|
449
|
+
if not self.force:
|
|
450
|
+
self.debug(f"Preview exists: {content.preview}")
|
|
451
|
+
self.skipped += 1
|
|
452
|
+
return True
|
|
453
|
+
else:
|
|
454
|
+
log(f"Force mode: regenerating preview for {content.title}", "info")
|
|
455
|
+
|
|
456
|
+
# List only mode
|
|
457
|
+
if list_only:
|
|
458
|
+
print(f"{Colors.YELLOW}Missing preview:{Colors.NC} {file_path}")
|
|
459
|
+
print(f" Title: {content.title}")
|
|
460
|
+
if content.preview:
|
|
461
|
+
print(f" Current preview (not found): {content.preview}")
|
|
462
|
+
print()
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
log(f"Generating preview for: {content.title}", "info")
|
|
466
|
+
|
|
467
|
+
# Generate filename and paths
|
|
468
|
+
safe_filename = self.generate_filename(content.title)
|
|
469
|
+
output_file = self.output_dir / f"{safe_filename}.png"
|
|
470
|
+
preview_url = f"/{self.output_dir.relative_to(self.project_root)}/{safe_filename}.png"
|
|
471
|
+
|
|
472
|
+
# Generate prompt
|
|
473
|
+
prompt = self.generate_prompt(content)
|
|
474
|
+
self.debug(f"Prompt: {prompt[:300]}...")
|
|
475
|
+
|
|
476
|
+
# Dry run mode
|
|
477
|
+
if self.dry_run:
|
|
478
|
+
log(f"[DRY RUN] Would generate image:", "info")
|
|
479
|
+
print(f" Output: {output_file}")
|
|
480
|
+
print(f" Preview URL: {preview_url}")
|
|
481
|
+
print(f" Prompt: {prompt[:200]}...")
|
|
482
|
+
print()
|
|
483
|
+
self.generated += 1
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
# Generate image
|
|
487
|
+
result = self.generate_image(prompt, output_file)
|
|
488
|
+
|
|
489
|
+
if result.success:
|
|
490
|
+
# Update front matter
|
|
491
|
+
if self.update_front_matter(file_path, preview_url):
|
|
492
|
+
log(f"Updated front matter with: {preview_url}", "success")
|
|
493
|
+
self.generated += 1
|
|
494
|
+
return True
|
|
495
|
+
else:
|
|
496
|
+
self.errors += 1
|
|
497
|
+
return False
|
|
498
|
+
else:
|
|
499
|
+
log(f"Failed to generate image: {result.error}", "warning")
|
|
500
|
+
self.errors += 1
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
def process_collection(self, collection_path: Path, list_only: bool = False):
|
|
504
|
+
"""Process all markdown files in a collection."""
|
|
505
|
+
if not collection_path.exists():
|
|
506
|
+
log(f"Collection not found: {collection_path}", "warning")
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
for md_file in collection_path.rglob("*.md"):
|
|
510
|
+
self.process_file(md_file, list_only)
|
|
511
|
+
|
|
512
|
+
def print_summary(self):
|
|
513
|
+
"""Print processing summary."""
|
|
514
|
+
print()
|
|
515
|
+
print(f"{Colors.CYAN}{'=' * 40}{Colors.NC}")
|
|
516
|
+
print(f"{Colors.CYAN}📊 Summary{Colors.NC}")
|
|
517
|
+
print(f"{Colors.CYAN}{'=' * 40}{Colors.NC}")
|
|
518
|
+
print(f" Files processed: {self.processed}")
|
|
519
|
+
print(f" Images generated: {self.generated}")
|
|
520
|
+
print(f" Files skipped: {self.skipped}")
|
|
521
|
+
print(f" Errors: {self.errors}")
|
|
522
|
+
print()
|
|
523
|
+
|
|
524
|
+
if self.dry_run:
|
|
525
|
+
log("This was a dry run. No actual changes were made.", "info")
|
|
526
|
+
|
|
527
|
+
if self.errors > 0:
|
|
528
|
+
log("Some files had errors. Check the output above.", "warning")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def main():
|
|
532
|
+
"""Main entry point."""
|
|
533
|
+
parser = argparse.ArgumentParser(
|
|
534
|
+
description="AI-powered preview image generator for Jekyll content"
|
|
535
|
+
)
|
|
536
|
+
parser.add_argument(
|
|
537
|
+
'-f', '--file',
|
|
538
|
+
help="Process a specific file only"
|
|
539
|
+
)
|
|
540
|
+
parser.add_argument(
|
|
541
|
+
'-c', '--collection',
|
|
542
|
+
choices=['posts', 'quickstart', 'docs', 'all'],
|
|
543
|
+
help="Process specific collection"
|
|
544
|
+
)
|
|
545
|
+
parser.add_argument(
|
|
546
|
+
'-p', '--provider',
|
|
547
|
+
choices=['openai', 'stability'],
|
|
548
|
+
default='openai',
|
|
549
|
+
help="AI provider for image generation"
|
|
550
|
+
)
|
|
551
|
+
parser.add_argument(
|
|
552
|
+
'-d', '--dry-run',
|
|
553
|
+
action='store_true',
|
|
554
|
+
help="Preview without making changes"
|
|
555
|
+
)
|
|
556
|
+
parser.add_argument(
|
|
557
|
+
'-v', '--verbose',
|
|
558
|
+
action='store_true',
|
|
559
|
+
help="Enable verbose output"
|
|
560
|
+
)
|
|
561
|
+
parser.add_argument(
|
|
562
|
+
'--force',
|
|
563
|
+
action='store_true',
|
|
564
|
+
help="Regenerate images even if preview exists"
|
|
565
|
+
)
|
|
566
|
+
parser.add_argument(
|
|
567
|
+
'--list-missing',
|
|
568
|
+
action='store_true',
|
|
569
|
+
help="Only list files with missing previews"
|
|
570
|
+
)
|
|
571
|
+
parser.add_argument(
|
|
572
|
+
'--output-dir',
|
|
573
|
+
default='assets/images/previews',
|
|
574
|
+
help="Output directory for generated images"
|
|
575
|
+
)
|
|
576
|
+
parser.add_argument(
|
|
577
|
+
'--style',
|
|
578
|
+
default='digital art, professional blog illustration, clean design',
|
|
579
|
+
help="Image style prompt"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
args = parser.parse_args()
|
|
583
|
+
|
|
584
|
+
# Determine project root
|
|
585
|
+
script_dir = Path(__file__).parent
|
|
586
|
+
project_root = script_dir.parent.parent
|
|
587
|
+
|
|
588
|
+
# Initialize generator
|
|
589
|
+
generator = PreviewGenerator(
|
|
590
|
+
project_root=project_root,
|
|
591
|
+
provider=args.provider,
|
|
592
|
+
output_dir=args.output_dir,
|
|
593
|
+
image_style=args.style,
|
|
594
|
+
dry_run=args.dry_run,
|
|
595
|
+
verbose=args.verbose,
|
|
596
|
+
force=args.force,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
print(f"{Colors.BLUE}{'=' * 40}{Colors.NC}")
|
|
600
|
+
print(f"{Colors.BLUE}🎨 Preview Image Generator{Colors.NC}")
|
|
601
|
+
print(f"{Colors.BLUE}{'=' * 40}{Colors.NC}")
|
|
602
|
+
print()
|
|
603
|
+
|
|
604
|
+
log(f"Provider: {args.provider}", "info")
|
|
605
|
+
log(f"Output Dir: {args.output_dir}", "info")
|
|
606
|
+
log(f"Dry Run: {args.dry_run}", "info")
|
|
607
|
+
print()
|
|
608
|
+
|
|
609
|
+
# Process files
|
|
610
|
+
if args.file:
|
|
611
|
+
file_path = Path(args.file)
|
|
612
|
+
if not file_path.is_absolute():
|
|
613
|
+
file_path = project_root / file_path
|
|
614
|
+
generator.process_file(file_path, args.list_missing)
|
|
615
|
+
elif args.collection:
|
|
616
|
+
collections = {
|
|
617
|
+
'posts': project_root / 'pages' / '_posts',
|
|
618
|
+
'quickstart': project_root / 'pages' / '_quickstart',
|
|
619
|
+
'docs': project_root / 'pages' / '_docs',
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if args.collection == 'all':
|
|
623
|
+
for name, path in collections.items():
|
|
624
|
+
log(f"Processing {name}...", "step")
|
|
625
|
+
generator.process_collection(path, args.list_missing)
|
|
626
|
+
else:
|
|
627
|
+
log(f"Processing {args.collection}...", "step")
|
|
628
|
+
generator.process_collection(collections[args.collection], args.list_missing)
|
|
629
|
+
else:
|
|
630
|
+
# Default: process all
|
|
631
|
+
collections = [
|
|
632
|
+
project_root / 'pages' / '_posts',
|
|
633
|
+
project_root / 'pages' / '_quickstart',
|
|
634
|
+
project_root / 'pages' / '_docs',
|
|
635
|
+
]
|
|
636
|
+
for collection in collections:
|
|
637
|
+
log(f"Processing {collection.name}...", "step")
|
|
638
|
+
generator.process_collection(collection, args.list_missing)
|
|
639
|
+
|
|
640
|
+
generator.print_summary()
|
|
641
|
+
|
|
642
|
+
return 0 if generator.errors == 0 else 1
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
if __name__ == "__main__":
|
|
646
|
+
sys.exit(main())
|