jekyll-theme-zer0 1.2.1 → 1.3.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.
@@ -0,0 +1,475 @@
1
+ /*
2
+ * obsidian-graph.js
3
+ *
4
+ * Renders an Obsidian-style interactive knowledge graph from
5
+ * /assets/data/wiki-index.json into the element with id `obsidian-graph`.
6
+ *
7
+ * Loaded only on the graph page (pages/_docs/obsidian/graph.md), which
8
+ * also pulls in cytoscape.js from a CDN. No runtime dependencies are
9
+ * added to the rest of the site.
10
+ *
11
+ * Nodes: one per indexed entry (collection doc or standalone page)
12
+ * Edges: directed, source -> target, derived from `entry.outgoing`
13
+ * (normalized [[wiki-link]] targets emitted by
14
+ * assets/data/wiki-index.json — see the Liquid template there).
15
+ *
16
+ * Targets are matched against the same lookup table the client-side
17
+ * resolver uses (title / basename / aliases, all normalized). Unresolved
18
+ * targets become floating "broken" nodes drawn in red so the graph also
19
+ * surfaces dangling links.
20
+ *
21
+ * Public hooks (read-only):
22
+ * window.ObsidianGraph.cy — cytoscape instance once initialized
23
+ * window.ObsidianGraph.byKey — normalized lookup table
24
+ * window.ObsidianGraph.entries — raw entries array
25
+ */
26
+ (function () {
27
+ 'use strict';
28
+
29
+ var CONTAINER_ID = 'obsidian-graph';
30
+ var INDEX_URL = (window.OBSIDIAN_WIKI_INDEX_URL ||
31
+ ((document.querySelector('base') || {}).href || '/') + 'assets/data/wiki-index.json');
32
+
33
+ function normalize(value) {
34
+ return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
35
+ }
36
+
37
+ function buildLookup(entries) {
38
+ var byKey = Object.create(null);
39
+ entries.forEach(function (entry) {
40
+ if (!entry || !entry.url) return;
41
+ var keys = [];
42
+ if (entry.title) keys.push(entry.title);
43
+ if (entry.basename) keys.push(entry.basename);
44
+ (entry.aliases || []).forEach(function (a) { if (a) keys.push(a); });
45
+ keys.forEach(function (k) {
46
+ var nk = normalize(k);
47
+ if (!nk || byKey[nk]) return; // first wins, mirrors plugin/resolver
48
+ byKey[nk] = entry;
49
+ });
50
+ });
51
+ return byKey;
52
+ }
53
+
54
+ // Detect Bootstrap's color-mode at init time so cytoscape gets concrete
55
+ // hex values (it rejects `var(--bs-…)`). We re-read on toggle so the
56
+ // graph stays legible when users flip light/dark.
57
+ function readTheme() {
58
+ var attr = (document.documentElement.getAttribute('data-bs-theme') ||
59
+ document.body.getAttribute('data-bs-theme') || '').toLowerCase();
60
+ var dark = attr === 'dark' || (!attr &&
61
+ window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
62
+ return dark ? {
63
+ label: '#e9ecef',
64
+ labelOutline: '#1b1f23',
65
+ edge: 'rgba(173,181,189,0.45)',
66
+ edgeArrow: 'rgba(173,181,189,0.65)',
67
+ canvasBg: '#1b1f23',
68
+ nodeBorder: 'rgba(255,255,255,0.22)'
69
+ } : {
70
+ label: '#1b1f23',
71
+ labelOutline: '#f8f9fa',
72
+ edge: 'rgba(73,80,87,0.40)',
73
+ edgeArrow: 'rgba(73,80,87,0.60)',
74
+ canvasBg: '#f8f9fa',
75
+ nodeBorder: 'rgba(0,0,0,0.20)'
76
+ };
77
+ }
78
+
79
+ function collectionColor(name) {
80
+ // Stable, distinguishable palette per collection. Falls back for pages.
81
+ var palette = {
82
+ posts: '#0d6efd', // primary blue
83
+ docs: '#198754', // success green
84
+ notes: '#6f42c1', // purple
85
+ notebooks: '#d63384', // pink
86
+ quickstart: '#fd7e14', // orange
87
+ about: '#20c997', // teal
88
+ hobbies: '#ffc107', // amber
89
+ news: '#6610f2', // indigo
90
+ services: '#0dcaf0' // cyan
91
+ };
92
+ return palette[name] || '#6c757d'; // gray for standalone pages
93
+ }
94
+
95
+ function buildElements(entries, byKey) {
96
+ var elements = [];
97
+ var seenTargets = Object.create(null);
98
+ var brokenIds = Object.create(null);
99
+
100
+ entries.forEach(function (entry) {
101
+ var nid = entry.url;
102
+ elements.push({
103
+ group: 'nodes',
104
+ data: {
105
+ id: nid,
106
+ label: entry.title || entry.basename || nid,
107
+ url: entry.url,
108
+ collection: entry.collection || 'page',
109
+ color: collectionColor(entry.collection),
110
+ excerpt: entry.excerpt || '',
111
+ broken: false
112
+ }
113
+ });
114
+ seenTargets[nid] = true;
115
+ });
116
+
117
+ entries.forEach(function (entry) {
118
+ (entry.outgoing || []).forEach(function (target) {
119
+ var nk = normalize(target);
120
+ var resolved = byKey[nk];
121
+ var targetId;
122
+ if (resolved) {
123
+ targetId = resolved.url;
124
+ if (targetId === entry.url) return; // skip self-loops, no insight
125
+ } else {
126
+ targetId = '__broken__:' + nk;
127
+ if (!brokenIds[targetId]) {
128
+ brokenIds[targetId] = true;
129
+ elements.push({
130
+ group: 'nodes',
131
+ data: {
132
+ id: targetId,
133
+ label: target,
134
+ url: null,
135
+ collection: 'broken',
136
+ color: '#dc3545',
137
+ excerpt: 'Unresolved wiki-link',
138
+ broken: true
139
+ }
140
+ });
141
+ }
142
+ }
143
+ elements.push({
144
+ group: 'edges',
145
+ data: {
146
+ id: 'e:' + entry.url + '->' + targetId,
147
+ source: entry.url,
148
+ target: targetId,
149
+ broken: !resolved
150
+ }
151
+ });
152
+ });
153
+ });
154
+
155
+ return elements;
156
+ }
157
+
158
+ function computeNodeDegree(elements) {
159
+ var degree = Object.create(null);
160
+ elements.forEach(function (el) {
161
+ if (el.group !== 'edges') return;
162
+ degree[el.data.source] = (degree[el.data.source] || 0) + 1;
163
+ degree[el.data.target] = (degree[el.data.target] || 0) + 1;
164
+ });
165
+ elements.forEach(function (el) {
166
+ if (el.group !== 'nodes') return;
167
+ el.data.degree = degree[el.data.id] || 0;
168
+ });
169
+ }
170
+
171
+ function renderGraph(container, elements) {
172
+ if (typeof window.cytoscape !== 'function') {
173
+ container.innerHTML =
174
+ '<div class="alert alert-danger" role="alert">' +
175
+ 'Graph view failed to load: <code>cytoscape</code> library is not ' +
176
+ 'available. Check your network connection or content security policy.' +
177
+ '</div>';
178
+ return null;
179
+ }
180
+
181
+ var theme = readTheme();
182
+ container.style.backgroundColor = theme.canvasBg;
183
+
184
+ var cy = window.cytoscape({
185
+ container: container,
186
+ elements: elements,
187
+ wheelSensitivity: 0.2,
188
+ minZoom: 0.1,
189
+ maxZoom: 4,
190
+ style: [
191
+ {
192
+ selector: 'node',
193
+ style: {
194
+ 'background-color': 'data(color)',
195
+ 'label': 'data(label)',
196
+ 'font-size': '11px',
197
+ 'font-weight': 500,
198
+ 'color': theme.label,
199
+ // Halo-only labels (outline matches canvas) — no white pill,
200
+ // no obstruction. Mirrors how Obsidian draws labels.
201
+ 'text-outline-color': theme.labelOutline,
202
+ 'text-outline-width': 2.5,
203
+ 'text-outline-opacity': 0.95,
204
+ 'text-background-opacity': 0,
205
+ 'text-border-opacity': 0,
206
+ 'text-valign': 'bottom',
207
+ 'text-margin-y': 5,
208
+ 'text-wrap': 'ellipsis',
209
+ 'text-max-width': '140px',
210
+ // Labels appear when zoomed-in or hovered — keeps the
211
+ // overview clean instead of being a wall of text.
212
+ 'min-zoomed-font-size': 9,
213
+ 'text-opacity': 0,
214
+ 'width': 'mapData(degree, 0, 20, 12, 50)',
215
+ 'height': 'mapData(degree, 0, 20, 12, 50)',
216
+ 'border-width': 1.5,
217
+ 'border-color': theme.nodeBorder,
218
+ 'transition-property': 'background-color, border-color, width, height, text-opacity',
219
+ 'transition-duration': '160ms'
220
+ }
221
+ },
222
+ {
223
+ // Always show labels for hub nodes (degree >= 6) so the user
224
+ // has anchor landmarks even when zoomed all the way out.
225
+ selector: 'node[degree >= 6]',
226
+ style: {
227
+ 'text-opacity': 1,
228
+ 'font-size': '12px',
229
+ 'font-weight': 600,
230
+ 'min-zoomed-font-size': 0
231
+ }
232
+ },
233
+ {
234
+ selector: 'node[?broken]',
235
+ style: {
236
+ 'border-style': 'dashed',
237
+ 'border-color': '#dc3545',
238
+ 'border-width': 2
239
+ }
240
+ },
241
+ {
242
+ selector: 'edge',
243
+ style: {
244
+ 'curve-style': 'bezier',
245
+ 'width': 1.5,
246
+ 'line-color': theme.edge,
247
+ 'target-arrow-color': theme.edgeArrow,
248
+ 'target-arrow-shape': 'triangle',
249
+ 'arrow-scale': 0.9,
250
+ 'transition-property': 'line-color, width',
251
+ 'transition-duration': '150ms'
252
+ }
253
+ },
254
+ {
255
+ selector: 'edge[?broken]',
256
+ style: {
257
+ 'line-color': 'rgba(220,53,69,0.45)',
258
+ 'target-arrow-color': 'rgba(220,53,69,0.55)',
259
+ 'line-style': 'dashed'
260
+ }
261
+ },
262
+ {
263
+ selector: '.faded',
264
+ style: {
265
+ 'opacity': 0.15,
266
+ 'text-opacity': 0.15
267
+ }
268
+ },
269
+ {
270
+ selector: 'node.highlighted',
271
+ style: {
272
+ 'border-color': '#fd7e14',
273
+ 'border-width': 3,
274
+ 'opacity': 1,
275
+ 'text-opacity': 1,
276
+ 'font-size': '12px',
277
+ 'font-weight': 700,
278
+ 'min-zoomed-font-size': 0,
279
+ 'z-index': 9999
280
+ }
281
+ },
282
+ {
283
+ selector: 'edge.highlighted',
284
+ style: {
285
+ 'line-color': '#fd7e14',
286
+ 'target-arrow-color': '#fd7e14',
287
+ 'width': 2.5,
288
+ 'opacity': 1,
289
+ 'z-index': 9999
290
+ }
291
+ }
292
+ ],
293
+ layout: {
294
+ name: 'cose',
295
+ animate: false,
296
+ randomize: true,
297
+ // Looser packing so clusters have breathing room and labels
298
+ // don't pile on top of each other.
299
+ nodeRepulsion: function () { return 18000; },
300
+ idealEdgeLength: function () { return 130; },
301
+ edgeElasticity: function () { return 80; },
302
+ nodeOverlap: 24,
303
+ gravity: 0.18,
304
+ nestingFactor: 1.2,
305
+ numIter: 2500,
306
+ padding: 40,
307
+ componentSpacing: 80
308
+ }
309
+ });
310
+
311
+ cy.on('tap', 'node', function (evt) {
312
+ var node = evt.target;
313
+ var url = node.data('url');
314
+ if (url) {
315
+ // Same-tab navigation; use Cmd/Ctrl-click for new tab via the
316
+ // standard handler below.
317
+ var native = evt.originalEvent;
318
+ if (native && (native.metaKey || native.ctrlKey)) {
319
+ window.open(url, '_blank', 'noopener');
320
+ } else {
321
+ window.location.href = url;
322
+ }
323
+ }
324
+ });
325
+
326
+ // Hover: highlight neighborhood, fade everything else.
327
+ cy.on('mouseover', 'node', function (evt) {
328
+ var node = evt.target;
329
+ var nhood = node.closedNeighborhood();
330
+ cy.elements().not(nhood).addClass('faded');
331
+ nhood.addClass('highlighted');
332
+ });
333
+ cy.on('mouseout', 'node', function () {
334
+ cy.elements().removeClass('faded highlighted');
335
+ });
336
+
337
+ return cy;
338
+ }
339
+
340
+ function wireSearch(cy, byKey) {
341
+ var input = document.getElementById('obsidian-graph-search');
342
+ var status = document.getElementById('obsidian-graph-status');
343
+ if (!input || !cy) return;
344
+
345
+ function run() {
346
+ var q = normalize(input.value);
347
+ cy.elements().removeClass('faded highlighted');
348
+ if (!q) {
349
+ if (status) status.textContent = '';
350
+ cy.fit(undefined, 70);
351
+ return;
352
+ }
353
+ var matches = cy.nodes().filter(function (n) {
354
+ return normalize(n.data('label')).indexOf(q) !== -1;
355
+ });
356
+ if (matches.length === 0) {
357
+ if (status) status.textContent = 'No nodes match “' + input.value + '”.';
358
+ return;
359
+ }
360
+ var nhood = matches.closedNeighborhood();
361
+ cy.elements().not(nhood).addClass('faded');
362
+ matches.addClass('highlighted');
363
+ if (status) status.textContent = matches.length + ' node' +
364
+ (matches.length === 1 ? '' : 's') + ' matched.';
365
+ cy.fit(matches, 100);
366
+ }
367
+
368
+ input.addEventListener('input', run);
369
+ }
370
+
371
+ function wireFitButton(cy) {
372
+ var btn = document.getElementById('obsidian-graph-fit');
373
+ if (!btn || !cy) return;
374
+ btn.addEventListener('click', function () {
375
+ cy.elements().removeClass('faded highlighted');
376
+ var input = document.getElementById('obsidian-graph-search');
377
+ if (input) input.value = '';
378
+ var status = document.getElementById('obsidian-graph-status');
379
+ if (status) status.textContent = '';
380
+ cy.fit(undefined, 70);
381
+ });
382
+ }
383
+
384
+ function applyOrphansVisibility(cy, show) {
385
+ if (!cy) return;
386
+ var orphans = cy.nodes().filter(function (n) { return n.degree(false) === 0; });
387
+ if (show) {
388
+ orphans.style('display', 'element');
389
+ } else {
390
+ orphans.style('display', 'none');
391
+ }
392
+ // Re-run a quick layout pass on visible elements so the connected
393
+ // cluster expands into the freed space.
394
+ cy.layout({
395
+ name: 'cose',
396
+ animate: false,
397
+ randomize: false,
398
+ nodeRepulsion: function () { return 18000; },
399
+ idealEdgeLength: function () { return 130; },
400
+ edgeElasticity: function () { return 80; },
401
+ nodeOverlap: 24,
402
+ gravity: 0.18,
403
+ numIter: 1200,
404
+ padding: 40,
405
+ componentSpacing: 80,
406
+ eles: cy.elements(':visible')
407
+ }).run();
408
+ cy.fit(cy.elements(':visible'), 70);
409
+ }
410
+
411
+ function wireOrphansToggle(cy) {
412
+ var toggle = document.getElementById('obsidian-graph-orphans');
413
+ if (!toggle || !cy) return;
414
+ // Hide orphans by default to match Obsidian's "Show orphans" off state.
415
+ applyOrphansVisibility(cy, toggle.checked);
416
+ toggle.addEventListener('change', function () {
417
+ applyOrphansVisibility(cy, toggle.checked);
418
+ });
419
+ }
420
+
421
+ function setStats(entries, elements) {
422
+ var nodes = elements.filter(function (e) { return e.group === 'nodes'; });
423
+ var edges = elements.filter(function (e) { return e.group === 'edges'; });
424
+ var broken = nodes.filter(function (e) { return e.data.broken; }).length;
425
+ var el = document.getElementById('obsidian-graph-stats');
426
+ if (!el) return;
427
+ el.innerHTML =
428
+ '<span class="badge text-bg-secondary me-2">' + entries.length + ' pages</span>' +
429
+ '<span class="badge text-bg-info me-2">' + edges.length + ' links</span>' +
430
+ (broken > 0 ?
431
+ '<span class="badge text-bg-danger">' + broken + ' broken</span>' :
432
+ '<span class="badge text-bg-success">0 broken</span>');
433
+ }
434
+
435
+ function init() {
436
+ var container = document.getElementById(CONTAINER_ID);
437
+ if (!container) return;
438
+
439
+ container.innerHTML =
440
+ '<div class="d-flex align-items-center justify-content-center h-100 text-muted">' +
441
+ '<div class="spinner-border me-2" role="status" aria-hidden="true"></div>' +
442
+ 'Loading graph data…</div>';
443
+
444
+ fetch(INDEX_URL, { credentials: 'same-origin' })
445
+ .then(function (r) {
446
+ if (!r.ok) throw new Error('HTTP ' + r.status);
447
+ return r.json();
448
+ })
449
+ .then(function (payload) {
450
+ var entries = (payload && payload.entries) || [];
451
+ var byKey = buildLookup(entries);
452
+ var elements = buildElements(entries, byKey);
453
+ computeNodeDegree(elements);
454
+ container.innerHTML = '';
455
+ setStats(entries, elements);
456
+ var cy = renderGraph(container, elements);
457
+ wireSearch(cy, byKey);
458
+ wireFitButton(cy);
459
+ wireOrphansToggle(cy);
460
+ window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
461
+ })
462
+ .catch(function (err) {
463
+ container.innerHTML =
464
+ '<div class="alert alert-danger" role="alert">' +
465
+ 'Failed to load graph data: ' + (err && err.message ? err.message : err) +
466
+ '</div>';
467
+ });
468
+ }
469
+
470
+ if (document.readyState === 'loading') {
471
+ document.addEventListener('DOMContentLoaded', init);
472
+ } else {
473
+ init();
474
+ }
475
+ })();