jekyll-theme-zer0 1.26.0 → 1.28.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +558 -5
  3. data/README.md +10 -27
  4. data/_data/authors.yml +4 -3
  5. data/_data/backlog.yml +28 -0
  6. data/_data/consumers.yml +147 -0
  7. data/_data/features.yml +142 -23
  8. data/_data/i18n/fr.yml +98 -0
  9. data/_data/i18n/languages.yml +36 -0
  10. data/_data/i18n/manifest.yml +1502 -0
  11. data/_data/navigation/docs.yml +2 -0
  12. data/_data/navigation/main.yml +6 -0
  13. data/_data/series.yml +19 -0
  14. data/_data/theme-manifest.yml +348 -268
  15. data/_data/ui-text.yml +36 -246
  16. data/_includes/README.md +25 -2
  17. data/_includes/analytics/google-tag-manager-body.html +11 -2
  18. data/_includes/analytics/google-tag-manager-head.html +16 -6
  19. data/_includes/analytics/posthog.html +33 -4
  20. data/_includes/components/abc-letter.html +43 -0
  21. data/_includes/components/author-avatar-url.html +4 -2
  22. data/_includes/components/book-card.html +42 -0
  23. data/_includes/components/book-nav.html +80 -0
  24. data/_includes/components/book-plate.html +31 -0
  25. data/_includes/components/book-toc.html +47 -0
  26. data/_includes/components/bookshelf.html +68 -0
  27. data/_includes/components/card-grid.html +60 -0
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/env-switcher.html +3 -1
  30. data/_includes/components/halfmoon.html +5 -1
  31. data/_includes/components/language-toggle.html +81 -0
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/search-modal.html +2 -2
  34. data/_includes/components/shortcuts-modal.html +1 -1
  35. data/_includes/components/theme-controls-bar.html +10 -2
  36. data/_includes/components/theme-customizer.html +8 -2
  37. data/_includes/components/translation-notice.html +27 -0
  38. data/_includes/content/intro.html +16 -15
  39. data/_includes/content/seo.html +9 -3
  40. data/_includes/core/color-mode-init.html +13 -4
  41. data/_includes/core/favicon.html +46 -0
  42. data/_includes/core/footer.html +9 -4
  43. data/_includes/core/head.html +18 -0
  44. data/_includes/core/header.html +9 -4
  45. data/_includes/core/hreflang.html +33 -0
  46. data/_includes/core/i18n.html +36 -0
  47. data/_includes/custom/body-end.html +18 -0
  48. data/_includes/custom/body-start.html +17 -0
  49. data/_includes/custom/footer.html +18 -0
  50. data/_includes/custom/head.html +18 -0
  51. data/_includes/navigation/breadcrumbs.html +1 -1
  52. data/_includes/navigation/local-graph.html +28 -2
  53. data/_includes/navigation/nav-tree.html +3 -3
  54. data/_includes/navigation/navbar.html +5 -4
  55. data/_includes/navigation/sidebar-config.html +21 -0
  56. data/_includes/navigation/sidebar-nav.html +4 -0
  57. data/_includes/navigation/sidebar-pagetree.html +150 -0
  58. data/_includes/navigation/sidebar-right.html +3 -2
  59. data/_includes/navigation/unified-drawer.html +9 -3
  60. data/_includes/obsidian/full-graph.html +165 -136
  61. data/_layouts/404.html +260 -0
  62. data/_layouts/README.md +2 -0
  63. data/_layouts/article.html +7 -1
  64. data/_layouts/book-abc.html +106 -0
  65. data/_layouts/book-story.html +91 -0
  66. data/_layouts/book.html +113 -0
  67. data/_layouts/collection.html +17 -5
  68. data/_layouts/default.html +5 -3
  69. data/_layouts/home.html +5 -2
  70. data/_layouts/landing.html +9 -0
  71. data/_layouts/news.html +159 -33
  72. data/_layouts/root.html +29 -9
  73. data/_layouts/section.html +61 -17
  74. data/_sass/components/_book.scss +423 -0
  75. data/_sass/core/_navbar.scss +13 -0
  76. data/_sass/core/_obsidian.scss +295 -7
  77. data/_sass/layouts/_navbar-extras.scss +6 -1
  78. data/_sass/theme/_backgrounds.scss +21 -8
  79. data/assets/css/main.scss +1 -0
  80. data/assets/js/auto-hide-nav.js +5 -1
  81. data/assets/js/halfmoon.js +26 -0
  82. data/assets/js/obsidian-graph.js +707 -265
  83. data/assets/js/obsidian-local-graph.js +161 -54
  84. data/assets/js/search-modal.js +4 -1
  85. data/scripts/README.md +20 -26
  86. data/scripts/bin/audit-consumer +1 -1
  87. data/scripts/bin/manifest +31 -5
  88. data/scripts/bin/sync-plugins +0 -1
  89. data/scripts/bin/validate +5 -1
  90. data/scripts/dev/rasterize-svg.js +65 -0
  91. data/scripts/features/generate-preview-images +49 -1390
  92. data/scripts/features/install-preview-generator +55 -33
  93. data/scripts/install/README.md +55 -25
  94. data/scripts/install/ai/client.sh +302 -93
  95. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  96. data/scripts/install/ai/prompts/wizard.system.md +8 -17
  97. data/scripts/install/ai/wizard.sh +10 -5
  98. data/scripts/install/apply.sh +7 -3
  99. data/scripts/install/cli.sh +54 -4
  100. data/scripts/install/config.sh +167 -0
  101. data/scripts/install/doctor.sh +38 -0
  102. data/scripts/install/plan.sh +10 -0
  103. data/scripts/install/spec.sh +15 -7
  104. data/scripts/install/template.sh +4 -0
  105. data/scripts/lib/README.md +1 -5
  106. data/scripts/lib/install/deploy/README.md +3 -9
  107. data/scripts/lib/preview_generator.py +2261 -1341
  108. data/scripts/propagate.rb +277 -0
  109. data/scripts/translate.rb +1204 -0
  110. metadata +34 -3
  111. data/_plugins/preview_image_generator.rb +0 -351
