jekyll-theme-zer0 0.10.6 → 0.15.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -0
  3. data/README.md +24 -8
  4. data/_data/navigation/about.yml +39 -11
  5. data/_data/navigation/docs.yml +53 -23
  6. data/_data/navigation/home.yml +27 -9
  7. data/_data/navigation/main.yml +27 -8
  8. data/_data/navigation/posts.yml +22 -6
  9. data/_data/navigation/quickstart.yml +8 -3
  10. data/_includes/README.md +2 -0
  11. data/_includes/components/js-cdn.html +4 -1
  12. data/_includes/components/post-card.html +2 -11
  13. data/_includes/components/preview-image.html +32 -0
  14. data/_includes/content/intro.html +5 -6
  15. data/_includes/core/header.html +14 -0
  16. data/_includes/navigation/sidebar-categories.html +20 -9
  17. data/_includes/navigation/sidebar-folders.html +8 -7
  18. data/_includes/navigation/sidebar-right.html +16 -10
  19. data/_layouts/blog.html +15 -45
  20. data/_layouts/category.html +4 -24
  21. data/_layouts/collection.html +2 -12
  22. data/_layouts/default.html +1 -1
  23. data/_layouts/journals.html +2 -12
  24. data/_layouts/notebook.html +296 -0
  25. data/_sass/core/_docs.scss +1 -1
  26. data/_sass/custom.scss +54 -17
  27. data/_sass/notebooks.scss +458 -0
  28. data/assets/images/notebooks/test-notebook_files/test-notebook_4_0.png +0 -0
  29. data/assets/js/sidebar.js +511 -0
  30. data/scripts/README.md +128 -105
  31. data/scripts/analyze-commits.sh +9 -311
  32. data/scripts/bin/build +22 -22
  33. data/scripts/build +7 -111
  34. data/scripts/convert-notebooks.sh +415 -0
  35. data/scripts/features/validate_preview_urls.py +500 -0
  36. data/scripts/fix-markdown-format.sh +8 -262
  37. data/scripts/generate-preview-images.sh +7 -787
  38. data/scripts/install-preview-generator.sh +8 -528
  39. data/scripts/lib/README.md +5 -5
  40. data/scripts/lib/gem.sh +19 -7
  41. data/scripts/release +7 -236
  42. data/scripts/setup.sh +9 -153
  43. data/scripts/test-auto-version.sh +7 -256
  44. data/scripts/test-mermaid.sh +7 -287
  45. data/scripts/test.sh +9 -154
  46. metadata +9 -10
  47. data/scripts/features/preview_generator.py +0 -646
  48. data/scripts/lib/test/run_tests.sh +0 -140
  49. data/scripts/lib/test/test_changelog.sh +0 -87
  50. data/scripts/lib/test/test_gem.sh +0 -68
  51. data/scripts/lib/test/test_git.sh +0 -82
  52. data/scripts/lib/test/test_validation.sh +0 -72
  53. data/scripts/lib/test/test_version.sh +0 -96
  54. data/scripts/version.sh +0 -178
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Preview URL Validator - Validates all preview image URLs in Jekyll content frontmatter.
4
+
5
+ This script checks:
6
+ 1. URL format validity (must start with /, be absolute path)
7
+ 2. File extension validity (.png, .jpg, .jpeg, .gif, .webp, .svg)
8
+ 3. File existence (actual image file exists on disk)
9
+ 4. Detects empty, null, or malformed preview values
10
+
11
+ Usage:
12
+ python3 validate_preview_urls.py
13
+ python3 validate_preview_urls.py --verbose
14
+ python3 validate_preview_urls.py --fix-suggestions
15
+
16
+ Exit codes:
17
+ 0 - All previews valid
18
+ 1 - Validation errors found
19
+ """
20
+
21
+ import argparse
22
+ import os
23
+ import re
24
+ import sys
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import List, Dict, Any, Optional, Tuple
28
+ import yaml
29
+
30
+ # Terminal colors
31
+ class Colors:
32
+ RED = '\033[0;31m'
33
+ GREEN = '\033[0;32m'
34
+ YELLOW = '\033[1;33m'
35
+ BLUE = '\033[0;34m'
36
+ CYAN = '\033[0;36m'
37
+ PURPLE = '\033[0;35m'
38
+ BOLD = '\033[1m'
39
+ NC = '\033[0m' # No Color
40
+
41
+
42
+ @dataclass
43
+ class ValidationError:
44
+ """Represents a validation error for a preview URL."""
45
+ file_path: str
46
+ preview_value: str
47
+ error_type: str
48
+ message: str
49
+ suggestion: Optional[str] = None
50
+
51
+
52
+ @dataclass
53
+ class ValidationResult:
54
+ """Result of validating a single file."""
55
+ file_path: str
56
+ title: str
57
+ preview_value: Optional[str]
58
+ is_valid: bool
59
+ errors: List[ValidationError] = field(default_factory=list)
60
+
61
+
62
+ @dataclass
63
+ class ValidationSummary:
64
+ """Summary of all validation results."""
65
+ total_files: int = 0
66
+ files_with_preview: int = 0
67
+ files_without_preview: int = 0
68
+ files_with_null_preview: int = 0
69
+ valid_previews: int = 0
70
+ invalid_previews: int = 0
71
+ missing_files: int = 0
72
+ format_errors: int = 0
73
+ results: List[ValidationResult] = field(default_factory=list)
74
+
75
+
76
+ class PreviewURLValidator:
77
+ """Validates preview image URLs in Jekyll content frontmatter."""
78
+
79
+ # Valid image extensions
80
+ VALID_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
81
+
82
+ # Content directories to scan
83
+ CONTENT_DIRS = [
84
+ 'pages/_posts',
85
+ 'pages/_docs',
86
+ 'pages/_quickstart',
87
+ 'pages/_about',
88
+ 'pages/_quests',
89
+ ]
90
+
91
+ def __init__(self, project_root: Path, verbose: bool = False):
92
+ self.project_root = project_root
93
+ self.verbose = verbose
94
+ self.summary = ValidationSummary()
95
+
96
+ def log(self, msg: str, level: str = "info"):
97
+ """Print formatted log message."""
98
+ colors = {
99
+ "info": Colors.BLUE,
100
+ "success": Colors.GREEN,
101
+ "warning": Colors.YELLOW,
102
+ "error": Colors.RED,
103
+ "debug": Colors.PURPLE,
104
+ }
105
+ color = colors.get(level, Colors.NC)
106
+ prefix = f"[{level.upper()}]"
107
+ print(f"{color}{prefix}{Colors.NC} {msg}")
108
+
109
+ def debug(self, msg: str):
110
+ """Print debug message if verbose mode enabled."""
111
+ if self.verbose:
112
+ self.log(msg, "debug")
113
+
114
+ def parse_front_matter(self, file_path: Path) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
115
+ """Parse front matter from a markdown file.
116
+
117
+ Returns:
118
+ Tuple of (front_matter_dict, raw_preview_value)
119
+ """
120
+ try:
121
+ content = file_path.read_text(encoding='utf-8')
122
+ except Exception as e:
123
+ self.log(f"Failed to read {file_path}: {e}", "error")
124
+ return None, None
125
+
126
+ # Extract front matter
127
+ fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
128
+ if not fm_match:
129
+ return None, None
130
+
131
+ fm_raw = fm_match.group(1)
132
+
133
+ # Extract raw preview value for better error messages
134
+ preview_match = re.search(r'^preview:\s*(.*)$', fm_raw, re.MULTILINE)
135
+ raw_preview = preview_match.group(1).strip() if preview_match else None
136
+
137
+ try:
138
+ front_matter = yaml.safe_load(fm_raw)
139
+ return front_matter, raw_preview
140
+ except yaml.YAMLError as e:
141
+ self.log(f"YAML parse error in {file_path}: {e}", "error")
142
+ return None, raw_preview
143
+
144
+ def validate_url_format(self, preview: str, file_path: str) -> List[ValidationError]:
145
+ """Validate the format of a preview URL."""
146
+ errors = []
147
+
148
+ # Check if empty or whitespace only
149
+ if not preview or not preview.strip():
150
+ errors.append(ValidationError(
151
+ file_path=file_path,
152
+ preview_value=repr(preview),
153
+ error_type="EMPTY_VALUE",
154
+ message="Preview value is empty or whitespace only",
155
+ suggestion="Set a valid image path or remove the preview field"
156
+ ))
157
+ return errors
158
+
159
+ preview = preview.strip()
160
+
161
+ # Check for 'null' string (should be YAML null, not string)
162
+ if preview.lower() == 'null':
163
+ errors.append(ValidationError(
164
+ file_path=file_path,
165
+ preview_value=preview,
166
+ error_type="STRING_NULL",
167
+ message="Preview is string 'null' instead of YAML null",
168
+ suggestion="Use 'preview: null' (without quotes) or 'preview: ~'"
169
+ ))
170
+ return errors
171
+
172
+ # Check if starts with /
173
+ if not preview.startswith('/'):
174
+ errors.append(ValidationError(
175
+ file_path=file_path,
176
+ preview_value=preview,
177
+ error_type="RELATIVE_PATH",
178
+ message="Preview URL should start with /",
179
+ suggestion=f"Use '/{preview}' for absolute path"
180
+ ))
181
+
182
+ # Check for valid extension
183
+ ext = Path(preview).suffix.lower()
184
+ if ext not in self.VALID_EXTENSIONS:
185
+ if ext:
186
+ errors.append(ValidationError(
187
+ file_path=file_path,
188
+ preview_value=preview,
189
+ error_type="INVALID_EXTENSION",
190
+ message=f"Invalid image extension: {ext}",
191
+ suggestion=f"Valid extensions: {', '.join(sorted(self.VALID_EXTENSIONS))}"
192
+ ))
193
+ else:
194
+ errors.append(ValidationError(
195
+ file_path=file_path,
196
+ preview_value=preview,
197
+ error_type="NO_EXTENSION",
198
+ message="Preview URL has no file extension",
199
+ suggestion="Add an image extension like .png, .jpg, .webp"
200
+ ))
201
+
202
+ # Check for suspicious patterns
203
+ if ' ' in preview:
204
+ errors.append(ValidationError(
205
+ file_path=file_path,
206
+ preview_value=preview,
207
+ error_type="DOUBLE_SPACE",
208
+ message="Preview URL contains double spaces",
209
+ suggestion="Remove extra spaces from path"
210
+ ))
211
+
212
+ if '\n' in preview or '\t' in preview:
213
+ errors.append(ValidationError(
214
+ file_path=file_path,
215
+ preview_value=repr(preview),
216
+ error_type="WHITESPACE_CHARS",
217
+ message="Preview URL contains newline or tab characters",
218
+ suggestion="Use a clean single-line path"
219
+ ))
220
+
221
+ # Check for URL encoding issues
222
+ if '%' in preview and not re.match(r'%[0-9A-Fa-f]{2}', preview):
223
+ errors.append(ValidationError(
224
+ file_path=file_path,
225
+ preview_value=preview,
226
+ error_type="ENCODING_ISSUE",
227
+ message="Preview URL may have encoding issues",
228
+ suggestion="Use properly URL-encoded characters or plain ASCII"
229
+ ))
230
+
231
+ return errors
232
+
233
+ def validate_file_exists(self, preview: str, file_path: str) -> List[ValidationError]:
234
+ """Check if the preview image file exists on disk."""
235
+ errors = []
236
+
237
+ if not preview or not preview.strip():
238
+ return errors
239
+
240
+ preview = preview.strip()
241
+
242
+ # Skip validation for null/empty
243
+ if preview.lower() == 'null':
244
+ return errors
245
+
246
+ # Remove leading slash for path checking
247
+ clean_path = preview.lstrip('/')
248
+
249
+ # Check direct path from project root
250
+ full_path = self.project_root / clean_path
251
+
252
+ if not full_path.exists():
253
+ # Try common variations
254
+ variations = [
255
+ self.project_root / clean_path,
256
+ self.project_root / 'assets' / clean_path,
257
+ self.project_root / clean_path.replace('/assets/', ''),
258
+ ]
259
+
260
+ found = False
261
+ for var_path in variations:
262
+ if var_path.exists():
263
+ found = True
264
+ break
265
+
266
+ if not found:
267
+ # Look for similar files to suggest
268
+ parent_dir = full_path.parent
269
+ similar_files = []
270
+ if parent_dir.exists():
271
+ target_name = full_path.stem.lower()
272
+ for f in parent_dir.iterdir():
273
+ if f.is_file() and f.suffix.lower() in self.VALID_EXTENSIONS:
274
+ if target_name[:10] in f.stem.lower() or f.stem.lower()[:10] in target_name:
275
+ similar_files.append(f'/{f.relative_to(self.project_root)}')
276
+
277
+ suggestion = "Verify the file path and ensure the image exists"
278
+ if similar_files:
279
+ suggestion = f"Did you mean: {similar_files[0]}"
280
+
281
+ errors.append(ValidationError(
282
+ file_path=file_path,
283
+ preview_value=preview,
284
+ error_type="FILE_NOT_FOUND",
285
+ message=f"Preview image file not found: {clean_path}",
286
+ suggestion=suggestion
287
+ ))
288
+
289
+ return errors
290
+
291
+ def validate_file(self, file_path: Path) -> ValidationResult:
292
+ """Validate a single content file."""
293
+ self.summary.total_files += 1
294
+
295
+ rel_path = str(file_path.relative_to(self.project_root))
296
+ self.debug(f"Checking: {rel_path}")
297
+
298
+ front_matter, raw_preview = self.parse_front_matter(file_path)
299
+
300
+ if front_matter is None:
301
+ return ValidationResult(
302
+ file_path=rel_path,
303
+ title="(parse error)",
304
+ preview_value=raw_preview,
305
+ is_valid=False,
306
+ errors=[ValidationError(
307
+ file_path=rel_path,
308
+ preview_value=raw_preview or "(none)",
309
+ error_type="PARSE_ERROR",
310
+ message="Could not parse front matter"
311
+ )]
312
+ )
313
+
314
+ title = front_matter.get('title', '(no title)')
315
+ preview = front_matter.get('preview')
316
+
317
+ # Handle various null representations
318
+ if preview is None:
319
+ self.summary.files_without_preview += 1
320
+ self.summary.files_with_null_preview += 1
321
+ return ValidationResult(
322
+ file_path=rel_path,
323
+ title=title,
324
+ preview_value=None,
325
+ is_valid=True, # null is valid
326
+ errors=[]
327
+ )
328
+
329
+ self.summary.files_with_preview += 1
330
+
331
+ # Convert to string for validation
332
+ preview_str = str(preview).strip()
333
+
334
+ errors = []
335
+
336
+ # Validate format
337
+ format_errors = self.validate_url_format(preview_str, rel_path)
338
+ errors.extend(format_errors)
339
+
340
+ # Validate file exists (only if format is valid enough)
341
+ if not any(e.error_type in ('EMPTY_VALUE', 'STRING_NULL') for e in format_errors):
342
+ existence_errors = self.validate_file_exists(preview_str, rel_path)
343
+ errors.extend(existence_errors)
344
+
345
+ is_valid = len(errors) == 0
346
+
347
+ if is_valid:
348
+ self.summary.valid_previews += 1
349
+ else:
350
+ self.summary.invalid_previews += 1
351
+ # Count error types
352
+ for e in errors:
353
+ if e.error_type == 'FILE_NOT_FOUND':
354
+ self.summary.missing_files += 1
355
+ elif e.error_type in ('RELATIVE_PATH', 'INVALID_EXTENSION', 'NO_EXTENSION', 'EMPTY_VALUE'):
356
+ self.summary.format_errors += 1
357
+
358
+ return ValidationResult(
359
+ file_path=rel_path,
360
+ title=title,
361
+ preview_value=preview_str,
362
+ is_valid=is_valid,
363
+ errors=errors
364
+ )
365
+
366
+ def scan_directory(self, directory: Path) -> List[ValidationResult]:
367
+ """Scan a directory for markdown files and validate them."""
368
+ results = []
369
+
370
+ if not directory.exists():
371
+ self.debug(f"Directory not found: {directory}")
372
+ return results
373
+
374
+ for md_file in directory.rglob("*.md"):
375
+ result = self.validate_file(md_file)
376
+ results.append(result)
377
+ self.summary.results.append(result)
378
+
379
+ return results
380
+
381
+ def run(self) -> ValidationSummary:
382
+ """Run validation on all content directories."""
383
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.NC}")
384
+ print(f"{Colors.CYAN}🔍 Preview URL Validator{Colors.NC}")
385
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.NC}\n")
386
+
387
+ for content_dir in self.CONTENT_DIRS:
388
+ dir_path = self.project_root / content_dir
389
+ if dir_path.exists():
390
+ self.log(f"Scanning: {content_dir}", "info")
391
+ self.scan_directory(dir_path)
392
+
393
+ return self.summary
394
+
395
+ def print_results(self, show_suggestions: bool = False):
396
+ """Print validation results."""
397
+ # Print errors
398
+ error_results = [r for r in self.summary.results if not r.is_valid]
399
+
400
+ if error_results:
401
+ print(f"\n{Colors.RED}{'=' * 60}{Colors.NC}")
402
+ print(f"{Colors.RED}❌ Validation Errors Found{Colors.NC}")
403
+ print(f"{Colors.RED}{'=' * 60}{Colors.NC}\n")
404
+
405
+ for result in error_results:
406
+ print(f"{Colors.BOLD}{result.file_path}{Colors.NC}")
407
+ print(f" Title: {result.title}")
408
+ print(f" Preview: {result.preview_value}")
409
+ for error in result.errors:
410
+ print(f" {Colors.RED}✗ [{error.error_type}]{Colors.NC} {error.message}")
411
+ if show_suggestions and error.suggestion:
412
+ print(f" {Colors.YELLOW}→ {error.suggestion}{Colors.NC}")
413
+ print()
414
+
415
+ # Print summary
416
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.NC}")
417
+ print(f"{Colors.CYAN}📊 Validation Summary{Colors.NC}")
418
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.NC}\n")
419
+
420
+ print(f" Total files scanned: {self.summary.total_files}")
421
+ print(f" Files with preview: {self.summary.files_with_preview}")
422
+ print(f" Files without preview: {self.summary.files_without_preview}")
423
+ print(f" Files with null preview: {self.summary.files_with_null_preview}")
424
+ print()
425
+ print(f" {Colors.GREEN}✓ Valid previews: {self.summary.valid_previews}{Colors.NC}")
426
+ print(f" {Colors.RED}✗ Invalid previews: {self.summary.invalid_previews}{Colors.NC}")
427
+ print(f" - Missing files: {self.summary.missing_files}")
428
+ print(f" - Format errors: {self.summary.format_errors}")
429
+ print()
430
+
431
+ if self.summary.invalid_previews == 0:
432
+ print(f"{Colors.GREEN}✅ All preview URLs are valid!{Colors.NC}\n")
433
+ return 0
434
+ else:
435
+ print(f"{Colors.RED}❌ Found {self.summary.invalid_previews} invalid preview URL(s){Colors.NC}\n")
436
+ return 1
437
+
438
+
439
+ def main():
440
+ """Main entry point."""
441
+ parser = argparse.ArgumentParser(
442
+ description="Validate preview image URLs in Jekyll content frontmatter"
443
+ )
444
+ parser.add_argument(
445
+ '-v', '--verbose',
446
+ action='store_true',
447
+ help="Enable verbose output"
448
+ )
449
+ parser.add_argument(
450
+ '-s', '--suggestions', '--fix-suggestions',
451
+ action='store_true',
452
+ help="Show fix suggestions for errors"
453
+ )
454
+ parser.add_argument(
455
+ '--json',
456
+ action='store_true',
457
+ help="Output results as JSON"
458
+ )
459
+
460
+ args = parser.parse_args()
461
+
462
+ # Determine project root
463
+ script_dir = Path(__file__).parent
464
+ project_root = script_dir.parent.parent
465
+
466
+ validator = PreviewURLValidator(project_root, verbose=args.verbose)
467
+ summary = validator.run()
468
+
469
+ if args.json:
470
+ import json
471
+ output = {
472
+ 'total_files': summary.total_files,
473
+ 'files_with_preview': summary.files_with_preview,
474
+ 'valid_previews': summary.valid_previews,
475
+ 'invalid_previews': summary.invalid_previews,
476
+ 'errors': [
477
+ {
478
+ 'file': r.file_path,
479
+ 'title': r.title,
480
+ 'preview': r.preview_value,
481
+ 'errors': [
482
+ {
483
+ 'type': e.error_type,
484
+ 'message': e.message,
485
+ 'suggestion': e.suggestion
486
+ }
487
+ for e in r.errors
488
+ ]
489
+ }
490
+ for r in summary.results if not r.is_valid
491
+ ]
492
+ }
493
+ print(json.dumps(output, indent=2))
494
+ return 1 if summary.invalid_previews > 0 else 0
495
+ else:
496
+ return validator.print_results(show_suggestions=args.suggestions)
497
+
498
+
499
+ if __name__ == "__main__":
500
+ sys.exit(main())