jekyll-theme-zer0 1.20.2 → 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.
@@ -34,6 +34,48 @@
34
34
  return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
35
35
  }
36
36
 
37
+ function prefersReducedMotion() {
38
+ return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
39
+ }
40
+
41
+ // Cytoscape is vendored under assets/vendor/ (no runtime CDN). Path comes from
42
+ // window.OBSIDIAN_CONFIG.cytoscapeUrl (set by Liquid), with a base fallback.
43
+ function cytoscapeSrc() {
44
+ var cfg = window.OBSIDIAN_CONFIG || {};
45
+ if (cfg.cytoscapeUrl) return cfg.cytoscapeUrl;
46
+ var base = (document.querySelector('base') || {}).href || '/';
47
+ return base.replace(/\/$/, '') + '/assets/vendor/cytoscape/cytoscape.min.js';
48
+ }
49
+
50
+ // Load cytoscape, coordinating with obsidian-local-graph.js via the shared
51
+ // window.__obsidianCytoscapeLoading queue. cb(true) on success, cb(false) on
52
+ // failure so the caller can show a recovery message.
53
+ function loadCytoscape(cb) {
54
+ if (typeof window.cytoscape === 'function') return cb(true);
55
+ if (window.__obsidianCytoscapeLoading) {
56
+ window.__obsidianCytoscapeLoading.push(cb);
57
+ return;
58
+ }
59
+ var queue = window.__obsidianCytoscapeLoading = [cb];
60
+ function flush(ok) {
61
+ window.__obsidianCytoscapeLoading = null;
62
+ queue.forEach(function (fn) { try { fn(ok); } catch (e) { /* ignore */ } });
63
+ }
64
+ var existing = document.querySelector('script[src*="cytoscape"]');
65
+ if (existing) {
66
+ if (typeof window.cytoscape === 'function') return flush(true);
67
+ existing.addEventListener('load', function () { flush(true); });
68
+ existing.addEventListener('error', function () { flush(false); });
69
+ return;
70
+ }
71
+ var s = document.createElement('script');
72
+ s.src = cytoscapeSrc();
73
+ s.defer = true;
74
+ s.onload = function () { flush(true); };
75
+ s.onerror = function () { flush(false); };
76
+ document.head.appendChild(s);
77
+ }
78
+
37
79
  function buildLookup(entries) {
38
80
  var byKey = Object.create(null);
39
81
  entries.forEach(function (entry) {
@@ -211,6 +253,8 @@
211
253
 
212
254
  var theme = readTheme();
213
255
  container.style.backgroundColor = theme.canvasBg;
256
+ var nodeDur = prefersReducedMotion() ? '0ms' : '160ms';
257
+ var edgeDur = prefersReducedMotion() ? '0ms' : '150ms';
214
258
 
215
259
  var cy = window.cytoscape({
216
260
  container: container,
@@ -245,7 +289,7 @@
245
289
  'border-color': theme.nodeBorder,
246
290
  'opacity': 0.88,
247
291
  'transition-property': 'background-color, border-color, width, height, text-opacity, opacity',
248
- 'transition-duration': '160ms'
292
+ 'transition-duration': nodeDur
249
293
  }
250
294
  },
251
295
  {
@@ -279,7 +323,7 @@
279
323
  'arrow-scale': 0.75,
280
324
  'opacity': 0.55,
281
325
  'transition-property': 'line-color, width, opacity',
282
- 'transition-duration': '150ms'
326
+ 'transition-duration': edgeDur
283
327
  }
284
328
  },
285
329
  {
@@ -409,18 +453,9 @@
409
453
  function applyOrphansVisibility(cy, show) {
410
454
  if (!cy) return;
411
455
  var orphans = cy.nodes().filter(function (n) { return n.degree(false) === 0; });
412
- if (show) {
413
- orphans.style('display', 'element');
414
- } else {
415
- orphans.style('display', 'none');
416
- }
417
- // Re-run a quick layout pass on visible elements so the connected
418
- // cluster expands into the freed space.
419
- cy.layout(Object.assign({}, COSE_LAYOUT, {
420
- randomize: false,
421
- numIter: 1400,
422
- eles: cy.elements(':visible')
423
- })).run();
456
+ orphans.style('display', show ? 'element' : 'none');
457
+ // The initial COSE pass already positioned every node, so just refit the
458
+ // visible set — avoids a jarring full relayout of the stable core on toggle.
424
459
  cy.fit(cy.elements(':visible'), 80);
425
460
  }
426
461
 
@@ -467,13 +502,23 @@
467
502
  var byKey = buildLookup(entries);
468
503
  var elements = buildElements(entries, byKey);
469
504
  computeNodeDegree(elements);
470
- container.innerHTML = '';
471
- setStats(entries, elements);
472
- var cy = renderGraph(container, elements);
473
- wireSearch(cy, byKey);
474
- wireFitButton(cy);
475
- wireOrphansToggle(cy);
476
- window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
505
+ loadCytoscape(function (ok) {
506
+ if (!ok) {
507
+ container.innerHTML =
508
+ '<div class="alert alert-danger" role="alert">' +
509
+ 'Graph view failed to load: the <code>cytoscape</code> library could ' +
510
+ 'not be fetched. Check your network connection or content security policy.' +
511
+ '</div>';
512
+ return;
513
+ }
514
+ container.innerHTML = '';
515
+ setStats(entries, elements);
516
+ var cy = renderGraph(container, elements);
517
+ wireSearch(cy, byKey);
518
+ wireFitButton(cy);
519
+ wireOrphansToggle(cy);
520
+ window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
521
+ });
477
522
  })
478
523
  .catch(function (err) {
479
524
  container.innerHTML =
@@ -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
- // Cytoscape is vendored locally (assets/vendor/cytoscape/) — no CDN, so the
30
- // graph works under strict CSP and offline. Resolve the vendored URL from this
31
- // script's own (trusted) src so it stays correct under any site baseurl,
32
- // without flowing DOM-supplied text into a script element. See issues #152, #205.
33
- var SELF_SRC = (document.currentScript && document.currentScript.src) || '';
34
- var CYTOSCAPE_URL = '/assets/vendor/cytoscape/cytoscape.min.js';
35
- var _selfMatch = SELF_SRC.match(/^(.*\/)assets\/js\/obsidian-local-graph\.js(?:[?#].*)?$/);
36
- if (_selfMatch) {
37
- CYTOSCAPE_URL = _selfMatch[1] + 'assets/vendor/cytoscape/cytoscape.min.js';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
57
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
- existing.addEventListener('load', function () {
260
- window.__obsidianCytoscapeLoading.forEach(function (fn) { fn(); });
261
- window.__obsidianCytoscapeLoading = null;
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 = CYTOSCAPE_URL;
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
- window.__obsidianCytoscapeLoading = null;
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': '160ms'
333
+ 'transition-duration': motion
311
334
  }
312
335
  },
313
336
  {
@@ -322,7 +345,11 @@
322
345
  }
323
346
  },
324
347
  {
325
- selector: 'node[broken]',
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
- setPanelAvailable(container, true);
392
- setStatus(container, 'Loading graph...', false);
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
- loadCytoscape(function () {
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 — hide and stay quiet.
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, '&amp;')
@@ -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.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') : '');
123
- return '<div class="obsidian-embed obsidian-embed-note">' +
124
- '<div class="obsidian-embed-header"><a href="' + escapeHtml(url) + '">' +
125
- escapeHtml(info.title || parts.page) + '</a></div>' +
126
- '<div class="obsidian-embed-excerpt">' + escapeHtml(info.excerpt || '') + '</div>' +
127
- '</div>';
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
- return '<a href="#" class="' + CONFIG.brokenLinkClass +
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) + '</a>';
176
+ escapeHtml(display) + '</span>';
139
177
  }
140
- var url = info.url + (parts.anchor ? '#' + parts.anchor.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') : '');
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
- var url = (CONFIG.tagBase.replace(/\/$/, '') + '/#' + tag.toLowerCase().replace(/\//g, '-'));
161
- return lead + '<a href="' + escapeHtml(url) + '" class="obsidian-tag">#' + escapeHtml(tag) + '</a>';
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 titleText = (m[3] || '').trim() || (type.charAt(0).toUpperCase() + type.slice(1));
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 (fold === '-') wrapper.dataset.collapsed = 'true';
295
-
296
- var titleEl = document.createElement('div');
297
- titleEl.className = 'obsidian-callout-title';
298
- titleEl.innerHTML = '<i class="bi ' + spec.icon + ' me-2" aria-hidden="true"></i>' + escapeHtml(titleText);
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) {