@@ -1,3 +1,4 @@
1
+ // Feature: ZER0-045
1
2
  /*
2
3
  * obsidian-local-graph.js
3
4
  *
@@ -12,8 +13,13 @@
12
13
  * Subgraph:
13
14
  * - center = current page (matched against entry.url, falling back to
14
15
  * normalized title/basename/aliases for permalink quirks)
15
- * - depth = configurable via data-depth attribute (default 1)
16
- * - direction = both incoming and outgoing wiki-links
16
+ * - depth = 1–3, user-adjustable in the panel (default from the
17
+ * data-depth attribute / `local_graph_depth` front matter)
18
+ * - direction = outgoing and/or incoming wiki-links, user-toggleable
19
+ *
20
+ * Depth + direction preferences persist to localStorage
21
+ * (zer0.obsidianLocalGraph.v1) and the graph re-renders in place when they
22
+ * change — same for Bootstrap color-mode (data-bs-theme) switches.
17
23
  *
18
24
  * If the current page is in the wiki-index but has no local links, the panel
19
25
  * stays available and renders a single-node graph for the current page.
@@ -26,6 +32,7 @@
26
32
  var PANEL_SELECTOR = '[data-obsidian-local-graph-panel]';
27
33
  var TOGGLE_SELECTOR = '[data-obsidian-local-graph-toggle]';
28
34
  var STATUS_SELECTOR = '[data-obsidian-local-graph-status]';
35
+ var STORAGE_KEY = 'zer0.obsidianLocalGraph.v1';
29
36
 
30
37
  // Cytoscape is vendored under assets/vendor/ (no runtime CDN — matches the
31
38
  // Bootstrap / Icons / Mermaid policy). The path is supplied by Liquid via
@@ -57,6 +64,28 @@
57
64
  .replace(/"/g, '"').replace(/'/g, ''');
58
65
  }
59
66
 
67
+ // Depth + direction preferences. Depth falls back to the page's data-depth
68
+ // (front-matter `local_graph_depth`), directions default to both on.
69
+ function loadPrefs(defaultDepth) {
70
+ var prefs = { depth: defaultDepth, outgoing: true, incoming: true };
71
+ try {
72
+ var saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null');
73
+ if (saved && typeof saved === 'object') {
74
+ var d = parseInt(saved.depth, 10);
75
+ if (d >= 1 && d <= 3) prefs.depth = d;
76
+ if (typeof saved.outgoing === 'boolean') prefs.outgoing = saved.outgoing;
77
+ if (typeof saved.incoming === 'boolean') prefs.incoming = saved.incoming;
78
+ }
79
+ } catch (e) { /* private mode / corrupt payload — use defaults */ }
80
+ return prefs;
81
+ }
82
+
83
+ function savePrefs(prefs) {
84
+ try {
85
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
86
+ } catch (e) { /* storage unavailable — prefs are session-only */ }
87
+ }
88
+
60
89
  function companionElements(container) {
61
90
  return {
62
91
  panel: container.closest(PANEL_SELECTOR),
@@ -143,10 +172,11 @@
143
172
  return palette[name] || '#6c757d';
144
173
  }
145
174
 
146
- // BFS from the current entry up to `depth` hops, following both
147
- // outgoing edges and incoming edges (any other entry whose `outgoing`
148
- // includes one of our keys).
149
- function buildSubgraph(entries, lookup, current, depth) {
175
+ // BFS from the current entry up to `depth` hops. Directions are opt-in:
176
+ // opts.outgoing follows this page's [[links]], opts.incoming follows any
177
+ // other entry whose `outgoing` includes one of our keys.
178
+ function buildSubgraph(entries, lookup, current, depth, opts) {
179
+ opts = opts || { outgoing: true, incoming: true };
150
180
  var visited = Object.create(null);
151
181
  var queue = [{ entry: current, dist: 0 }];
152
182
  var nodes = [];
@@ -206,45 +236,49 @@
206
236
  if (item.dist >= depth) continue;
207
237
 
208
238
  // Outgoing edges
209
- (entry.outgoing || []).forEach(function (target) {
210
- var nk = normalize(target);
211
- var resolved = lookup.byKey[nk];
212
- if (resolved) {
213
- if (resolved.url === entry.url) return;
214
- addEdge(entry.url, resolved.url, false);
215
- if (!visited[resolved.url]) {
216
- queue.push({ entry: resolved, dist: item.dist + 1 });
239
+ if (opts.outgoing) {
240
+ (entry.outgoing || []).forEach(function (target) {
241
+ var nk = normalize(target);
242
+ var resolved = lookup.byKey[nk];
243
+ if (resolved) {
244
+ if (resolved.url === entry.url) return;
245
+ addEdge(entry.url, resolved.url, false);
246
+ if (!visited[resolved.url]) {
247
+ queue.push({ entry: resolved, dist: item.dist + 1 });
248
+ }
249
+ } else {
250
+ var brokenId = '__broken__:' + nk;
251
+ if (!visited[brokenId]) {
252
+ visited[brokenId] = true;
253
+ nodes.push({
254
+ group: 'nodes',
255
+ data: {
256
+ id: brokenId,
257
+ label: target,
258
+ url: null,
259
+ collection: 'broken',
260
+ color: '#dc3545',
261
+ broken: true
262
+ }
263
+ });
264
+ }
265
+ addEdge(entry.url, brokenId, true);
217
266
  }
218
- } else {
219
- var brokenId = '__broken__:' + nk;
220
- if (!visited[brokenId]) {
221
- visited[brokenId] = true;
222
- nodes.push({
223
- group: 'nodes',
224
- data: {
225
- id: brokenId,
226
- label: target,
227
- url: null,
228
- collection: 'broken',
229
- color: '#dc3545',
230
- broken: true
231
- }
232
- });
233
- }
234
- addEdge(entry.url, brokenId, true);
235
- }
236
- });
267
+ });
268
+ }
237
269
 
238
270
  // Incoming edges (anyone whose outgoing matches one of our keys)
239
- keysFor(entry).forEach(function (k) {
240
- (reverse[k] || []).forEach(function (src) {
241
- if (src.url === entry.url) return;
242
- addEdge(src.url, entry.url, false);
243
- if (!visited[src.url]) {
244
- queue.push({ entry: src, dist: item.dist + 1 });
245
- }
271
+ if (opts.incoming) {
272
+ keysFor(entry).forEach(function (k) {
273
+ (reverse[k] || []).forEach(function (src) {
274
+ if (src.url === entry.url) return;
275
+ addEdge(src.url, entry.url, false);
276
+ if (!visited[src.url]) {
277
+ queue.push({ entry: src, dist: item.dist + 1 });
278
+ }
279
+ });
246
280
  });
247
- });
281
+ }
248
282
  }
249
283
 
250
284
  return nodes.concat(edges);
@@ -299,6 +333,11 @@
299
333
  }
300
334
 
301
335
  function render(container, elements, currentUrl) {
336
+ // Re-renders (depth/direction/theme changes) replace the prior instance.
337
+ if (container.__obsidianLocalGraph) {
338
+ try { container.__obsidianLocalGraph.destroy(); } catch (e) { /* ignore */ }
339
+ container.__obsidianLocalGraph = null;
340
+ }
302
341
  var theme = readTheme();
303
342
  container.style.backgroundColor = theme.canvasBg;
304
343
  var motion = prefersReducedMotion() ? '0ms' : '160ms';
@@ -460,6 +499,59 @@
460
499
  return nav;
461
500
  }
462
501
 
502
+ // Reflect prefs into the depth radio group + direction switches.
503
+ function syncControls(prefs) {
504
+ var radio = document.getElementById('obsidian-lg-depth-' + prefs.depth);
505
+ if (radio) radio.checked = true;
506
+ var outgoing = document.getElementById('obsidian-lg-outgoing');
507
+ if (outgoing) outgoing.checked = prefs.outgoing;
508
+ var incoming = document.getElementById('obsidian-lg-incoming');
509
+ if (incoming) incoming.checked = prefs.incoming;
510
+ }
511
+
512
+ function wireControls(prefs, rebuild) {
513
+ [1, 2, 3].forEach(function (d) {
514
+ var radio = document.getElementById('obsidian-lg-depth-' + d);
515
+ if (!radio) return;
516
+ radio.addEventListener('change', function () {
517
+ if (!radio.checked) return;
518
+ prefs.depth = d;
519
+ savePrefs(prefs);
520
+ rebuild();
521
+ });
522
+ });
523
+ var outgoing = document.getElementById('obsidian-lg-outgoing');
524
+ if (outgoing) {
525
+ outgoing.addEventListener('change', function () {
526
+ prefs.outgoing = outgoing.checked;
527
+ savePrefs(prefs);
528
+ rebuild();
529
+ });
530
+ }
531
+ var incoming = document.getElementById('obsidian-lg-incoming');
532
+ if (incoming) {
533
+ incoming.addEventListener('change', function () {
534
+ prefs.incoming = incoming.checked;
535
+ savePrefs(prefs);
536
+ rebuild();
537
+ });
538
+ }
539
+ }
540
+
541
+ // Re-render on Bootstrap color-mode changes so canvas colors track the
542
+ // theme without a reload. Local graphs are small — a full rebuild is cheap.
543
+ function watchTheme(rebuild) {
544
+ var restyle = debounce(rebuild, 60);
545
+ var observer = new MutationObserver(restyle);
546
+ [document.documentElement, document.body].forEach(function (el) {
547
+ observer.observe(el, { attributes: true, attributeFilter: ['data-bs-theme'] });
548
+ });
549
+ if (window.matchMedia) {
550
+ var mq = window.matchMedia('(prefers-color-scheme: dark)');
551
+ if (mq.addEventListener) mq.addEventListener('change', restyle);
552
+ }
553
+ }
554
+
463
555
  function init() {
464
556
  var container = document.getElementById(CONTAINER_ID);
465
557
  if (!container) return;
@@ -478,8 +570,9 @@
478
570
  resizeGraph(container);
479
571
  }, 150));
480
572
 
481
- var depth = parseInt(container.getAttribute('data-depth') || '1', 10);
482
- if (!isFinite(depth) || depth < 1) depth = 1;
573
+ var defaultDepth = parseInt(container.getAttribute('data-depth') || '1', 10);
574
+ if (!isFinite(defaultDepth) || defaultDepth < 1) defaultDepth = 1;
575
+ var prefs = loadPrefs(Math.min(defaultDepth, 3));
483
576
  var indexUrl = container.getAttribute('data-index-url') ||
484
577
  ((document.querySelector('base') || {}).href || '/') +
485
578
  'assets/data/wiki-index.json';
@@ -496,17 +589,21 @@
496
589
  // Confirmed in-index: reveal the panel + FAB now.
497
590
  setPanelAvailable(container, true);
498
591
  setStatus(container, 'Loading graph…', false);
499
-
500
- var elements = buildSubgraph(entries, lookup, current, depth);
501
- var nodeCount = elements.filter(function (element) { return element.group === 'nodes'; }).length;
502
- var edgeCount = elements.filter(function (element) { return element.group === 'edges'; }).length;
503
- // Accessible text fallback (also the graceful degradation if cytoscape
504
- // can't load): a list of linked neighbours below the canvas.
505
- var fallback = renderTextFallback(container, elements, current);
506
-
507
- loadCytoscape(function (ok) {
508
- if (ok === false) {
509
- // Keep the text list visible; hide the empty canvas.
592
+ syncControls(prefs);
593
+
594
+ var graphAvailable = null; // unknown until loadCytoscape resolves
595
+
596
+ function rebuild() {
597
+ var elements = buildSubgraph(entries, lookup, current, prefs.depth, {
598
+ outgoing: prefs.outgoing,
599
+ incoming: prefs.incoming
600
+ });
601
+ var nodeCount = elements.filter(function (el) { return el.group === 'nodes'; }).length;
602
+ var edgeCount = elements.filter(function (el) { return el.group === 'edges'; }).length;
603
+ // Accessible text fallback (also the graceful degradation if
604
+ // cytoscape can't load): a list of linked neighbours below the canvas.
605
+ var fallback = renderTextFallback(container, elements, current);
606
+ if (graphAvailable === false) {
510
607
  container.hidden = true;
511
608
  setStatus(container, 'Showing linked pages (interactive graph unavailable).', false);
512
609
  return;
@@ -515,6 +612,16 @@
515
612
  setStatus(container, nodeCount + ' pages · ' + edgeCount + ' links', false);
516
613
  // Graph is the visual representation; keep the list for AT only.
517
614
  if (fallback) fallback.classList.add('visually-hidden');
615
+ }
616
+
617
+ wireControls(prefs, rebuild);
618
+ watchTheme(function () {
619
+ if (container.__obsidianLocalGraph) rebuild();
620
+ });
621
+
622
+ loadCytoscape(function (ok) {
623
+ graphAvailable = ok !== false;
624
+ rebuild();
518
625
  });
519
626
  })
520
627
  .catch(function (err) {
@@ -74,8 +74,11 @@
74
74
  if (typeof bootstrap === 'undefined') return;
75
75
  const cookieEl = document.getElementById('cookieSettingsModal');
76
76
  const infoEl = document.getElementById('info-section');
77
+ const drawerEl = document.getElementById('zer0UnifiedDrawer');
77
78
  afterModalClosed(cookieEl, () => {
78
- afterOffcanvasClosed(infoEl, showSearchModal);
79
+ afterOffcanvasClosed(infoEl, () => {
80
+ afterOffcanvasClosed(drawerEl, showSearchModal);
81
+ });
79
82
  });
80
83
  };
81
84
 
data/scripts/README.md CHANGED
@@ -18,7 +18,7 @@ scripts/
18
18
  │ ├── git.sh # Git operations
19
19
  │ ├── changelog.sh # Changelog generation
20
20
  │ ├── gem.sh # Gem build/publish
21
- │ └── preview_generator.py # Python preview image generator
21
+ │ └── preview_generator.py # Preview-image engine (Claude orchestrates; ZER0-004)
22
22
  ├── features/ # Feature-specific scripts
23
23
  │ ├── generate-preview-images # AI preview image generator
24
24
  │ ├── pixelate-preview-images # Shrink preview banners (pixelate + PNG-8)
@@ -65,10 +65,7 @@ Build the gem without the full release workflow.
65
65
  ```
66
66
 
67
67
  #### `bin/validate`
68
- Run preflight validation before refactors, pull requests, and releases. The
69
- quick path validates repository files, version consistency, YAML parsing, active
70
- configuration contracts, config-file classification, and navigation data before
71
- the Docker/local build stages run.
68
+ Run preflight validation before refactors, pull requests, and releases. The quick path validates repository files, version consistency, YAML parsing, active configuration contracts, config-file classification, and navigation data before the Docker/local build stages run.
72
69
 
73
70
  ```bash
74
71
  ./scripts/bin/validate [options]
@@ -115,22 +112,23 @@ Options:
115
112
  --dry-run Preview without changes
116
113
  --collection TYPE Generate for specific collection (posts, docs, etc.)
117
114
  -f, --file PATH Process specific file
118
- --provider PROVIDER Use specific AI provider (openai, stability, xai)
115
+ --provider PROVIDER Renderer (openai, xai, stability, gemini, local)
116
+ --prompt-engine ENG claude analyzes the article (default) | template
117
+ --review ENG claude reviews the render (default) | none
119
118
  --assets-prefix Custom assets path prefix (default: /assets)
120
119
  --no-auto-prefix Disable automatic path prefixing
121
120
 
122
- AI Providers:
123
- openai - OpenAI DALL-E (requires OPENAI_API_KEY)
124
- stability - Stability AI (requires STABILITY_API_KEY)
121
+ Renderers (Claude orchestrates all of them — analysis + review via
122
+ CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY / logged-in `claude` CLI):
123
+ openai - OpenAI gpt-image-2 / DALL-E, default (requires OPENAI_API_KEY)
125
124
  xai - xAI Grok image generation (requires XAI_API_KEY)
125
+ stability - Stability AI (requires STABILITY_API_KEY)
126
+ gemini - Google Gemini (requires GEMINI_API_KEY)
127
+ local - Deterministic template SVG/PNG (no API key)
126
128
  ```
127
129
 
128
130
  #### `pixelate-preview-images`
129
- Pixelate + palette-quantize the preview banners so they are dramatically smaller
130
- files while retaining the retro pixel-art look. Pure Python stdlib (no
131
- ImageMagick / Pillow / pngquant needed) — it downsamples the image and reduces
132
- it to an indexed PNG-8 palette. Typical savings on the AI-generated banners are
133
- ~90% (e.g. a 2.7&nbsp;MB banner becomes ~230&nbsp;KB).
131
+ Pixelate + palette-quantize the preview banners so they are dramatically smaller files while retaining the retro pixel-art look. Pure Python stdlib (no ImageMagick / Pillow / pngquant needed) — it downsamples the image and reduces it to an indexed PNG-8 palette. Typical savings on the AI-generated banners are ~90% (e.g. a 2.7&nbsp;MB banner becomes ~230&nbsp;KB).
134
132
 
135
133
  ```bash
136
134
  # Preview the savings for every banner (no changes), 4 workers:
@@ -157,10 +155,7 @@ Options (forwarded to scripts/features/pixelate_images.py):
157
155
  -j, --jobs N Parallel worker processes (default: 1)
158
156
  ```
159
157
 
160
- With no path argument it processes `preview_images.output_dir` from
161
- `_config.yml` (default `assets/images/previews`). Non-PNG and 16-bit/interlaced
162
- inputs are skipped gracefully. The engine has a built-in `--selftest`, exercised
163
- by `scripts/test/lib/test_pixelate_images.sh`.
158
+ With no path argument it processes `preview_images.output_dir` from `_config.yml` (default `assets/images/previews`). Non-PNG and 16-bit/interlaced inputs are skipped gracefully. The engine has a built-in `--selftest`, exercised by `scripts/test/lib/test_pixelate_images.sh`.
164
159
 
165
160
  #### `install-preview-generator`
166
161
  Install the preview image generator feature.
@@ -254,7 +249,7 @@ These are sourced by other scripts, not executed directly:
254
249
  - `git.sh` - Git operations (tags, commits, branches)
255
250
  - `changelog.sh` - Changelog generation from commits
256
251
  - `gem.sh` - Gem build/publish operations
257
- - `preview_generator.py` - Python preview image generator
252
+ - `preview_generator.py` - Preview-image engine (Claude analyze/review + openai/xai/stability/gemini/local renderers)
258
253
 
259
254
  ### Test Suites (scripts/test/)
260
255
 
@@ -272,8 +267,7 @@ These are sourced by other scripts, not executed directly:
272
267
 
273
268
  ## Backward Compatibility
274
269
 
275
- Legacy script paths (e.g., `./scripts/build.sh`) are maintained as thin wrappers
276
- that forward to the canonical locations in `bin/`, `utils/`, `features/`, or `test/`.
270
+ Legacy script paths (e.g., `./scripts/build.sh`) are maintained as thin wrappers that forward to the canonical locations in `bin/`, `utils/`, `features/`, or `test/`.
277
271
 
278
272
  ## Development Workflow
279
273
 
@@ -538,15 +532,15 @@ The script is designed to integrate with AI agents for automated content managem
538
532
  3. **Front Matter Updates**: Automatically updates the markdown file with the new preview path
539
533
  4. **Idempotent**: Won't regenerate images that already exist (unless `--force`)
540
534
 
541
- ### Python Alternative
535
+ ### Engine
542
536
 
543
- A Python version is available at `scripts/lib/preview_generator.py` with additional features:
537
+ All logic lives in the single-file Python engine `scripts/lib/preview_generator.py` (the shell entry points are thin wrappers around it):
544
538
 
545
539
  ```bash
546
- # Install dependencies
547
- pip install openai pyyaml requests
540
+ # Install the one dependency
541
+ pip3 install pyyaml
548
542
 
549
- # Run Python version
543
+ # Run the engine directly (same flags as the wrapper)
550
544
  python3 scripts/lib/preview_generator.py --collection posts --dry-run
551
545
  ```
552
546
 
@@ -190,7 +190,7 @@ if [[ ${#THEMABLE_DIRS[@]} -eq 0 ]]; then
190
190
  THEMABLE_DIRS=("_layouts" "_includes" "_sass" "assets/css" "assets/js")
191
191
  fi
192
192
  if [[ ${#REQUIRED_PLUGIN_FILES[@]} -eq 0 && ${#OPTIONAL_PLUGIN_FILES[@]} -eq 0 ]]; then
193
- REQUIRED_PLUGIN_FILES=("_plugins/preview_image_generator.rb" "_plugins/obsidian_links.rb")
193
+ REQUIRED_PLUGIN_FILES=("_plugins/obsidian_links.rb")
194
194
  OPTIONAL_PLUGIN_FILES=("_plugins/admin_page_urls.rb" "_plugins/content_statistics_generator.rb" "_plugins/theme_version.rb")
195
195
  fi
196
196
 
data/scripts/bin/manifest CHANGED
@@ -35,12 +35,15 @@ DESCRIPTION:
35
35
  Run automatically by scripts/bin/release before tagging.
36
36
 
37
37
  OPTIONS:
38
+ --check Exit non-zero if the committed manifest is stale; write
39
+ nothing. `generated_at` is ignored in the comparison.
38
40
  --dry-run Print manifest to stdout; don't write the file
39
41
  --verbose Show each file being processed
40
42
  --help, -h Show this help message
41
43
 
42
44
  EXAMPLES:
43
45
  ./scripts/bin/manifest # Regenerate _data/theme-manifest.yml
46
+ ./scripts/bin/manifest --check # CI gate: is the manifest current?
44
47
  ./scripts/bin/manifest --dry-run # Preview output
45
48
  ./scripts/bin/manifest --verbose # Show every file being hashed
46
49
  EOF
@@ -67,7 +70,6 @@ THEME_DATA_PATHS=(
67
70
  # Plugins that remote_theme consumers MUST vendor locally (GitHub Pages won't
68
71
  # load plugins from a remote theme).
69
72
  REQUIRED_PLUGIN_PATHS=(
70
- "_plugins/preview_image_generator.rb"
71
73
  "_plugins/obsidian_links.rb"
72
74
  )
73
75
 
@@ -82,9 +84,11 @@ OPTIONAL_PLUGIN_PATHS=(
82
84
  # Argument parsing
83
85
  DRY_RUN=false
84
86
  VERBOSE=false
87
+ CHECK=false
85
88
 
86
89
  while [[ $# -gt 0 ]]; do
87
90
  case $1 in
91
+ --check) CHECK=true; DRY_RUN=true ;;
88
92
  --dry-run) DRY_RUN=true ;;
89
93
  --verbose) VERBOSE=true ;;
90
94
  --help|-h) show_usage; exit 0 ;;
@@ -94,7 +98,14 @@ while [[ $# -gt 0 ]]; do
94
98
  done
95
99
 
96
100
  # ---------------------------------------------------------------------------
97
- step "Generating theme manifest..."
101
+ # Under --dry-run stdout carries only the manifest YAML — the CI freshness gate
102
+ # (ci.yml "Theme manifest freshness") diffs that stdout straight against the
103
+ # committed file, so progress logs must not pollute it.
104
+ if [[ "$DRY_RUN" == "true" ]]; then
105
+ step "Generating theme manifest..." >&2
106
+ else
107
+ step "Generating theme manifest..."
108
+ fi
98
109
  cd "$REPO_ROOT"
99
110
 
100
111
  CURRENT_VERSION=$(get_current_version)
@@ -174,10 +185,25 @@ YAML
174
185
  )
175
186
 
176
187
  # ---------------------------------------------------------------------------
177
- if [[ "$DRY_RUN" == "true" ]]; then
178
- info "[DRY RUN] Would write _data/theme-manifest.yml:"
188
+ MANIFEST_FILE="$REPO_ROOT/_data/theme-manifest.yml"
189
+
190
+ if [[ "$CHECK" == "true" ]]; then
191
+ # Staleness = any difference other than the generation timestamp, which moves
192
+ # on every run and would otherwise report a false positive every time.
193
+ if [[ ! -f "$MANIFEST_FILE" ]]; then
194
+ error "Manifest missing: _data/theme-manifest.yml — run ./scripts/bin/manifest"
195
+ fi
196
+ if diff -u \
197
+ <(grep -v '^generated_at:' "$MANIFEST_FILE") \
198
+ <(echo "$MANIFEST_CONTENT" | grep -v '^generated_at:') >&2; then
199
+ success "Manifest is current (version $CURRENT_VERSION)" >&2
200
+ exit 0
201
+ fi
202
+ error "Manifest is stale — run ./scripts/bin/manifest and commit the result"
203
+ elif [[ "$DRY_RUN" == "true" ]]; then
204
+ info "[DRY RUN] Would write _data/theme-manifest.yml:" >&2
179
205
  echo "$MANIFEST_CONTENT"
180
206
  else
181
- echo "$MANIFEST_CONTENT" > "$REPO_ROOT/_data/theme-manifest.yml"
207
+ echo "$MANIFEST_CONTENT" > "$MANIFEST_FILE"
182
208
  success "Written: _data/theme-manifest.yml (version $CURRENT_VERSION)"
183
209
  fi
@@ -172,7 +172,6 @@ fi
172
172
  # Default hardcoded fallback if manifest has no plugin entries at all
173
173
  if [[ ${#REQUIRED_PLUGINS[@]} -eq 0 && ${#OPTIONAL_PLUGINS[@]} -eq 0 ]]; then
174
174
  REQUIRED_PLUGINS=(
175
- "_plugins/preview_image_generator.rb"
176
175
  "_plugins/obsidian_links.rb"
177
176
  )
178
177
  OPTIONAL_PLUGINS=(
data/scripts/bin/validate CHANGED
@@ -391,7 +391,11 @@ if File.file?('_config_secrets_local.yml')
391
391
  assert(!tracked, '_config_secrets_local.yml must remain untracked and ignored')
392
392
  end
393
393
 
394
- ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures]
394
+ # examples/ holds self-contained demo sites, each with its own _config.yml and
395
+ # _config_dev.yml. Those belong to the example, not to this site's config
396
+ # surface, so prune the tree rather than registering every example's configs in
397
+ # classified_files below (which would need editing for each new example).
398
+ ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures examples]
395
399
  config_like_files = []
396
400
 
397
401
  Find.find('.') do |path|
@@ -0,0 +1,65 @@
1
+ // Feature: ZER0-004
2
+ // SVG → PNG rasterizer for the preview-image engine (scripts/lib/preview_generator.py).
3
+ // Last resort in the rasterizer chain (rsvg-convert → inkscape → magick → THIS):
4
+ // uses the repo's Playwright devDependency (the same Chromium that powers the
5
+ // visual test tiers) so contributors need no native image tooling installed.
6
+ //
7
+ // Usage:
8
+ // node scripts/dev/rasterize-svg.js <in.svg> <out.png> [width] [height]
9
+ //
10
+ // Exit codes:
11
+ // 0 PNG written
12
+ // 1 rasterization failed (Chromium error, unreadable SVG, …)
13
+ // 2 usage error
14
+ // 3 @playwright/test not resolvable (caller should fall through / keep .svg)
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const [svgPath, pngPath, widthArg, heightArg] = process.argv.slice(2);
19
+ if (!svgPath || !pngPath) {
20
+ console.error('Usage: node scripts/dev/rasterize-svg.js <in.svg> <out.png> [width] [height]');
21
+ process.exit(2);
22
+ }
23
+ const width = parseInt(widthArg, 10) || 1536;
24
+ const height = parseInt(heightArg, 10) || 1024;
25
+
26
+ let chromium;
27
+ try {
28
+ ({ chromium } = require('@playwright/test'));
29
+ } catch (e) {
30
+ console.error('rasterize-svg: @playwright/test not resolvable from ' + process.cwd());
31
+ process.exit(3);
32
+ }
33
+
34
+ (async () => {
35
+ const svg = fs.readFileSync(path.resolve(svgPath), 'utf8');
36
+ const browser = await chromium.launch();
37
+ try {
38
+ const ctx = await browser.newContext({
39
+ viewport: { width, height },
40
+ deviceScaleFactor: 1,
41
+ });
42
+ // Defense-in-depth: banner SVG must be self-contained, so block ALL
43
+ // network fetches — an external reference that slipped past the engine's
44
+ // sanitizer becomes a no-op instead of an SSRF/tracking request.
45
+ await ctx.route('**/*', (route) => route.abort());
46
+ const page = await ctx.newPage();
47
+ // Inline the SVG in a zero-margin shim and stretch it to the viewport so the
48
+ // screenshot is exactly width×height regardless of the SVG's own attributes.
49
+ await page.setContent(
50
+ `<!DOCTYPE html><html><head><style>
51
+ html,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden;background:#000}
52
+ svg{display:block;width:${width}px;height:${height}px}
53
+ </style></head><body>${svg}</body></html>`,
54
+ { waitUntil: 'load', timeout: 30000 }
55
+ );
56
+ await page.screenshot({ path: path.resolve(pngPath), fullPage: false });
57
+ await ctx.close();
58
+ console.log('wrote', pngPath);
59
+ } finally {
60
+ await browser.close();
61
+ }
62
+ })().catch((err) => {
63
+ console.error('rasterize-svg: ' + (err && err.message ? err.message : err));
64
+ process.exit(1);
65
+ });