feat add .mld and .txt as documentation formats in file view

.mld files are parsed with ocamldoc-style markup ({1 Heading}, {[code]}, {b bold}, {i italic}). .txt files render as plain prose paragraphs. Both now display as formatted documentation rather than syntax- highlighted source when browsed as file/ blobs.

Commit
373227209f9d008fcfe83a9958d95d7f22847a1d
Author
Claude Sonnet 4 <claude@anthropic.invalid>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
lib/readme.ml
index c0beba6b..3ebb4a48 100644..100644
@@ -437,16 +437,195 @@
437 437 inline = org_inline;
438 438 }
439 439
440 Added: (* {1 Mld (ocamldoc) parsing} *)
441 Added:
442 Added: let mld_heading line =
443 Added: let trimmed = String.trim line in
444 Added: let len = String.length trimmed in
445 Added: if len < 4 || trimmed.[0] <> '{' then None
446 Added: else
447 Added: match trimmed.[1] with
448 Added: | '0' .. '6' when len > 3 && trimmed.[2] = ' ' ->
449 Added: let level = Char.code trimmed.[1] - Char.code '0' in
450 Added: let text_start = 3 in
451 Added: let text_end = if trimmed.[len - 1] = '}' then len - 1 else len in
452 Added: let text =
453 Added: String.sub trimmed text_start (text_end - text_start) |> String.trim
454 Added: in
455 Added: Some (max 1 level, text)
456 Added: | _ -> None
457 Added:
458 Added: let mld_code_block_open line =
459 Added: let trimmed = String.trim line in
460 Added: if String.starts_with ~prefix:"{[" trimmed then
461 Added: let rest = String.sub trimmed 2 (String.length trimmed - 2) in
462 Added: if
463 Added: String.length rest > 0
464 Added: && rest.[String.length rest - 1] = ']'
465 Added: && String.length rest > 1
466 Added: && rest.[String.length rest - 2] = '}'
467 Added: then
468 Added: (* Single-line code block: {[code]} on one line *)
469 Added: None
470 Added: else Some rest
471 Added: else None
472 Added:
473 Added: let mld_code_block_single line =
474 Added: let trimmed = String.trim line in
475 Added: let len = String.length trimmed in
476 Added: if
477 Added: len >= 4
478 Added: && String.starts_with ~prefix:"{[" trimmed
479 Added: && trimmed.[len - 2] = ']'
480 Added: && trimmed.[len - 1] = '}'
481 Added: then Some (String.sub trimmed 2 (len - 4))
482 Added: else None
483 Added:
484 Added: let is_mld_code_block_close line =
485 Added: let trimmed = String.trim line in
486 Added: String.length trimmed >= 2
487 Added: && trimmed.[String.length trimmed - 2] = ']'
488 Added: && trimmed.[String.length trimmed - 1] = '}'
489 Added:
490 Added: let parse_mld_blocks lines =
491 Added: let is_boundary line =
492 Added: String.trim line = ""
493 Added: || Option.is_some (mld_heading line)
494 Added: || Option.is_some (mld_code_block_open line)
495 Added: || Option.is_some (mld_code_block_single line)
496 Added: in
497 Added: let rec take_paragraph collected = function
498 Added: | line :: _ as rest when is_boundary line -> (List.rev collected, rest)
499 Added: | line :: rest -> take_paragraph (String.trim line :: collected) rest
500 Added: | [] -> (List.rev collected, [])
501 Added: in
502 Added: let open Doc_format in
503 Added: let rec loop blocks = function
504 Added: | [] -> List.rev blocks
505 Added: | line :: rest when String.trim line = "" -> loop blocks rest
506 Added: | line :: rest -> (
507 Added: match mld_heading line with
508 Added: | Some (level, text) -> loop (Heading (level, text) :: blocks) rest
509 Added: | None -> (
510 Added: match mld_code_block_single line with
511 Added: | Some code -> loop (Code_block (None, code) :: blocks) rest
512 Added: | None -> (
513 Added: match mld_code_block_open line with
514 Added: | Some first_line ->
515 Added: let code_lines, rest =
516 Added: Doc_format.take_until is_mld_code_block_close [] rest
517 Added: in
518 Added: let all_lines =
519 Added: if first_line = "" then code_lines
520 Added: else first_line :: code_lines
521 Added: in
522 Added: let code = String.concat "\n" all_lines in
523 Added: (* Strip trailing ]} if present in last consumed line *)
524 Added: loop (Code_block (None, code) :: blocks) rest
525 Added: | None ->
526 Added: let paragraph, rest = take_paragraph [] (line :: rest) in
527 Added: loop
528 Added: (Paragraph (String.concat " " paragraph) :: blocks)
529 Added: rest)))
530 Added: in
531 Added: loop [] lines
532 Added:
533 Added: let mld_inline text =
534 Added: let len = String.length text in
535 Added: let buf = Buffer.create 64 in
536 Added: let nodes = ref [] in
537 Added: let flush () =
538 Added: if Buffer.length buf > 0 then (
539 Added: nodes := Ui.text (Buffer.contents buf) :: !nodes;
540 Added: Buffer.clear buf)
541 Added: in
542 Added: let rec loop i =
543 Added: if i >= len then flush ()
544 Added: else
545 Added: match text.[i] with
546 Added: | '{' when i + 1 < len -> (
547 Added: match text.[i + 1] with
548 Added: | ('b' | 'i' | 'e') when i + 2 < len && text.[i + 2] = ' ' ->
549 Added: flush ();
550 Added: let close = find_brace_close (i + 3) 1 in
551 Added: let content = String.sub text (i + 3) (close - i - 3) in
552 Added: nodes :=
553 Added: Ui.inline ~class_:"readme-emphasis" [ Ui.text content ]
554 Added: :: !nodes;
555 Added: loop (close + 1)
556 Added: | '[' ->
557 Added: flush ();
558 Added: let close = find_code_close (i + 2) in
559 Added: let content = String.sub text (i + 2) (close - i - 2) in
560 Added: nodes := Ui.code_inline ~class_:"readme-code" content :: !nodes;
561 Added: loop (close + 2)
562 Added: | _ ->
563 Added: Buffer.add_char buf '{';
564 Added: loop (i + 1))
565 Added: | c ->
566 Added: Buffer.add_char buf c;
567 Added: loop (i + 1)
568 Added: and find_brace_close start depth =
569 Added: if start >= len then len
570 Added: else if text.[start] = '}' then
571 Added: if depth <= 1 then start else find_brace_close (start + 1) (depth - 1)
572 Added: else if text.[start] = '{' then find_brace_close (start + 1) (depth + 1)
573 Added: else find_brace_close (start + 1) depth
574 Added: and find_code_close start =
575 Added: if start + 1 >= len then len
576 Added: else if text.[start] = ']' && text.[start + 1] = '}' then start
577 Added: else find_code_close (start + 1)
578 Added: in
579 Added: loop 0;
580 Added: List.rev !nodes
581 Added:
582 Added: let mld : Doc_format.format =
583 Added: {
584 Added: name = "mld";
585 Added: css_class = "readme-document readme-mld";
586 Added: parse =
587 Added: (fun content ->
588 Added: let lines = String.split_on_char '\n' content in
589 Added: {
590 Added: Doc_format.title = None;
591 Added: metadata = [];
592 Added: blocks = parse_mld_blocks lines;
593 Added: });
594 Added: inline = mld_inline;
595 Added: }
596 Added:
597 Added: (* {1 Plain text} *)
598 Added:
599 Added: let plaintext : Doc_format.format =
600 Added: {
601 Added: name = "plaintext";
602 Added: css_class = "readme-document readme-plaintext";
603 Added: parse =
604 Added: (fun content ->
605 Added: let lines = String.split_on_char '\n' content in
606 Added: let blocks =
607 Added: List.filter_map
608 Added: (fun line ->
609 Added: let trimmed = String.trim line in
610 Added: if trimmed = "" then None else Some (Doc_format.Paragraph trimmed))
611 Added: lines
612 Added: in
613 Added: { Doc_format.title = None; metadata = []; blocks });
614 Added: inline = plain_inline;
615 Added: }
616 Added:
440 617 (* {1 Public API} *)
441 618
442 619 let format_of_filename filename =
443 620 match Filename.extension filename |> String.lowercase_ascii with
444 621 | ".org" -> org
622 Added: | ".mld" -> mld
623 Added: | ".txt" -> plaintext
445 624 | _ -> markdown
446 625
447 626 let is_doc_filename filename =
448 627 match Filename.extension filename |> String.lowercase_ascii with
449 Removed: | ".md" | ".markdown" | ".org" -> true
628 Added: | ".md" | ".markdown" | ".org" | ".mld" | ".txt" -> true
450 629 | _ -> false
451 630
452 631 let render ~filename content =