OCaml HTML5 parser/serialiser based on Python's JustHTML
at main 11 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: MIT 4 ---------------------------------------------------------------------------*) 5 6open Brr 7open Htmlrw_js_types 8 9module Data_attr = struct 10 let severity = Jstr.v "data-html5rw-severity" 11 let message = Jstr.v "data-html5rw-message" 12 let code = Jstr.v "data-html5rw-code" 13 let count = Jstr.v "data-html5rw-count" 14end 15 16module Css_class = struct 17 let error = Jstr.v "html5rw-error" 18 let warning = Jstr.v "html5rw-warning" 19 let info = Jstr.v "html5rw-info" 20 let has_issues = Jstr.v "html5rw-has-issues" 21 let highlighted = Jstr.v "html5rw-highlighted" 22 let tooltip = Jstr.v "html5rw-tooltip" 23 let tooltip_visible = Jstr.v "html5rw-tooltip-visible" 24end 25 26type tooltip = { 27 container : El.t; 28 _target : El.t; 29} 30 31let severity_class = function 32 | Htmlrw_check.Error -> Css_class.error 33 | Htmlrw_check.Warning -> Css_class.warning 34 | Htmlrw_check.Info -> Css_class.info 35 36let annotate_element ~config el msg = 37 if config.add_data_attrs then begin 38 El.set_at Data_attr.severity 39 (Some (Jstr.v (Htmlrw_check.severity_to_string msg.Htmlrw_check.severity))) el; 40 El.set_at Data_attr.message (Some (Jstr.v msg.Htmlrw_check.text)) el; 41 El.set_at Data_attr.code 42 (Some (Jstr.v (Htmlrw_check.error_code_to_string msg.Htmlrw_check.error_code))) el 43 end; 44 if config.add_classes then begin 45 El.set_class (severity_class msg.Htmlrw_check.severity) true el; 46 El.set_class Css_class.has_issues true el 47 end 48 49let rec create_tooltip ~position target messages = 50 let doc = El.document target in 51 52 (* Create tooltip container *) 53 let container = El.v (Jstr.v "div") ~at:[At.class' Css_class.tooltip] [] in 54 55 (* Add messages to tooltip *) 56 let msg_els = List.map (fun msg -> 57 let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in 58 let sev_class = Jstr.v ("html5rw-tooltip-" ^ sev) in 59 El.v (Jstr.v "div") ~at:[At.class' sev_class] [ 60 El.v (Jstr.v "span") ~at:[At.class' (Jstr.v "html5rw-tooltip-severity")] [ 61 El.txt' (String.uppercase_ascii sev) 62 ]; 63 El.v (Jstr.v "span") ~at:[At.class' (Jstr.v "html5rw-tooltip-text")] [ 64 El.txt' msg.Htmlrw_check.text 65 ] 66 ] 67 ) messages in 68 El.set_children container msg_els; 69 70 (* Position the tooltip *) 71 let pos_class = match position with 72 | `Above -> "html5rw-tooltip-above" 73 | `Below -> "html5rw-tooltip-below" 74 | `Auto -> "html5rw-tooltip-auto" 75 in 76 El.set_class (Jstr.v pos_class) true container; 77 78 (* Add to body for proper z-index handling *) 79 El.append_children (Document.body doc) [container]; 80 81 (* Set up hover events *) 82 let hide () = 83 El.set_class Css_class.tooltip_visible false container 84 in 85 let show () = 86 (* Hide any other visible tooltips first *) 87 let doc = El.document target in 88 let visible = El.fold_find_by_selector (fun el acc -> el :: acc) 89 (Jstr.v ".html5rw-tooltip-visible") [] ~root:(Document.body doc) in 90 List.iter (fun el -> El.set_class Css_class.tooltip_visible false el) visible; 91 (* Position and show this tooltip *) 92 let x = El.bound_x target in 93 let y = El.bound_y target in 94 let h = El.bound_h target in 95 let tooltip_y = match position with 96 | `Below | `Auto -> y +. h +. 4.0 97 | `Above -> y -. 4.0 98 in 99 El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%.0fpx" x)) container; 100 El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%.0fpx" tooltip_y)) container; 101 El.set_class Css_class.tooltip_visible true container 102 in 103 104 ignore (Ev.listen Ev.mouseenter (fun _ -> show ()) (El.as_target target)); 105 ignore (Ev.listen Ev.mouseleave (fun _ -> hide ()) (El.as_target target)); 106 (* Also hide on mouseout for better reliability *) 107 ignore (Ev.listen Ev.mouseout (fun ev -> 108 let related = Jv.get (Ev.to_jv ev) "relatedTarget" in 109 (* Hide if mouse moved to something outside the target *) 110 if Jv.is_null related then hide () 111 else 112 (* Use JS contains method directly *) 113 let contains = Jv.call (El.to_jv target) "contains" [| related |] |> Jv.to_bool in 114 if not contains then hide () 115 ) (El.as_target target)); 116 117 { container; _target = target } 118 119and annotate ~config ~root:_ messages = 120 (* Group messages by element - use a list since we can't hash elements *) 121 let el_messages : (El.t * Htmlrw_check.message list) list ref = ref [] in 122 List.iter (fun bm -> 123 match bm.element_ref with 124 | Some { element = Some el; _ } -> 125 let found = ref false in 126 el_messages := List.map (fun (e, msgs) -> 127 if Jv.strict_equal (El.to_jv e) (El.to_jv el) then begin 128 found := true; 129 (e, bm.message :: msgs) 130 end else (e, msgs) 131 ) !el_messages; 132 if not !found then 133 el_messages := (el, [bm.message]) :: !el_messages 134 | _ -> () 135 ) messages; 136 137 (* Annotate each element *) 138 List.iter (fun (el, msgs) -> 139 (* Use highest severity *) 140 let highest = List.fold_left (fun acc msg -> 141 match acc, msg.Htmlrw_check.severity with 142 | Htmlrw_check.Error, _ -> Htmlrw_check.Error 143 | _, Htmlrw_check.Error -> Htmlrw_check.Error 144 | Htmlrw_check.Warning, _ -> Htmlrw_check.Warning 145 | _, Htmlrw_check.Warning -> Htmlrw_check.Warning 146 | _ -> Htmlrw_check.Info 147 ) Htmlrw_check.Info msgs in 148 149 let primary_msg = { 150 Htmlrw_check.severity = highest; 151 text = (match msgs with m :: _ -> m.Htmlrw_check.text | [] -> ""); 152 error_code = (match msgs with m :: _ -> m.Htmlrw_check.error_code 153 | [] -> Htmlrw_check.Conformance (`Misc `Multiple_h1)); 154 location = None; 155 element = None; 156 attribute = None; 157 extract = None; 158 } in 159 annotate_element ~config el primary_msg; 160 161 if config.add_data_attrs then 162 El.set_at Data_attr.count (Some (Jstr.v (string_of_int (List.length msgs)))) el; 163 164 if config.show_tooltips then 165 ignore (create_tooltip ~position:config.tooltip_position el msgs) 166 ) !el_messages 167 168let show_tooltip t = 169 El.set_class Css_class.tooltip_visible true t.container 170 171let hide_tooltip t = 172 El.set_class Css_class.tooltip_visible false t.container 173 174let remove_tooltip t = 175 El.remove t.container 176 177let tooltips_in root = 178 let doc = El.document root in 179 let tooltip_els = El.fold_find_by_selector (fun el acc -> el :: acc) 180 (Jstr.v ".html5rw-tooltip") [] ~root:(Document.body doc) in 181 List.map (fun container -> { container; _target = root }) tooltip_els 182 183let clear_element el = 184 El.set_at Data_attr.severity None el; 185 El.set_at Data_attr.message None el; 186 El.set_at Data_attr.code None el; 187 El.set_at Data_attr.count None el; 188 El.set_class Css_class.error false el; 189 El.set_class Css_class.warning false el; 190 El.set_class Css_class.info false el; 191 El.set_class Css_class.has_issues false el; 192 El.set_class Css_class.highlighted false el 193 194let clear root = 195 Htmlrw_js_dom.iter_elements clear_element root; 196 List.iter remove_tooltip (tooltips_in root) 197 198let highlight_element el = 199 El.set_class Css_class.highlighted true el; 200 (* Call scrollIntoView directly with options object *) 201 let opts = Jv.obj [| 202 "behavior", Jv.of_string "smooth"; 203 "block", Jv.of_string "center" 204 |] in 205 ignore (Jv.call (El.to_jv el) "scrollIntoView" [| opts |]) 206 207let unhighlight_element el = 208 El.set_class Css_class.highlighted false el 209 210let _highlighted_elements : El.t list ref = ref [] 211 212let clear_highlights () = 213 List.iter unhighlight_element !_highlighted_elements; 214 _highlighted_elements := [] 215 216let inject_default_styles ~theme = 217 let theme_vars = match theme with 218 | `Light -> {| 219 --html5rw-error-color: #e74c3c; 220 --html5rw-warning-color: #f39c12; 221 --html5rw-info-color: #3498db; 222 --html5rw-bg: #ffffff; 223 --html5rw-text: #333333; 224 --html5rw-border: #dddddd; 225 |} 226 | `Dark -> {| 227 --html5rw-error-color: #ff6b6b; 228 --html5rw-warning-color: #feca57; 229 --html5rw-info-color: #54a0ff; 230 --html5rw-bg: #2d3436; 231 --html5rw-text: #dfe6e9; 232 --html5rw-border: #636e72; 233 |} 234 | `Auto -> {| 235 --html5rw-error-color: #e74c3c; 236 --html5rw-warning-color: #f39c12; 237 --html5rw-info-color: #3498db; 238 --html5rw-bg: #ffffff; 239 --html5rw-text: #333333; 240 --html5rw-border: #dddddd; 241 |} 242 in 243 let css = Printf.sprintf {| 244 :root { %s } 245 246 @media (prefers-color-scheme: dark) { 247 :root { 248 --html5rw-error-color: #ff6b6b; 249 --html5rw-warning-color: #feca57; 250 --html5rw-info-color: #54a0ff; 251 --html5rw-bg: #2d3436; 252 --html5rw-text: #dfe6e9; 253 --html5rw-border: #636e72; 254 } 255 } 256 257 .html5rw-error { 258 outline: 2px solid var(--html5rw-error-color) !important; 259 outline-offset: 2px; 260 } 261 262 .html5rw-warning { 263 outline: 2px solid var(--html5rw-warning-color) !important; 264 outline-offset: 2px; 265 } 266 267 .html5rw-info { 268 outline: 2px solid var(--html5rw-info-color) !important; 269 outline-offset: 2px; 270 } 271 272 .html5rw-highlighted { 273 background-color: rgba(52, 152, 219, 0.3) !important; 274 animation: html5rw-pulse 1s ease-in-out; 275 } 276 277 @keyframes html5rw-pulse { 278 0%%, 100%% { background-color: rgba(52, 152, 219, 0.3); } 279 50%% { background-color: rgba(52, 152, 219, 0.5); } 280 } 281 282 .html5rw-tooltip { 283 position: fixed; 284 z-index: 100000; 285 background: var(--html5rw-bg); 286 border: 1px solid var(--html5rw-border); 287 border-radius: 6px; 288 padding: 8px 12px; 289 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 290 max-width: 400px; 291 font-family: system-ui, -apple-system, sans-serif; 292 font-size: 13px; 293 color: var(--html5rw-text); 294 opacity: 0; 295 visibility: hidden; 296 transition: opacity 0.2s, visibility 0.2s; 297 pointer-events: none; 298 } 299 300 .html5rw-tooltip-visible { 301 opacity: 1; 302 visibility: visible; 303 } 304 305 .html5rw-tooltip-error .html5rw-tooltip-severity { 306 color: var(--html5rw-error-color); 307 font-weight: 600; 308 margin-right: 8px; 309 } 310 311 .html5rw-tooltip-warning .html5rw-tooltip-severity { 312 color: var(--html5rw-warning-color); 313 font-weight: 600; 314 margin-right: 8px; 315 } 316 317 .html5rw-tooltip-info .html5rw-tooltip-severity { 318 color: var(--html5rw-info-color); 319 font-weight: 600; 320 margin-right: 8px; 321 } 322 323 .html5rw-tooltip > div { 324 margin-bottom: 4px; 325 } 326 327 .html5rw-tooltip > div:last-child { 328 margin-bottom: 0; 329 } 330 |} theme_vars in 331 332 let doc = G.document in 333 let style_el = El.v (Jstr.v "style") [] in 334 El.set_children style_el [El.txt' css]; 335 El.set_at (Jstr.v "data-html5rw-styles") (Some (Jstr.v "true")) style_el; 336 El.append_children (Document.head doc) [style_el]; 337 style_el 338 339let remove_injected_styles style_el = 340 El.remove style_el