OCaml HTML5 parser/serialiser based on Python's JustHTML
at main 16 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 9let console_log msg = 10 ignore (Jv.call (Jv.get Jv.global "console") "log" [| Jv.of_string ("[html5rw-ui] " ^ msg) |]) 11 12module Css_class = struct 13 let panel = Jstr.v "html5rw-panel" 14 let panel_header = Jstr.v "html5rw-panel-header" 15 let panel_content = Jstr.v "html5rw-panel-content" 16 let panel_collapsed = Jstr.v "html5rw-panel-collapsed" 17 let panel_dragging = Jstr.v "html5rw-panel-dragging" 18 let warning_list = Jstr.v "html5rw-warning-list" 19 let warning_row = Jstr.v "html5rw-warning-row" 20 let warning_row_error = Jstr.v "html5rw-warning-row-error" 21 let warning_row_warning = Jstr.v "html5rw-warning-row-warning" 22 let warning_row_info = Jstr.v "html5rw-warning-row-info" 23 let severity_badge = Jstr.v "html5rw-severity-badge" 24 let message_text = Jstr.v "html5rw-message-text" 25 let selector_path = Jstr.v "html5rw-selector-path" 26 let collapse_btn = Jstr.v "html5rw-collapse-btn" 27 let close_btn = Jstr.v "html5rw-close-btn" 28 let summary_badge = Jstr.v "html5rw-summary-badge" 29 let error_count = Jstr.v "html5rw-error-count" 30 let warning_count = Jstr.v "html5rw-warning-count" 31 let theme_light = Jstr.v "html5rw-theme-light" 32 let theme_dark = Jstr.v "html5rw-theme-dark" 33end 34 35type t = { 36 root : El.t; 37 header : El.t; 38 content : El.t; 39 badge : El.t; 40 config : panel_config; 41 mutable result : result; 42 mutable collapsed : bool; 43 mutable highlighted : El.t option; 44 mutable on_warning_click : (browser_message -> unit) option; 45 mutable on_collapse_toggle : (bool -> unit) option; 46 mutable on_close : (unit -> unit) option; 47 mutable on_move : (int * int -> unit) option; 48} 49 50let _current_panel : t option ref = ref None 51 52let current () = !_current_panel 53let root_element t = t.root 54let header_element t = t.header 55let content_element t = t.content 56let badge_element t = t.badge 57 58let is_visible t = 59 let display = El.computed_style (Jstr.v "display") t.root in 60 not (Jstr.equal display (Jstr.v "none")) 61 62let is_collapsed t = t.collapsed 63 64let position t = 65 let x = int_of_float (El.bound_x t.root) in 66 let y = int_of_float (El.bound_y t.root) in 67 (x, y) 68 69let set_position t x y = 70 El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) t.root; 71 El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) t.root; 72 El.set_inline_style (Jstr.v "right") (Jstr.v "auto") t.root 73 74let highlighted_element t = t.highlighted 75 76let clear_highlight t = 77 console_log (Printf.sprintf "clear_highlight: highlighted is %s" 78 (if t.highlighted = None then "None" else "Some")); 79 match t.highlighted with 80 | Some el -> 81 console_log "clear_highlight: unhighlighting element"; 82 Htmlrw_js_annotate.unhighlight_element el; 83 t.highlighted <- None; 84 console_log "clear_highlight: done" 85 | None -> 86 console_log "clear_highlight: nothing to clear" 87 88let navigate_to_element t bm = 89 clear_highlight t; 90 match bm.element_ref with 91 | Some { element = Some el; _ } -> 92 Htmlrw_js_annotate.highlight_element el; 93 t.highlighted <- Some el 94 | _ -> () 95 96let severity_row_class = function 97 | Htmlrw_check.Error -> Css_class.warning_row_error 98 | Htmlrw_check.Warning -> Css_class.warning_row_warning 99 | Htmlrw_check.Info -> Css_class.warning_row_info 100 101let create_warning_row ~config t bm = 102 let msg = bm.message in 103 let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in 104 105 let badge = El.v (Jstr.v "span") ~at:[At.class' Css_class.severity_badge] [ 106 El.txt' (String.uppercase_ascii sev) 107 ] in 108 109 let text = El.v (Jstr.v "span") ~at:[At.class' Css_class.message_text] [ 110 El.txt' msg.Htmlrw_check.text 111 ] in 112 113 let children = [badge; text] in 114 let children = 115 if config.show_selector_path then 116 match bm.element_ref with 117 | Some ref -> 118 let path = El.v (Jstr.v "span") ~at:[At.class' Css_class.selector_path] [ 119 El.txt' ref.selector 120 ] in 121 children @ [path] 122 | None -> children 123 else 124 children 125 in 126 127 let row = El.v (Jstr.v "div") ~at:[ 128 At.class' Css_class.warning_row; 129 At.class' (severity_row_class msg.Htmlrw_check.severity); 130 ] children in 131 132 if config.click_to_highlight then begin 133 ignore (Ev.listen Ev.click (fun _ -> 134 navigate_to_element t bm; 135 match t.on_warning_click with 136 | Some f -> f bm 137 | None -> () 138 ) (El.as_target row)) 139 end; 140 141 row 142 143let build_content ~config t = 144 let messages = 145 if config.group_by_severity then 146 let errors, warnings, infos = List.fold_left (fun (e, w, i) bm -> 147 match bm.message.Htmlrw_check.severity with 148 | Htmlrw_check.Error -> (bm :: e, w, i) 149 | Htmlrw_check.Warning -> (e, bm :: w, i) 150 | Htmlrw_check.Info -> (e, w, bm :: i) 151 ) ([], [], []) t.result.messages in 152 List.rev errors @ List.rev warnings @ List.rev infos 153 else 154 t.result.messages 155 in 156 157 let rows = List.map (create_warning_row ~config t) messages in 158 let list = El.v (Jstr.v "div") ~at:[At.class' Css_class.warning_list] rows in 159 160 (match config.max_height with 161 | Some h -> 162 El.set_inline_style (Jstr.v "maxHeight") (Jstr.v (Printf.sprintf "%dpx" h)) list; 163 El.set_inline_style (Jstr.v "overflowY") (Jstr.v "auto") list 164 | None -> ()); 165 list 166 167let update t result = 168 t.result <- result; 169 let list = build_content ~config:t.config t in 170 El.set_children t.content [list]; 171 let error_count = List.length (List.filter (fun bm -> 172 bm.message.Htmlrw_check.severity = Htmlrw_check.Error 173 ) result.messages) in 174 let warning_count = List.length (List.filter (fun bm -> 175 bm.message.Htmlrw_check.severity = Htmlrw_check.Warning 176 ) result.messages) in 177 El.set_children t.badge [ 178 El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count) 179 ] 180 181let collapse t = 182 t.collapsed <- true; 183 El.set_class Css_class.panel_collapsed true t.root; 184 match t.on_collapse_toggle with Some f -> f true | None -> () 185 186let expand t = 187 t.collapsed <- false; 188 El.set_class Css_class.panel_collapsed false t.root; 189 match t.on_collapse_toggle with Some f -> f false | None -> () 190 191let toggle_collapsed t = 192 if t.collapsed then expand t else collapse t 193 194let show t = 195 El.set_inline_style (Jstr.v "display") (Jstr.v "block") t.root 196 197let hide t = 198 El.set_inline_style (Jstr.v "display") (Jstr.v "none") t.root 199 200let destroy t = 201 console_log "destroy: starting"; 202 clear_highlight t; 203 console_log "destroy: cleared highlight"; 204 (* Clear _current_panel before removing element to avoid comparison issues *) 205 (match !_current_panel with 206 | Some p when p.root == t.root -> _current_panel := None 207 | _ -> ()); 208 console_log "destroy: cleared current_panel ref"; 209 El.remove t.root; 210 console_log "destroy: removed root element, done" 211 212let hide_current () = 213 console_log (Printf.sprintf "hide_current: current_panel is %s" 214 (if !_current_panel = None then "None" else "Some")); 215 match !_current_panel with 216 | Some t -> 217 console_log "hide_current: destroying existing panel"; 218 destroy t 219 | None -> 220 console_log "hide_current: no panel to destroy" 221 222let create ~config result = 223 console_log (Printf.sprintf "create: starting with %d messages" (List.length result.messages)); 224 hide_current (); 225 console_log "create: hide_current done"; 226 227 let _doc = G.document in 228 229 let title = El.v (Jstr.v "span") [El.txt' "HTML5 Validation"] in 230 231 let close_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.close_btn] [ 232 El.txt' "x" 233 ] in 234 235 let header = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_header] [ 236 title; close_btn 237 ] in 238 239 let error_count = List.length (List.filter (fun bm -> 240 bm.message.Htmlrw_check.severity = Htmlrw_check.Error 241 ) result.messages) in 242 let warning_count = List.length (List.filter (fun bm -> 243 bm.message.Htmlrw_check.severity = Htmlrw_check.Warning 244 ) result.messages) in 245 246 let badge = El.v (Jstr.v "div") ~at:[At.class' Css_class.summary_badge] [ 247 El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count) 248 ] in 249 250 let content = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_content] [] in 251 252 let theme_class = match config.theme with 253 | `Light -> Css_class.theme_light 254 | `Dark -> Css_class.theme_dark 255 | `Auto -> Css_class.theme_light 256 in 257 258 let root = El.v (Jstr.v "div") ~at:[ 259 At.class' Css_class.panel; 260 At.class' theme_class; 261 ] [header; badge; content] in 262 263 (match config.initial_position with 264 | `TopRight -> 265 El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root; 266 El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root 267 | `TopLeft -> 268 El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root; 269 El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root 270 | `BottomRight -> 271 El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root; 272 El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root 273 | `BottomLeft -> 274 El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root; 275 El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root 276 | `Custom (x, y) -> 277 El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) root; 278 El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) root); 279 280 let t = { 281 root; header; content; badge; config; result; 282 collapsed = config.start_collapsed; 283 highlighted = None; 284 on_warning_click = None; 285 on_collapse_toggle = None; 286 on_close = None; 287 on_move = None; 288 } in 289 290 update t result; 291 292 (* Stop mousedown from bubbling to header (prevents drag interference) *) 293 ignore (Ev.listen Ev.mousedown (fun ev -> 294 console_log "close_btn: mousedown, stopping propagation"; 295 Ev.stop_propagation ev 296 ) (El.as_target close_btn)); 297 298 ignore (Ev.listen Ev.click (fun ev -> 299 console_log "close_btn: click handler starting"; 300 Ev.stop_propagation ev; 301 console_log "close_btn: stopped propagation, calling destroy"; 302 destroy t; 303 console_log "close_btn: destroy done, checking on_close callback"; 304 (match t.on_close with Some f -> f () | None -> ()); 305 console_log "close_btn: click handler done" 306 ) (El.as_target close_btn)); 307 308 if config.draggable then begin 309 let dragging = ref false in 310 let offset_x = ref 0.0 in 311 let offset_y = ref 0.0 in 312 313 ignore (Ev.listen Ev.mousedown (fun ev -> 314 let m = Ev.as_type ev in 315 dragging := true; 316 offset_x := Ev.Mouse.client_x m -. El.bound_x root; 317 offset_y := Ev.Mouse.client_y m -. El.bound_y root; 318 El.set_class Css_class.panel_dragging true root 319 ) (El.as_target header)); 320 321 ignore (Ev.listen Ev.mousemove (fun ev -> 322 if !dragging then begin 323 let m = Ev.as_type ev in 324 let x = int_of_float (Ev.Mouse.client_x m -. !offset_x) in 325 let y = int_of_float (Ev.Mouse.client_y m -. !offset_y) in 326 set_position t x y; 327 match t.on_move with Some f -> f (x, y) | None -> () 328 end 329 ) (Window.as_target G.window)); 330 331 ignore (Ev.listen Ev.mouseup (fun _ -> 332 dragging := false; 333 El.set_class Css_class.panel_dragging false root 334 ) (Window.as_target G.window)) 335 end; 336 337 if config.start_collapsed then 338 El.set_class Css_class.panel_collapsed true root; 339 340 console_log "create: appending panel to document body"; 341 El.append_children (Document.body G.document) [root]; 342 343 _current_panel := Some t; 344 console_log "create: panel creation complete"; 345 t 346 347let on_warning_click t f = t.on_warning_click <- Some f 348let on_collapse_toggle t f = t.on_collapse_toggle <- Some f 349let on_close t f = t.on_close <- Some f 350let on_move t f = t.on_move <- Some f 351 352let inject_default_styles ~theme = 353 let theme_vars = match theme with 354 | `Light -> {| 355 --html5rw-panel-bg: #ffffff; 356 --html5rw-panel-text: #333333; 357 --html5rw-panel-border: #dddddd; 358 --html5rw-panel-header-bg: #f5f5f5; 359 |} 360 | `Dark -> {| 361 --html5rw-panel-bg: #2d3436; 362 --html5rw-panel-text: #dfe6e9; 363 --html5rw-panel-border: #636e72; 364 --html5rw-panel-header-bg: #1e272e; 365 |} 366 | `Auto -> {| 367 --html5rw-panel-bg: #ffffff; 368 --html5rw-panel-text: #333333; 369 --html5rw-panel-border: #dddddd; 370 --html5rw-panel-header-bg: #f5f5f5; 371 |} 372 in 373 374 let css = Printf.sprintf {| 375 :root { %s } 376 377 @media (prefers-color-scheme: dark) { 378 :root { 379 --html5rw-panel-bg: #2d3436; 380 --html5rw-panel-text: #dfe6e9; 381 --html5rw-panel-border: #636e72; 382 --html5rw-panel-header-bg: #1e272e; 383 } 384 } 385 386 .html5rw-panel { 387 position: fixed; 388 z-index: 99999; 389 width: 400px; 390 background: var(--html5rw-panel-bg); 391 border: 1px solid var(--html5rw-panel-border); 392 border-radius: 8px; 393 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 394 font-family: system-ui, -apple-system, sans-serif; 395 font-size: 13px; 396 color: var(--html5rw-panel-text); 397 } 398 399 .html5rw-panel-header { 400 display: flex; 401 align-items: center; 402 padding: 12px 16px; 403 background: var(--html5rw-panel-header-bg); 404 border-bottom: 1px solid var(--html5rw-panel-border); 405 border-radius: 8px 8px 0 0; 406 cursor: move; 407 user-select: none; 408 } 409 410 .html5rw-panel-header span { flex: 1; font-weight: 600; } 411 412 .html5rw-panel-header button { 413 width: 24px; height: 24px; margin-left: 8px; 414 border: none; border-radius: 4px; 415 background: transparent; color: var(--html5rw-panel-text); 416 cursor: pointer; font-size: 14px; 417 display: flex; align-items: center; justify-content: center; 418 } 419 420 .html5rw-panel-header button:hover { background: rgba(0, 0, 0, 0.1); } 421 .html5rw-panel-content { padding: 0; } 422 .html5rw-panel-collapsed .html5rw-panel-content { display: none; } 423 .html5rw-panel-collapsed .html5rw-summary-badge { display: block; } 424 .html5rw-summary-badge { display: none; padding: 12px 16px; text-align: center; font-weight: 500; } 425 .html5rw-warning-list { max-height: 400px; overflow-y: auto; } 426 427 .html5rw-warning-row { 428 display: flex; flex-direction: column; 429 padding: 10px 16px; 430 border-bottom: 1px solid var(--html5rw-panel-border); 431 cursor: pointer; transition: background 0.15s; 432 } 433 434 .html5rw-warning-row:hover { background: rgba(0, 0, 0, 0.05); } 435 .html5rw-warning-row:last-child { border-bottom: none; } 436 437 .html5rw-severity-badge { 438 display: inline-block; padding: 2px 6px; border-radius: 3px; 439 font-size: 10px; font-weight: 600; text-transform: uppercase; margin-right: 8px; 440 } 441 442 .html5rw-warning-row-error .html5rw-severity-badge { background: #e74c3c; color: white; } 443 .html5rw-warning-row-warning .html5rw-severity-badge { background: #f39c12; color: white; } 444 .html5rw-warning-row-info .html5rw-severity-badge { background: #3498db; color: white; } 445 .html5rw-message-text { flex: 1; line-height: 1.4; } 446 447 .html5rw-selector-path { 448 display: block; margin-top: 4px; font-size: 11px; color: #888; 449 font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 450 } 451 452 .html5rw-panel-dragging { opacity: 0.9; } 453 |} theme_vars in 454 455 let doc = G.document in 456 let style_el = El.v (Jstr.v "style") [El.txt' css] in 457 El.set_at (Jstr.v "data-html5rw-panel-styles") (Some (Jstr.v "true")) style_el; 458 El.append_children (Document.head doc) [style_el]; 459 style_el