My aggregated monorepo of OCaml code, automaintained

Fix scrollycode SPA navigation bug and add extensions authoring guide

The scrollycode JS runtime was embedded as an inline <script> in the
page body, which the SPA navigator never executes. Moved it to a
registered support file (extensions/scrollycode.js) loaded via Js_url
in <head>, with a MutationObserver to detect new containers inserted
by SPA content swaps.

Added a new "Writing Extensions" doc page to odoc covering the
extension API, resource types, the SPA navigation pitfall, and the
scrollycode fix as a case study.

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

+309 -13
+29 -12
odoc-scrollycode-extension/src/scrollycode_extension.ml
··· 537 537 }); 538 538 } 539 539 540 - // Initialize all scrollycode containers on the page 541 - document.addEventListener('DOMContentLoaded', function() { 542 - document.querySelectorAll('.sc-container').forEach(initScrollycode); 543 - }); 540 + // Initialize any uninitialised scrollycode containers. 541 + function initAll() { 542 + document.querySelectorAll('.sc-container').forEach(function(c) { 543 + if (!c.dataset.scInit) { 544 + c.dataset.scInit = '1'; 545 + initScrollycode(c); 546 + } 547 + }); 548 + } 549 + 550 + // Run now if DOM is ready, otherwise wait. 551 + if (document.readyState === 'loading') { 552 + document.addEventListener('DOMContentLoaded', function() { 553 + initAll(); 554 + // Watch for new containers added by SPA navigation. 555 + new MutationObserver(function() { initAll(); }) 556 + .observe(document.body, { childList: true, subtree: true }); 557 + }); 558 + } else { 559 + initAll(); 560 + new MutationObserver(function() { initAll(); }) 561 + .observe(document.body, { childList: true, subtree: true }); 562 + } 544 563 })(); 545 564 |} 546 565 ··· 695 714 </div> 696 715 |}; 697 716 698 - (* JavaScript *) 699 - Buffer.add_string buf "<script>\n"; 700 - Buffer.add_string buf shared_js; 701 - Buffer.add_string buf "</script>\n"; 702 - 703 717 Buffer.contents buf 704 718 705 719 (** {1 Extension Registration} *) ··· 734 748 overrides = []; 735 749 resources = [ 736 750 Css_url "extensions/scrollycode.css"; 751 + Js_url "extensions/scrollycode.js"; 737 752 Js_inline (meta_tag_script "x-ocaml-backend" "jtw"); 738 753 Js_inline (meta_tag_script "x-ocaml-worker" x_ocaml_worker_url); 739 754 Js_url "_x-ocaml/x-ocaml.js"; ··· 742 757 } 743 758 end 744 759 745 - (* Register extension and structural CSS support file. 746 - Force-link Scrollycode_themes to ensure theme support files are registered. *) 760 + (* Register extension and structural CSS support file. *) 747 761 let () = 748 - ignore (Scrollycode_themes.warm_css : string); 749 762 Odoc_extension_api.Registry.register (module Scrolly); 750 763 Odoc_extension_api.Registry.register_support_file ~prefix:"scrolly" { 751 764 filename = "extensions/scrollycode.css"; 752 765 content = Inline Scrollycode_css.structural_css; 766 + }; 767 + Odoc_extension_api.Registry.register_support_file ~prefix:"scrolly" { 768 + filename = "extensions/scrollycode.js"; 769 + content = Inline shared_js; 753 770 }; 754 771 (* Find x-ocaml.js: env var, then dune build install dir (walk up from CWD). *) 755 772 let x_ocaml_js_path =
+273
odoc/doc/extensions.mld
··· 1 + {0 Writing Extensions} 2 + 3 + [odoc] supports a plugin system for custom tags and code blocks. Extensions 4 + are OCaml libraries loaded at doc-generation time that transform custom 5 + markup into HTML, LaTeX, or other output formats. 6 + 7 + This guide covers the practical aspects of writing an extension, with 8 + particular attention to the pitfalls around JavaScript and SPA navigation. 9 + 10 + {1 Extension Types} 11 + 12 + There are two kinds of extension: 13 + 14 + {ul 15 + {- {b Tag extensions} handle custom tags like [@@note], [@@rfc], [@@scrolly]. 16 + They receive the tag's content as a list of block elements and return 17 + document content, resources, and assets.} 18 + {- {b Code block extensions} handle fenced code blocks with a custom 19 + language, e.g., [{@@dot ...}] or [{@@ocaml ...}]. 20 + They receive the code text plus any options and return the same output 21 + types.}} 22 + 23 + Both are registered as dune-site plugins and discovered automatically. 24 + 25 + {1 The Extension Interface} 26 + 27 + A tag extension implements the {!Odoc_extension_api.Extension} signature: 28 + 29 + {[ 30 + module My_ext : Odoc_extension_api.Extension = struct 31 + let prefix = "my-ext" 32 + 33 + let to_document ~tag content = 34 + let html = (* ... generate HTML from content ... *) in 35 + { 36 + Odoc_extension_api.content = [ { attr = []; desc = Raw_markup ("html", html) } ]; 37 + overrides = []; 38 + resources = [ 39 + Css_url "extensions/my-ext.css"; 40 + Js_url "extensions/my-ext.js"; 41 + ]; 42 + assets = []; 43 + } 44 + end 45 + ]} 46 + 47 + Register it alongside any support files: 48 + 49 + {[ 50 + let () = 51 + Odoc_extension_api.Registry.register (module My_ext); 52 + Odoc_extension_api.Registry.register_support_file ~prefix:"my-ext" { 53 + filename = "extensions/my-ext.css"; 54 + content = Inline my_css_string; 55 + }; 56 + Odoc_extension_api.Registry.register_support_file ~prefix:"my-ext" { 57 + filename = "extensions/my-ext.js"; 58 + content = Inline my_js_string; 59 + } 60 + ]} 61 + 62 + {1 Resources and the HTML [<head>]} 63 + 64 + Extensions declare page-level resources (JavaScript, CSS) that are injected 65 + into [<head>]. There are four resource types: 66 + 67 + {table 68 + {tr {th Type} {th Rendered as} {th SPA behaviour}} 69 + {tr {td [Js_url "path.js"]} {td [<script src="...">]} {td Deduplicated by URL; loaded at most once}} 70 + {tr {td [Css_url "path.css"]} {td [<link rel="stylesheet" href="...">]} {td Deduplicated by URL; loaded at most once}} 71 + {tr {td [Js_inline code]} {td [<script data-spa-inline="hash">code</script>]} {td Executed {b once}; skipped if hash already in DOM}} 72 + {tr {td [Css_inline code]} {td [<style>code</style>]} {td Re-injected every navigation (idempotent)}} 73 + } 74 + 75 + {2 Prefer [Js_url] over inline scripts} 76 + 77 + Put your runtime JavaScript in a support file and reference it with 78 + [Js_url]. This gives you: 79 + 80 + {ul 81 + {- Clean separation of concerns (JS in a [.js] file, not an OCaml string)} 82 + {- Proper browser caching} 83 + {- Correct deduplication across SPA navigations} 84 + {- The script loads once and stays alive for the lifetime of the page}} 85 + 86 + Use [Js_inline] only for small bootstrapping snippets that must run once 87 + (e.g., injecting a [<meta>] tag). Never put your main runtime in an 88 + inline script. 89 + 90 + {1:spa SPA Navigation: The Critical Pitfall} 91 + 92 + The odoc {b docsite shell} (and similar shells) implement single-page app 93 + navigation: clicking a sidebar link fetches the target page via [fetch()], 94 + swaps the content area, and updates the URL with [history.pushState]. No 95 + full page reload occurs. 96 + 97 + This has important consequences for extensions that include JavaScript: 98 + 99 + {2 The problem} 100 + 101 + Consider this naive approach — embedding JavaScript directly into the 102 + generated HTML body: 103 + 104 + {[ 105 + (* BAD: Inline <script> in the body HTML *) 106 + let html = Printf.sprintf {| 107 + <div class="my-widget">...</div> 108 + <script> 109 + document.addEventListener('DOMContentLoaded', function() { 110 + initMyWidget(document.querySelector('.my-widget')); 111 + }); 112 + </script> 113 + |} in 114 + { content = [{ attr = []; desc = Raw_markup ("html", html) }]; ... } 115 + ]} 116 + 117 + This breaks under SPA navigation for two reasons: 118 + 119 + {ol 120 + {- The shell swaps only the content area ([.odoc-content]). Body scripts 121 + from the fetched page are {b not executed} — the shell only processes 122 + scripts found in [<head>].} 123 + {- Even if the script were in [<head>], [DOMContentLoaded] fires only once 124 + per page lifecycle. On SPA navigation the event never re-fires, so the 125 + initialisation function never runs.}} 126 + 127 + The result: the extension works on a full page load (e.g., opening the URL 128 + directly), but silently fails when the user navigates to the page via a 129 + sidebar link. This is particularly insidious because it only manifests in 130 + certain navigation paths. 131 + 132 + {2 The solution} 133 + 134 + Move your JavaScript to a head-loaded support file. Inside it, handle 135 + both initial load {e and} subsequent SPA navigations: 136 + 137 + {[ 138 + // extensions/my-ext.js — loaded via Js_url 139 + (function() { 140 + 'use strict'; 141 + 142 + function initWidget(container) { 143 + // ... set up event listeners, observers, etc. ... 144 + } 145 + 146 + // Initialise any uninitialised widgets on the page. 147 + function initAll() { 148 + document.querySelectorAll('.my-widget').forEach(function(el) { 149 + if (!el.dataset.myInit) { 150 + el.dataset.myInit = '1'; 151 + initWidget(el); 152 + } 153 + }); 154 + } 155 + 156 + // Run on initial page load. 157 + if (document.readyState === 'loading') { 158 + document.addEventListener('DOMContentLoaded', function() { 159 + initAll(); 160 + observe(); 161 + }); 162 + } else { 163 + initAll(); 164 + observe(); 165 + } 166 + 167 + // Watch for new content injected by SPA navigation. 168 + function observe() { 169 + new MutationObserver(function() { initAll(); }) 170 + .observe(document.body, { childList: true, subtree: true }); 171 + } 172 + })(); 173 + ]} 174 + 175 + Key points: 176 + 177 + {ul 178 + {- {b Guard against double-init.} Use a [data-*] attribute to mark 179 + initialised elements. The [MutationObserver] fires on every DOM 180 + mutation, so [initAll] may be called many times.} 181 + {- {b Check [document.readyState].} The script is in [<head>], so 182 + [document.body] doesn't exist yet on the initial load. Wait for 183 + [DOMContentLoaded] before attaching the [MutationObserver].} 184 + {- {b Don't rely on [DOMContentLoaded] alone.} After SPA navigation the 185 + [Js_url] script has already loaded and [DOMContentLoaded] already fired. 186 + The [MutationObserver] is what detects the new content.}} 187 + 188 + {2 Case study: Scrollycode} 189 + 190 + The scrollycode extension provides scroll-driven code tutorials. As users 191 + scroll through explanatory steps, an [IntersectionObserver] detects which 192 + step is visible and updates a sticky code panel. 193 + 194 + Initially, the scrollycode runtime was embedded as an inline [<script>] in 195 + the generated HTML body, gated on [DOMContentLoaded]: 196 + 197 + {[ 198 + (* OLD — broken under SPA navigation *) 199 + Buffer.add_string buf "<script>\n"; 200 + Buffer.add_string buf shared_js; (* contains DOMContentLoaded listener *) 201 + Buffer.add_string buf "</script>\n"; 202 + ]} 203 + 204 + This worked perfectly on direct page loads. But when a user navigated to a 205 + scrollycode page via the sidebar: 206 + 207 + {ol 208 + {- The shell swapped the content area, inserting the scrollycode HTML.} 209 + {- The body [<script>] was {b not executed} (the shell only processes 210 + head scripts).} 211 + {- The [IntersectionObserver] was never set up.} 212 + {- The code panel stayed frozen on step 1 regardless of scroll position.}} 213 + 214 + The fix was to: 215 + 216 + {ol 217 + {- Register the JS as a support file and reference it via [Js_url]: 218 + {[ 219 + Odoc_extension_api.Registry.register_support_file ~prefix:"scrolly" { 220 + filename = "extensions/scrollycode.js"; 221 + content = Inline shared_js; 222 + }; 223 + (* In resources: *) 224 + resources = [ Js_url "extensions/scrollycode.js"; ... ]; 225 + ]} 226 + } 227 + {- Replace the [DOMContentLoaded] gate with [readyState] check + 228 + [MutationObserver] (as shown in the pattern above).} 229 + {- Add a [data-sc-init] guard on each [.sc-container] to prevent 230 + double-initialisation.}} 231 + 232 + {1 Testing Extensions} 233 + 234 + Test your extension under both navigation modes: 235 + 236 + {ul 237 + {- {b Direct load:} Open the URL directly in the browser. This is the easy 238 + case and usually works.} 239 + {- {b SPA navigation:} Start on a different page in the same documentation 240 + site, then click a sidebar link to navigate to a page using your 241 + extension. This is where body-script and [DOMContentLoaded] bugs 242 + surface.}} 243 + 244 + Automated testing with Playwright (or similar) should cover both paths: 245 + 246 + {[ 247 + // Direct load 248 + await page.goto('/my-extension-page.html'); 249 + expect(await page.locator('.my-widget').getAttribute('data-my-init')).toBe('1'); 250 + 251 + // SPA navigation 252 + await page.goto('/some-other-page.html'); 253 + await page.click('a[href*="my-extension-page"]'); 254 + await page.waitForTimeout(500); 255 + expect(await page.locator('.my-widget').getAttribute('data-my-init')).toBe('1'); 256 + ]} 257 + 258 + {1 Checklist} 259 + 260 + Before shipping an extension, verify: 261 + 262 + {ul 263 + {- {b No body scripts.} All JavaScript is delivered via [Js_url] (support 264 + files) or small [Js_inline] bootstraps in [resources]. Nothing is 265 + embedded in the HTML body via [Raw_markup].} 266 + {- {b No [DOMContentLoaded] dependency.} Use [document.readyState] check + 267 + [MutationObserver] instead.} 268 + {- {b Double-init guard.} Every element you initialise is marked (e.g., 269 + with a [data-*] attribute) and skipped on subsequent [initAll] calls.} 270 + {- {b SPA navigation tested.} Both direct-load and sidebar-navigation 271 + paths work.} 272 + {- {b [MutationObserver] set up after [document.body] exists.} If your 273 + script is in [<head>], [document.body] is [null] on initial parse.}}
+7 -1
odoc/doc/index.mld
··· 1 - @children_order odoc_for_authors cheatsheet dune features ocamldoc_differences interface driver json deprecated/ main_index odoc.document/ odoc.examples/ odoc.extension_api/ odoc.extension_registry/ odoc.html/ odoc.html_support_files/ odoc.index/ odoc.json_index/ odoc.latex/ odoc.loader/ odoc.manpage/ odoc.markdown/ odoc.model/ odoc.model_desc/ odoc.ocamlary/ odoc.occurrences/ odoc.odoc/ odoc.odoc_utils/ odoc.search/ odoc.search_html_frontend/ odoc.syntax_highlighter/ odoc.xref2/ odoc.xref_test/ 1 + @children_order odoc_for_authors cheatsheet dune features extensions ocamldoc_differences interface driver json deprecated/ main_index odoc.document/ odoc.examples/ odoc.extension_api/ odoc.extension_registry/ odoc.html/ odoc.html_support_files/ odoc.index/ odoc.json_index/ odoc.latex/ odoc.loader/ odoc.manpage/ odoc.markdown/ odoc.model/ odoc.model_desc/ odoc.ocamlary/ odoc.occurrences/ odoc.odoc/ odoc.odoc_utils/ odoc.search/ odoc.search_html_frontend/ odoc.syntax_highlighter/ odoc.xref2/ odoc.xref_test/ 2 2 @short_title The odoc documentation generator 3 3 4 4 {0 The [odoc] documentation generator} ··· 44 44 {1 For Authors} 45 45 46 46 For guidance on how to document your OCaml project, see {!page-odoc_for_authors}. 47 + 48 + {1 For Extension Authors} 49 + 50 + To create custom tags or code blocks, see {{!page-extensions}Writing Extensions}. 51 + This covers the extension API, support file registration, and critical 52 + pitfalls around JavaScript and SPA navigation. 47 53 48 54 {1 For Integrators} 49 55