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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +33 -4
  4. data/_plugins/preview_image_generator.rb +258 -0
  5. data/_plugins/theme_version.rb +88 -0
  6. data/assets/images/previews/git-workflow-best-practices-for-modern-teams.png +0 -0
  7. data/scripts/README.md +443 -0
  8. data/scripts/analyze-commits.sh +313 -0
  9. data/scripts/build +115 -0
  10. data/scripts/build.sh +33 -0
  11. data/scripts/build.sh.legacy +174 -0
  12. data/scripts/example-usage.sh +102 -0
  13. data/scripts/fix-markdown-format.sh +265 -0
  14. data/scripts/gem-publish.sh +42 -0
  15. data/scripts/gem-publish.sh.legacy +700 -0
  16. data/scripts/generate-preview-images.sh +846 -0
  17. data/scripts/install-preview-generator.sh +531 -0
  18. data/scripts/lib/README.md +263 -0
  19. data/scripts/lib/changelog.sh +313 -0
  20. data/scripts/lib/common.sh +154 -0
  21. data/scripts/lib/gem.sh +226 -0
  22. data/scripts/lib/git.sh +205 -0
  23. data/scripts/lib/preview_generator.py +646 -0
  24. data/scripts/lib/test/run_tests.sh +140 -0
  25. data/scripts/lib/test/test_changelog.sh +87 -0
  26. data/scripts/lib/test/test_gem.sh +68 -0
  27. data/scripts/lib/test/test_git.sh +82 -0
  28. data/scripts/lib/test/test_validation.sh +72 -0
  29. data/scripts/lib/test/test_version.sh +96 -0
  30. data/scripts/lib/validation.sh +139 -0
  31. data/scripts/lib/version.sh +178 -0
  32. data/scripts/release +240 -0
  33. data/scripts/release.sh +33 -0
  34. data/scripts/release.sh.legacy +342 -0
  35. data/scripts/setup.sh +155 -0
  36. data/scripts/test-auto-version.sh +260 -0
  37. data/scripts/test-mermaid.sh +251 -0
  38. data/scripts/test.sh +156 -0
  39. data/scripts/version.sh +152 -0
  40. 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())