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.
@@ -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) {
@@ -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
- empty.textContent = 'No results found.';
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
- const viewAll = document.createElement('a');
210
- viewAll.className = 'd-block mt-2 small';
211
- viewAll.href = `/sitemap/?q=${encodeURIComponent(query)}`;
212
- viewAll.textContent = 'View all results';
213
- resultsContainer.appendChild(viewAll);
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) => (response.ok ? response.json() : []))
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
  }