this repo has no description

Add SPA navigation to jon-shell reference section

Switch from server-rendered sidebar to JS-rendered sidebar from
sidebar_data JSON (same approach as odoc-docsite). On link clicks
within /reference/, fetch the page, swap .odoc-content, update title
and body class — no full reload. Back/forward via popstate.

- New odoc_jon_shell_js.ml: sidebar rendering, collapsible entries,
SPA navigation with click interception and resource deduplication
- odoc_jon_shell.ml: inject BASE_URL/CURRENT_URL/sidebar JSON in head,
register JS support file, empty sidebar container filled by JS,
extension resources moved to head for SPA discovery
- odoc_jon_shell_css.ml: collapsible toggle triangles, sidebar-label
styling for non-link entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+602 -34
+122 -33
odoc-jon-shell/src/odoc_jon_shell.ml
··· 5 5 module Html = Tyxml.Html 6 6 module Url = Odoc_document.Url 7 7 8 - (* Register CSS as a support file *) 8 + (* Register CSS and JS as support files *) 9 9 let () = 10 10 Odoc_extension_registry.register_support_file ~prefix:"jon-shell" 11 11 { 12 12 filename = "extensions/jon-shell.css"; 13 13 content = Inline Odoc_jon_shell_css.css; 14 + }; 15 + Odoc_extension_registry.register_support_file ~prefix:"jon-shell" 16 + { 17 + filename = "extensions/jon-shell.js"; 18 + content = Inline Odoc_jon_shell_js.js; 14 19 } 20 + 21 + (* Serialize sidebar data to JSON for inline embedding *) 22 + let sidebar_json_script sidebar_data = 23 + match sidebar_data with 24 + | None -> [] 25 + | Some data -> 26 + let json = Odoc_html.Sidebar.to_json data in 27 + let json_str = Json.to_string json in 28 + [ 29 + Html.script 30 + (Html.cdata_script 31 + (Printf.sprintf "window.__SIDEBAR_DATA__ = %s;" json_str)); 32 + ] 15 33 16 34 (* --- Helpers --- *) 17 35 ··· 24 42 25 43 (* --- Page assembly --- *) 26 44 27 - let page_creator ~config ~url ~uses_katex ~resources ~sidebar ~header ~preamble 28 - content = 45 + let page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~header 46 + ~preamble content = 29 47 let support_uri = Odoc_html.Config.support_uri config in 30 48 let file_uri = file_uri ~config ~url in 31 49 let shell_css_uri = file_uri support_uri "extensions/jon-shell.css" in 50 + let shell_js_uri = file_uri support_uri "extensions/jon-shell.js" in 51 + 52 + (* Compute BASE_URL - relative path from current page to root *) 53 + let base_url = 54 + let page = Url.Path.{ kind = `File; parent = None; name = "" } in 55 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 56 + in 57 + 58 + (* Current URL as relative path from root *) 59 + let current_url = 60 + let filename = Odoc_html.Link.Path.as_filename ~config url in 61 + Fpath.to_string filename 62 + in 32 63 33 64 (* Deduplicate resources *) 34 65 let deduplicate_resources resources = ··· 41 72 aux [] [] resources 42 73 in 43 74 44 - (* Extension resources: CSS goes in head, JS goes at end of body *) 45 - let extension_css, extension_js = 75 + (* Extension resources: all go in head for SPA resource discovery *) 76 + let extension_head_elements = 46 77 let open Odoc_extension_registry in 47 78 let is_absolute_url url = 48 79 String.is_prefix ~affix:"http://" url 49 80 || String.is_prefix ~affix:"https://" url 50 81 in 51 82 let resources = deduplicate_resources resources in 52 - List.partition_map 83 + List.concat_map 53 84 (function 54 85 | Css_url css_url -> 55 86 let resolved = 56 87 if is_absolute_url css_url then css_url 57 88 else file_uri support_uri css_url 58 89 in 59 - Left (Html.link ~rel:[ `Stylesheet ] ~href:resolved ()) 60 - | Css_inline code -> 61 - Left (Html.style [ Html.cdata_style code ]) 90 + [ Html.link ~rel:[ `Stylesheet ] ~href:resolved () ] 91 + | Css_inline code -> [ Html.style [ Html.cdata_style code ] ] 62 92 | Js_url js_url -> 63 93 let resolved = 64 94 if is_absolute_url js_url then js_url 65 95 else file_uri support_uri js_url 66 96 in 67 - Right (Html.script ~a:[ Html.a_src resolved; Html.a_defer () ] (Html.txt "")) 97 + [ Html.script ~a:[ Html.a_src resolved ] (Html.txt "") ] 68 98 | Js_inline code -> 69 - Right (Html.script (Html.cdata_script code))) 99 + let id = 100 + Printf.sprintf "%x" (Hashtbl.hash code land 0x7FFFFFFF) 101 + in 102 + [ 103 + Html.script 104 + ~a:[ Html.a_user_data "spa-inline" id ] 105 + (Html.cdata_script code); 106 + ]) 70 107 resources 71 108 in 72 109 ··· 114 151 ] 115 152 (); 116 153 Html.link ~rel:[ `Stylesheet ] ~href:shell_css_uri (); 154 + (* Inject BASE_URL and CURRENT_URL for SPA JS *) 155 + Html.script 156 + (Html.Unsafe.data 157 + (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 158 + base_url current_url)); 117 159 ] 118 - @ extension_css @ katex_elements 160 + @ katex_elements @ extension_head_elements 161 + @ sidebar_json_script sidebar_data 119 162 in 120 163 Html.head (Html.title (Html.txt title_string)) meta_elements 121 164 in 122 165 123 166 let sidebar_nav = 124 - match sidebar with 125 - | Some elts -> 167 + match sidebar_data with 168 + | Some _ -> 126 169 [ 127 170 Html.nav 128 - ~a:[ Html.a_class [ "jon-shell-sidebar"; "odoc-global-toc" ] ] 129 - (elts :> Html_types.nav_content Html.elt list); 171 + ~a: 172 + [ 173 + Html.a_class [ "jon-shell-sidebar"; "odoc-global-toc" ]; 174 + Html.a_id "sidebar-content"; 175 + ] 176 + []; 130 177 ] 131 178 | None -> [] 132 179 in ··· 159 206 ~a:[ Html.a_class [ "jon-shell-footer" ] ] 160 207 [ Html.txt "jon ludlam" ]; 161 208 ] 162 - @ extension_js 209 + @ [ 210 + Html.script 211 + ~a:[ Html.a_src shell_js_uri; Html.a_defer () ] 212 + (Html.txt ""); 213 + ] 163 214 in 164 215 165 216 let htmlpp = Html.pp ~indent:(Odoc_html.Config.indent config) () in ··· 172 223 in 173 224 content 174 225 175 - let make ~config ~url ~header ~preamble ~uses_katex ~resources ~sidebar ~assets 176 - content children = 226 + let make ~config ~url ~header ~preamble ~uses_katex ~resources ~sidebar_data 227 + ~assets content children = 177 228 let filename = Odoc_html.Link.Path.as_filename ~config url in 178 229 let content = 179 - page_creator ~config ~url ~uses_katex ~resources ~sidebar ~header ~preamble 180 - content 230 + page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~header 231 + ~preamble content 181 232 in 182 233 { Odoc_document.Renderer.filename; content; children; path = url; assets } 183 234 184 - let src_page_creator ~config ~url ~header title content = 235 + let src_page_creator ~config ~url ~header ~sidebar_data title content = 185 236 let support_uri = Odoc_html.Config.support_uri config in 186 237 let file_uri = file_uri ~config ~url in 187 238 let shell_css_uri = file_uri support_uri "extensions/jon-shell.css" in 239 + let shell_js_uri = file_uri support_uri "extensions/jon-shell.js" in 240 + 241 + (* Compute BASE_URL and CURRENT_URL for SPA *) 242 + let base_url = 243 + let page = Url.Path.{ kind = `File; parent = None; name = "" } in 244 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 245 + in 246 + let current_url = 247 + let filename = Odoc_html.Link.Path.as_filename ~config url in 248 + Fpath.to_string filename 249 + in 188 250 189 251 let title_string = Printf.sprintf "Source: %s" title in 190 252 ··· 200 262 ] 201 263 (); 202 264 Html.link ~rel:[ `Stylesheet ] ~href:shell_css_uri (); 265 + Html.script 266 + (Html.Unsafe.data 267 + (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 268 + base_url current_url)); 203 269 ] 270 + @ sidebar_json_script sidebar_data 204 271 in 205 272 Html.head (Html.title (Html.txt title_string)) meta_elements 206 273 in 207 274 275 + let sidebar_nav = 276 + match sidebar_data with 277 + | Some _ -> 278 + [ 279 + Html.nav 280 + ~a: 281 + [ 282 + Html.a_class [ "jon-shell-sidebar"; "odoc-global-toc" ]; 283 + Html.a_id "sidebar-content"; 284 + ] 285 + []; 286 + ] 287 + | None -> [] 288 + in 289 + 208 290 let body = 209 291 [ 210 292 Html.header ··· 221 303 ]; 222 304 Html.main 223 305 ~a:[ Html.a_class [ "jon-shell-main" ] ] 224 - [ 225 - Html.div 226 - ~a:[ Html.a_class [ "odoc-content" ] ] 227 - ((header :> Html_types.div_content Html.elt list) 228 - @ (content :> Html_types.div_content Html.elt list)); 229 - ]; 306 + (sidebar_nav 307 + @ [ 308 + Html.div 309 + ~a:[ Html.a_class [ "odoc-content" ] ] 310 + ((header :> Html_types.div_content Html.elt list) 311 + @ (content :> Html_types.div_content Html.elt list)); 312 + ]); 230 313 Html.footer 231 314 ~a:[ Html.a_class [ "jon-shell-footer" ] ] 232 315 [ Html.txt "jon ludlam" ]; 316 + Html.script 317 + ~a:[ Html.a_src shell_js_uri; Html.a_defer () ] 318 + (Html.txt ""); 233 319 ] 234 320 in 235 321 ··· 244 330 in 245 331 content 246 332 247 - let make_src ~config ~url ~header title content = 333 + let make_src ~config ~url ~header ~sidebar_data title content = 248 334 let filename = Odoc_html.Link.Path.as_filename ~config url in 249 - let content = src_page_creator ~config ~url ~header title content in 335 + let content = 336 + src_page_creator ~config ~url ~header ~sidebar_data title content 337 + in 250 338 { 251 339 Odoc_document.Renderer.filename; 252 340 content; ··· 264 352 let make ~config (data : Odoc_html.Html_shell.page_data) = 265 353 make ~config ~url:data.url ~header:data.header ~preamble:data.preamble 266 354 ~uses_katex:data.uses_katex ~resources:data.resources 267 - ~sidebar:data.sidebar ~assets:data.assets data.content data.children 355 + ~sidebar_data:data.sidebar_data ~assets:data.assets data.content 356 + data.children 268 357 269 358 let make_src ~config (data : Odoc_html.Html_shell.src_page_data) = 270 - make_src ~config ~url:data.url ~header:data.header data.title 271 - data.content 359 + make_src ~config ~url:data.url ~header:data.header 360 + ~sidebar_data:data.sidebar_data data.title data.content 272 361 end)
+206 -1
odoc-jon-shell/src/odoc_jon_shell_css.ml
··· 160 160 margin: 0; 161 161 } 162 162 163 - .jon-shell-sidebar a { 163 + .jon-shell-sidebar a, 164 + .jon-shell-sidebar .sidebar-label { 164 165 display: block; 165 166 padding: 3px 8px; 166 167 color: var(--text-muted); ··· 177 178 .jon-shell-sidebar a.current_unit { 178 179 color: var(--link-color); 179 180 font-weight: 600; 181 + } 182 + 183 + /* Collapsible entries */ 184 + .jon-shell-sidebar .sidebar-toggle { 185 + display: inline-block; 186 + width: 16px; 187 + height: 16px; 188 + cursor: pointer; 189 + vertical-align: middle; 190 + margin-right: 2px; 191 + position: relative; 192 + top: -1px; 193 + } 194 + 195 + .jon-shell-sidebar .sidebar-toggle::before { 196 + content: ""; 197 + display: block; 198 + width: 0; 199 + height: 0; 200 + border-style: solid; 201 + border-width: 4px 0 4px 6px; 202 + border-color: transparent transparent transparent var(--text-muted); 203 + margin: 4px auto; 204 + transition: transform 0.15s; 205 + } 206 + 207 + .jon-shell-sidebar li:not(.collapsed) > .sidebar-toggle::before { 208 + transform: rotate(90deg); 209 + } 210 + 211 + .jon-shell-sidebar li.collapsed > ul { 212 + display: none; 180 213 } 181 214 182 215 /* Hide the bare "index" text breadcrumb from odoc sidebar */ ··· 326 359 cursor: pointer; 327 360 font-family: var(--font-mono); 328 361 font-size: 0.9rem; 362 + } 363 + 364 + /* Source links float right inside spec blocks and headings */ 365 + a.source_link { 366 + float: right; 367 + color: var(--text-muted); 368 + font-family: var(--font-body); 369 + font-size: 0.8rem; 370 + font-weight: normal; 371 + } 372 + 373 + a.source_link:hover { 374 + color: var(--link-color); 375 + } 376 + 377 + /* Source code pages */ 378 + .source_container { 379 + display: flex; 380 + margin-top: 0; 381 + font-family: var(--font-mono); 382 + font-size: 0.85rem; 383 + line-height: 1.4; 384 + background: var(--code-bg); 385 + border: 1px solid var(--code-border); 386 + border-radius: 6px; 387 + overflow-x: auto; 388 + } 389 + 390 + .source_line_column { 391 + padding: 12px 0; 392 + text-align: right; 393 + color: var(--text-muted); 394 + background: var(--code-bg); 395 + border-right: 1px solid var(--code-border); 396 + user-select: none; 397 + } 398 + 399 + .source_line { 400 + padding: 0 12px; 401 + } 402 + 403 + .source_code { 404 + flex-grow: 1; 405 + padding: 12px 16px; 406 + color: var(--text-color); 407 + overflow-x: auto; 408 + } 409 + 410 + .source_code pre { 411 + margin: 0; 412 + background: none; 413 + border: none; 414 + padding: 0; 415 + } 416 + 417 + .source_code code { 418 + background: none; 419 + border: none; 420 + padding: 0; 421 + font-size: inherit; 422 + } 423 + 424 + .odoc-src pre a { 425 + color: inherit; 426 + } 427 + 428 + /* Source directory listings */ 429 + .odoc-directory::before { 430 + content: "\01F4C1"; 431 + margin-right: 0.3em; 432 + } 433 + 434 + /* Source code syntax highlighting */ 435 + :root { 436 + --src-keyword: #cf222e; 437 + --src-uident: #0550ae; 438 + --src-lident: var(--text-color); 439 + --src-literal: #0a3069; 440 + --src-comment: var(--text-muted); 441 + --src-docstring: #116329; 442 + --src-separator: #953800; 443 + --src-parens: #953800; 444 + --src-operator: #8250df; 445 + --src-underscore: var(--text-muted); 446 + } 447 + 448 + @media (prefers-color-scheme: dark) { 449 + :root { 450 + --src-keyword: #ff7b72; 451 + --src-uident: #79c0ff; 452 + --src-lident: var(--text-color); 453 + --src-literal: #a5d6ff; 454 + --src-comment: var(--text-muted); 455 + --src-docstring: #7ee787; 456 + --src-separator: #d29922; 457 + --src-parens: #d29922; 458 + --src-operator: #d2a8ff; 459 + --src-underscore: var(--text-muted); 460 + } 461 + } 462 + 463 + /* Keywords */ 464 + .AND, .ANDOP, .AS, .ASSERT, 465 + .BAR, .BEGIN, 466 + .CLASS, .CONSTRAINT, 467 + .DO, .DONE, .DOWNTO, 468 + .ELSE, .END, .EXCEPTION, .EXTERNAL, 469 + .FOR, .FUN, .FUNCTION, .FUNCTOR, 470 + .IF, .IN, .INCLUDE, .INHERIT, .INITIALIZER, 471 + .LAZY, .LESSMINUS, .LET, .LETOP, 472 + .MATCH, .METHOD, .MINUSGREATER, .MODULE, .MUTABLE, 473 + .NEW, .NONREC, 474 + .OBJECT, .OF, .OPEN, 475 + .PERCENT, .PRIVATE, 476 + .REC, 477 + .SEMISEMI, .SIG, .STRUCT, 478 + .THEN, .TO, .TRY, .TYPE, 479 + .VAL, .VIRTUAL, 480 + .WHEN, .WITH, .WHILE { 481 + color: var(--src-keyword); 482 + } 483 + 484 + /* Separators */ 485 + .COMMA, .COLON, .COLONGREATER, .SEMI { 486 + color: var(--src-separator); 487 + } 488 + 489 + /* Parens */ 490 + .BARRBRACKET, 491 + .LBRACE, .LBRACELESS, 492 + .LBRACKET, .LBRACKETAT, .LBRACKETATAT, .LBRACKETATATAT, 493 + .LBRACKETBAR, .LBRACKETGREATER, .LBRACKETLESS, 494 + .LBRACKETPERCENT, .LBRACKETPERCENTPERCENT, 495 + .LPAREN, .RBRACE, .RBRACKET, .RPAREN { 496 + color: var(--src-parens); 497 + } 498 + 499 + /* Operators */ 500 + .BANG, .PREFIXOP, 501 + .INFIXOP0, .INFIXOP1, .INFIXOP2, .INFIXOP3, .INFIXOP4, 502 + .BARBAR, .PLUS, .STAR, .AMPERAMPER, .AMPERAND, .COLONEQUAL, 503 + .GREATER, .LESS, .MINUS, .MINUSDOT, .MINUSGREATER, 504 + .OR, .PLUSDOT, .PLUSEQ, .EQUAL { 505 + color: var(--src-operator); 506 + } 507 + 508 + /* Upper case idents */ 509 + .UIDENT, .COLONCOLON, .TRUE, .FALSE { 510 + color: var(--src-uident); 511 + } 512 + 513 + /* Lower case idents */ 514 + .LIDENT, .QUESTION, .QUOTE, .TILDE { 515 + color: var(--src-lident); 516 + } 517 + 518 + /* Literals */ 519 + .STRING, .CHAR, .INT, .FLOAT, .QUOTED_STRING_EXPR, .QUOTED_STRING_ITEM { 520 + color: var(--src-literal); 521 + } 522 + 523 + .UNDERSCORE { 524 + color: var(--src-underscore); 525 + } 526 + 527 + .DOCSTRING { 528 + color: var(--src-docstring); 529 + } 530 + 531 + .COMMENT { 532 + color: var(--src-comment); 533 + font-style: italic; 329 534 } 330 535 331 536 .anchor {
+274
odoc-jon-shell/src/odoc_jon_shell_js.ml
··· 1 + let js = 2 + {| 3 + // Global state 4 + var BASE_URL = window.BASE_URL || './'; 5 + var CURRENT_URL = window.CURRENT_URL || 'index.html'; 6 + 7 + // Compute the root URL for absolute fetching (handles SPA navigation) 8 + var ROOT_URL = new URL(BASE_URL, window.location.href).href; 9 + 10 + // DOMParser for SPA navigation 11 + var parser = new DOMParser(); 12 + 13 + // Sidebar rendering from __SIDEBAR_DATA__ JSON 14 + function renderEntry(entry) { 15 + var node = entry.node; 16 + var isActive = node.url === CURRENT_URL; 17 + var children = entry.children || []; 18 + var hasChildren = children.length > 0; 19 + var li = '<li'; 20 + if (hasChildren) li += ' class="collapsed"'; 21 + li += '>'; 22 + if (hasChildren) { 23 + li += '<span class="sidebar-toggle"></span>'; 24 + } 25 + if (node.url) { 26 + li += '<a href="' + BASE_URL + node.url + '"' + 27 + ' data-nav="' + node.url + '"' + 28 + (isActive ? ' class="current_unit"' : '') + '>' + 29 + node.content + '</a>'; 30 + } else { 31 + li += '<span class="sidebar-label">' + node.content + '</span>'; 32 + } 33 + if (hasChildren) { 34 + li += '<ul>' + children.map(renderEntry).join('') + '</ul>'; 35 + } 36 + return li + '</li>'; 37 + } 38 + 39 + function initSidebar(data) { 40 + var container = document.getElementById('sidebar-content'); 41 + if (!container) return; 42 + var html = '<ul>' + data.map(renderEntry).join('') + '</ul>'; 43 + container.innerHTML = html; 44 + 45 + // Toggle click handler 46 + container.addEventListener('click', function(e) { 47 + var toggle = e.target.closest('.sidebar-toggle'); 48 + if (!toggle) return; 49 + var li = toggle.parentElement; 50 + li.classList.toggle('collapsed'); 51 + }); 52 + 53 + updateSidebarActive(); 54 + } 55 + 56 + function updateSidebarActive() { 57 + var container = document.getElementById('sidebar-content'); 58 + if (!container) return; 59 + 60 + // Remove old active 61 + container.querySelectorAll('.current_unit').forEach(function(el) { 62 + el.classList.remove('current_unit'); 63 + }); 64 + 65 + // Find exact match 66 + var activeLink = container.querySelector('[data-nav="' + CURRENT_URL + '"]'); 67 + 68 + // If no exact match, try ancestor URLs 69 + if (!activeLink) { 70 + var parts = CURRENT_URL.split('#')[0].split('/'); 71 + while (parts.length > 1 && !activeLink) { 72 + parts.pop(); 73 + var tryUrl = parts.join('/') + '/index.html'; 74 + activeLink = container.querySelector('[data-nav="' + tryUrl + '"]'); 75 + } 76 + } 77 + 78 + if (activeLink) { 79 + activeLink.classList.add('current_unit'); 80 + // Expand all ancestor <li> nodes so the active link is visible 81 + var parent = activeLink.parentElement; 82 + while (parent && parent !== container) { 83 + if (parent.tagName === 'LI') { 84 + parent.classList.remove('collapsed'); 85 + } 86 + parent = parent.parentElement; 87 + } 88 + // Scroll into view 89 + setTimeout(function() { 90 + activeLink.scrollIntoView({ block: 'center', behavior: 'instant' }); 91 + }, 0); 92 + } 93 + } 94 + 95 + // SPA Navigation 96 + async function navigateTo(url, pushState) { 97 + if (pushState === undefined) pushState = true; 98 + try { 99 + var response = await fetch(ROOT_URL + url); 100 + if (!response.ok) throw new Error('Failed to load page'); 101 + var html = await response.text(); 102 + var doc = parser.parseFromString(html, 'text/html'); 103 + 104 + // Swap content 105 + var newContent = doc.querySelector('.odoc-content'); 106 + if (newContent) { 107 + document.querySelector('.odoc-content').innerHTML = newContent.innerHTML; 108 + } 109 + 110 + // Update title 111 + var newTitle = doc.querySelector('title'); 112 + if (newTitle) { 113 + document.title = newTitle.textContent; 114 + } 115 + 116 + // Update body class (regular page vs source page) 117 + document.body.className = doc.body.className; 118 + 119 + // Load new CSS/JS resources from fetched page 120 + var fetchedPageBase = ROOT_URL + url; 121 + var inlineScripts = Array.from(doc.querySelectorAll('head script:not([src])')); 122 + 123 + var newScriptLoadPromises = []; 124 + doc.querySelectorAll('head link[rel="stylesheet"], head script[src]').forEach(function(el) { 125 + var attr = el.tagName === 'LINK' ? 'href' : 'src'; 126 + var resUrl = el.getAttribute(attr); 127 + if (!resUrl) return; 128 + var abs = new URL(resUrl, fetchedPageBase).href; 129 + var selector = el.tagName === 'LINK' 130 + ? 'link[rel="stylesheet"]' 131 + : 'script[src]'; 132 + var already = Array.from(document.querySelectorAll('head ' + selector)).some(function(existing) { 133 + var existingUrl = existing.getAttribute(attr); 134 + if (!existingUrl) return false; 135 + return new URL(existingUrl, window.location.href).href === abs; 136 + }); 137 + if (!already) { 138 + var clone = el.cloneNode(true); 139 + clone.setAttribute(attr, abs); 140 + if (el.tagName === 'SCRIPT') { 141 + var p = new Promise(function(resolve) { 142 + clone.onload = resolve; 143 + clone.onerror = resolve; 144 + }); 145 + newScriptLoadPromises.push(p); 146 + } 147 + document.head.appendChild(clone); 148 + } 149 + }); 150 + 151 + // After external scripts load, execute inline scripts (deduplicated) 152 + Promise.all(newScriptLoadPromises).then(function() { 153 + inlineScripts.forEach(function(el) { 154 + var id = el.getAttribute('data-spa-inline'); 155 + if (id && document.querySelector('head script[data-spa-inline="' + id + '"]')) return; 156 + var s = el.cloneNode(true); 157 + document.head.appendChild(s); 158 + }); 159 + }); 160 + 161 + // Update state 162 + CURRENT_URL = url; 163 + if (pushState) { 164 + history.pushState({ url: url }, '', ROOT_URL + url); 165 + } 166 + 167 + updateSidebarActive(); 168 + 169 + // Scroll handling 170 + var hash = url.indexOf('#') >= 0 ? '#' + url.split('#')[1] : ''; 171 + if (hash) { 172 + setTimeout(function() { 173 + var target = document.querySelector(hash); 174 + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 175 + }, 100); 176 + } else { 177 + window.scrollTo(0, 0); 178 + } 179 + 180 + } catch (e) { 181 + console.error('Navigation failed:', e); 182 + window.location.href = ROOT_URL + url; 183 + } 184 + } 185 + 186 + // Popstate for back/forward 187 + window.addEventListener('popstate', function(e) { 188 + if (e.state && e.state.url) { 189 + navigateTo(e.state.url, false); 190 + } 191 + }); 192 + 193 + // Click interception for SPA navigation 194 + document.addEventListener('click', function(e) { 195 + var link = e.target.closest('a[href]'); 196 + if (!link) return; 197 + 198 + var href = link.getAttribute('href'); 199 + if (!href) return; 200 + 201 + // Skip external, hash-only, mailto, javascript links 202 + if (href.indexOf('http') === 0 || href.indexOf('//') === 0 || href.indexOf('#') === 0 || 203 + href.indexOf('mailto:') === 0 || href.indexOf('javascript:') === 0) { 204 + return; 205 + } 206 + 207 + // If the link has data-nav, use it directly (sidebar links) 208 + var navPath = link.dataset.nav; 209 + if (navPath) { 210 + // Only SPA-navigate within reference/ 211 + if (navPath.indexOf('reference/') !== 0) return; 212 + e.preventDefault(); 213 + navigateTo(navPath); 214 + return; 215 + } 216 + 217 + // Resolve relative href against current URL 218 + var targetUrl = href; 219 + if (href.indexOf('/') === 0) { 220 + targetUrl = href.slice(1); 221 + } else if (href.indexOf('./') === 0) { 222 + var currentDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1); 223 + targetUrl = currentDir + href.slice(2); 224 + } else if (href.indexOf('../') === 0) { 225 + var currentParts = CURRENT_URL.split('/'); 226 + currentParts.pop(); 227 + var hrefParts = href.split('/'); 228 + for (var i = 0; i < hrefParts.length; i++) { 229 + var part = hrefParts[i]; 230 + if (part === '..') { 231 + currentParts.pop(); 232 + } else if (part !== '.') { 233 + currentParts.push(part); 234 + } 235 + } 236 + targetUrl = currentParts.join('/'); 237 + } else { 238 + var curDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1); 239 + targetUrl = curDir + href; 240 + } 241 + 242 + // Skip if navigating to same page with just a hash change 243 + if (targetUrl.indexOf('#') >= 0) { 244 + var pathAndHash = targetUrl.split('#'); 245 + if (pathAndHash[0] === CURRENT_URL || pathAndHash[0] === '') { 246 + return; 247 + } 248 + } 249 + 250 + // Skip links outside /reference/ (e.g. /blog/ in header) 251 + if (targetUrl.indexOf('reference/') !== 0) { 252 + return; 253 + } 254 + 255 + e.preventDefault(); 256 + navigateTo(targetUrl); 257 + }); 258 + 259 + // Initialize 260 + (function() { 261 + // Mark header for SPA detection 262 + var header = document.querySelector('.jon-shell-header'); 263 + if (header) header.dataset.spaInit = 'true'; 264 + 265 + // Set initial history state 266 + history.replaceState({ url: CURRENT_URL }, '', window.location.href); 267 + 268 + // Read sidebar data from inline script tag 269 + var inlineData = window.__SIDEBAR_DATA__; 270 + if (inlineData) { 271 + initSidebar(inlineData); 272 + } 273 + })(); 274 + |}