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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +558 -5
- data/README.md +10 -27
- data/_data/authors.yml +4 -3
- data/_data/backlog.yml +28 -0
- data/_data/consumers.yml +147 -0
- data/_data/features.yml +142 -23
- data/_data/i18n/fr.yml +98 -0
- data/_data/i18n/languages.yml +36 -0
- data/_data/i18n/manifest.yml +1502 -0
- data/_data/navigation/docs.yml +2 -0
- data/_data/navigation/main.yml +6 -0
- data/_data/series.yml +19 -0
- data/_data/theme-manifest.yml +348 -268
- data/_data/ui-text.yml +36 -246
- data/_includes/README.md +25 -2
- data/_includes/analytics/google-tag-manager-body.html +11 -2
- data/_includes/analytics/google-tag-manager-head.html +16 -6
- data/_includes/analytics/posthog.html +33 -4
- data/_includes/components/abc-letter.html +43 -0
- data/_includes/components/author-avatar-url.html +4 -2
- data/_includes/components/book-card.html +42 -0
- data/_includes/components/book-nav.html +80 -0
- data/_includes/components/book-plate.html +31 -0
- data/_includes/components/book-toc.html +47 -0
- data/_includes/components/bookshelf.html +68 -0
- data/_includes/components/card-grid.html +60 -0
- data/_includes/components/data-card.html +95 -0
- data/_includes/components/env-switcher.html +3 -1
- data/_includes/components/halfmoon.html +5 -1
- data/_includes/components/language-toggle.html +81 -0
- data/_includes/components/page-feedback.html +65 -2
- data/_includes/components/search-modal.html +2 -2
- data/_includes/components/shortcuts-modal.html +1 -1
- data/_includes/components/theme-controls-bar.html +10 -2
- data/_includes/components/theme-customizer.html +8 -2
- data/_includes/components/translation-notice.html +27 -0
- data/_includes/content/intro.html +16 -15
- data/_includes/content/seo.html +9 -3
- data/_includes/core/color-mode-init.html +13 -4
- data/_includes/core/favicon.html +46 -0
- data/_includes/core/footer.html +9 -4
- data/_includes/core/head.html +18 -0
- data/_includes/core/header.html +9 -4
- data/_includes/core/hreflang.html +33 -0
- data/_includes/core/i18n.html +36 -0
- data/_includes/custom/body-end.html +18 -0
- data/_includes/custom/body-start.html +17 -0
- data/_includes/custom/footer.html +18 -0
- data/_includes/custom/head.html +18 -0
- data/_includes/navigation/breadcrumbs.html +1 -1
- data/_includes/navigation/local-graph.html +28 -2
- data/_includes/navigation/nav-tree.html +3 -3
- data/_includes/navigation/navbar.html +5 -4
- data/_includes/navigation/sidebar-config.html +21 -0
- data/_includes/navigation/sidebar-nav.html +4 -0
- data/_includes/navigation/sidebar-pagetree.html +150 -0
- data/_includes/navigation/sidebar-right.html +3 -2
- data/_includes/navigation/unified-drawer.html +9 -3
- data/_includes/obsidian/full-graph.html +165 -136
- data/_layouts/404.html +260 -0
- data/_layouts/README.md +2 -0
- data/_layouts/article.html +7 -1
- data/_layouts/book-abc.html +106 -0
- data/_layouts/book-story.html +91 -0
- data/_layouts/book.html +113 -0
- data/_layouts/collection.html +17 -5
- data/_layouts/default.html +5 -3
- data/_layouts/home.html +5 -2
- data/_layouts/landing.html +9 -0
- data/_layouts/news.html +159 -33
- data/_layouts/root.html +29 -9
- data/_layouts/section.html +61 -17
- data/_sass/components/_book.scss +423 -0
- data/_sass/core/_navbar.scss +13 -0
- data/_sass/core/_obsidian.scss +295 -7
- data/_sass/layouts/_navbar-extras.scss +6 -1
- data/_sass/theme/_backgrounds.scss +21 -8
- data/assets/css/main.scss +1 -0
- data/assets/js/auto-hide-nav.js +5 -1
- data/assets/js/halfmoon.js +26 -0
- data/assets/js/obsidian-graph.js +707 -265
- data/assets/js/obsidian-local-graph.js +161 -54
- data/assets/js/search-modal.js +4 -1
- data/scripts/README.md +20 -26
- data/scripts/bin/audit-consumer +1 -1
- data/scripts/bin/manifest +31 -5
- data/scripts/bin/sync-plugins +0 -1
- data/scripts/bin/validate +5 -1
- data/scripts/dev/rasterize-svg.js +65 -0
- data/scripts/features/generate-preview-images +49 -1390
- data/scripts/features/install-preview-generator +55 -33
- data/scripts/install/README.md +55 -25
- data/scripts/install/ai/client.sh +302 -93
- data/scripts/install/ai/prompts/spec.schema.json +1 -1
- data/scripts/install/ai/prompts/wizard.system.md +8 -17
- data/scripts/install/ai/wizard.sh +10 -5
- data/scripts/install/apply.sh +7 -3
- data/scripts/install/cli.sh +54 -4
- data/scripts/install/config.sh +167 -0
- data/scripts/install/doctor.sh +38 -0
- data/scripts/install/plan.sh +10 -0
- data/scripts/install/spec.sh +15 -7
- data/scripts/install/template.sh +4 -0
- data/scripts/lib/README.md +1 -5
- data/scripts/lib/install/deploy/README.md +3 -9
- data/scripts/lib/preview_generator.py +2261 -1341
- data/scripts/propagate.rb +277 -0
- data/scripts/translate.rb +1204 -0
- metadata +34 -3
- 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 =
|
|
16
|
-
*
|
|
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
|
|
147
|
-
// 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
|
-
(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (resolved
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
}
|
|
219
|
-
|
|
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
|
-
|
|
240
|
-
(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
482
|
-
if (!isFinite(
|
|
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
|
-
|
|
501
|
-
var
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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) {
|
data/assets/js/search-modal.js
CHANGED
|
@@ -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,
|
|
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 #
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 MB banner becomes ~230 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 MB banner becomes ~230 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` -
|
|
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
|
-
###
|
|
535
|
+
### Engine
|
|
542
536
|
|
|
543
|
-
|
|
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
|
|
547
|
-
|
|
540
|
+
# Install the one dependency
|
|
541
|
+
pip3 install pyyaml
|
|
548
542
|
|
|
549
|
-
# Run
|
|
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
|
|
data/scripts/bin/audit-consumer
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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" > "$
|
|
207
|
+
echo "$MANIFEST_CONTENT" > "$MANIFEST_FILE"
|
|
182
208
|
success "Written: _data/theme-manifest.yml (version $CURRENT_VERSION)"
|
|
183
209
|
fi
|
data/scripts/bin/sync-plugins
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|