this repo has no description
0
fork

Configure Feed

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

Add --config key=value support, scrollycode theming, and SPA inline script execution

Add a config_values field to Html.Config.t and a --config KEY=VALUE
repeatable CLI argument to odoc html-generate, allowing arbitrary
configuration to flow from the command line through to shell plugins.

The docsite shell reads scrollycode.theme from config_values and emits
a <link> for the corresponding theme CSS in both page_creator and
src_page_creator. The dune-workspace passes --config
scrollycode.theme=warm via html_flags.

SPA navigation now collects head script:not([src]) elements from
fetched pages and executes them after newly added external scripts
have loaded. Inline scripts are stamped with a data-spa-inline
attribute (content hash) at HTML generation time; the SPA checks this
attribute to avoid re-executing scripts already present in <head>.

Document the resource type's SPA execution semantics in both
odoc_extension_registry.ml and odoc_extension_api.ml, covering
deduplication behaviour, execution timing, and guidance for extension
authors (prefer MutationObserver over re-execution, avoid
DOMContentLoaded in inline scripts).

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

+153 -16
+1 -1
dune-workspace
··· 15 15 (env 16 16 (dev 17 17 (odoc 18 - (html_flags --shell jon-shell) 18 + (html_flags --shell jon-shell --config scrollycode.theme=warm) 19 19 )))
+23 -3
odoc-docsite/src/odoc_docsite_js.ml
··· 310 310 document.title = newTitle.textContent; 311 311 } 312 312 313 - // Inject extension CSS/JS from the fetched page that aren't already loaded 313 + // Collect inline scripts first (before we start loading external scripts) 314 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 = []; 315 319 doc.querySelectorAll('head link[rel="stylesheet"], head script[src]').forEach(function(el) { 316 320 var attr = el.tagName === 'LINK' ? 'href' : 'src'; 317 321 var resUrl = el.getAttribute(attr); 318 322 if (!resUrl) return; 319 - // Resolve to absolute for comparison 320 323 var abs = new URL(resUrl, fetchedPageBase).href; 321 324 var selector = el.tagName === 'LINK' 322 325 ? 'link[rel="stylesheet"]' ··· 328 331 }); 329 332 if (!already) { 330 333 var clone = el.cloneNode(true); 331 - // Fix relative URL to be root-relative 332 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 + } 333 342 document.head.appendChild(clone); 334 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 + }); 335 355 }); 336 356 337 357 CURRENT_URL = url;
+20 -1
odoc-docsite/src/odoc_docsite_shell.ml
··· 33 33 let page = Url.Path.{ kind = `File; parent = uri; name = file } in 34 34 Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 35 35 36 + let scrollycode_theme_links ~config ~url = 37 + match 38 + List.assoc_opt "scrollycode.theme" 39 + (Odoc_html.Config.config_values config) 40 + with 41 + | None -> [] 42 + | Some theme -> 43 + let support_uri = Odoc_html.Config.support_uri config in 44 + let css_url = 45 + file_uri ~config ~url support_uri 46 + ("extensions/scrollycode-" ^ theme ^ ".css") 47 + in 48 + [ Html.link ~rel:[ `Stylesheet ] ~href:css_url () ] 49 + 36 50 (* TyXML helper for generating TOC *) 37 51 let html_of_toc toc = 38 52 let open Odoc_html.Types in ··· 164 178 else file_uri support_uri css_url 165 179 in 166 180 [ Html.link ~rel:[ `Stylesheet ] ~href:resolved () ] 167 - | Js_inline code -> [ Html.script (Html.cdata_script code) ] 181 + | Js_inline code -> 182 + let id = Printf.sprintf "%x" (Hashtbl.hash code land 0x7FFFFFFF) in 183 + [ Html.script ~a:[ Html.a_user_data "spa-inline" id ] 184 + (Html.cdata_script code) ] 168 185 | Css_inline code -> [ Html.style [ Html.cdata_style code ] ]) 169 186 resources 170 187 in ··· 224 241 base_url current_url)); 225 242 ] 226 243 @ katex_elements @ extension_head_elements 244 + @ scrollycode_theme_links ~config ~url 227 245 @ sidebar_json_script sidebar_data 228 246 in 229 247 Html.head (Html.title (Html.txt title_string)) meta_elements ··· 385 403 (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 386 404 base_url current_url)); 387 405 ] 406 + @ scrollycode_theme_links ~config ~url 388 407 @ sidebar_json_script sidebar_data 389 408 in 390 409 Html.head (Html.title (Html.txt title_string)) meta_elements
+34 -6
odoc/src/extension_api/odoc_extension_api.ml
··· 20 20 21 21 (** {1 Extension Types} *) 22 22 23 - (** Resources that can be injected into the page (HTML only) *) 23 + (** Resources that can be injected into the HTML [<head>]. 24 + 25 + See {!Odoc_extension_registry} for full documentation on execution 26 + timing, SPA deduplication semantics, and guidance for extension 27 + authors. 28 + 29 + {b Summary:} 30 + 31 + {ul 32 + {- [Js_url] / [Css_url] — deduplicated by resolved URL. Loaded at 33 + most once across SPA navigations.} 34 + {- [Js_inline] — stamped with [data-spa-inline] at generation time. 35 + Executed {b exactly once}: on the first SPA navigation that 36 + introduces the script. Do not use [DOMContentLoaded] inside 37 + these; use a [MutationObserver] if you need to react to content 38 + changes on subsequent navigations.} 39 + {- [Css_inline] — injected on every navigation (CSS is additive and 40 + idempotent).}} *) 24 41 type resource = Odoc_extension_registry.resource = 25 - | Js_url of string (** External JavaScript: <script src="..."> *) 26 - | Css_url of string (** External CSS: <link rel="stylesheet" href="..."> *) 27 - | Js_inline of string (** Inline JavaScript: <script>...</script> *) 28 - | Css_inline of string (** Inline CSS: <style>...</style> *) 42 + | Js_url of string 43 + (** External JavaScript: emitted as [<script src="…">]. 44 + Deduplicated by absolute URL on SPA navigation. *) 45 + | Css_url of string 46 + (** External CSS: emitted as [<link rel="stylesheet" href="…">]. 47 + Deduplicated by absolute URL on SPA navigation. *) 48 + | Js_inline of string 49 + (** Inline JavaScript: emitted as [<script data-spa-inline="…">…</script>]. 50 + Runs once on first encounter; skipped on subsequent SPA navigations 51 + that carry an identical script. *) 52 + | Css_inline of string 53 + (** Inline CSS: emitted as [<style>…</style>]. 54 + Re-injected on each SPA navigation (idempotent). *) 29 55 30 56 (** Binary asset generated by an extension. 31 57 Assets are written alongside the HTML output. To reference an asset ··· 67 93 E.g., [("html", "<div>...</div>"); ("markdown", "...")] *) 68 94 69 95 resources : resource list; 70 - (** Page-level resources (JS/CSS). Only used by HTML backend. *) 96 + (** Page-level resources (JS/CSS). Only used by HTML backend. 97 + See {!resource} for execution and deduplication semantics, 98 + especially in SPA (single-page app) shells. *) 71 99 72 100 assets : asset list; 73 101 (** Binary assets to write alongside HTML output.
+52 -1
odoc/src/extension_registry/odoc_extension_registry.ml
··· 8 8 module Comment = Odoc_model.Comment 9 9 module Location_ = Odoc_model.Location_ 10 10 11 - (** Resources that can be injected into the page (HTML only) *) 11 + (** {2 Page Resources} 12 + 13 + Resources that an extension requests to be injected into the HTML 14 + [<head>]. The shell plugin is responsible for rendering these; the 15 + behaviour described below applies to the {b docsite} shell shipped 16 + with [odoc-docsite]. 17 + 18 + {3 Execution timing} 19 + 20 + On the {b initial full page load} every resource listed by every 21 + extension present on that page is emitted into [<head>] in order. 22 + External scripts ([Js_url]) are loaded via a normal 23 + [<script src="…">] tag and execute when the browser fetches them. 24 + Inline scripts ([Js_inline]) execute synchronously in document order. 25 + 26 + {3 SPA (single-page app) navigation} 27 + 28 + The docsite shell intercepts link clicks and fetches pages via 29 + [fetch()] instead of a full reload. After swapping the page content 30 + it reconciles [<head>] resources: 31 + 32 + {ul 33 + {- {b [Js_url] / [Css_url]:} Compared by resolved absolute URL. 34 + Resources already present in the live document are skipped; 35 + new ones are appended to [<head>] and (for scripts) their 36 + [onload] events are awaited before continuing.} 37 + {- {b [Js_inline]:} Each inline script is stamped at HTML-generation 38 + time with a [data-spa-inline] attribute containing a hash of its 39 + content. On SPA navigation the shell checks whether a [<script>] 40 + with the same [data-spa-inline] value already exists in [<head>]. 41 + If so it is {b not re-executed}. This means inline scripts run 42 + {b exactly once} across SPA navigations — the first time a page 43 + carrying that script is visited.} 44 + {- {b [Css_inline]:} Injected into [<head>] on every navigation 45 + (CSS is additive and idempotent, so duplicates are harmless).}} 46 + 47 + {3 Guidance for extension authors} 48 + 49 + {ul 50 + {- Prefer [Js_url] for library code (e.g. the mermaid runtime). 51 + The deduplication-by-URL ensures it is loaded exactly once.} 52 + {- Use [Js_inline] for one-time initialisation that must run after 53 + the library is loaded (e.g. registering a global observer). 54 + It will execute once; on subsequent SPA navigations the library 55 + and observer are still alive.} 56 + {- If your extension needs to re-process content on every SPA 57 + navigation, set up a [MutationObserver] or listen for the 58 + ["popstate"] event in your [Js_inline] init script rather than 59 + relying on the script being re-executed.} 60 + {- Do {b not} gate initialisation on [DOMContentLoaded] inside a 61 + [Js_inline] script — that event does not re-fire during SPA 62 + navigation.}} *) 12 63 type resource = 13 64 | Js_url of string 14 65 | Css_url of string
+4 -1
odoc/src/html/config.ml
··· 16 16 shell : string option; 17 17 home_breadcrumb : string option; 18 18 mode_links : string option; 19 + config_values : (string * string) list; 19 20 } 20 21 21 22 let v ?(search_result = false) ?theme_uri ?support_uri ?(search_uris = []) 22 23 ?(extra_css = []) ~semantic_uris ~indent ~flat ~open_details ~as_json ?shell 23 - ~remap ?home_breadcrumb ?mode_links () = 24 + ~remap ?home_breadcrumb ?mode_links ?(config_values = []) () = 24 25 { 25 26 semantic_uris; 26 27 indent; ··· 36 37 remap; 37 38 home_breadcrumb; 38 39 mode_links; 40 + config_values; 39 41 } 40 42 41 43 let theme_uri config : Types.uri = ··· 67 69 let home_breadcrumb config = config.home_breadcrumb 68 70 69 71 let mode_links config = config.mode_links 72 + let config_values config = config.config_values
+2
odoc/src/html/config.mli
··· 17 17 remap:(string * string) list -> 18 18 ?home_breadcrumb:string -> 19 19 ?mode_links:string -> 20 + ?config_values:(string * string) list -> 20 21 unit -> 21 22 t 22 23 (** [search_result] indicates whether this is a summary for a search result. In ··· 49 50 val home_breadcrumb : t -> string option 50 51 51 52 val mode_links : t -> string option 53 + val config_values : t -> (string * string) list
+17 -3
odoc/src/odoc/bin/main.ml
··· 1318 1318 $(i,https://example.com/modes#local)." 1319 1319 in 1320 1320 Arg.(value & opt (some string) None & info [ "mode-links" ] ~docv:"URI" ~doc) 1321 + let config_values_raw = 1322 + let doc = 1323 + "A configuration key=value pair passed to the shell plugin. \ 1324 + May be repeated, e.g. $(b,--config scrollycode.theme=warm)." 1325 + in 1326 + Arg.(value & opt_all string [] & info [ "config" ] ~docv:"KEY=VALUE" ~doc) 1321 1327 1322 1328 let extra_args = 1323 1329 let config semantic_uris closed_details indent theme_uri support_uri 1324 1330 search_uris extra_css flat as_json shell remap remap_file 1325 - home_breadcrumb mode_links = 1331 + home_breadcrumb mode_links config_values_raw = 1326 1332 let open_details = not closed_details in 1327 1333 let remap = 1328 1334 match remap_file with ··· 1335 1341 | None -> acc) 1336 1342 [] 1337 1343 in 1344 + let config_values = 1345 + List.filter_map 1346 + ~f:(fun s -> 1347 + match String.cut ~sep:"=" s with 1348 + | None -> None 1349 + | Some (k, v) -> Some (k, v)) 1350 + config_values_raw 1351 + in 1338 1352 let html_config = 1339 1353 Odoc_html.Config.v ~theme_uri ~support_uri ~search_uris ~extra_css 1340 1354 ~semantic_uris ~indent ~flat ~open_details ~as_json ?shell ~remap 1341 - ?home_breadcrumb ?mode_links () 1355 + ?home_breadcrumb ?mode_links ~config_values () 1342 1356 in 1343 1357 { Html_page.html_config } 1344 1358 in 1345 1359 Term.( 1346 1360 const config $ semantic_uris $ closed_details $ indent $ theme_uri 1347 1361 $ support_uri $ search_uri $ extra_css $ flat $ as_json $ shell $ remap 1348 - $ remap_file $ home_breadcrumb $ mode_links) 1362 + $ remap_file $ home_breadcrumb $ mode_links $ config_values_raw) 1349 1363 end 1350 1364 1351 1365 module Odoc_html = Make_renderer (Odoc_html_args)