(** Scrollycode Extension for odoc Provides scroll-driven code tutorials. Theme styling is handled externally via CSS custom properties defined in {!Scrollycode_css} and set by theme files in {!Scrollycode_themes}. Authoring format uses [@scrolly] custom tags with an ordered list inside, where each list item is a tutorial step containing a bold title, prose paragraphs, and a code block. For backward compatibility, \@scrolly.warm / \@scrolly.dark / \@scrolly.notebook are still accepted but the theme suffix is ignored — theme selection is now a CSS concern. *) module Comment = Odoc_model.Comment module Location_ = Odoc_model.Location_ module Block = Odoc_document.Types.Block module Inline = Odoc_document.Types.Inline module Scrollycode_css = Scrollycode_css module Scrollycode_themes = Scrollycode_themes (** {1 Step Extraction} *) (** A single tutorial step extracted from the ordered list structure *) type step = { title : string; prose : string; code : string; focus : int list; (** 1-based line numbers to highlight *) } (** Extract plain text from inline elements *) let rec text_of_inline (el : Comment.inline_element Location_.with_location) = match el.Location_.value with | `Space -> " " | `Word w -> w | `Code_span c -> "`" ^ c ^ "`" | `Math_span m -> m | `Raw_markup (_, r) -> r | `Styled (_, content) -> text_of_inlines content | `Reference (_, content) -> text_of_link_content content | `Link (_, content) -> text_of_link_content content and text_of_inlines content = String.concat "" (List.map text_of_inline content) and text_of_link_content content = String.concat "" (List.map text_of_non_link content) and text_of_non_link (el : Comment.non_link_inline_element Location_.with_location) = match el.Location_.value with | `Space -> " " | `Word w -> w | `Code_span c -> "`" ^ c ^ "`" | `Math_span m -> m | `Raw_markup (_, r) -> r | `Styled (_, content) -> text_of_link_content content let text_of_paragraph (p : Comment.paragraph) = String.concat "" (List.map text_of_inline p) (** Extract title, prose, code and focus lines from a single list item *) let extract_step (item : Comment.nestable_block_element Location_.with_location list) : step = let title = ref "" in let prose_parts = ref [] in let code = ref "" in let focus = ref [] in List.iter (fun (el : Comment.nestable_block_element Location_.with_location) -> match el.Location_.value with | `Paragraph p -> ( let text = text_of_paragraph p in (* Check if the paragraph starts with bold text — that's the title *) match p with | first :: _ when (match first.Location_.value with | `Styled (`Bold, _) -> true | _ -> false) -> if !title = "" then title := text else prose_parts := text :: !prose_parts | _ -> prose_parts := text :: !prose_parts) | `Code_block { content = code_content; _ } -> let code_text = code_content.Location_.value in (* Check for focus annotation in the code: lines starting with >>> *) let lines = String.split_on_char '\n' code_text in let focused_lines = ref [] in let clean_lines = List.mapi (fun i line -> if String.length line >= 4 && String.sub line 0 4 = "(* >" then ( focused_lines := (i + 1) :: !focused_lines; (* Remove the focus marker *) let rest = String.sub line 4 (String.length line - 4) in let rest = if String.length rest >= 4 && String.sub rest (String.length rest - 4) 4 = "< *)" then String.sub rest 0 (String.length rest - 4) else rest in String.trim rest) else line) lines in code := String.concat "\n" clean_lines; focus := List.rev !focused_lines | `Verbatim v -> prose_parts := v :: !prose_parts | _ -> ()) item; { title = !title; prose = String.concat "\n\n" (List.rev !prose_parts); code = !code; focus = !focus; } (** Extract all steps from the tag content (expects an ordered list) *) let extract_steps (content : Comment.nestable_block_element Location_.with_location list) : string * step list = (* First element might be a paragraph with the tutorial title *) let tutorial_title = ref "Tutorial" in let steps = ref [] in List.iter (fun (el : Comment.nestable_block_element Location_.with_location) -> match el.Location_.value with | `Paragraph p -> let text = text_of_paragraph p in if !steps = [] then tutorial_title := text | `List (`Ordered, items) -> steps := List.map extract_step items | _ -> ()) content; (!tutorial_title, !steps) (** {1 HTML Escaping} *) let html_escape s = let buf = Buffer.create (String.length s) in String.iter (function | '&' -> Buffer.add_string buf "&" | '<' -> Buffer.add_string buf "<" | '>' -> Buffer.add_string buf ">" | '"' -> Buffer.add_string buf """ | c -> Buffer.add_char buf c) s; Buffer.contents buf (** {1 Diff Computation} *) type diff_line = | Same of string | Added of string | Removed of string (** Simple LCS-based line diff between two code strings *) let diff_lines old_code new_code = let old_lines = String.split_on_char '\n' old_code |> Array.of_list in let new_lines = String.split_on_char '\n' new_code |> Array.of_list in let n = Array.length old_lines in let m = Array.length new_lines in let dp = Array.make_matrix (n + 1) (m + 1) 0 in for i = 1 to n do for j = 1 to m do if old_lines.(i-1) = new_lines.(j-1) then dp.(i).(j) <- dp.(i-1).(j-1) + 1 else dp.(i).(j) <- max dp.(i-1).(j) dp.(i).(j-1) done done; let result = ref [] in let i = ref n and j = ref m in while !i > 0 || !j > 0 do if !i > 0 && !j > 0 && old_lines.(!i-1) = new_lines.(!j-1) then begin result := Same old_lines.(!i-1) :: !result; decr i; decr j end else if !j > 0 && (!i = 0 || dp.(!i).(!j-1) >= dp.(!i-1).(!j)) then begin result := Added new_lines.(!j-1) :: !result; decr j end else begin result := Removed old_lines.(!i-1) :: !result; decr i end done; !result (** {1 OCaml Syntax Highlighting} A simple lexer-based highlighter for OCaml code. Produces HTML spans with classes for keywords, types, strings, comments, operators. *) let ocaml_keywords = [ "let"; "in"; "if"; "then"; "else"; "match"; "with"; "fun"; "function"; "type"; "module"; "struct"; "sig"; "end"; "open"; "include"; "val"; "rec"; "and"; "of"; "when"; "as"; "begin"; "do"; "done"; "for"; "to"; "while"; "downto"; "try"; "exception"; "raise"; "mutable"; "ref"; "true"; "false"; "assert"; "failwith"; "not"; ] let ocaml_types = [ "int"; "float"; "string"; "bool"; "unit"; "list"; "option"; "array"; "char"; "bytes"; "result"; "exn"; "ref"; ] (** Tokenize and highlight OCaml code into HTML *) let highlight_ocaml code = let len = String.length code in let buf = Buffer.create (len * 2) in let i = ref 0 in let peek () = if !i < len then Some code.[!i] else None in let advance () = incr i in let current () = code.[!i] in while !i < len do match current () with (* Comments *) | '(' when !i + 1 < len && code.[!i + 1] = '*' -> Buffer.add_string buf ""; Buffer.add_string buf "(*"; i := !i + 2; let depth = ref 1 in while !depth > 0 && !i < len do if !i + 1 < len && code.[!i] = '(' && code.[!i + 1] = '*' then ( Buffer.add_string buf "(*"; i := !i + 2; incr depth) else if !i + 1 < len && code.[!i] = '*' && code.[!i + 1] = ')' then ( Buffer.add_string buf "*)"; i := !i + 2; decr depth) else ( Buffer.add_string buf (html_escape (String.make 1 code.[!i])); advance ()) done; Buffer.add_string buf "" (* Strings *) | '"' -> Buffer.add_string buf ""; Buffer.add_char buf '"'; advance (); while !i < len && current () <> '"' do if current () = '\\' && !i + 1 < len then ( Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance (); Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance ()) else ( Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance ()) done; if !i < len then ( Buffer.add_char buf '"'; advance ()); Buffer.add_string buf "" (* Char literals *) | '\'' when !i + 2 < len && code.[!i + 2] = '\'' -> Buffer.add_string buf ""; Buffer.add_char buf '\''; advance (); Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance (); Buffer.add_char buf '\''; advance (); Buffer.add_string buf "" (* Numbers *) | '0' .. '9' -> Buffer.add_string buf ""; while !i < len && match current () with | '0' .. '9' | '.' | '_' | 'x' | 'o' | 'b' | 'a' .. 'f' | 'A' .. 'F' -> true | _ -> false do Buffer.add_char buf (current ()); advance () done; Buffer.add_string buf "" (* Identifiers and keywords *) | 'a' .. 'z' | '_' -> let start = !i in while !i < len && match current () with | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' | '\'' -> true | _ -> false do advance () done; let word = String.sub code start (!i - start) in if List.mem word ocaml_keywords then Buffer.add_string buf (Printf.sprintf "%s" (html_escape word)) else if List.mem word ocaml_types then Buffer.add_string buf (Printf.sprintf "%s" (html_escape word)) else Buffer.add_string buf (html_escape word) (* Module/constructor names (capitalized identifiers) *) | 'A' .. 'Z' -> let start = !i in while !i < len && match current () with | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' | '\'' -> true | _ -> false do advance () done; let word = String.sub code start (!i - start) in Buffer.add_string buf (Printf.sprintf "%s" (html_escape word)) (* Operators *) | '|' | '-' | '+' | '*' | '/' | '=' | '<' | '>' | '@' | '^' | '~' | '!' | '?' | '%' | '&' -> Buffer.add_string buf ""; Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance (); (* Consume multi-char operators *) while !i < len && match current () with | '|' | '-' | '+' | '*' | '/' | '=' | '<' | '>' | '@' | '^' | '~' | '!' | '?' | '%' | '&' -> true | _ -> false do Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance () done; Buffer.add_string buf "" (* Punctuation *) | ':' | ';' | '.' | ',' | '[' | ']' | '{' | '}' | '(' | ')' -> Buffer.add_string buf (Printf.sprintf "%s" (html_escape (String.make 1 (current ())))); advance () (* Arrow special case: -> *) | ' ' | '\t' | '\n' | '\r' -> Buffer.add_char buf (current ()); advance () | _ -> let _ = peek () in Buffer.add_string buf (html_escape (String.make 1 (current ()))); advance () done; Buffer.contents buf (** Render a diff as HTML with colored lines *) let render_diff_html diff = let buf = Buffer.create 1024 in List.iter (fun line -> match line with | Same s -> Buffer.add_string buf (Printf.sprintf "
%s
\n" (html_escape step.prose)); (* Diff block *) Buffer.add_string buf "%s
\n" (html_escape step.prose)); (* Hidden code slot for JS to read *) Buffer.add_string buf "