this repo has no description
at daceafdde29f6fd5a026147be2ffe395724328fe 758 lines 26 kB view raw
1(** Scrollycode Extension for odoc 2 3 Provides scroll-driven code tutorials. Theme styling is handled 4 externally via CSS custom properties defined in {!Scrollycode_css} 5 and set by theme files in {!Scrollycode_themes}. 6 7 Authoring format uses [@scrolly] custom tags with an ordered 8 list inside, where each list item is a tutorial step containing 9 a bold title, prose paragraphs, and a code block. 10 11 For backward compatibility, @scrolly.warm / @scrolly.dark / 12 @scrolly.notebook are still accepted but the theme suffix is 13 ignored — theme selection is now a CSS concern. *) 14 15module Comment = Odoc_model.Comment 16module Location_ = Odoc_model.Location_ 17module Block = Odoc_document.Types.Block 18module Inline = Odoc_document.Types.Inline 19 20(** {1 Step Extraction} *) 21 22(** A single tutorial step extracted from the ordered list structure *) 23type step = { 24 title : string; 25 prose : string; 26 code : string; 27 focus : int list; (** 1-based line numbers to highlight *) 28} 29 30(** Extract plain text from inline elements *) 31let rec text_of_inline (el : Comment.inline_element Location_.with_location) = 32 match el.Location_.value with 33 | `Space -> " " 34 | `Word w -> w 35 | `Code_span c -> "`" ^ c ^ "`" 36 | `Math_span m -> m 37 | `Raw_markup (_, r) -> r 38 | `Styled (_, content) -> text_of_inlines content 39 | `Reference (_, content) -> text_of_link_content content 40 | `Link (_, content) -> text_of_link_content content 41 42and text_of_inlines content = 43 String.concat "" (List.map text_of_inline content) 44 45and text_of_link_content content = 46 String.concat "" (List.map text_of_non_link content) 47 48and text_of_non_link 49 (el : Comment.non_link_inline_element Location_.with_location) = 50 match el.Location_.value with 51 | `Space -> " " 52 | `Word w -> w 53 | `Code_span c -> "`" ^ c ^ "`" 54 | `Math_span m -> m 55 | `Raw_markup (_, r) -> r 56 | `Styled (_, content) -> text_of_link_content content 57 58let text_of_paragraph (p : Comment.paragraph) = 59 String.concat "" (List.map text_of_inline p) 60 61(** Extract title, prose, code and focus lines from a single list item *) 62let extract_step 63 (item : Comment.nestable_block_element Location_.with_location list) : step 64 = 65 let title = ref "" in 66 let prose_parts = ref [] in 67 let code = ref "" in 68 let focus = ref [] in 69 List.iter 70 (fun (el : Comment.nestable_block_element Location_.with_location) -> 71 match el.Location_.value with 72 | `Paragraph p -> ( 73 let text = text_of_paragraph p in 74 (* Check if the paragraph starts with bold text — that's the title *) 75 match p with 76 | first :: _ 77 when (match first.Location_.value with 78 | `Styled (`Bold, _) -> true 79 | _ -> false) -> 80 if !title = "" then title := text 81 else prose_parts := text :: !prose_parts 82 | _ -> prose_parts := text :: !prose_parts) 83 | `Code_block { content = code_content; _ } -> 84 let code_text = code_content.Location_.value in 85 (* Check for focus annotation in the code: lines starting with >>> *) 86 let lines = String.split_on_char '\n' code_text in 87 let focused_lines = ref [] in 88 let clean_lines = 89 List.mapi 90 (fun i line -> 91 if 92 String.length line >= 4 93 && String.sub line 0 4 = "(* >" 94 then ( 95 focused_lines := (i + 1) :: !focused_lines; 96 (* Remove the focus marker *) 97 let rest = String.sub line 4 (String.length line - 4) in 98 let rest = 99 if 100 String.length rest >= 4 101 && String.sub rest (String.length rest - 4) 4 = "< *)" 102 then String.sub rest 0 (String.length rest - 4) 103 else rest 104 in 105 String.trim rest) 106 else line) 107 lines 108 in 109 code := String.concat "\n" clean_lines; 110 focus := List.rev !focused_lines 111 | `Verbatim v -> prose_parts := v :: !prose_parts 112 | _ -> ()) 113 item; 114 { 115 title = !title; 116 prose = String.concat "\n\n" (List.rev !prose_parts); 117 code = !code; 118 focus = !focus; 119 } 120 121(** Extract all steps from the tag content (expects an ordered list) *) 122let extract_steps 123 (content : 124 Comment.nestable_block_element Location_.with_location list) : 125 string * step list = 126 (* First element might be a paragraph with the tutorial title *) 127 let tutorial_title = ref "Tutorial" in 128 let steps = ref [] in 129 List.iter 130 (fun (el : Comment.nestable_block_element Location_.with_location) -> 131 match el.Location_.value with 132 | `Paragraph p -> 133 let text = text_of_paragraph p in 134 if !steps = [] then tutorial_title := text 135 | `List (`Ordered, items) -> 136 steps := List.map extract_step items 137 | _ -> ()) 138 content; 139 (!tutorial_title, !steps) 140 141(** {1 HTML Escaping} *) 142 143let html_escape s = 144 let buf = Buffer.create (String.length s) in 145 String.iter 146 (function 147 | '&' -> Buffer.add_string buf "&amp;" 148 | '<' -> Buffer.add_string buf "&lt;" 149 | '>' -> Buffer.add_string buf "&gt;" 150 | '"' -> Buffer.add_string buf "&quot;" 151 | c -> Buffer.add_char buf c) 152 s; 153 Buffer.contents buf 154 155(** {1 Diff Computation} *) 156 157type diff_line = 158 | Same of string 159 | Added of string 160 | Removed of string 161 162(** Simple LCS-based line diff between two code strings *) 163let diff_lines old_code new_code = 164 let old_lines = String.split_on_char '\n' old_code |> Array.of_list in 165 let new_lines = String.split_on_char '\n' new_code |> Array.of_list in 166 let n = Array.length old_lines in 167 let m = Array.length new_lines in 168 let dp = Array.make_matrix (n + 1) (m + 1) 0 in 169 for i = 1 to n do 170 for j = 1 to m do 171 if old_lines.(i-1) = new_lines.(j-1) then 172 dp.(i).(j) <- dp.(i-1).(j-1) + 1 173 else 174 dp.(i).(j) <- max dp.(i-1).(j) dp.(i).(j-1) 175 done 176 done; 177 let result = ref [] in 178 let i = ref n and j = ref m in 179 while !i > 0 || !j > 0 do 180 if !i > 0 && !j > 0 && old_lines.(!i-1) = new_lines.(!j-1) then begin 181 result := Same old_lines.(!i-1) :: !result; 182 decr i; decr j 183 end else if !j > 0 && (!i = 0 || dp.(!i).(!j-1) >= dp.(!i-1).(!j)) then begin 184 result := Added new_lines.(!j-1) :: !result; 185 decr j 186 end else begin 187 result := Removed old_lines.(!i-1) :: !result; 188 decr i 189 end 190 done; 191 !result 192 193(** {1 OCaml Syntax Highlighting} 194 195 A simple lexer-based highlighter for OCaml code. Produces HTML spans 196 with classes for keywords, types, strings, comments, operators. *) 197 198let ocaml_keywords = 199 [ 200 "let"; "in"; "if"; "then"; "else"; "match"; "with"; "fun"; "function"; 201 "type"; "module"; "struct"; "sig"; "end"; "open"; "include"; "val"; 202 "rec"; "and"; "of"; "when"; "as"; "begin"; "do"; "done"; "for"; "to"; 203 "while"; "downto"; "try"; "exception"; "raise"; "mutable"; "ref"; 204 "true"; "false"; "assert"; "failwith"; "not"; 205 ] 206 207let ocaml_types = 208 [ 209 "int"; "float"; "string"; "bool"; "unit"; "list"; "option"; "array"; 210 "char"; "bytes"; "result"; "exn"; "ref"; 211 ] 212 213(** Tokenize and highlight OCaml code into HTML *) 214let highlight_ocaml code = 215 let len = String.length code in 216 let buf = Buffer.create (len * 2) in 217 let i = ref 0 in 218 let peek () = if !i < len then Some code.[!i] else None in 219 let advance () = incr i in 220 let current () = code.[!i] in 221 while !i < len do 222 match current () with 223 (* Comments *) 224 | '(' when !i + 1 < len && code.[!i + 1] = '*' -> 225 Buffer.add_string buf "<span class=\"hl-comment\">"; 226 Buffer.add_string buf "(*"; 227 i := !i + 2; 228 let depth = ref 1 in 229 while !depth > 0 && !i < len do 230 if !i + 1 < len && code.[!i] = '(' && code.[!i + 1] = '*' then ( 231 Buffer.add_string buf "(*"; 232 i := !i + 2; 233 incr depth) 234 else if !i + 1 < len && code.[!i] = '*' && code.[!i + 1] = ')' then ( 235 Buffer.add_string buf "*)"; 236 i := !i + 2; 237 decr depth) 238 else ( 239 Buffer.add_string buf (html_escape (String.make 1 code.[!i])); 240 advance ()) 241 done; 242 Buffer.add_string buf "</span>" 243 (* Strings *) 244 | '"' -> 245 Buffer.add_string buf "<span class=\"hl-string\">"; 246 Buffer.add_char buf '"'; 247 advance (); 248 while !i < len && current () <> '"' do 249 if current () = '\\' && !i + 1 < len then ( 250 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 251 advance (); 252 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 253 advance ()) 254 else ( 255 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 256 advance ()) 257 done; 258 if !i < len then ( 259 Buffer.add_char buf '"'; 260 advance ()); 261 Buffer.add_string buf "</span>" 262 (* Char literals *) 263 | '\'' when !i + 2 < len && code.[!i + 2] = '\'' -> 264 Buffer.add_string buf "<span class=\"hl-string\">"; 265 Buffer.add_char buf '\''; 266 advance (); 267 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 268 advance (); 269 Buffer.add_char buf '\''; 270 advance (); 271 Buffer.add_string buf "</span>" 272 (* Numbers *) 273 | '0' .. '9' -> 274 Buffer.add_string buf "<span class=\"hl-number\">"; 275 while 276 !i < len 277 && 278 match current () with 279 | '0' .. '9' | '.' | '_' | 'x' | 'o' | 'b' | 'a' .. 'f' 280 | 'A' .. 'F' -> 281 true 282 | _ -> false 283 do 284 Buffer.add_char buf (current ()); 285 advance () 286 done; 287 Buffer.add_string buf "</span>" 288 (* Identifiers and keywords *) 289 | 'a' .. 'z' | '_' -> 290 let start = !i in 291 while 292 !i < len 293 && 294 match current () with 295 | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' | '\'' -> true 296 | _ -> false 297 do 298 advance () 299 done; 300 let word = String.sub code start (!i - start) in 301 if List.mem word ocaml_keywords then 302 Buffer.add_string buf 303 (Printf.sprintf "<span class=\"hl-keyword\">%s</span>" 304 (html_escape word)) 305 else if List.mem word ocaml_types then 306 Buffer.add_string buf 307 (Printf.sprintf "<span class=\"hl-type\">%s</span>" 308 (html_escape word)) 309 else Buffer.add_string buf (html_escape word) 310 (* Module/constructor names (capitalized identifiers) *) 311 | 'A' .. 'Z' -> 312 let start = !i in 313 while 314 !i < len 315 && 316 match current () with 317 | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' | '\'' -> true 318 | _ -> false 319 do 320 advance () 321 done; 322 let word = String.sub code start (!i - start) in 323 Buffer.add_string buf 324 (Printf.sprintf "<span class=\"hl-module\">%s</span>" 325 (html_escape word)) 326 (* Operators *) 327 | '|' | '-' | '+' | '*' | '/' | '=' | '<' | '>' | '@' | '^' | '~' 328 | '!' | '?' | '%' | '&' -> 329 Buffer.add_string buf "<span class=\"hl-operator\">"; 330 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 331 advance (); 332 (* Consume multi-char operators *) 333 while 334 !i < len 335 && 336 match current () with 337 | '|' | '-' | '+' | '*' | '/' | '=' | '<' | '>' | '@' | '^' 338 | '~' | '!' | '?' | '%' | '&' -> 339 true 340 | _ -> false 341 do 342 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 343 advance () 344 done; 345 Buffer.add_string buf "</span>" 346 (* Punctuation *) 347 | ':' | ';' | '.' | ',' | '[' | ']' | '{' | '}' | '(' | ')' -> 348 Buffer.add_string buf 349 (Printf.sprintf "<span class=\"hl-punct\">%s</span>" 350 (html_escape (String.make 1 (current ())))); 351 advance () 352 (* Arrow special case: -> *) 353 | ' ' | '\t' | '\n' | '\r' -> 354 Buffer.add_char buf (current ()); 355 advance () 356 | _ -> 357 let _ = peek () in 358 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 359 advance () 360 done; 361 Buffer.contents buf 362 363(** Render a diff as HTML with colored lines *) 364let render_diff_html diff = 365 let buf = Buffer.create 1024 in 366 List.iter (fun line -> 367 match line with 368 | Same s -> 369 Buffer.add_string buf 370 (Printf.sprintf "<div class=\"sc-diff-line sc-diff-same\">%s</div>\n" 371 (highlight_ocaml s)) 372 | Added s -> 373 Buffer.add_string buf 374 (Printf.sprintf "<div class=\"sc-diff-line sc-diff-added\">%s</div>\n" 375 (highlight_ocaml s)) 376 | Removed s -> 377 Buffer.add_string buf 378 (Printf.sprintf "<div class=\"sc-diff-line sc-diff-removed\">%s</div>\n" 379 (highlight_ocaml s))) 380 diff; 381 Buffer.contents buf 382 383(** {1 Shared JavaScript} 384 385 The scrollycode runtime handles IntersectionObserver-based step 386 detection and line-level transition animations. *) 387 388let shared_js = 389 {| 390(function() { 391 'use strict'; 392 393 function initScrollycode(container) { 394 var steps = container.querySelectorAll('.sc-step'); 395 var codeBody = container.querySelector('.sc-code-body'); 396 var stepBadge = container.querySelector('.sc-step-badge'); 397 var pips = container.querySelectorAll('.sc-pip'); 398 var currentStep = -1; 399 400 function parseLines(el) { 401 if (!el) return []; 402 var items = el.querySelectorAll('.sc-line'); 403 return Array.from(items).map(function(line) { 404 return { id: line.dataset.id, html: line.innerHTML, focused: line.classList.contains('sc-focused') }; 405 }); 406 } 407 408 function renderStep(index) { 409 if (index === currentStep || index < 0 || index >= steps.length) return; 410 411 var stepEl = steps[index]; 412 var codeSlot = stepEl.querySelector('.sc-code-slot'); 413 var newLines = parseLines(codeSlot); 414 var oldLines = parseLines(codeBody); 415 var oldById = {}; 416 oldLines.forEach(function(l) { oldById[l.id] = l; }); 417 var newById = {}; 418 newLines.forEach(function(l) { newById[l.id] = l; }); 419 420 // Determine exiting lines 421 var exiting = oldLines.filter(function(l) { return !newById[l.id]; }); 422 423 // Animate exit 424 exiting.forEach(function(l, i) { 425 var el = codeBody.querySelector('[data-id="' + l.id + '"]'); 426 if (el) { 427 el.style.animationDelay = (i * 30) + 'ms'; 428 el.classList.add('sc-exiting'); 429 } 430 }); 431 432 var exitTime = exiting.length > 0 ? 200 + exiting.length * 30 : 0; 433 434 setTimeout(function() { 435 // Rebuild DOM 436 codeBody.innerHTML = ''; 437 var firstNew = null; 438 newLines.forEach(function(l, i) { 439 var div = document.createElement('div'); 440 var isNew = !oldById[l.id]; 441 div.className = 'sc-line' + (l.focused ? ' sc-focused' : '') + (isNew ? ' sc-entering' : ''); 442 div.dataset.id = l.id; 443 div.innerHTML = '<span class="sc-line-number">' + (i + 1) + '</span>' + l.html; 444 if (isNew) { 445 div.style.animationDelay = (i * 25) + 'ms'; 446 if (!firstNew) firstNew = div; 447 } 448 codeBody.appendChild(div); 449 }); 450 451 // Scroll to first new line, with some context above 452 if (firstNew) { 453 var lineH = firstNew.offsetHeight || 24; 454 var scrollTarget = firstNew.offsetTop - lineH * 2; 455 codeBody.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }); 456 } 457 458 // Update badge and pips 459 if (stepBadge) stepBadge.textContent = (index + 1) + ' / ' + steps.length; 460 pips.forEach(function(pip, i) { 461 pip.classList.toggle('sc-active', i === index); 462 }); 463 }, exitTime); 464 465 currentStep = index; 466 } 467 468 // Set up IntersectionObserver 469 var observer = new IntersectionObserver(function(entries) { 470 entries.forEach(function(entry) { 471 if (entry.isIntersecting) { 472 var idx = parseInt(entry.target.dataset.stepIndex, 10); 473 renderStep(idx); 474 } 475 }); 476 }, { 477 rootMargin: '-30% 0px -30% 0px', 478 threshold: 0 479 }); 480 481 steps.forEach(function(step) { observer.observe(step); }); 482 483 // Initialize first step 484 renderStep(0); 485 486 // Playground overlay 487 var overlay = document.getElementById('sc-playground-overlay'); 488 var closeBtn = overlay ? overlay.querySelector('.sc-playground-close') : null; 489 490 if (overlay && closeBtn) { 491 // Close button 492 closeBtn.addEventListener('click', function() { 493 overlay.classList.remove('sc-open'); 494 }); 495 496 // ESC key closes 497 document.addEventListener('keydown', function(e) { 498 if (e.key === 'Escape') overlay.classList.remove('sc-open'); 499 }); 500 501 // Click outside closes 502 overlay.addEventListener('click', function(e) { 503 if (e.target === overlay) overlay.classList.remove('sc-open'); 504 }); 505 } 506 507 // Try it buttons 508 container.querySelectorAll('.sc-playground-btn').forEach(function(btn) { 509 btn.addEventListener('click', function() { 510 var stepIndex = parseInt(btn.dataset.step, 10); 511 // Collect code from all steps up to and including this one 512 var allCode = []; 513 for (var si = 0; si <= stepIndex; si++) { 514 var slot = steps[si].querySelector('.sc-code-slot'); 515 if (slot) { 516 var lines = slot.querySelectorAll('.sc-line'); 517 var code = Array.from(lines).map(function(l) { 518 return l.textContent.replace(/^\d+/, ''); 519 }).join('\n'); 520 allCode.push(code); 521 } 522 } 523 var fullCode = allCode.join('\n\n'); 524 525 var editor = document.getElementById('sc-playground-x-ocaml'); 526 if (editor) { 527 editor.textContent = fullCode; 528 // Trigger re-initialization if x-ocaml supports it 529 if (editor.setSource) editor.setSource(fullCode); 530 } 531 532 if (overlay) overlay.classList.add('sc-open'); 533 }); 534 }); 535 } 536 537 // Initialize all scrollycode containers on the page 538 document.addEventListener('DOMContentLoaded', function() { 539 document.querySelectorAll('.sc-container').forEach(initScrollycode); 540 }); 541})(); 542|} 543 544(** {1 HTML Generation} *) 545 546(** Generate the code lines HTML for a step's code slot *) 547let generate_code_lines code focus = 548 let lines = String.split_on_char '\n' code in 549 let buf = Buffer.create 1024 in 550 List.iteri 551 (fun i line -> 552 let line_num = i + 1 in 553 let focused = focus = [] || List.mem line_num focus in 554 let highlighted = highlight_ocaml line in 555 Buffer.add_string buf 556 (Printf.sprintf 557 "<div class=\"sc-line%s\" data-id=\"L%d\">%s</div>\n" 558 (if focused then " sc-focused" else "") 559 line_num highlighted)) 560 lines; 561 Buffer.contents buf 562 563(** Generate the mobile stacked layout with diffs between steps *) 564let generate_mobile_html steps = 565 let buf = Buffer.create 8192 in 566 Buffer.add_string buf "<div class=\"sc-mobile\">\n"; 567 let prev_code = ref None in 568 List.iteri (fun i step -> 569 Buffer.add_string buf 570 (Printf.sprintf " <div class=\"sc-mobile-step\">\n"); 571 Buffer.add_string buf 572 (Printf.sprintf " <div class=\"sc-mobile-step-num\">Step %02d</div>\n" (i + 1)); 573 if step.title <> "" then 574 Buffer.add_string buf 575 (Printf.sprintf " <h2>%s</h2>\n" (html_escape step.title)); 576 if step.prose <> "" then 577 Buffer.add_string buf 578 (Printf.sprintf " <p>%s</p>\n" (html_escape step.prose)); 579 (* Diff block *) 580 Buffer.add_string buf " <div class=\"sc-diff-block\">\n"; 581 let diff = match !prev_code with 582 | None -> 583 List.map (fun l -> Added l) (String.split_on_char '\n' step.code) 584 | Some prev -> 585 diff_lines prev step.code 586 in 587 Buffer.add_string buf (render_diff_html diff); 588 Buffer.add_string buf " </div>\n"; 589 Buffer.add_string buf 590 (Printf.sprintf " <button class=\"sc-playground-btn\" data-step=\"%d\">&#9654; Try it</button>\n" i); 591 Buffer.add_string buf " </div>\n"; 592 prev_code := Some step.code) 593 steps; 594 Buffer.add_string buf "</div>\n"; 595 Buffer.contents buf 596 597(** Generate the full scrollycode HTML. 598 Theme styling is handled externally via CSS — this produces 599 theme-agnostic semantic HTML. *) 600let generate_html ~title ~filename steps = 601 let buf = Buffer.create 16384 in 602 603 (* Container — no theme class, CSS custom properties handle theming *) 604 Buffer.add_string buf "<div class=\"sc-container\">\n"; 605 606 (* Hero *) 607 Buffer.add_string buf "<div class=\"sc-hero\">\n"; 608 Buffer.add_string buf 609 (Printf.sprintf " <h1>%s</h1>\n" (html_escape title)); 610 Buffer.add_string buf "</div>\n"; 611 612 (* Progress pips *) 613 Buffer.add_string buf "<nav class=\"sc-progress\">\n"; 614 List.iteri 615 (fun i _step -> 616 Buffer.add_string buf 617 (Printf.sprintf " <div class=\"sc-pip%s\"></div>\n" 618 (if i = 0 then " sc-active" else ""))) 619 steps; 620 Buffer.add_string buf "</nav>\n"; 621 622 (* Desktop layout *) 623 Buffer.add_string buf "<div class=\"sc-desktop\">\n"; 624 Buffer.add_string buf "<div class=\"sc-tutorial\">\n"; 625 626 (* Steps column *) 627 Buffer.add_string buf " <div class=\"sc-steps-col\">\n"; 628 List.iteri 629 (fun i step -> 630 Buffer.add_string buf 631 (Printf.sprintf 632 " <div class=\"sc-step\" data-step-index=\"%d\">\n" i); 633 Buffer.add_string buf 634 (Printf.sprintf 635 " <div class=\"sc-step-number\">Step %02d</div>\n" (i + 1)); 636 if step.title <> "" then 637 Buffer.add_string buf 638 (Printf.sprintf " <h2>%s</h2>\n" (html_escape step.title)); 639 if step.prose <> "" then 640 Buffer.add_string buf 641 (Printf.sprintf " <p>%s</p>\n" (html_escape step.prose)); 642 (* Hidden code slot for JS to read *) 643 Buffer.add_string buf " <div class=\"sc-code-slot\">\n"; 644 Buffer.add_string buf (generate_code_lines step.code step.focus); 645 Buffer.add_string buf " </div>\n"; 646 Buffer.add_string buf 647 (Printf.sprintf " <button class=\"sc-playground-btn\" data-step=\"%d\">&#9654; Try it</button>\n" i); 648 Buffer.add_string buf " </div>\n") 649 steps; 650 Buffer.add_string buf " </div>\n"; 651 652 (* Code column *) 653 Buffer.add_string buf " <div class=\"sc-code-col\">\n"; 654 Buffer.add_string buf " <div class=\"sc-code-panel\">\n"; 655 Buffer.add_string buf " <div class=\"sc-code-header\">\n"; 656 Buffer.add_string buf 657 " <div class=\"sc-dots\"><span></span><span></span><span></span></div>\n"; 658 Buffer.add_string buf 659 (Printf.sprintf " <span class=\"sc-filename\">%s</span>\n" 660 (html_escape filename)); 661 Buffer.add_string buf 662 (Printf.sprintf 663 " <span class=\"sc-step-badge\">1 / %d</span>\n" 664 (List.length steps)); 665 Buffer.add_string buf " </div>\n"; 666 Buffer.add_string buf " <div class=\"sc-code-body\">\n"; 667 (* Initial code from first step *) 668 (match steps with 669 | first :: _ -> Buffer.add_string buf (generate_code_lines first.code first.focus) 670 | [] -> ()); 671 Buffer.add_string buf " </div>\n"; 672 Buffer.add_string buf " </div>\n"; 673 Buffer.add_string buf " </div>\n"; 674 675 Buffer.add_string buf "</div>\n"; 676 Buffer.add_string buf "</div>\n"; 677 678 (* Mobile stacked layout *) 679 Buffer.add_string buf (generate_mobile_html steps); 680 681 (* Playground overlay *) 682 Buffer.add_string buf {|<div id="sc-playground-overlay" class="sc-playground-overlay"> 683 <div class="sc-playground-container"> 684 <div class="sc-playground-header"> 685 <span class="sc-playground-title">Playground</span> 686 <button class="sc-playground-close">&times;</button> 687 </div> 688 <div class="sc-playground-editor"> 689 <x-ocaml id="sc-playground-x-ocaml" run-on="click"></x-ocaml> 690 </div> 691 </div> 692</div> 693|}; 694 695 (* JavaScript *) 696 Buffer.add_string buf "<script>\n"; 697 Buffer.add_string buf shared_js; 698 Buffer.add_string buf "</script>\n"; 699 700 (* x-ocaml for playground *) 701 let x_ocaml_js_url = 702 match Sys.getenv_opt "ODOC_X_OCAML_JS" with 703 | Some url -> url 704 | None -> "/_x-ocaml/x-ocaml.js" 705 in 706 let x_ocaml_worker_url = 707 match Sys.getenv_opt "ODOC_X_OCAML_WORKER" with 708 | Some url -> url 709 | None -> "/_x-ocaml/worker.js" 710 in 711 Printf.bprintf buf {|<script src="%s" src-worker="%s" backend="jtw"></script> 712|} x_ocaml_js_url x_ocaml_worker_url; 713 714 Buffer.contents buf 715 716(** {1 Extension Registration} *) 717 718module Scrolly : Odoc_extension_api.Extension = struct 719 let prefix = "scrolly" 720 721 let to_document ~tag:_ content = 722 let tutorial_title, steps = extract_steps content in 723 let filename = "main.ml" in 724 let html = generate_html ~title:tutorial_title ~filename steps in 725 let block : Block.t = 726 [ 727 { 728 Odoc_document.Types.Block.attr = [ "scrollycode" ]; 729 desc = Raw_markup ("html", html); 730 }; 731 ] 732 in 733 { 734 Odoc_extension_api.content = block; 735 overrides = []; 736 resources = [ 737 Css_url "extensions/scrollycode.css"; 738 ]; 739 assets = []; 740 } 741end 742 743(* Register extension and structural CSS support file. 744 Force-link Scrollycode_themes to ensure theme support files are registered. *) 745let () = 746 ignore (Scrollycode_themes.warm_css : string); 747 Odoc_extension_api.Registry.register (module Scrolly); 748 Odoc_extension_api.Registry.register_support_file ~prefix:"scrolly" { 749 filename = "extensions/scrollycode.css"; 750 content = Inline Scrollycode_css.structural_css; 751 }; 752 (match Sys.getenv_opt "ODOC_X_OCAML_JS_PATH" with 753 | Some path -> 754 Odoc_extension_api.Registry.register_support_file ~prefix:"scrolly" { 755 filename = "_x-ocaml/x-ocaml.js"; 756 content = Copy_from path; 757 } 758 | None -> ())