jekyll-theme-zer0 1.21.0 → 1.22.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 +18 -0
- data/_data/backlog.yml +124 -2
- data/_includes/components/component-showcase.html +18 -10
- data/_includes/components/js-cdn.html +10 -2
- data/_includes/components/post-card.html +18 -4
- data/_includes/content/backlinks.html +47 -18
- data/_includes/content/giscus.html +30 -44
- data/_includes/core/footer-fabs.html +5 -2
- data/_includes/navigation/local-graph-fab.html +1 -1
- data/_includes/navigation/local-graph.html +4 -3
- data/_includes/navigation/section-sidebar.html +10 -3
- data/_includes/obsidian/full-graph.html +7 -5
- data/_layouts/article.html +23 -6
- data/_plugins/obsidian_links.rb +84 -15
- data/_sass/core/_obsidian.scss +59 -8
- data/assets/data/wiki-index.json +50 -3
- data/assets/js/obsidian-graph.js +66 -21
- data/assets/js/obsidian-local-graph.js +125 -32
- data/assets/js/obsidian-wiki-links.js +118 -21
- data/assets/js/search-modal.js +45 -9
- data/scripts/bin/giscus-discussions +509 -0
- metadata +3 -2
|
@@ -26,15 +26,35 @@
|
|
|
26
26
|
var PANEL_SELECTOR = '[data-obsidian-local-graph-panel]';
|
|
27
27
|
var TOGGLE_SELECTOR = '[data-obsidian-local-graph-toggle]';
|
|
28
28
|
var STATUS_SELECTOR = '[data-obsidian-local-graph-status]';
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
|
|
30
|
+
// Cytoscape is vendored under assets/vendor/ (no runtime CDN — matches the
|
|
31
|
+
// Bootstrap / Icons / Mermaid policy). The path is supplied by Liquid via
|
|
32
|
+
// window.OBSIDIAN_CONFIG.cytoscapeUrl, with a base-relative fallback.
|
|
33
|
+
function cytoscapeSrc() {
|
|
34
|
+
var cfg = window.OBSIDIAN_CONFIG || {};
|
|
35
|
+
if (cfg.cytoscapeUrl) return cfg.cytoscapeUrl;
|
|
36
|
+
var base = (document.querySelector('base') || {}).href || '/';
|
|
37
|
+
return base.replace(/\/$/, '') + '/assets/vendor/cytoscape/cytoscape.min.js';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function prefersReducedMotion() {
|
|
41
|
+
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function debounce(fn, wait) {
|
|
45
|
+
var t;
|
|
46
|
+
return function () {
|
|
47
|
+
var ctx = this;
|
|
48
|
+
var args = arguments;
|
|
49
|
+
clearTimeout(t);
|
|
50
|
+
t = setTimeout(function () { fn.apply(ctx, args); }, wait);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function escapeHtml(value) {
|
|
55
|
+
return String(value == null ? '' : value)
|
|
56
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
57
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
function companionElements(container) {
|
|
@@ -246,32 +266,34 @@
|
|
|
246
266
|
};
|
|
247
267
|
}
|
|
248
268
|
|
|
269
|
+
// Load cytoscape (vendored, same-origin). Invokes cb(true) on success,
|
|
270
|
+
// cb(false) on failure so callers can fall back to the text list.
|
|
249
271
|
function loadCytoscape(cb) {
|
|
250
|
-
if (typeof window.cytoscape === 'function') return cb();
|
|
272
|
+
if (typeof window.cytoscape === 'function') return cb(true);
|
|
251
273
|
// Re-use any in-flight load (e.g. when the full graph page also loads it).
|
|
252
274
|
if (window.__obsidianCytoscapeLoading) {
|
|
253
275
|
window.__obsidianCytoscapeLoading.push(cb);
|
|
254
276
|
return;
|
|
255
277
|
}
|
|
256
|
-
window.__obsidianCytoscapeLoading = [cb];
|
|
278
|
+
var queue = window.__obsidianCytoscapeLoading = [cb];
|
|
279
|
+
function flush(ok) {
|
|
280
|
+
window.__obsidianCytoscapeLoading = null;
|
|
281
|
+
queue.forEach(function (fn) { try { fn(ok); } catch (e) { /* ignore */ } });
|
|
282
|
+
}
|
|
257
283
|
var existing = document.querySelector('script[src*="cytoscape"]');
|
|
258
284
|
if (existing) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
});
|
|
285
|
+
if (typeof window.cytoscape === 'function') return flush(true);
|
|
286
|
+
existing.addEventListener('load', function () { flush(true); });
|
|
287
|
+
existing.addEventListener('error', function () { flush(false); });
|
|
263
288
|
return;
|
|
264
289
|
}
|
|
265
290
|
var s = document.createElement('script');
|
|
266
|
-
s.src =
|
|
291
|
+
s.src = cytoscapeSrc();
|
|
267
292
|
s.defer = true;
|
|
268
|
-
s.onload = function () {
|
|
269
|
-
window.__obsidianCytoscapeLoading.forEach(function (fn) { fn(); });
|
|
270
|
-
window.__obsidianCytoscapeLoading = null;
|
|
271
|
-
};
|
|
293
|
+
s.onload = function () { flush(true); };
|
|
272
294
|
s.onerror = function () {
|
|
273
295
|
console.warn('[obsidian-local-graph] failed to load cytoscape');
|
|
274
|
-
|
|
296
|
+
flush(false);
|
|
275
297
|
};
|
|
276
298
|
document.head.appendChild(s);
|
|
277
299
|
}
|
|
@@ -279,6 +301,7 @@
|
|
|
279
301
|
function render(container, elements, currentUrl) {
|
|
280
302
|
var theme = readTheme();
|
|
281
303
|
container.style.backgroundColor = theme.canvasBg;
|
|
304
|
+
var motion = prefersReducedMotion() ? '0ms' : '160ms';
|
|
282
305
|
|
|
283
306
|
var cy = window.cytoscape({
|
|
284
307
|
container: container,
|
|
@@ -307,7 +330,7 @@
|
|
|
307
330
|
'border-width': 1.5,
|
|
308
331
|
'border-color': theme.nodeBorder,
|
|
309
332
|
'transition-property': 'background-color, border-color, width, height',
|
|
310
|
-
'transition-duration':
|
|
333
|
+
'transition-duration': motion
|
|
311
334
|
}
|
|
312
335
|
},
|
|
313
336
|
{
|
|
@@ -322,7 +345,11 @@
|
|
|
322
345
|
}
|
|
323
346
|
},
|
|
324
347
|
{
|
|
325
|
-
|
|
348
|
+
// `[?broken]` = truthiness, NOT `[broken]` (existence): every edge
|
|
349
|
+
// carries a broken:false field, so the existence form would paint
|
|
350
|
+
// all of them dashed-red even when nothing is broken. Mirrors the
|
|
351
|
+
// selectors in obsidian-graph.js (the full graph).
|
|
352
|
+
selector: 'node[?broken]',
|
|
326
353
|
style: {
|
|
327
354
|
'background-color': '#dc3545',
|
|
328
355
|
'border-style': 'dashed',
|
|
@@ -341,7 +368,7 @@
|
|
|
341
368
|
}
|
|
342
369
|
},
|
|
343
370
|
{
|
|
344
|
-
selector: 'edge[broken]',
|
|
371
|
+
selector: 'edge[?broken]',
|
|
345
372
|
style: { 'line-style': 'dashed', 'line-color': '#dc3545' }
|
|
346
373
|
}
|
|
347
374
|
],
|
|
@@ -385,11 +412,60 @@
|
|
|
385
412
|
return cy;
|
|
386
413
|
}
|
|
387
414
|
|
|
415
|
+
// Accessible text fallback: a list of linked neighbours rendered as real
|
|
416
|
+
// <a> links. Always present for screen readers / no-cytoscape users; hidden
|
|
417
|
+
// (visually) once the interactive graph renders. Returns the element or null.
|
|
418
|
+
function renderTextFallback(container, elements, current) {
|
|
419
|
+
var host = container.parentNode;
|
|
420
|
+
if (!host) return null;
|
|
421
|
+
var prior = host.querySelector('.obsidian-local-graph-fallback');
|
|
422
|
+
if (prior) prior.parentNode.removeChild(prior);
|
|
423
|
+
|
|
424
|
+
var byId = Object.create(null);
|
|
425
|
+
var outgoing = [];
|
|
426
|
+
var incoming = [];
|
|
427
|
+
var seenOut = Object.create(null);
|
|
428
|
+
var seenIn = Object.create(null);
|
|
429
|
+
elements.forEach(function (el) {
|
|
430
|
+
if (el.group === 'nodes') byId[el.data.id] = el.data;
|
|
431
|
+
});
|
|
432
|
+
elements.forEach(function (el) {
|
|
433
|
+
if (el.group !== 'edges') return;
|
|
434
|
+
if (el.data.source === current.url && el.data.target !== current.url) {
|
|
435
|
+
var t = byId[el.data.target];
|
|
436
|
+
if (t && t.url && !seenOut[t.url]) { seenOut[t.url] = true; outgoing.push(t); }
|
|
437
|
+
} else if (el.data.target === current.url && el.data.source !== current.url) {
|
|
438
|
+
var s = byId[el.data.source];
|
|
439
|
+
if (s && s.url && !seenIn[s.url]) { seenIn[s.url] = true; incoming.push(s); }
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
if (!outgoing.length && !incoming.length) return null;
|
|
443
|
+
|
|
444
|
+
function section(title, items) {
|
|
445
|
+
if (!items.length) return '';
|
|
446
|
+
var lis = items.map(function (d) {
|
|
447
|
+
return '<li class="list-group-item py-1 px-2 bg-transparent">' +
|
|
448
|
+
'<a href="' + escapeHtml(d.url) + '">' + escapeHtml(d.label || d.url) + '</a></li>';
|
|
449
|
+
}).join('');
|
|
450
|
+
return '<p class="small text-secondary mb-1 mt-2">' + title + '</p>' +
|
|
451
|
+
'<ul class="list-group list-group-flush small mb-0">' + lis + '</ul>';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
var nav = document.createElement('nav');
|
|
455
|
+
nav.className = 'obsidian-local-graph-fallback mt-2';
|
|
456
|
+
nav.setAttribute('aria-label', 'Pages linked to this page');
|
|
457
|
+
nav.innerHTML = section('Links from this page', outgoing) +
|
|
458
|
+
section('Links to this page', incoming);
|
|
459
|
+
host.insertBefore(nav, container.nextSibling);
|
|
460
|
+
return nav;
|
|
461
|
+
}
|
|
462
|
+
|
|
388
463
|
function init() {
|
|
389
464
|
var container = document.getElementById(CONTAINER_ID);
|
|
390
465
|
if (!container) return;
|
|
391
|
-
|
|
392
|
-
|
|
466
|
+
// Panel + FAB start hidden (progressive enhancement): reveal ONLY once the
|
|
467
|
+
// current page is confirmed in the wiki-index, so unindexed/slow/no-JS pages
|
|
468
|
+
// never flash a dead button.
|
|
393
469
|
|
|
394
470
|
var panel = container.closest(PANEL_SELECTOR);
|
|
395
471
|
if (panel) {
|
|
@@ -398,9 +474,9 @@
|
|
|
398
474
|
});
|
|
399
475
|
}
|
|
400
476
|
|
|
401
|
-
window.addEventListener('resize', function () {
|
|
477
|
+
window.addEventListener('resize', debounce(function () {
|
|
402
478
|
resizeGraph(container);
|
|
403
|
-
});
|
|
479
|
+
}, 150));
|
|
404
480
|
|
|
405
481
|
var depth = parseInt(container.getAttribute('data-depth') || '1', 10);
|
|
406
482
|
if (!isFinite(depth) || depth < 1) depth = 1;
|
|
@@ -416,16 +492,33 @@
|
|
|
416
492
|
var lookup = buildLookup(entries);
|
|
417
493
|
var current = findCurrentEntry(lookup);
|
|
418
494
|
if (!current) { setPanelAvailable(container, false); return; }
|
|
495
|
+
|
|
496
|
+
// Confirmed in-index: reveal the panel + FAB now.
|
|
497
|
+
setPanelAvailable(container, true);
|
|
498
|
+
setStatus(container, 'Loading graph…', false);
|
|
499
|
+
|
|
419
500
|
var elements = buildSubgraph(entries, lookup, current, depth);
|
|
420
|
-
|
|
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.
|
|
510
|
+
container.hidden = true;
|
|
511
|
+
setStatus(container, 'Showing linked pages (interactive graph unavailable).', false);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
421
514
|
render(container, elements, current.url);
|
|
422
|
-
var nodeCount = elements.filter(function (element) { return element.group === 'nodes'; }).length;
|
|
423
|
-
var edgeCount = elements.filter(function (element) { return element.group === 'edges'; }).length;
|
|
424
515
|
setStatus(container, nodeCount + ' pages · ' + edgeCount + ' links', false);
|
|
516
|
+
// Graph is the visual representation; keep the list for AT only.
|
|
517
|
+
if (fallback) fallback.classList.add('visually-hidden');
|
|
425
518
|
});
|
|
426
519
|
})
|
|
427
520
|
.catch(function (err) {
|
|
428
|
-
// Sidebar panel failing is non-fatal —
|
|
521
|
+
// Sidebar panel failing is non-fatal — keep it hidden and stay quiet.
|
|
429
522
|
console.warn('[obsidian-local-graph] init failed:', err);
|
|
430
523
|
setPanelAvailable(container, false);
|
|
431
524
|
});
|
|
@@ -64,6 +64,35 @@
|
|
|
64
64
|
return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Replicate kramdown's basic header-id algorithm so [[Page#Heading]] fragments
|
|
68
|
+
// land on the heading kramdown generated. Kept byte-identical to
|
|
69
|
+
// Jekyll::Obsidian::Converter#anchorize in _plugins/obsidian_links.rb.
|
|
70
|
+
function anchorize(anchor) {
|
|
71
|
+
return String(anchor == null ? '' : anchor).trim()
|
|
72
|
+
.replace(/^[^a-zA-Z]+/, '')
|
|
73
|
+
.replace(/[^a-zA-Z0-9 -]/g, '')
|
|
74
|
+
.replace(/ /g, '-')
|
|
75
|
+
.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Match Jekyll's default `slugify` filter (used by pages/tags.md anchor ids)
|
|
79
|
+
// so inline #tags link to a real on-page anchor. Mirrors Converter#slugify.
|
|
80
|
+
function tagSlug(tag) {
|
|
81
|
+
return String(tag || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Skip hex-colour-shaped tokens in prose (e.g. `#ffffff`, `#fff`, `#1a2b3c`)
|
|
85
|
+
// so they aren't linkified as tags. Suppress standard CSS hex lengths (3/6/8)
|
|
86
|
+
// and any digit-containing hex; length 4/5/7 all-letter tokens stay tags so
|
|
87
|
+
// the iconic hex-words (#cafe, #dead, #beef, #face) still link. Mirrors the
|
|
88
|
+
// Ruby `color_like_tag?` helper byte-for-byte.
|
|
89
|
+
function isColorLikeTag(tag) {
|
|
90
|
+
var t = String(tag || '');
|
|
91
|
+
if (!/^[0-9a-fA-F]+$/.test(t)) return false;
|
|
92
|
+
if (t.length === 3 || t.length === 6 || t.length === 8) return true;
|
|
93
|
+
return t.length >= 3 && t.length <= 8 && /\d/.test(t);
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
function escapeHtml(value) {
|
|
68
97
|
return String(value == null ? '' : value)
|
|
69
98
|
.replace(/&/g, '&')
|
|
@@ -119,12 +148,19 @@
|
|
|
119
148
|
return '<div class="obsidian-embed obsidian-embed-broken alert alert-warning" role="alert">' +
|
|
120
149
|
'Embed not found: <code>' + escapeHtml(target) + '</code></div>';
|
|
121
150
|
}
|
|
122
|
-
var url = info.url + (parts.anchor ? '#' + parts.anchor
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
var url = info.url + (parts.anchor ? '#' + anchorize(parts.anchor) : '');
|
|
152
|
+
// Mirror the Liquid card in _includes/content/transclude.html so embeds
|
|
153
|
+
// styled by .obsidian-embed-source / .obsidian-embed-body render identically
|
|
154
|
+
// on the GitHub Pages (client) path. Excerpt is plain text (no client-side
|
|
155
|
+
// markdownify) — escaped to stay XSS-safe.
|
|
156
|
+
return '<aside class="obsidian-embed obsidian-embed-note card my-3" aria-label="Embedded note: ' +
|
|
157
|
+
escapeHtml(info.title || parts.page) + '">' +
|
|
158
|
+
'<div class="card-header"><span class="obsidian-embed-source">' +
|
|
159
|
+
'<i class="bi bi-link-45deg me-1" aria-hidden="true"></i>Embedded: ' +
|
|
160
|
+
'<a href="' + escapeHtml(url) + '">' + escapeHtml(info.title || parts.page) + '</a>' +
|
|
161
|
+
'</span></div>' +
|
|
162
|
+
'<div class="card-body obsidian-embed-body">' + escapeHtml(info.excerpt || '') + '</div>' +
|
|
163
|
+
'</aside>';
|
|
128
164
|
}
|
|
129
165
|
|
|
130
166
|
function renderWikiLink(target, aliasText, byKey, currentUrl) {
|
|
@@ -132,12 +168,14 @@
|
|
|
132
168
|
var display = aliasText || (parts.anchor ? parts.page + ' \u203A ' + parts.anchor : parts.page);
|
|
133
169
|
var info = byKey[normalize(parts.page)];
|
|
134
170
|
if (!info) {
|
|
135
|
-
|
|
171
|
+
// Non-navigating <span>: a broken link has no target, so a click must
|
|
172
|
+
// not scroll the page to the top (the old href="#" behaviour).
|
|
173
|
+
return '<span class="' + CONFIG.brokenLinkClass +
|
|
136
174
|
'" data-wiki-target="' + escapeHtml(parts.page) +
|
|
137
175
|
'" title="Unresolved wiki-link: ' + escapeHtml(parts.page) + '">' +
|
|
138
|
-
escapeHtml(display) + '</
|
|
176
|
+
escapeHtml(display) + '</span>';
|
|
139
177
|
}
|
|
140
|
-
var url = info.url + (parts.anchor ? '#' + parts.anchor
|
|
178
|
+
var url = info.url + (parts.anchor ? '#' + anchorize(parts.anchor) : '');
|
|
141
179
|
var currentAttr = currentUrl && info.url === currentUrl ? ' aria-current="page"' : '';
|
|
142
180
|
return '<a href="' + escapeHtml(url) + '" class="' + CONFIG.wikiLinkClass +
|
|
143
181
|
'" data-wiki-target="' + escapeHtml(parts.page) + '"' + currentAttr + '>' +
|
|
@@ -157,8 +195,13 @@
|
|
|
157
195
|
return renderWikiLink(target.trim(), (alias || '').trim(), byKey, currentUrl);
|
|
158
196
|
});
|
|
159
197
|
html = html.replace(TAG_RE, function (_match, lead, tag) {
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
// Not a tag (hex colour): keep the literal text, but still escape the
|
|
199
|
+
// raw lead char so the reconstruction loop can't inject markup.
|
|
200
|
+
if (isColorLikeTag(tag)) return escapeHtml(lead) + '#' + tag;
|
|
201
|
+
var url = (CONFIG.tagBase.replace(/\/$/, '') + '/#' + tagSlug(tag));
|
|
202
|
+
// The lead char is raw source text (often `<`/`&`) — escape it so the
|
|
203
|
+
// reconstruction loop in rewriteContainer can't inject markup.
|
|
204
|
+
return escapeHtml(lead) + '<a href="' + escapeHtml(url) + '" class="obsidian-tag">#' + escapeHtml(tag) + '</a>';
|
|
162
205
|
});
|
|
163
206
|
return html;
|
|
164
207
|
}
|
|
@@ -256,17 +299,17 @@
|
|
|
256
299
|
// `[!warning]+ Foldable warning`.
|
|
257
300
|
var CALLOUT_HEAD_RE = /^\s*\[!([A-Za-z]+)\]([+-]?)\s*([^\n]*)/;
|
|
258
301
|
|
|
302
|
+
var calloutSeq = 0;
|
|
303
|
+
|
|
259
304
|
function rewriteCallouts(container) {
|
|
260
305
|
if (!container) return 0;
|
|
261
306
|
var quotes = container.querySelectorAll('blockquote');
|
|
262
307
|
var count = 0;
|
|
263
308
|
quotes.forEach(function (bq) {
|
|
264
309
|
if (bq.dataset.obsidianCallout) return; // already processed
|
|
310
|
+
// Kramdown emits the callout head as the first <p> of the blockquote, so
|
|
311
|
+
// the first element child is the only place the `[!type]` marker can be.
|
|
265
312
|
var firstChild = bq.firstElementChild;
|
|
266
|
-
// Walk past whitespace text nodes
|
|
267
|
-
while (firstChild && firstChild.nodeName !== 'P' && firstChild.nodeType !== 1) {
|
|
268
|
-
firstChild = firstChild.nextElementSibling;
|
|
269
|
-
}
|
|
270
313
|
if (!firstChild || firstChild.nodeName !== 'P') return;
|
|
271
314
|
|
|
272
315
|
var rawText = firstChild.textContent || '';
|
|
@@ -276,7 +319,10 @@
|
|
|
276
319
|
var type = m[1].toLowerCase();
|
|
277
320
|
var spec = CALLOUT_TYPES[type] || CALLOUT_TYPES.note;
|
|
278
321
|
var fold = m[2];
|
|
279
|
-
var
|
|
322
|
+
var foldable = fold === '+' || fold === '-';
|
|
323
|
+
var collapsed = fold === '-';
|
|
324
|
+
var typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
|
|
325
|
+
var titleText = (m[3] || '').trim() || typeLabel;
|
|
280
326
|
|
|
281
327
|
// Strip the "[!type]…" head from the first paragraph (keep any trailing text)
|
|
282
328
|
var headLength = m[0].length;
|
|
@@ -291,15 +337,36 @@
|
|
|
291
337
|
wrapper.className = 'alert alert-' + spec.alert + ' obsidian-callout obsidian-callout-' + type;
|
|
292
338
|
wrapper.setAttribute('role', 'alert');
|
|
293
339
|
wrapper.dataset.obsidianCallout = type;
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
var
|
|
297
|
-
|
|
298
|
-
|
|
340
|
+
if (collapsed) wrapper.setAttribute('data-collapsed', 'true');
|
|
341
|
+
|
|
342
|
+
var bodyId = 'obsidian-callout-body-' + (++calloutSeq);
|
|
343
|
+
// Icon is decorative; voice the type for screen readers.
|
|
344
|
+
var titleInner = '<i class="bi ' + spec.icon + ' me-2" aria-hidden="true"></i>' +
|
|
345
|
+
'<span class="visually-hidden">' + escapeHtml(typeLabel) + ': </span>' +
|
|
346
|
+
escapeHtml(titleText);
|
|
347
|
+
|
|
348
|
+
var titleEl;
|
|
349
|
+
if (foldable) {
|
|
350
|
+
titleEl = document.createElement('button');
|
|
351
|
+
titleEl.setAttribute('type', 'button');
|
|
352
|
+
titleEl.className = 'obsidian-callout-title obsidian-callout-toggle';
|
|
353
|
+
titleEl.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
354
|
+
titleEl.setAttribute('aria-controls', bodyId);
|
|
355
|
+
titleEl.innerHTML = titleInner +
|
|
356
|
+
'<i class="bi bi-chevron-down obsidian-callout-chevron ms-auto" aria-hidden="true"></i>';
|
|
357
|
+
} else {
|
|
358
|
+
titleEl = document.createElement('div');
|
|
359
|
+
titleEl.className = 'obsidian-callout-title';
|
|
360
|
+
titleEl.setAttribute('role', 'heading');
|
|
361
|
+
titleEl.setAttribute('aria-level', '3');
|
|
362
|
+
titleEl.innerHTML = titleInner;
|
|
363
|
+
}
|
|
299
364
|
wrapper.appendChild(titleEl);
|
|
300
365
|
|
|
301
366
|
var bodyEl = document.createElement('div');
|
|
302
367
|
bodyEl.className = 'obsidian-callout-body';
|
|
368
|
+
bodyEl.setAttribute('id', bodyId);
|
|
369
|
+
if (collapsed) bodyEl.setAttribute('hidden', '');
|
|
303
370
|
// Move blockquote children into the body, preserving inner HTML
|
|
304
371
|
while (bq.firstChild) {
|
|
305
372
|
bodyEl.appendChild(bq.firstChild);
|
|
@@ -312,10 +379,40 @@
|
|
|
312
379
|
return count;
|
|
313
380
|
}
|
|
314
381
|
|
|
382
|
+
// Delegated toggle for foldable callouts. Bound once on the content container
|
|
383
|
+
// so it works for callouts rendered by EITHER path (server-side Ruby plugin or
|
|
384
|
+
// client-side rewriteCallouts). Native <button> handles Enter/Space for free.
|
|
385
|
+
function wireCalloutToggles(container) {
|
|
386
|
+
if (!container || !container.addEventListener || container.__obsidianCalloutToggleBound) return;
|
|
387
|
+
container.__obsidianCalloutToggleBound = true;
|
|
388
|
+
container.addEventListener('click', function (e) {
|
|
389
|
+
var btn = e.target && e.target.closest && e.target.closest('.obsidian-callout-toggle');
|
|
390
|
+
if (!btn || !container.contains(btn)) return;
|
|
391
|
+
var callout = btn.closest('.obsidian-callout');
|
|
392
|
+
var body = callout && callout.querySelector('.obsidian-callout-body');
|
|
393
|
+
if (!body) return;
|
|
394
|
+
var expanded = btn.getAttribute('aria-expanded') === 'true';
|
|
395
|
+
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
|
396
|
+
body.hidden = expanded;
|
|
397
|
+
if (expanded) {
|
|
398
|
+
callout.setAttribute('data-collapsed', 'true');
|
|
399
|
+
} else {
|
|
400
|
+
callout.removeAttribute('data-collapsed');
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
315
405
|
function init() {
|
|
406
|
+
// Respect `obsidian: { enabled: false }` even if the script is loaded.
|
|
407
|
+
if (OBSIDIAN_CONFIG.enabled === false) return;
|
|
408
|
+
|
|
316
409
|
var container = document.querySelector('#main-content, .bd-content, main, article') || document.body;
|
|
317
410
|
if (!container) return;
|
|
318
411
|
|
|
412
|
+
// Bind the foldable-callout toggle first so it works even when the index
|
|
413
|
+
// fetch fails or when callouts were rendered server-side by the plugin.
|
|
414
|
+
wireCalloutToggles(container);
|
|
415
|
+
|
|
319
416
|
fetch(CONFIG.indexUrl, { credentials: 'same-origin', cache: 'force-cache' })
|
|
320
417
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
321
418
|
.then(function (payload) {
|
data/assets/js/search-modal.js
CHANGED
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
const searchIndexUrl = new URL('/search.json', window.location.origin);
|
|
62
62
|
let searchIndex = null;
|
|
63
63
|
let searchIndexPromise = null;
|
|
64
|
+
let searchIndexAvailable = true;
|
|
64
65
|
let searchTimeout = null;
|
|
65
66
|
|
|
66
67
|
const showSearchModal = () => {
|
|
@@ -136,13 +137,27 @@
|
|
|
136
137
|
clearResults();
|
|
137
138
|
});
|
|
138
139
|
|
|
139
|
-
// Prevent empty submissions
|
|
140
|
+
// Prevent empty submissions, and keep submissions in-modal when the
|
|
141
|
+
// /sitemap/ target isn't published (remote-theme consumers) so the
|
|
142
|
+
// form's no-JS action doesn't navigate to a 404.
|
|
140
143
|
if (searchForm && searchInput) {
|
|
141
144
|
searchForm.addEventListener('submit', (event) => {
|
|
142
145
|
if (!searchInput.value.trim()) {
|
|
143
146
|
event.preventDefault();
|
|
144
147
|
searchInput.focus();
|
|
148
|
+
return;
|
|
145
149
|
}
|
|
150
|
+
// The index load is what tells us whether /sitemap/ exists. If it
|
|
151
|
+
// hasn't resolved yet, hold the navigation, then either render
|
|
152
|
+
// in-modal (no sitemap) or submit to /sitemap/ (sitemap present).
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
loadSearchIndex().then(() => {
|
|
155
|
+
if (searchIndexAvailable) {
|
|
156
|
+
searchForm.submit();
|
|
157
|
+
} else {
|
|
158
|
+
triggerSearch();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
146
161
|
});
|
|
147
162
|
}
|
|
148
163
|
|
|
@@ -175,7 +190,11 @@
|
|
|
175
190
|
if (!items.length) {
|
|
176
191
|
const empty = document.createElement('div');
|
|
177
192
|
empty.className = 'text-muted small';
|
|
178
|
-
|
|
193
|
+
// Distinguish "the index couldn't be loaded" from "no matches",
|
|
194
|
+
// so search degrades clearly where /search.json isn't published.
|
|
195
|
+
empty.textContent = searchIndexAvailable
|
|
196
|
+
? 'No results found.'
|
|
197
|
+
: 'Search is unavailable on this site.';
|
|
179
198
|
resultsContainer.appendChild(empty);
|
|
180
199
|
return;
|
|
181
200
|
}
|
|
@@ -206,11 +225,16 @@
|
|
|
206
225
|
|
|
207
226
|
resultsContainer.appendChild(list);
|
|
208
227
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
228
|
+
// The "view all" target (/sitemap/) ships from the same plugin-only
|
|
229
|
+
// generator as /search.json; only offer it when the index loaded, so
|
|
230
|
+
// remote-theme consumers without it aren't sent to a 404.
|
|
231
|
+
if (searchIndexAvailable) {
|
|
232
|
+
const viewAll = document.createElement('a');
|
|
233
|
+
viewAll.className = 'd-block mt-2 small';
|
|
234
|
+
viewAll.href = `/sitemap/?q=${encodeURIComponent(query)}`;
|
|
235
|
+
viewAll.textContent = 'View all results';
|
|
236
|
+
resultsContainer.appendChild(viewAll);
|
|
237
|
+
}
|
|
214
238
|
}
|
|
215
239
|
|
|
216
240
|
function escapeHtml(value) {
|
|
@@ -262,12 +286,24 @@
|
|
|
262
286
|
if (searchIndex) return Promise.resolve(searchIndex);
|
|
263
287
|
if (!searchIndexPromise) {
|
|
264
288
|
searchIndexPromise = fetch(searchIndexUrl.toString())
|
|
265
|
-
.then((response) =>
|
|
289
|
+
.then((response) => {
|
|
290
|
+
// A missing /search.json (e.g. a remote-theme GitHub Pages
|
|
291
|
+
// consumer where the plugin-only generator never ran) is not
|
|
292
|
+
// an empty index — record it so the UI can degrade clearly.
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
searchIndexAvailable = false;
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
return response.json();
|
|
298
|
+
})
|
|
266
299
|
.then((data) => {
|
|
267
300
|
searchIndex = Array.isArray(data) ? data : [];
|
|
268
301
|
return searchIndex;
|
|
269
302
|
})
|
|
270
|
-
.catch(() =>
|
|
303
|
+
.catch(() => {
|
|
304
|
+
searchIndexAvailable = false;
|
|
305
|
+
return [];
|
|
306
|
+
});
|
|
271
307
|
}
|
|
272
308
|
return searchIndexPromise;
|
|
273
309
|
}
|