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