My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve margin note CSS and add @figure V3 with rich captions

Three things bundled:

1. Margin note (`{&margin ...}`) now floats to the right of the
paragraph as a proper sidenote, with a responsive fallback that
collapses to a block on narrow screens. The inline-chip look is
gone.

2. Two new inline extensions:
- `{&image SRC "alt"}` → `<img src="SRC" alt="alt">`
- `{&linked-image URL SRC "alt"}` → `<a href="URL"><img ...></a>`
Both parse a whitespace-separated payload with quoted values.

3. `@figure` V3 form: if the body paragraph starts with a
`{&linked-image ...}` or `{&image ...}` inline, that inline
supplies the image and the remaining paragraph inlines become the
`<figcaption>` content — so bold, italic, links and references in
captions now render. V1 (attribute-only, plain-text caption) still
works as a fallback when no image inline is present.

Plus five placeholder tag pages under site/tags/ so the `@page-tags`
chips in the demo page actually link somewhere that exists, and an
updated demo page showing all three features side-by-side.

The link-phase validation for @page-tags (fail-build-if-tag-page-
missing) isn't in this commit — Env.lookup_page_by_path / by_name did
not reliably find pages under the site package. Needs more research
into how the monorepo's site package exposes pages to the Env at
link phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+196 -8
+196 -8
src/odoc_jons_plugins.ml
··· 534 534 let () = 535 535 Api.Registry.register (module Page_tags) 536 536 537 + (* Whitespace-separated tokeniser with support for double-quoted 538 + values. Used by @figure and the image / linked-image inlines. *) 539 + module Tok = struct 540 + let is_ws c = c = ' ' || c = '\t' || c = '\n' || c = '\r' 541 + 542 + let tokenise s = 543 + let len = Stdlib.String.length s in 544 + let i = ref 0 in 545 + let out = ref [] in 546 + let skip_ws () = 547 + while !i < len && is_ws s.[!i] do incr i done 548 + in 549 + while !i < len do 550 + skip_ws (); 551 + if !i < len then begin 552 + let start = !i in 553 + let tok = 554 + if s.[!i] = '"' then begin 555 + incr i; 556 + let tstart = !i in 557 + while !i < len && s.[!i] <> '"' do incr i done; 558 + let t = Stdlib.String.sub s tstart (!i - tstart) in 559 + if !i < len then incr i; 560 + t 561 + end else begin 562 + while !i < len && not (is_ws s.[!i]) do incr i done; 563 + Stdlib.String.sub s start (!i - start) 564 + end 565 + in 566 + if tok <> "" then out := tok :: !out 567 + end 568 + done; 569 + List.rev !out 570 + end 571 + 537 572 (* --- Figure extension --- 538 573 539 574 [@figure src=foo.png alt="…" link="…"] ··· 673 708 let raw_block html = 674 709 Block.{ attr = []; desc = Raw_markup ("html", html) } 675 710 711 + (* V3: detect a {&image …} or {&linked-image …} custom inline in the 712 + first paragraph and use it as the image. The rest of the 713 + paragraph's inlines become the rich-formatted caption. Falls back 714 + to V1 attribute-only form if no such inline is present. *) 715 + let ext_image_prefix = "odoc-ext:image" 716 + let ext_linked_prefix = "odoc-ext:linked-image" 717 + 718 + (* Split a paragraph's inlines around the first image/linked-image 719 + custom inline. Returns (before, image_html, after) or None. *) 720 + let find_image_inline 721 + (inlines : Odoc_model.Comment.inline_element 722 + Odoc_model.Location_.with_location list) = 723 + let rec go acc = function 724 + | [] -> None 725 + | (el : Odoc_model.Comment.inline_element 726 + Odoc_model.Location_.with_location) :: rest -> 727 + (match el.value with 728 + | `Raw_markup (target, payload) 729 + when target = ext_image_prefix -> 730 + (match Tok.tokenise payload with 731 + | src :: rest_toks -> 732 + let alt = Stdlib.String.concat " " rest_toks in 733 + let html = 734 + Printf.sprintf {|<img src="%s" alt="%s">|} 735 + (escape src) (escape alt) 736 + in 737 + Some (List.rev acc, html, rest) 738 + | [] -> go (el :: acc) rest) 739 + | `Raw_markup (target, payload) 740 + when target = ext_linked_prefix -> 741 + (match Tok.tokenise payload with 742 + | url :: src :: rest_toks -> 743 + let alt = Stdlib.String.concat " " rest_toks in 744 + let html = 745 + Printf.sprintf 746 + {|<a href="%s"><img src="%s" alt="%s"></a>|} 747 + (escape url) (escape src) (escape alt) 748 + in 749 + Some (List.rev acc, html, rest) 750 + | _ -> go (el :: acc) rest) 751 + | _ -> go (el :: acc) rest) 752 + in 753 + go [] inlines 754 + 755 + (* Skip leading/trailing whitespace-only inlines for a cleaner caption. *) 756 + let trim_inlines inlines = 757 + let is_blank (el : Odoc_model.Comment.inline_element 758 + Odoc_model.Location_.with_location) = 759 + match el.value with 760 + | `Space -> true 761 + | _ -> false 762 + in 763 + let rec drop_head = function 764 + | x :: rest when is_blank x -> drop_head rest 765 + | xs -> xs 766 + in 767 + let drop_tail xs = List.rev (drop_head (List.rev xs)) in 768 + drop_tail (drop_head inlines) 769 + 770 + let try_v3 content = 771 + match content with 772 + | [] -> None 773 + | (first : Odoc_model.Comment.nestable_block_element 774 + Odoc_model.Location_.with_location) :: _ -> 775 + (match first.value with 776 + | `Paragraph inlines -> 777 + (match find_image_inline inlines with 778 + | None -> None 779 + | Some (before, img_html, after) -> 780 + Some (img_html, trim_inlines (before @ after))) 781 + | _ -> None) 782 + 783 + let render_v3 ~img_html ~caption_inlines = 784 + let caption_ir : Inline.t = 785 + Odoc_document.Comment.inline_element_list caption_inlines 786 + in 787 + let has_caption = caption_inlines <> [] in 788 + let open_fig = {|<figure class="figure">|} in 789 + let opens = if has_caption then "<figcaption>" else "" in 790 + let closes = if has_caption then "</figcaption>" else "" in 791 + let blocks = 792 + [ raw_block (open_fig ^ img_html ^ opens) ] 793 + @ (if has_caption 794 + then [ Block.{ attr = []; desc = Inline caption_ir } ] 795 + else []) 796 + @ [ raw_block (closes ^ "</figure>") ] 797 + in 798 + { 799 + Api.content = blocks; 800 + overrides = []; 801 + resources = [ Api.Css_inline figure_css ]; 802 + assets = []; 803 + } 804 + 676 805 (* v1 design: the entire tag body is treated as the attribute string. 677 806 Syntax: [@figure src=foo.png alt="Caption text" link=https://…] 678 807 The [alt] attribute doubles as the figcaption (plain text). For 679 808 rich-text captions, fall back to raw HTML until a v2 exists. *) 680 809 let to_document ~tag:_ content = 810 + match try_v3 content with 811 + | Some (img_html, caption_inlines) -> 812 + render_v3 ~img_html ~caption_inlines 813 + | None -> 814 + (* Fall through to V1 *) 815 + let _to_document_v1 = () in 816 + let _ = _to_document_v1 in 681 817 let attr_line = 682 818 Api.text_of_nestable_block_elements content 683 819 |> Stdlib.String.trim ··· 766 902 Printf.sprintf {|<kbd>%s</kbd>|} (escape payload) 767 903 end 768 904 905 + module Image_inline = struct 906 + let prefix = "image" 907 + let escape = Margin.escape 908 + let to_html payload = 909 + match Tok.tokenise payload with 910 + | [] -> "" 911 + | src :: rest -> 912 + let alt = Stdlib.String.concat " " rest in 913 + Printf.sprintf {|<img src="%s" alt="%s">|} 914 + (escape src) (escape alt) 915 + end 916 + 917 + module Linked_image_inline = struct 918 + let prefix = "linked-image" 919 + let escape = Margin.escape 920 + let to_html payload = 921 + match Tok.tokenise payload with 922 + | [] | [ _ ] -> "" 923 + | url :: src :: rest -> 924 + let alt = Stdlib.String.concat " " rest in 925 + Printf.sprintf 926 + {|<a href="%s"><img src="%s" alt="%s"></a>|} 927 + (escape url) (escape src) (escape alt) 928 + end 929 + 769 930 let inline_extensions_css = {| 770 - /* Inline extension: margin note */ 931 + /* Inline extension: margin note 932 + 933 + Float a small sidenote to the right of the paragraph. The main 934 + content column is flush with the page sidebar on the right so we 935 + can't escape into an outer gutter; instead we float inside the 936 + column and let the paragraph text wrap around it. On narrow 937 + viewports it falls back to a pull-quote style block. */ 771 938 .margin-note { 772 - display: inline-block; 773 - padding: 0.1em 0.5em; 774 - margin: 0 0.2em; 775 - font-size: 0.85em; 939 + float: right; 940 + clear: right; 941 + width: 12em; 942 + margin: 0.1em 0 0.4em 1.2em; 943 + padding: 0 0 0 0.7em; 944 + font-size: 0.8em; 945 + line-height: 1.5; 776 946 color: var(--text-muted, #666); 777 - background: var(--surface-alt, #f3f3f3); 778 - border-radius: 3px; 779 947 border-left: 2px solid var(--accent-color, #b44e2d); 948 + font-style: normal; 780 949 } 950 + 951 + /* Keep the sidenote from overlapping figures or code blocks that 952 + come after it. */ 953 + .margin-note + * { 954 + clear: right; 955 + } 956 + 957 + /* Narrow viewports: show inline before the next block. */ 958 + @media (max-width: 800px) { 959 + .margin-note { 960 + float: none; 961 + display: block; 962 + width: auto; 963 + margin: 0.4em 0 0.4em 0; 964 + font-size: 0.85em; 965 + } 966 + } 967 + 781 968 @media (prefers-color-scheme: dark) { 782 969 .margin-note { 783 - background: rgba(255,255,255,0.04); 784 970 color: var(--text-muted, #aaa); 785 971 } 786 972 } ··· 789 975 let () = 790 976 Api.Registry.register_inline (module Margin); 791 977 Api.Registry.register_inline (module Kbd); 978 + Api.Registry.register_inline (module Image_inline); 979 + Api.Registry.register_inline (module Linked_image_inline); 792 980 (* Inline extensions don't have a per-page resource hook; ship their 793 981 CSS via the shell plugin's support-file mechanism so it is 794 982 available wherever the shell runs. *)