this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

+280 -1
+273
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
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