this repo has no description

Refactor scrollycode themes into CSS custom properties and add odoc shell plugins

Three-layer architecture separating content, theme, and renderer:

Phase 1 — Scrollycode refactor:
- Strip ~1030 lines of embedded theme CSS from scrollycode_extension.ml
- Create scrollycode_css.ml with structural CSS using CSS custom properties
- Create scrollycode_themes.ml with three theme files (warm/dark/notebook)
registered as support files
- Simplify @scrolly tag: theme suffix now ignored (CSS concern, not content)
- Playground overlay styled via --xo-* custom properties on x-ocaml element

Phase 2 — Extra CSS support:
- Add --extra-css flag to odoc html-generate for injecting additional
<link> tags (used for per-page theme selection)

Phase 3 — Shell plugin system:
- Add Html_shell module with Shell interface and hashtable registry
- Register "default" shell in html_page.ml, "json" shell in
html_fragment_json.ml
- Replace hardcoded if/else in generator.ml with shell registry lookup
- Add --shell NAME flag (--as-json kept as backward-compat alias)

x-ocaml theming:
- Replace hardcoded colors in style.css with var(--xo-*, fallback)
covering editor, gutter, buttons, tooltips, and output areas
- CSS custom properties inherit through shadow DOM boundary, so
consumers theme x-ocaml with pure CSS — no JS injection needed

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

