Testing of the @doc-json output
1let js = {|
2// Global state
3const BASE_URL = window.BASE_URL || './';
4let CURRENT_URL = window.CURRENT_URL || 'index.html';
5let sidebarData = null;
6
7// Compute the root URL for absolute fetching (handles SPA navigation)
8const ROOT_URL = new URL(BASE_URL, window.location.href).href;
9
10// Sidebar state management
11const STORAGE_KEY = 'odoc-docsite-sidebar-state';
12let sidebarState = {};
13try {
14 sidebarState = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
15} catch (e) {}
16
17function saveSidebarState() {
18 try {
19 localStorage.setItem(STORAGE_KEY, JSON.stringify(sidebarState));
20 } catch (e) {}
21}
22
23function escapeHtml(s) {
24 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
25 .replace(/"/g, '"').replace(/'/g, ''');
26}
27
28// Sidebar rendering
29function kindBadge(kind) {
30 if (!kind) return '';
31 const labels = {
32 'package': 'P', 'module': 'M', 'module-type': 'MT',
33 'page': 'pg', 'leaf-page': 'pg', 'class': 'C', 'class-type': 'CT',
34 'library': 'L'
35 };
36 const label = labels[kind] || kind[0].toUpperCase();
37 var cssKind = kind.replace(/[^a-z]/g, '-');
38 return '<span class="sidebar-kind sidebar-kind-' + cssKind + '" title="' + kind + '">' + label + '</span>';
39}
40
41function renderSidebarEntry(entry) {
42 var node = entry.node;
43 var hasChildren = entry.children && entry.children.length > 0;
44 var isActive = node.url === CURRENT_URL;
45 var stateKey = node.content;
46 // Default to collapsed unless explicitly expanded
47 var isCollapsed = sidebarState[stateKey] !== true;
48 var badge = kindBadge(node.kind);
49
50 // Content may contain inline HTML (e.g. <code>odoc</code>), so we
51 // pass it through as-is rather than escaping it.
52 var contentHtml = node.content;
53
54 // Render packages/modules with children as collapsible groups
55 if (hasChildren) {
56 var childrenHtml = entry.children.map(function(c) { return renderSidebarEntry(c); }).join('');
57 var linkHtml = node.url
58 ? '<a class="sidebar-link' + (isActive ? ' active' : '') + '" href="' + BASE_URL + node.url + '" data-nav="' + node.url + '">' + badge + contentHtml + '</a>'
59 : '<span class="sidebar-label">' + badge + contentHtml + '</span>';
60 return '<div class="sidebar-group' + (isCollapsed ? ' collapsed' : '') + '" data-id="' + escapeHtml(stateKey) + '">' +
61 '<div class="sidebar-group-header">' +
62 '<svg class="sidebar-toggle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
63 '<polyline points="6 9 12 15 18 9"></polyline>' +
64 '</svg>' +
65 linkHtml +
66 '</div>' +
67 '<div class="sidebar-children">' + childrenHtml + '</div>' +
68 '</div>';
69 } else if (node.url) {
70 // Leaf nodes: add spacer for alignment with expandable items
71 var spacer = '<span class="sidebar-toggle-spacer"></span>';
72 return '<a class="sidebar-link sidebar-leaf' + (isActive ? ' active' : '') + '" href="' + BASE_URL + node.url + '" data-nav="' + node.url + '">' + spacer + badge + contentHtml + '</a>';
73 } else {
74 return '<span class="sidebar-link">' + badge + contentHtml + '</span>';
75 }
76}
77
78function updateSidebarActive(options) {
79 options = options || {};
80 var scrollIntoView = options.scrollIntoView || false;
81 var updatePackageFilter = options.updatePackageFilter || false;
82 var container = document.getElementById('sidebar-content');
83 if (!container) return;
84
85 // Remove old active
86 container.querySelectorAll('.sidebar-link.active').forEach(function(el) { el.classList.remove('active'); });
87
88 // Try to find exact match first
89 var activeLink = container.querySelector('[data-nav="' + CURRENT_URL + '"]');
90
91 // If no exact match, find the best matching ancestor
92 if (!activeLink) {
93 var urlWithoutHash = CURRENT_URL.split('#')[0];
94 var parts = urlWithoutHash.split('/');
95
96 while (parts.length > 1 && !activeLink) {
97 parts.pop();
98 var tryUrl = parts.join('/') + '/index.html';
99 activeLink = container.querySelector('[data-nav="' + tryUrl + '"]');
100 }
101
102 if (!activeLink) {
103 var urlPackage = getPackageFromUrl(CURRENT_URL);
104 if (urlPackage) {
105 activeLink = container.querySelector('[data-nav="' + urlPackage + '/index.html"]');
106 }
107 }
108 }
109
110 if (activeLink) {
111 activeLink.classList.add('active');
112 var parent = activeLink.closest('.sidebar-group');
113 while (parent) {
114 parent.classList.remove('collapsed');
115 sidebarState[parent.dataset.id] = true;
116 parent = parent.parentElement.closest('.sidebar-group');
117 }
118 saveSidebarState();
119 if (scrollIntoView) {
120 setTimeout(function() { activeLink.scrollIntoView({ block: 'center', behavior: 'instant' }); }, 0);
121 }
122 }
123
124 if (updatePackageFilter) {
125 var select = document.getElementById('package-select');
126 if (select && sidebarData) {
127 var urlPackage2 = getPackageFromUrl(CURRENT_URL);
128 var packages = sidebarData
129 .filter(function(entry) { return entry.node.kind === 'package'; })
130 .map(function(entry) { return entry.node.content; });
131
132 if (CURRENT_URL === 'index.html' || !urlPackage2) {
133 if (select.value !== '') {
134 select.value = '';
135 selectedPackage = '';
136 filterSidebarByPackage('');
137 }
138 } else if (packages.indexOf(urlPackage2) >= 0 && select.value !== urlPackage2) {
139 select.value = urlPackage2;
140 selectedPackage = urlPackage2;
141 filterSidebarByPackage(urlPackage2);
142 }
143 }
144 }
145}
146
147function initSidebar(data) {
148 sidebarData = data;
149 var container = document.getElementById('sidebar-content');
150 if (!container) return;
151
152 if (CURRENT_URL === 'index.html') {
153 sidebarState = {};
154 saveSidebarState();
155 }
156
157 var html = data.map(function(entry) { return renderSidebarEntry(entry); }).join('');
158 container.innerHTML = html;
159
160 container.querySelectorAll('.sidebar-group-header').forEach(function(header) {
161 header.addEventListener('click', function(e) {
162 if (e.target.closest('a')) return;
163 var group = header.closest('.sidebar-group');
164 var id = group.dataset.id;
165 group.classList.toggle('collapsed');
166 sidebarState[id] = !group.classList.contains('collapsed');
167 saveSidebarState();
168 });
169 });
170
171 initPackageSelector(data);
172
173 if (CURRENT_URL !== 'index.html') {
174 updateSidebarActive({ scrollIntoView: true, updatePackageFilter: true });
175 }
176}
177
178// Package selector functionality
179var PACKAGE_STORAGE_KEY = 'odoc-docsite-selected-package';
180var selectedPackage = '';
181try {
182 selectedPackage = localStorage.getItem(PACKAGE_STORAGE_KEY) || '';
183} catch (e) {}
184
185function saveSelectedPackage(pkg) {
186 selectedPackage = pkg;
187 try {
188 localStorage.setItem(PACKAGE_STORAGE_KEY, pkg);
189 } catch (e) {}
190}
191
192function getPackageFromUrl(url) {
193 var parts = url.split('/');
194 return parts.length > 0 ? parts[0] : '';
195}
196
197function initPackageSelector(data) {
198 var select = document.getElementById('package-select');
199 if (!select) return;
200
201 var packages = data
202 .filter(function(entry) { return entry.node.kind === 'package'; })
203 .map(function(entry) { return entry.node.content; })
204 .sort();
205
206 select.innerHTML = '<option value="">All packages</option>';
207 packages.forEach(function(pkg) {
208 var option = document.createElement('option');
209 option.value = pkg;
210 option.textContent = pkg;
211 select.appendChild(option);
212 });
213
214 var urlPackage = getPackageFromUrl(CURRENT_URL);
215 if (CURRENT_URL === 'index.html' || !urlPackage) {
216 selectedPackage = '';
217 select.value = '';
218 filterSidebarByPackage('');
219 } else if (packages.indexOf(urlPackage) >= 0) {
220 selectedPackage = urlPackage;
221 select.value = urlPackage;
222 filterSidebarByPackage(urlPackage);
223 }
224
225 select.addEventListener('change', function(e) {
226 var pkg = e.target.value;
227 saveSelectedPackage(pkg);
228 filterSidebarByPackage(pkg);
229 });
230}
231
232function filterSidebarByPackage(pkg) {
233 var container = document.getElementById('sidebar-content');
234 if (!container) return;
235
236 var groups = container.querySelectorAll(':scope > .sidebar-group, :scope > .sidebar-link');
237
238 groups.forEach(function(group) {
239 if (!pkg) {
240 group.style.display = '';
241 } else {
242 var id = group.dataset.id || group.textContent.trim();
243 group.style.display = (id === pkg) ? '' : 'none';
244 }
245 });
246
247 if (pkg) {
248 var matchingGroup = container.querySelector('.sidebar-group[data-id="' + pkg + '"]');
249 if (matchingGroup) {
250 matchingGroup.classList.remove('collapsed');
251 sidebarState[pkg] = true;
252 saveSidebarState();
253 }
254 }
255}
256
257// Highlight element matching URL hash
258function highlightHash() {
259 document.querySelectorAll('.hash-highlight').forEach(function(el) { el.classList.remove('hash-highlight'); });
260
261 var hash = window.location.hash;
262 if (hash) {
263 var target = document.querySelector(hash);
264 if (target) {
265 target.classList.add('hash-highlight');
266 setTimeout(function() {
267 target.scrollIntoView({ behavior: 'smooth', block: 'center' });
268 }, 50);
269 }
270 }
271}
272
273window.addEventListener('hashchange', highlightHash);
274
275// SPA Navigation
276async function navigateTo(url, pushState) {
277 if (pushState === undefined) pushState = true;
278 try {
279 var response = await fetch(ROOT_URL + url);
280 if (!response.ok) throw new Error('Failed to load page');
281 var html = await response.text();
282
283 var parser = new DOMParser();
284 var doc = parser.parseFromString(html, 'text/html');
285
286 var newContent = doc.querySelector('.odoc-content');
287 var newBreadcrumbs = doc.querySelector('.odoc-nav');
288 var newToc = doc.querySelector('.odoc-tocs');
289 var newTitle = doc.querySelector('title');
290
291 if (newContent) {
292 document.querySelector('.odoc-content').innerHTML = newContent.innerHTML;
293 }
294 if (newBreadcrumbs) {
295 document.querySelector('.odoc-nav').innerHTML = newBreadcrumbs.innerHTML;
296 }
297
298 var existingToc = document.querySelector('.odoc-tocs');
299 if (newToc) {
300 if (existingToc) {
301 existingToc.outerHTML = newToc.outerHTML;
302 } else {
303 document.querySelector('.docsite-main').insertAdjacentHTML('beforeend', newToc.outerHTML);
304 }
305 } else if (existingToc) {
306 existingToc.remove();
307 }
308
309 if (newTitle) {
310 document.title = newTitle.textContent;
311 }
312
313 // Collect inline scripts first (before we start loading external scripts)
314 var fetchedPageBase = ROOT_URL + url;
315 var inlineScripts = Array.from(doc.querySelectorAll('head script:not([src])'));
316
317 // Inject external CSS/JS; track load events for newly added scripts
318 var newScriptLoadPromises = [];
319 doc.querySelectorAll('head link[rel="stylesheet"], head script[src]').forEach(function(el) {
320 var attr = el.tagName === 'LINK' ? 'href' : 'src';
321 var resUrl = el.getAttribute(attr);
322 if (!resUrl) return;
323 var abs = new URL(resUrl, fetchedPageBase).href;
324 var selector = el.tagName === 'LINK'
325 ? 'link[rel="stylesheet"]'
326 : 'script[src]';
327 var already = Array.from(document.querySelectorAll('head ' + selector)).some(function(existing) {
328 var existingUrl = existing.getAttribute(attr);
329 if (!existingUrl) return false;
330 return new URL(existingUrl, window.location.href).href === abs;
331 });
332 if (!already) {
333 var clone = el.cloneNode(true);
334 clone.setAttribute(attr, abs);
335 if (el.tagName === 'SCRIPT') {
336 var p = new Promise(function(resolve) {
337 clone.onload = resolve;
338 clone.onerror = resolve;
339 });
340 newScriptLoadPromises.push(p);
341 }
342 document.head.appendChild(clone);
343 }
344 });
345
346 // After all newly added external scripts have loaded, execute inline scripts.
347 // Deduplicate via data-spa-inline attribute set during HTML generation.
348 Promise.all(newScriptLoadPromises).then(function() {
349 inlineScripts.forEach(function(el) {
350 var id = el.getAttribute('data-spa-inline');
351 if (id && document.querySelector('head script[data-spa-inline="' + id + '"]')) return;
352 var s = el.cloneNode(true);
353 document.head.appendChild(s);
354 });
355 document.dispatchEvent(new CustomEvent('odoc-spa-loaded'));
356 });
357
358 CURRENT_URL = url;
359 if (pushState) {
360 history.pushState({ url: url }, '', ROOT_URL + url);
361 }
362
363 updateSidebarActive();
364
365 var searchResults = document.querySelector('.search-results');
366 var searchInput = document.querySelector('.search-input');
367 if (searchResults) searchResults.classList.remove('active');
368 if (searchInput) searchInput.value = '';
369
370 var hash = url.indexOf('#') >= 0 ? '#' + url.split('#')[1] : window.location.hash;
371 if (hash) {
372 setTimeout(function() { highlightHash(); }, 100);
373 } else {
374 document.querySelector('.docsite-main').scrollTop = 0;
375 window.scrollTo(0, 0);
376 }
377
378 } catch (e) {
379 console.error('Navigation failed:', e);
380 window.location.href = ROOT_URL + url;
381 }
382}
383
384window.addEventListener('popstate', function(e) {
385 if (e.state && e.state.url) {
386 navigateTo(e.state.url, false);
387 }
388});
389
390// Intercept link clicks for SPA navigation
391document.addEventListener('click', function(e) {
392 var link = e.target.closest('a[href]');
393 if (!link) return;
394
395 var href = link.getAttribute('href');
396 if (!href) return;
397
398 if (href.indexOf('http') === 0 || href.indexOf('//') === 0 || href.indexOf('#') === 0 ||
399 href.indexOf('mailto:') === 0 || href.indexOf('javascript:') === 0) {
400 return;
401 }
402
403 var navPath = link.dataset.nav;
404 if (navPath) {
405 e.preventDefault();
406 navigateTo(navPath);
407 return;
408 }
409
410 var targetUrl = href;
411 if (href.indexOf('/') === 0) {
412 targetUrl = href.slice(1);
413 } else if (href.indexOf('./') === 0) {
414 var currentDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1);
415 targetUrl = currentDir + href.slice(2);
416 } else if (href.indexOf('../') === 0) {
417 var currentParts = CURRENT_URL.split('/');
418 currentParts.pop();
419 var hrefParts = href.split('/');
420 for (var i = 0; i < hrefParts.length; i++) {
421 var part = hrefParts[i];
422 if (part === '..') {
423 currentParts.pop();
424 } else if (part !== '.') {
425 currentParts.push(part);
426 }
427 }
428 targetUrl = currentParts.join('/');
429 } else {
430 var curDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1);
431 targetUrl = curDir + href;
432 }
433
434 if (targetUrl.indexOf('#') >= 0) {
435 var pathAndHash = targetUrl.split('#');
436 if (pathAndHash[0] === CURRENT_URL || pathAndHash[0] === '') {
437 return;
438 }
439 }
440
441 e.preventDefault();
442 navigateTo(targetUrl);
443});
444
445// Initialize
446(function() {
447 history.replaceState({ url: CURRENT_URL }, '', window.location.href);
448
449 // Read sidebar data from inline script tag if available
450 var inlineData = window.__DOCSITE_SIDEBAR_DATA__;
451 if (inlineData) {
452 initSidebar(inlineData);
453 } else {
454 // Fallback: fetch sidebar.json
455 fetch(ROOT_URL + 'sidebar.json')
456 .then(function(r) { return r.json(); })
457 .then(function(data) { initSidebar(data); })
458 .catch(function(e) {
459 console.error('Failed to load sidebar:', e);
460 var el = document.getElementById('sidebar-content');
461 if (el) el.innerHTML = '<div class="sidebar-error">Failed to load navigation</div>';
462 });
463 }
464
465 if (window.location.hash) {
466 setTimeout(highlightHash, 100);
467 }
468})();
469
470// Search functionality
471(function() {
472 var worker;
473 var waiting = 0;
474
475 var searchInput = document.querySelector('.search-input');
476 var searchResults = document.querySelector('.search-results');
477 var searchWrapper = document.querySelector('.search-wrapper');
478
479 if (!searchInput) return;
480
481 function showBusy() {
482 waiting++;
483 searchWrapper.classList.add('search-busy');
484 }
485
486 function hideBusy() {
487 if (waiting > 0) waiting--;
488 if (waiting === 0) {
489 searchWrapper.classList.remove('search-busy');
490 }
491 }
492
493 function createWorker() {
494 var searchUrls = ['db.js', 'sherlodoc.js'].map(function(url) { return '"' + ROOT_URL + url + '"'; });
495 var blob = new Blob(['importScripts(' + searchUrls.join(',') + ');'], { type: 'application/javascript' });
496 var blobUrl = URL.createObjectURL(blob);
497 var w = new Worker(blobUrl);
498 URL.revokeObjectURL(blobUrl);
499 return w;
500 }
501
502 searchInput.addEventListener('focus', function() {
503 if (!worker) {
504 worker = createWorker();
505 worker.onmessage = function(e) {
506 hideBusy();
507 var results = e.data;
508 searchResults.innerHTML = '';
509
510 if (results.length === 0 && searchInput.value.trim() !== '') {
511 searchResults.innerHTML = '<div class="search-no-result">No results found</div>';
512 } else {
513 results.forEach(function(entry) {
514 var link = document.createElement('a');
515 link.className = 'search-result';
516 link.href = ROOT_URL + entry.url;
517 link.dataset.nav = entry.url;
518 link.innerHTML = entry.html;
519 searchResults.appendChild(link);
520 });
521 }
522
523 if (searchInput.value.trim() !== '') {
524 searchResults.classList.add('active');
525 }
526 };
527 }
528 });
529
530 searchInput.addEventListener('input', function(e) {
531 var query = e.target.value.trim();
532 if (query === '') {
533 searchResults.classList.remove('active');
534 searchResults.innerHTML = '';
535 return;
536 }
537 showBusy();
538 worker.postMessage(query);
539 });
540
541 document.addEventListener('keydown', function(e) {
542 if (e.key === '/' && document.activeElement !== searchInput) {
543 e.preventDefault();
544 searchInput.focus();
545 }
546 if (e.key === 'Escape' && document.activeElement === searchInput) {
547 searchInput.blur();
548 searchResults.classList.remove('active');
549 }
550 if (e.key === 'ArrowDown' && searchResults.classList.contains('active')) {
551 e.preventDefault();
552 var items = Array.from(searchResults.querySelectorAll('.search-result'));
553 var idx = items.findIndex(function(el) { return el === document.activeElement; });
554 if (idx < items.length - 1) items[idx + 1].focus();
555 else if (idx === -1 && items.length > 0) items[0].focus();
556 }
557 if (e.key === 'ArrowUp' && searchResults.classList.contains('active')) {
558 e.preventDefault();
559 var items2 = Array.from(searchResults.querySelectorAll('.search-result'));
560 var idx2 = items2.findIndex(function(el) { return el === document.activeElement; });
561 if (idx2 > 0) items2[idx2 - 1].focus();
562 else if (idx2 === 0) searchInput.focus();
563 }
564 });
565
566 document.addEventListener('click', function(e) {
567 if (!searchWrapper.contains(e.target)) searchResults.classList.remove('active');
568 });
569})();
570
571// Mobile menu toggle
572(function() {
573 var menuToggle = document.querySelector('.menu-toggle');
574 var sidebar = document.querySelector('.docsite-sidebar');
575 if (menuToggle && sidebar) {
576 menuToggle.addEventListener('click', function() { sidebar.classList.toggle('open'); });
577 }
578})();
579|}