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
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 "&"
148 | '<' -> Buffer.add_string buf "<"
149 | '>' -> Buffer.add_string buf ">"
150 | '"' -> Buffer.add_string buf """
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\">▶ 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\">▶ 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">×</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 -> ())