+227 -29
+10 -2
src/html/config.ml
··· 4 4 theme_uri : Types.uri option; 5 5 support_uri : Types.uri option; 6 6 search_uris : Types.file_uri list; 7 + extra_css : string list; 7 8 remap : (string * string) list; 8 9 semantic_uris : bool; 9 10 search_result : bool; ··· 12 13 flat : bool; 13 14 open_details : bool; 14 15 as_json : bool; 16 + shell : string option; 15 17 home_breadcrumb : string option; 16 18 } 17 19 18 20 let v ?(search_result = false) ?theme_uri ?support_uri ?(search_uris = []) 19 - ~semantic_uris ~indent ~flat ~open_details ~as_json ~remap ?home_breadcrumb 20 - () = 21 + ?(extra_css = []) ~semantic_uris ~indent ~flat ~open_details ~as_json ?shell 22 + ~remap ?home_breadcrumb () = 21 23 { 22 24 semantic_uris; 23 25 indent; ··· 26 28 theme_uri; 27 29 support_uri; 28 30 search_uris; 31 + extra_css; 29 32 as_json; 33 + shell; 30 34 search_result; 31 35 remap; 32 36 home_breadcrumb; ··· 40 44 41 45 let search_uris config = config.search_uris 42 46 47 + let extra_css config = config.extra_css 48 + 43 49 let semantic_uris config = config.semantic_uris 44 50 45 51 let indent config = config.indent ··· 51 57 let as_json config = config.as_json 52 58 53 59 let search_result config = config.search_result 60 + 61 + let shell config = config.shell 54 62 55 63 let remap config = config.remap 56 64
+6
src/html/config.mli
··· 7 7 ?theme_uri:Types.uri -> 8 8 ?support_uri:Types.uri -> 9 9 ?search_uris:Types.file_uri list -> 10 + ?extra_css:string list -> 10 11 semantic_uris:bool -> 11 12 indent:bool -> 12 13 flat:bool -> 13 14 open_details:bool -> 14 15 as_json:bool -> 16 + ?shell:string -> 15 17 remap:(string * string) list -> 16 18 ?home_breadcrumb:string -> 17 19 unit -> ··· 25 27 26 28 val search_uris : t -> Types.file_uri list 27 29 30 + val extra_css : t -> string list 31 + 28 32 val semantic_uris : t -> bool 29 33 30 34 val indent : t -> bool ··· 36 40 val as_json : t -> bool 37 41 38 42 val search_result : t -> bool 43 + 44 + val shell : t -> string option 39 45 40 46 val remap : t -> (string * string) list 41 47
+29 -20
src/html/generator.ml
··· 672 672 let header, preamble = Doctree.PageTitle.render_title ?source_anchor p in 673 673 let header = items ~config ~resolve header in 674 674 let preamble = items ~config ~resolve preamble in 675 - if Config.as_json config then 676 - let source_anchor = 677 - match source_anchor with 678 - | Some url -> Some (Link.href ~config ~resolve url) 679 - | None -> None 680 - in 681 - Html_fragment_json.make ~config 682 - ~preamble:(preamble :> any Html.elt list) 683 - ~header ~breadcrumbs ~toc ~url ~uses_katex ~source_anchor 684 - ~resources ~assets content 685 - subpages 686 - else 687 - Html_page.make ~sidebar ~config ~header:(header @ preamble) ~toc 688 - ~breadcrumbs ~url ~uses_katex ~resources ~assets content subpages 675 + let source_anchor = 676 + match source_anchor with 677 + | Some url -> Some (Link.href ~config ~resolve url) 678 + | None -> None 679 + in 680 + let shell_name = 681 + match Config.shell config with 682 + | Some name -> name 683 + | None -> if Config.as_json config then "json" else "default" 684 + in 685 + let (module Shell : Html_shell.S) = 686 + match Html_shell.find shell_name with 687 + | Some shell -> shell 688 + | None -> Html_shell.default () 689 + in 690 + Shell.make ~config 691 + { url; header; preamble; content; breadcrumbs; toc; sidebar; 692 + uses_katex; source_anchor; resources; assets; children = subpages } 689 693 690 694 and source_page ~config ~sidebar sp = 691 695 let { Source_page.url; contents } = sp in ··· 703 707 let header = 704 708 items ~config ~resolve (Doctree.PageTitle.render_src_title sp) 705 709 in 706 - if Config.as_json config then 707 - Html_fragment_json.make_src ~config ~url ~breadcrumbs ~sidebar ~header 708 - [ doc ] 709 - else 710 - Html_page.make_src ~breadcrumbs ~header ~config ~url ~sidebar title 711 - [ doc ] 710 + let shell_name = 711 + match Config.shell config with 712 + | Some name -> name 713 + | None -> if Config.as_json config then "json" else "default" 714 + in 715 + let (module Shell : Html_shell.S) = 716 + match Html_shell.find shell_name with 717 + | Some shell -> shell 718 + | None -> Html_shell.default () 719 + in 720 + Shell.make_src ~config { url; header; breadcrumbs; sidebar; title; content = [ doc ] } 712 721 end 713 722 714 723 let render ~config ~sidebar = function
+19
src/html/html_fragment_json.ml
··· 111 111 ])) 112 112 in 113 113 { Odoc_document.Renderer.filename; content; children = []; path = url; assets = [] } 114 + 115 + (* Register as the "json" shell *) 116 + let () = 117 + Html_shell.register 118 + (module struct 119 + let name = "json" 120 + 121 + let make ~config (data : Html_shell.page_data) = 122 + make ~config 123 + ~preamble:(data.preamble :> Html_types.flow5 Html.elt list) 124 + ~header:data.header ~breadcrumbs:data.breadcrumbs ~toc:data.toc 125 + ~url:data.url ~uses_katex:data.uses_katex 126 + ~source_anchor:data.source_anchor ~resources:data.resources 127 + ~assets:data.assets data.content data.children 128 + 129 + let make_src ~config (data : Html_shell.src_page_data) = 130 + make_src ~config ~url:data.url ~breadcrumbs:data.breadcrumbs 131 + ~sidebar:data.sidebar ~header:data.header data.content 132 + end)
+24 -1
src/html/html_page.ml
··· 221 221 [ Html.style [ Html.cdata_style code ] ]) 222 222 resources 223 223 in 224 + let extra_css_links = 225 + List.map 226 + (fun href -> Html.link ~rel:[ `Stylesheet ] ~href ()) 227 + (Config.extra_css config) 228 + in 224 229 let meta_elements = 225 230 let highlightjs_meta = 226 231 let highlight_js_uri = file_uri support_uri "highlight.pack.js" in ··· 257 262 else [] 258 263 in 259 264 default_meta_elements ~config ~url @ highlightjs_meta @ katex_meta 260 - @ extension_resources 265 + @ extension_resources @ extra_css_links 261 266 in 262 267 let meta_elements = meta_elements @ search_scripts in 263 268 Html.head (Html.title (Html.txt title_string)) meta_elements ··· 335 340 src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar title content 336 341 in 337 342 { Odoc_document.Renderer.filename; content; children = []; path = url; assets = [] } 343 + 344 + (* Register as the default shell *) 345 + let () = 346 + Html_shell.register 347 + (module struct 348 + let name = "default" 349 + 350 + let make ~config (data : Html_shell.page_data) = 351 + make ~config ~url:data.url 352 + ~header:(data.header @ data.preamble) 353 + ~breadcrumbs:data.breadcrumbs ~sidebar:data.sidebar ~toc:data.toc 354 + ~uses_katex:data.uses_katex ~resources:data.resources 355 + ~assets:data.assets data.content data.children 356 + 357 + let make_src ~config (data : Html_shell.src_page_data) = 358 + make_src ~config ~url:data.url ~breadcrumbs:data.breadcrumbs 359 + ~header:data.header ~sidebar:data.sidebar data.title data.content 360 + end)
+51
src/html/html_shell.ml
··· 1 + (* Page shell interface and registry. *) 2 + 3 + module Html = Tyxml.Html 4 + 5 + type page_data = { 6 + url : Odoc_document.Url.Path.t; 7 + header : Html_types.flow5_without_header_footer Html.elt list; 8 + preamble : Html_types.flow5_without_header_footer Html.elt list; 9 + content : Html_types.div_content Html.elt list; 10 + breadcrumbs : Types.breadcrumbs; 11 + toc : Types.toc list; 12 + sidebar : Html_types.div_content Html.elt list option; 13 + uses_katex : bool; 14 + source_anchor : string option; 15 + resources : Odoc_extension_registry.resource list; 16 + assets : Odoc_extension_registry.asset list; 17 + children : Odoc_document.Renderer.page list; 18 + } 19 + 20 + type src_page_data = { 21 + url : Odoc_document.Url.Path.t; 22 + header : Html_types.flow5_without_header_footer Html.elt list; 23 + breadcrumbs : Types.breadcrumbs; 24 + sidebar : Html_types.div_content Html.elt list option; 25 + title : string; 26 + content : Html_types.div_content Html.elt list; 27 + } 28 + 29 + module type S = sig 30 + val name : string 31 + val make : config:Config.t -> page_data -> Odoc_document.Renderer.page 32 + val make_src : config:Config.t -> src_page_data -> Odoc_document.Renderer.page 33 + end 34 + 35 + (* Registry *) 36 + 37 + let shells : (string, (module S)) Hashtbl.t = Hashtbl.create 4 38 + 39 + let register (module Shell : S) = 40 + Hashtbl.replace shells Shell.name (module Shell : S) 41 + 42 + let find name = Hashtbl.find_opt shells name 43 + 44 + let list_shells () = 45 + Hashtbl.fold (fun name _ acc -> name :: acc) shells [] 46 + |> List.sort String.compare 47 + 48 + let default () = 49 + match Hashtbl.find_opt shells "default" with 50 + | Some shell -> shell 51 + | None -> failwith "No default shell registered"
+62
src/html/html_shell.mli
··· 1 + (* Page shell interface for pluggable HTML page assembly. 2 + 3 + A shell controls the overall page structure: the <head> contents, 4 + the <body> layout, and how pre-rendered HTML fragments are arranged 5 + into a complete page. *) 6 + 7 + module Html := Tyxml.Html 8 + 9 + (** Data for assembling a documentation page. All HTML fragments are 10 + pre-rendered by the generator. *) 11 + type page_data = { 12 + url : Odoc_document.Url.Path.t; 13 + header : Html_types.flow5_without_header_footer Html.elt list; 14 + preamble : Html_types.flow5_without_header_footer Html.elt list; 15 + content : Html_types.div_content Html.elt list; 16 + breadcrumbs : Types.breadcrumbs; 17 + toc : Types.toc list; 18 + sidebar : Html_types.div_content Html.elt list option; 19 + uses_katex : bool; 20 + source_anchor : string option; 21 + resources : Odoc_extension_registry.resource list; 22 + assets : Odoc_extension_registry.asset list; 23 + children : Odoc_document.Renderer.page list; 24 + } 25 + 26 + (** Data for assembling a source code page. *) 27 + type src_page_data = { 28 + url : Odoc_document.Url.Path.t; 29 + header : Html_types.flow5_without_header_footer Html.elt list; 30 + breadcrumbs : Types.breadcrumbs; 31 + sidebar : Html_types.div_content Html.elt list option; 32 + title : string; 33 + content : Html_types.div_content Html.elt list; 34 + } 35 + 36 + (** The interface that a page shell must implement. *) 37 + module type S = sig 38 + val name : string 39 + (** Short identifier for CLI selection, e.g. "default" or "json". *) 40 + 41 + val make : 42 + config:Config.t -> page_data -> Odoc_document.Renderer.page 43 + (** Assemble a documentation page from pre-rendered fragments. *) 44 + 45 + val make_src : 46 + config:Config.t -> src_page_data -> Odoc_document.Renderer.page 47 + (** Assemble a source code page from pre-rendered fragments. *) 48 + end 49 + 50 + (** {1 Shell Registry} *) 51 + 52 + val register : (module S) -> unit 53 + (** Register a shell. Shells are identified by name. *) 54 + 55 + val find : string -> (module S) option 56 + (** Look up a shell by name. *) 57 + 58 + val list_shells : unit -> string list 59 + (** List all registered shell names. *) 60 + 61 + val default : unit -> (module S) 62 + (** Return the default shell ("default"). Raises if no default is registered. *)
+26 -6
src/odoc/bin/main.ml
··· 1258 1258 & opt_all Uri.convert_file_uri [] 1259 1259 & info ~docv:"URI" ~doc [ "search-uri" ]) 1260 1260 1261 + let extra_css = 1262 + let doc = 1263 + "Additional CSS files to include in generated HTML pages. Can be \ 1264 + specified multiple times. URIs are included as-is in <link> tags." 1265 + in 1266 + Arg.(value & opt_all string [] & info ~docv:"URI" ~doc [ "extra-css" ]) 1267 + 1261 1268 let flat = 1262 1269 let doc = 1263 1270 "Output HTML files in 'flat' mode, where the hierarchy of modules / \ ··· 1272 1279 fragments (preamble, content) together with metadata (uses_katex, \ 1273 1280 breadcrumbs, table of contents) are emitted in JSON format. The \ 1274 1281 structure of the output should be considered unstable and no guarantees \ 1275 - are made about backward compatibility." 1282 + are made about backward compatibility. Equivalent to $(b,--shell json)." 1276 1283 in 1277 1284 Arg.(value & flag & info ~doc [ "as-json" ]) 1278 1285 1286 + let shell = 1287 + let doc = 1288 + "Select the page shell for HTML output. Built-in shells: \ 1289 + $(b,default) (standard HTML pages), $(b,json) (embeddable JSON \ 1290 + fragments). Additional shells can be installed as plugins." 1291 + in 1292 + Arg.( 1293 + value 1294 + & opt (some string) None 1295 + & info ~docv:"NAME" ~doc [ "shell" ]) 1296 + 1279 1297 let remap = 1280 1298 let convert_remap = 1281 1299 let parse inp = ··· 1294 1312 1295 1313 let extra_args = 1296 1314 let config semantic_uris closed_details indent theme_uri support_uri 1297 - search_uris flat as_json remap remap_file home_breadcrumb = 1315 + search_uris extra_css flat as_json shell remap remap_file 1316 + home_breadcrumb = 1298 1317 let open_details = not closed_details in 1299 1318 let remap = 1300 1319 match remap_file with ··· 1308 1327 [] 1309 1328 in 1310 1329 let html_config = 1311 - Odoc_html.Config.v ~theme_uri ~support_uri ~search_uris ~semantic_uris 1312 - ~indent ~flat ~open_details ~as_json ~remap ?home_breadcrumb () 1330 + Odoc_html.Config.v ~theme_uri ~support_uri ~search_uris ~extra_css 1331 + ~semantic_uris ~indent ~flat ~open_details ~as_json ?shell ~remap 1332 + ?home_breadcrumb () 1313 1333 in 1314 1334 { Html_page.html_config } 1315 1335 in 1316 1336 Term.( 1317 1337 const config $ semantic_uris $ closed_details $ indent $ theme_uri 1318 - $ support_uri $ search_uri $ flat $ as_json $ remap $ remap_file 1319 - $ home_breadcrumb) 1338 + $ support_uri $ search_uri $ extra_css $ flat $ as_json $ shell $ remap 1339 + $ remap_file $ home_breadcrumb) 1320 1340 end 1321 1341 1322 1342 module Odoc_html = Make_renderer (Odoc_html_args)