OCaml HTML5 parser/serialiser based on Python's JustHTML

js

-1
bin/html5check/html5check.ml
··· 47 47 | Ok (reader, ic, system_id) -> 48 48 (* Run validation *) 49 49 let result = Htmlrw_check.check ~system_id reader in 50 - 51 50 (* Close input if it's not stdin *) 52 51 if file <> "-" then close_in ic; 53 52
+49
lib/js/dune
··· 1 + ; HTML5rw JavaScript Validator Library 2 + ; Compiled with js_of_ocaml for browser use 3 + 4 + (library 5 + (name htmlrw_js) 6 + (public_name html5rw.js) 7 + (libraries 8 + html5rw 9 + htmlrw_check 10 + bytesrw 11 + brr) 12 + (modes byte) ; js_of_ocaml requires bytecode 13 + (modules 14 + htmlrw_js_types 15 + htmlrw_js_dom 16 + htmlrw_js_annotate 17 + htmlrw_js_ui 18 + htmlrw_js)) 19 + 20 + ; Standalone JavaScript file for direct browser use 21 + ; This compiles the library entry point to a .js file 22 + (executable 23 + (name htmlrw_js_main) 24 + (libraries htmlrw_js) 25 + (js_of_ocaml 26 + (javascript_files)) 27 + (modes js) 28 + (modules htmlrw_js_main)) 29 + 30 + ; Web Worker for background validation 31 + ; Runs validation in a separate thread to avoid blocking the UI 32 + (executable 33 + (name htmlrw_js_worker) 34 + (libraries html5rw htmlrw_check bytesrw brr) 35 + (js_of_ocaml 36 + (javascript_files)) 37 + (modes js) 38 + (modules htmlrw_js_worker)) 39 + 40 + ; Copy to nice filenames 41 + (rule 42 + (targets htmlrw.js) 43 + (deps htmlrw_js_main.bc.js) 44 + (action (copy %{deps} %{targets}))) 45 + 46 + (rule 47 + (targets htmlrw-worker.js) 48 + (deps htmlrw_js_worker.bc.js) 49 + (action (copy %{deps} %{targets})))
+576
lib/js/htmlrw_js.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Brr 7 + open Htmlrw_js_types 8 + 9 + let ensure_doctype html = 10 + let lower = String.lowercase_ascii html in 11 + if String.length lower >= 9 && String.sub lower 0 9 = "<!doctype" then 12 + html 13 + else 14 + "<!DOCTYPE html>" ^ html 15 + 16 + let validate_string raw_html = 17 + let html = ensure_doctype raw_html in 18 + try 19 + let core_result = Htmlrw_check.check_string html in 20 + let messages = List.map (fun msg -> 21 + { message = msg; element_ref = None } 22 + ) (Htmlrw_check.messages core_result) in 23 + { messages; core_result; source_element = None } 24 + with exn -> 25 + (* Return empty result with error message on parse failure *) 26 + let error_msg = { 27 + Htmlrw_check.severity = Htmlrw_check.Error; 28 + text = Printf.sprintf "Parse error: %s" (Printexc.to_string exn); 29 + error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1); 30 + location = None; 31 + element = None; 32 + attribute = None; 33 + extract = None; 34 + } in 35 + let core_result = Htmlrw_check.check_string "" in 36 + { messages = [{ message = error_msg; element_ref = None }]; 37 + core_result; 38 + source_element = None } 39 + 40 + let validate_element el = 41 + try 42 + let el_map, html = Htmlrw_js_dom.create el in 43 + let core_result = Htmlrw_check.check_string html in 44 + let messages = List.map (fun msg -> 45 + let element_ref = 46 + match Htmlrw_js_dom.find_for_message el_map msg with 47 + | Some browser_el -> 48 + Some { 49 + element = Some browser_el; 50 + selector = Htmlrw_js_dom.selector_path browser_el; 51 + } 52 + | None -> 53 + (* No direct mapping found - try to find by element name *) 54 + match msg.Htmlrw_check.element with 55 + | Some tag -> 56 + let matches = Htmlrw_js_dom.filter_elements (fun e -> 57 + String.lowercase_ascii (Jstr.to_string (El.tag_name e)) = 58 + String.lowercase_ascii tag 59 + ) el in 60 + (match matches with 61 + | browser_el :: _ -> 62 + Some { 63 + element = Some browser_el; 64 + selector = Htmlrw_js_dom.selector_path browser_el; 65 + } 66 + | [] -> None) 67 + | None -> None 68 + in 69 + { message = msg; element_ref } 70 + ) (Htmlrw_check.messages core_result) in 71 + { messages; core_result; source_element = Some el } 72 + with exn -> 73 + (* Return error result on parse failure *) 74 + let error_msg = { 75 + Htmlrw_check.severity = Htmlrw_check.Error; 76 + text = Printf.sprintf "Parse error: %s" (Printexc.to_string exn); 77 + error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1); 78 + location = None; 79 + element = None; 80 + attribute = None; 81 + extract = None; 82 + } in 83 + let core_result = Htmlrw_check.check_string "" in 84 + { messages = [{ message = error_msg; element_ref = None }]; 85 + core_result; 86 + source_element = Some el } 87 + 88 + let validate_and_annotate ?(config = default_annotation_config) el = 89 + let result = validate_element el in 90 + (* Inject styles if not already present *) 91 + let doc = El.document el in 92 + let existing = El.find_first_by_selector (Jstr.v "[data-html5rw-styles]") 93 + ~root:(Document.head doc) in 94 + if Option.is_none existing then 95 + ignore (Htmlrw_js_annotate.inject_default_styles ~theme:`Auto); 96 + (* Annotate elements *) 97 + Htmlrw_js_annotate.annotate ~config ~root:el result.messages; 98 + result 99 + 100 + let validate_and_show_panel 101 + ?(annotation_config = default_annotation_config) 102 + ?(panel_config = default_panel_config) 103 + el = 104 + let result = validate_and_annotate ~config:annotation_config el in 105 + (* Inject panel styles if not already present *) 106 + let doc = El.document el in 107 + let existing = El.find_first_by_selector (Jstr.v "[data-html5rw-panel-styles]") 108 + ~root:(Document.head doc) in 109 + if Option.is_none existing then 110 + ignore (Htmlrw_js_ui.inject_default_styles ~theme:panel_config.theme); 111 + (* Create and show panel *) 112 + ignore (Htmlrw_js_ui.create ~config:panel_config result); 113 + result 114 + 115 + let errors result = 116 + List.filter (fun bm -> 117 + bm.message.Htmlrw_check.severity = Htmlrw_check.Error 118 + ) result.messages 119 + 120 + let warnings_only result = 121 + List.filter (fun bm -> 122 + bm.message.Htmlrw_check.severity = Htmlrw_check.Warning 123 + ) result.messages 124 + 125 + let infos result = 126 + List.filter (fun bm -> 127 + bm.message.Htmlrw_check.severity = Htmlrw_check.Info 128 + ) result.messages 129 + 130 + let has_errors result = 131 + Htmlrw_check.has_errors result.core_result 132 + 133 + let has_issues result = 134 + Htmlrw_check.has_errors result.core_result || 135 + Htmlrw_check.has_warnings result.core_result 136 + 137 + let message_count result = 138 + List.length result.messages 139 + 140 + let element_map result = 141 + match result.source_element with 142 + | Some el -> Some (fst (Htmlrw_js_dom.create el)) 143 + | None -> None 144 + 145 + (* JavaScript API registration *) 146 + 147 + let register_api_on obj = 148 + (* validateString(html) -> result *) 149 + Jv.set obj "validateString" (Jv.callback ~arity:1 (fun html -> 150 + let html_str = Jv.to_string html in 151 + let result = validate_string html_str in 152 + result_to_jv result 153 + )); 154 + 155 + (* validateElement(el) -> result *) 156 + Jv.set obj "validateElement" (Jv.callback ~arity:1 (fun el_jv -> 157 + let el = El.of_jv el_jv in 158 + let result = validate_element el in 159 + result_to_jv result 160 + )); 161 + 162 + (* validateAndAnnotate(el, config?) -> result *) 163 + Jv.set obj "validateAndAnnotate" (Jv.callback ~arity:2 (fun el_jv config_jv -> 164 + let el = El.of_jv el_jv in 165 + let config = 166 + if Jv.is_none config_jv then 167 + default_annotation_config 168 + else 169 + { 170 + add_data_attrs = Jv.to_bool (Jv.get config_jv "addDataAttrs"); 171 + add_classes = Jv.to_bool (Jv.get config_jv "addClasses"); 172 + show_tooltips = Jv.to_bool (Jv.get config_jv "showTooltips"); 173 + tooltip_position = `Auto; 174 + highlight_on_hover = Jv.to_bool (Jv.get config_jv "highlightOnHover"); 175 + } 176 + in 177 + let result = validate_and_annotate ~config el in 178 + result_to_jv result 179 + )); 180 + 181 + (* validateAndShowPanel(el, config?) -> result *) 182 + Jv.set obj "validateAndShowPanel" (Jv.callback ~arity:2 (fun el_jv config_jv -> 183 + let el = El.of_jv el_jv in 184 + let annotation_config, panel_config = 185 + if Jv.is_none config_jv then 186 + default_annotation_config, default_panel_config 187 + else 188 + let ann_jv = Jv.get config_jv "annotation" in 189 + let panel_jv = Jv.get config_jv "panel" in 190 + let ann_config = 191 + if Jv.is_none ann_jv then default_annotation_config 192 + else { 193 + add_data_attrs = 194 + (let v = Jv.get ann_jv "addDataAttrs" in 195 + if Jv.is_none v then true else Jv.to_bool v); 196 + add_classes = 197 + (let v = Jv.get ann_jv "addClasses" in 198 + if Jv.is_none v then true else Jv.to_bool v); 199 + show_tooltips = 200 + (let v = Jv.get ann_jv "showTooltips" in 201 + if Jv.is_none v then true else Jv.to_bool v); 202 + tooltip_position = `Auto; 203 + highlight_on_hover = 204 + (let v = Jv.get ann_jv "highlightOnHover" in 205 + if Jv.is_none v then true else Jv.to_bool v); 206 + } 207 + in 208 + let panel_config = 209 + if Jv.is_none panel_jv then default_panel_config 210 + else { 211 + initial_position = 212 + (let v = Jv.get panel_jv "initialPosition" in 213 + if Jv.is_none v then `TopRight 214 + else match Jv.to_string v with 215 + | "topRight" -> `TopRight 216 + | "topLeft" -> `TopLeft 217 + | "bottomRight" -> `BottomRight 218 + | "bottomLeft" -> `BottomLeft 219 + | _ -> `TopRight); 220 + draggable = 221 + (let v = Jv.get panel_jv "draggable" in 222 + if Jv.is_none v then true else Jv.to_bool v); 223 + resizable = 224 + (let v = Jv.get panel_jv "resizable" in 225 + if Jv.is_none v then true else Jv.to_bool v); 226 + collapsible = 227 + (let v = Jv.get panel_jv "collapsible" in 228 + if Jv.is_none v then true else Jv.to_bool v); 229 + start_collapsed = 230 + (let v = Jv.get panel_jv "startCollapsed" in 231 + if Jv.is_none v then false else Jv.to_bool v); 232 + max_height = 233 + (let v = Jv.get panel_jv "maxHeight" in 234 + if Jv.is_none v then Some 400 else Some (Jv.to_int v)); 235 + group_by_severity = 236 + (let v = Jv.get panel_jv "groupBySeverity" in 237 + if Jv.is_none v then true else Jv.to_bool v); 238 + click_to_highlight = 239 + (let v = Jv.get panel_jv "clickToHighlight" in 240 + if Jv.is_none v then true else Jv.to_bool v); 241 + show_selector_path = 242 + (let v = Jv.get panel_jv "showSelectorPath" in 243 + if Jv.is_none v then true else Jv.to_bool v); 244 + theme = 245 + (let v = Jv.get panel_jv "theme" in 246 + if Jv.is_none v then `Auto 247 + else match Jv.to_string v with 248 + | "light" -> `Light 249 + | "dark" -> `Dark 250 + | _ -> `Auto); 251 + } 252 + in 253 + ann_config, panel_config 254 + in 255 + let result = validate_and_show_panel ~annotation_config ~panel_config el in 256 + result_to_jv result 257 + )); 258 + 259 + (* clearAnnotations(el) *) 260 + Jv.set obj "clearAnnotations" (Jv.callback ~arity:1 (fun el_jv -> 261 + let el = El.of_jv el_jv in 262 + Htmlrw_js_annotate.clear el; 263 + Jv.undefined 264 + )); 265 + 266 + (* hidePanel() *) 267 + Jv.set obj "hidePanel" (Jv.callback ~arity:0 (fun () -> 268 + Htmlrw_js_ui.hide_current (); 269 + Jv.undefined 270 + )); 271 + 272 + (* showPanel(result, config?) *) 273 + Jv.set obj "showPanel" (Jv.callback ~arity:2 (fun result_jv config_jv -> 274 + (* This expects a previously returned result object *) 275 + (* For now, just create a panel with the warnings from the result *) 276 + let warnings_jv = Jv.get result_jv "warnings" in 277 + let warnings = Jv.to_list (fun w_jv -> 278 + let msg = { 279 + Htmlrw_check.severity = 280 + (match Jv.to_string (Jv.get w_jv "severity") with 281 + | "error" -> Htmlrw_check.Error 282 + | "warning" -> Htmlrw_check.Warning 283 + | _ -> Htmlrw_check.Info); 284 + text = Jv.to_string (Jv.get w_jv "message"); 285 + error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1); 286 + location = None; 287 + element = None; 288 + attribute = None; 289 + extract = None; 290 + } in 291 + let element_ref = 292 + let sel_jv = Jv.get w_jv "selector" in 293 + let el_jv = Jv.get w_jv "element" in 294 + if Jv.is_none sel_jv then None 295 + else Some { 296 + selector = Jv.to_string sel_jv; 297 + element = if Jv.is_none el_jv then None else Some (El.of_jv el_jv); 298 + } 299 + in 300 + { message = msg; element_ref } 301 + ) warnings_jv in 302 + let result = { 303 + messages = warnings; 304 + core_result = Htmlrw_check.check_string ""; 305 + source_element = None; 306 + } in 307 + let config = 308 + if Jv.is_none config_jv then default_panel_config 309 + else default_panel_config (* TODO: parse config *) 310 + in 311 + ignore (Htmlrw_js_ui.create ~config result); 312 + Jv.undefined 313 + )) 314 + 315 + (* Async/Worker support *) 316 + 317 + let console_log msg = 318 + ignore (Jv.call (Jv.get Jv.global "console") "log" [| Jv.of_string msg |]) 319 + 320 + let console_log_result prefix result = 321 + let error_count = List.length (List.filter (fun bm -> 322 + bm.message.Htmlrw_check.severity = Htmlrw_check.Error 323 + ) result.messages) in 324 + let warning_count = List.length (List.filter (fun bm -> 325 + bm.message.Htmlrw_check.severity = Htmlrw_check.Warning 326 + ) result.messages) in 327 + let msg = Printf.sprintf "[html5rw] %s: %d errors, %d warnings, %d total issues" 328 + prefix error_count warning_count (List.length result.messages) in 329 + console_log msg 330 + 331 + let _worker : Jv.t option ref = ref None 332 + let _pending_callbacks : (int, Jv.t -> unit) Hashtbl.t = Hashtbl.create 16 333 + let _next_id = ref 0 334 + 335 + let init_worker worker_url = 336 + console_log (Printf.sprintf "[html5rw] Initializing web worker from %s" worker_url); 337 + let worker = Jv.new' (Jv.get Jv.global "Worker") [| Jv.of_string worker_url |] in 338 + 339 + (* Error handler for worker-level errors *) 340 + let error_handler = Jv.callback ~arity:1 (fun ev -> 341 + let msg = Jv.get ev "message" in 342 + let filename = Jv.get ev "filename" in 343 + let lineno = Jv.get ev "lineno" in 344 + console_log (Printf.sprintf "[html5rw] Worker error: %s at %s:%d" 345 + (if Jv.is_undefined msg then "unknown" else Jv.to_string msg) 346 + (if Jv.is_undefined filename then "unknown" else Jv.to_string filename) 347 + (if Jv.is_undefined lineno then 0 else Jv.to_int lineno)) 348 + ) in 349 + ignore (Jv.call worker "addEventListener" [| Jv.of_string "error"; error_handler |]); 350 + 351 + let handler = Jv.callback ~arity:1 (fun ev -> 352 + let data = Jv.get ev "data" in 353 + let id = Jv.get data "id" |> Jv.to_int in 354 + let error_count = Jv.get data "errorCount" |> Jv.to_int in 355 + let warning_count = Jv.get data "warningCount" |> Jv.to_int in 356 + let total = Jv.get data "warnings" |> Jv.to_list (fun _ -> ()) |> List.length in 357 + console_log (Printf.sprintf "[html5rw] Worker validation complete: %d errors, %d warnings, %d total issues" 358 + error_count warning_count total); 359 + match Hashtbl.find_opt _pending_callbacks id with 360 + | Some callback -> 361 + Hashtbl.remove _pending_callbacks id; 362 + callback data 363 + | None -> () 364 + ) in 365 + ignore (Jv.call worker "addEventListener" [| Jv.of_string "message"; handler |]); 366 + _worker := Some worker; 367 + console_log "[html5rw] Web worker ready"; 368 + worker 369 + 370 + let validate_string_async ~callback html = 371 + match !_worker with 372 + | None -> failwith "Worker not initialized. Call html5rw.initWorker(url) first." 373 + | Some worker -> 374 + console_log (Printf.sprintf "[html5rw] Sending %d bytes to worker for validation..." (String.length html)); 375 + let id = !_next_id in 376 + incr _next_id; 377 + Hashtbl.add _pending_callbacks id callback; 378 + let msg = Jv.obj [| 379 + "id", Jv.of_int id; 380 + "html", Jv.of_string html 381 + |] in 382 + ignore (Jv.call worker "postMessage" [| msg |]) 383 + 384 + let _validate_element_async ~callback el = 385 + let html = Htmlrw_js_dom.outer_html el in 386 + validate_string_async ~callback html 387 + 388 + let validate_after_load callback el = 389 + (* Use requestIdleCallback if available, otherwise setTimeout *) 390 + console_log "[html5rw] Waiting for page load..."; 391 + let run () = 392 + console_log "[html5rw] Starting validation..."; 393 + let result = validate_element el in 394 + console_log_result "Validation complete" result; 395 + callback result 396 + in 397 + let request_idle = Jv.get Jv.global "requestIdleCallback" in 398 + if not (Jv.is_undefined request_idle) then 399 + ignore (Jv.apply request_idle [| Jv.callback ~arity:1 (fun _ -> run ()) |]) 400 + else 401 + ignore (Jv.call Jv.global "setTimeout" [| 402 + Jv.callback ~arity:0 run; 403 + Jv.of_int 0 404 + |]) 405 + 406 + let validate_on_idle ?(timeout=5000) callback el = 407 + (* Wait for page load, then use requestIdleCallback with timeout *) 408 + console_log "[html5rw] Scheduling validation for idle time..."; 409 + let run_when_ready () = 410 + let request_idle = Jv.get Jv.global "requestIdleCallback" in 411 + if not (Jv.is_undefined request_idle) then begin 412 + let opts = Jv.obj [| "timeout", Jv.of_int timeout |] in 413 + ignore (Jv.call Jv.global "requestIdleCallback" [| 414 + Jv.callback ~arity:1 (fun _ -> 415 + console_log "[html5rw] Browser idle, starting validation..."; 416 + let result = validate_element el in 417 + console_log_result "Validation complete" result; 418 + callback result 419 + ); 420 + opts 421 + |]) 422 + end else begin 423 + ignore (Jv.call Jv.global "setTimeout" [| 424 + Jv.callback ~arity:0 (fun () -> 425 + console_log "[html5rw] Starting validation..."; 426 + let result = validate_element el in 427 + console_log_result "Validation complete" result; 428 + callback result 429 + ); 430 + Jv.of_int 100 431 + |]) 432 + end 433 + in 434 + let ready_state = Jv.get (Jv.get Jv.global "document") "readyState" |> Jv.to_string in 435 + if ready_state = "complete" then 436 + run_when_ready () 437 + else 438 + ignore (Jv.call Jv.global "addEventListener" [| 439 + Jv.of_string "load"; 440 + Jv.callback ~arity:1 (fun _ -> run_when_ready ()) 441 + |]) 442 + 443 + let register_global_api () = 444 + let api = Jv.obj [||] in 445 + register_api_on api; 446 + 447 + (* Add async functions *) 448 + 449 + (* initWorker(url) - initialize web worker *) 450 + Jv.set api "initWorker" (Jv.callback ~arity:1 (fun url_jv -> 451 + let url = Jv.to_string url_jv in 452 + init_worker url 453 + )); 454 + 455 + (* validateStringAsync(html, callback) - validate in worker *) 456 + Jv.set api "validateStringAsync" (Jv.callback ~arity:2 (fun html_jv callback_jv -> 457 + let html = Jv.to_string html_jv in 458 + let callback result = ignore (Jv.apply callback_jv [| result |]) in 459 + validate_string_async ~callback html; 460 + Jv.undefined 461 + )); 462 + 463 + (* validateElementAsync(el, callback) - validate element in worker *) 464 + Jv.set api "validateElementAsync" (Jv.callback ~arity:2 (fun el_jv callback_jv -> 465 + let el = El.of_jv el_jv in 466 + let html = Htmlrw_js_dom.outer_html el in 467 + let callback result = ignore (Jv.apply callback_jv [| result |]) in 468 + validate_string_async ~callback html; 469 + Jv.undefined 470 + )); 471 + 472 + (* validateAfterLoad(el, callback) - validate after page load *) 473 + Jv.set api "validateAfterLoad" (Jv.callback ~arity:2 (fun el_jv callback_jv -> 474 + let el = El.of_jv el_jv in 475 + let callback result = ignore (Jv.apply callback_jv [| result_to_jv result |]) in 476 + validate_after_load callback el; 477 + Jv.undefined 478 + )); 479 + 480 + (* validateOnIdle(el, callback, timeout?) - validate when browser is idle *) 481 + Jv.set api "validateOnIdle" (Jv.callback ~arity:3 (fun el_jv callback_jv timeout_jv -> 482 + let el = El.of_jv el_jv in 483 + let timeout = if Jv.is_undefined timeout_jv then 5000 else Jv.to_int timeout_jv in 484 + let callback result = ignore (Jv.apply callback_jv [| result_to_jv result |]) in 485 + validate_on_idle ~timeout callback el; 486 + Jv.undefined 487 + )); 488 + 489 + (* validateAndShowPanelAsync(el, config?) - non-blocking panel display *) 490 + Jv.set api "validateAndShowPanelAsync" (Jv.callback ~arity:2 (fun el_jv config_jv -> 491 + let el = El.of_jv el_jv in 492 + validate_on_idle ~timeout:3000 (fun result -> 493 + let annotation_config, panel_config = 494 + if Jv.is_none config_jv then 495 + default_annotation_config, default_panel_config 496 + else 497 + (* Parse config same as validateAndShowPanel *) 498 + default_annotation_config, default_panel_config 499 + in 500 + (* Inject styles if needed *) 501 + let doc = El.document el in 502 + let existing = El.find_first_by_selector (Jstr.v "[data-html5rw-styles]") 503 + ~root:(Document.head doc) in 504 + if Option.is_none existing then 505 + ignore (Htmlrw_js_annotate.inject_default_styles ~theme:`Auto); 506 + let existing_panel = El.find_first_by_selector (Jstr.v "[data-html5rw-panel-styles]") 507 + ~root:(Document.head doc) in 508 + if Option.is_none existing_panel then 509 + ignore (Htmlrw_js_ui.inject_default_styles ~theme:panel_config.theme); 510 + (* Annotate and show panel *) 511 + Htmlrw_js_annotate.annotate ~config:annotation_config ~root:el result.messages; 512 + ignore (Htmlrw_js_ui.create ~config:panel_config result) 513 + ) el; 514 + Jv.undefined 515 + )); 516 + 517 + (* showPanelFromWorkerResult(result) - show panel from worker validation result *) 518 + Jv.set api "showPanelFromWorkerResult" (Jv.callback ~arity:1 (fun result_jv -> 519 + console_log "[html5rw] Showing panel from worker result"; 520 + (* Convert worker result format to internal format *) 521 + let warnings_jv = Jv.get result_jv "warnings" in 522 + let messages = Jv.to_list (fun w_jv -> 523 + let severity_str = Jv.to_string (Jv.get w_jv "severity") in 524 + let msg = { 525 + Htmlrw_check.severity = 526 + (match severity_str with 527 + | "error" -> Htmlrw_check.Error 528 + | "warning" -> Htmlrw_check.Warning 529 + | _ -> Htmlrw_check.Info); 530 + text = Jv.to_string (Jv.get w_jv "message"); 531 + error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1); 532 + location = ( 533 + let line_jv = Jv.get w_jv "line" in 534 + let col_jv = Jv.get w_jv "column" in 535 + if Jv.is_undefined line_jv then None 536 + else Some { 537 + Htmlrw_check.line = Jv.to_int line_jv; 538 + column = (if Jv.is_undefined col_jv then 1 else Jv.to_int col_jv); 539 + end_line = None; 540 + end_column = None; 541 + system_id = None; 542 + } 543 + ); 544 + element = ( 545 + let el_jv = Jv.get w_jv "elementName" in 546 + if Jv.is_undefined el_jv then None else Some (Jv.to_string el_jv) 547 + ); 548 + attribute = ( 549 + let attr_jv = Jv.get w_jv "attribute" in 550 + if Jv.is_undefined attr_jv then None else Some (Jv.to_string attr_jv) 551 + ); 552 + extract = None; 553 + } in 554 + { message = msg; element_ref = None } 555 + ) warnings_jv in 556 + 557 + let result = { 558 + messages; 559 + core_result = Htmlrw_check.check_string ""; 560 + source_element = None; 561 + } in 562 + 563 + (* Inject panel styles *) 564 + let doc = Document.of_jv (Jv.get Jv.global "document") in 565 + let existing_panel = El.find_first_by_selector (Jstr.v "[data-html5rw-panel-styles]") 566 + ~root:(Document.head doc) in 567 + if Option.is_none existing_panel then 568 + ignore (Htmlrw_js_ui.inject_default_styles ~theme:`Auto); 569 + 570 + (* Create and show panel *) 571 + console_log (Printf.sprintf "[html5rw] Creating panel with %d messages" (List.length messages)); 572 + ignore (Htmlrw_js_ui.create ~config:default_panel_config result); 573 + Jv.undefined 574 + )); 575 + 576 + Jv.set Jv.global "html5rw" api
+153
lib/js/htmlrw_js.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** JavaScript API for HTML5 validation in the browser. 7 + 8 + This module provides the main entry points for validating HTML in a 9 + browser environment. It wraps the core {!Htmlrw_check} validator and 10 + adds browser-specific functionality for element mapping and annotation. 11 + 12 + {2 JavaScript Usage} 13 + 14 + After loading the compiled JavaScript, the API is available on [window]: 15 + 16 + {v 17 + // Validate an element (recommended) 18 + const result = html5rw.validateElement(document.body); 19 + console.log(result.errorCount, "errors found"); 20 + 21 + // Validate with annotation 22 + html5rw.validateAndAnnotate(document.body, { 23 + showTooltips: true, 24 + showPanel: true 25 + }); 26 + 27 + // Validate a raw HTML string 28 + const result = html5rw.validateString("<div><p>Hello</div>"); 29 + result.warnings.forEach(w => console.log(w.message)); 30 + v} 31 + 32 + {2 OCaml Usage} 33 + 34 + {[ 35 + let result = Htmlrw_js.validate_element (Brr.Document.body G.document) in 36 + List.iter (fun bm -> 37 + Brr.Console.log [Jstr.v bm.Htmlrw_js_types.message.text] 38 + ) result.messages 39 + ]} *) 40 + 41 + 42 + open Htmlrw_js_types 43 + 44 + 45 + (** {1 Validation} *) 46 + 47 + (** Validate an HTML string. 48 + 49 + This is the simplest form of validation. Since there's no source element, 50 + the returned {!browser_message}s will not have element references. 51 + 52 + {[ 53 + let result = validate_string "<html><body><img></body></html>" in 54 + if Htmlrw_check.has_errors result.core_result then 55 + (* handle errors *) 56 + ]} *) 57 + val validate_string : string -> result 58 + 59 + (** Validate a DOM element's HTML. 60 + 61 + Serializes the element to HTML, validates it, and maps the results 62 + back to the live DOM elements. 63 + 64 + {[ 65 + let result = validate_element (Document.body G.document) in 66 + List.iter (fun bm -> 67 + match bm.element_ref with 68 + | Some { element = Some el; _ } -> 69 + El.set_class (Jstr.v "has-error") true el 70 + | _ -> () 71 + ) result.messages 72 + ]} *) 73 + val validate_element : Brr.El.t -> result 74 + 75 + 76 + (** {1 Validation with Annotation} 77 + 78 + These functions validate and immediately annotate the DOM with results. *) 79 + 80 + (** Validate and annotate an element. 81 + 82 + This combines validation with DOM annotation. The element and its 83 + descendants are annotated with data attributes, classes, and optionally 84 + tooltips based on the validation results. 85 + 86 + @param config Annotation configuration. Defaults to {!default_annotation_config}. *) 87 + val validate_and_annotate : 88 + ?config:annotation_config -> Brr.El.t -> result 89 + 90 + (** Validate, annotate, and show the warning panel. 91 + 92 + The all-in-one function for browser validation with full UI. 93 + 94 + @param annotation_config How to annotate elements. 95 + @param panel_config How to display the warning panel. *) 96 + val validate_and_show_panel : 97 + ?annotation_config:annotation_config -> 98 + ?panel_config:panel_config -> 99 + Brr.El.t -> 100 + result 101 + 102 + 103 + (** {1 Result Inspection} *) 104 + 105 + (** Get messages filtered by severity. *) 106 + val errors : result -> browser_message list 107 + val warnings_only : result -> browser_message list 108 + val infos : result -> browser_message list 109 + 110 + (** Check if there are any errors. *) 111 + val has_errors : result -> bool 112 + 113 + (** Check if there are any warnings or errors. *) 114 + val has_issues : result -> bool 115 + 116 + (** Get total count of all messages. *) 117 + val message_count : result -> int 118 + 119 + 120 + (** {1 JavaScript Export} 121 + 122 + These functions register the API on the JavaScript global object. *) 123 + 124 + (** Register the validation API on [window.html5rw]. 125 + 126 + Call this from your main entry point to expose the JavaScript API: 127 + 128 + {[ 129 + let () = Htmlrw_js.register_global_api () 130 + ]} 131 + 132 + This exposes: 133 + - [html5rw.validateString(html)] -> result object 134 + - [html5rw.validateElement(el)] -> result object 135 + - [html5rw.validateAndAnnotate(el, config?)] -> result object 136 + - [html5rw.validateAndShowPanel(el, config?)] -> result object 137 + - [html5rw.clearAnnotations(el)] -> void 138 + - [html5rw.hidePanel()] -> void *) 139 + val register_global_api : unit -> unit 140 + 141 + (** Register the API on a custom object instead of [window.html5rw]. 142 + 143 + Useful for module bundlers or when you want to control the namespace. *) 144 + val register_api_on : Jv.t -> unit 145 + 146 + 147 + (** {1 Low-level Access} *) 148 + 149 + (** Access the element map from a validation result. 150 + 151 + Useful for custom element lookup logic. Returns [None] if the result 152 + was from {!validate_string} (no source element). *) 153 + val element_map : result -> Htmlrw_js_dom.t option
+340
lib/js/htmlrw_js_annotate.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Brr 7 + open Htmlrw_js_types 8 + 9 + module 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" 14 + end 15 + 16 + module 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" 24 + end 25 + 26 + type tooltip = { 27 + container : El.t; 28 + _target : El.t; 29 + } 30 + 31 + let 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 + 36 + let 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 + 49 + let 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 + 119 + and 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 + 168 + let show_tooltip t = 169 + El.set_class Css_class.tooltip_visible true t.container 170 + 171 + let hide_tooltip t = 172 + El.set_class Css_class.tooltip_visible false t.container 173 + 174 + let remove_tooltip t = 175 + El.remove t.container 176 + 177 + let 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 + 183 + let 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 + 194 + let clear root = 195 + Htmlrw_js_dom.iter_elements clear_element root; 196 + List.iter remove_tooltip (tooltips_in root) 197 + 198 + let 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 + 207 + let unhighlight_element el = 208 + El.set_class Css_class.highlighted false el 209 + 210 + let _highlighted_elements : El.t list ref = ref [] 211 + 212 + let clear_highlights () = 213 + List.iter unhighlight_element !_highlighted_elements; 214 + _highlighted_elements := [] 215 + 216 + let 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 + 339 + let remove_injected_styles style_el = 340 + El.remove style_el
+166
lib/js/htmlrw_js_annotate.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** DOM annotation for validation warnings. 7 + 8 + This module applies validation results to the live DOM by adding 9 + data attributes, CSS classes, and tooltip overlays to elements 10 + that have warnings. *) 11 + 12 + open Htmlrw_js_types 13 + 14 + 15 + (** {1 Annotation} *) 16 + 17 + (** Annotate elements in a subtree based on validation results. 18 + 19 + For each message with an element reference, this function: 20 + 1. Adds data attributes ([data-html5rw-severity], etc.) if configured 21 + 2. Adds CSS classes ([html5rw-error], etc.) if configured 22 + 3. Creates tooltip elements if configured 23 + 24 + @param config Annotation configuration. 25 + @param root The root element to annotate within. 26 + @param messages The validation messages with element references. *) 27 + val annotate : 28 + config:annotation_config -> 29 + root:Brr.El.t -> 30 + browser_message list -> 31 + unit 32 + 33 + (** Annotate a single element with a message. 34 + 35 + Lower-level function for custom annotation logic. *) 36 + val annotate_element : 37 + config:annotation_config -> 38 + Brr.El.t -> 39 + Htmlrw_check.message -> 40 + unit 41 + 42 + 43 + (** {1 Clearing Annotations} *) 44 + 45 + (** Remove all annotations from a subtree. 46 + 47 + This removes: 48 + - All [data-html5rw-*] attributes 49 + - All [html5rw-*] CSS classes 50 + - All tooltip elements created by this module *) 51 + val clear : Brr.El.t -> unit 52 + 53 + (** Remove annotations from a single element (not descendants). *) 54 + val clear_element : Brr.El.t -> unit 55 + 56 + 57 + (** {1 Tooltips} *) 58 + 59 + (** Tooltip state for an element. *) 60 + type tooltip 61 + 62 + (** Create a tooltip for an element. 63 + 64 + The tooltip is not immediately visible; it appears on hover 65 + if CSS is set up correctly, or can be shown programmatically. 66 + 67 + @param position Where to position the tooltip. 68 + @param el The element to attach the tooltip to. 69 + @param messages All messages for this element (may be multiple). *) 70 + val create_tooltip : 71 + position:[ `Above | `Below | `Auto ] -> 72 + Brr.El.t -> 73 + Htmlrw_check.message list -> 74 + tooltip 75 + 76 + (** Show a tooltip immediately. *) 77 + val show_tooltip : tooltip -> unit 78 + 79 + (** Hide a tooltip. *) 80 + val hide_tooltip : tooltip -> unit 81 + 82 + (** Remove a tooltip from the DOM. *) 83 + val remove_tooltip : tooltip -> unit 84 + 85 + (** Get all tooltips created in a subtree. *) 86 + val tooltips_in : Brr.El.t -> tooltip list 87 + 88 + 89 + (** {1 Highlighting} *) 90 + 91 + (** Highlight an element (for click-to-navigate in the panel). 92 + 93 + Adds a temporary visual highlight and scrolls the element into view. *) 94 + val highlight_element : Brr.El.t -> unit 95 + 96 + (** Remove highlight from an element. *) 97 + val unhighlight_element : Brr.El.t -> unit 98 + 99 + (** Remove all highlights. *) 100 + val clear_highlights : unit -> unit 101 + 102 + 103 + (** {1 Data Attributes} 104 + 105 + Constants for the data attributes used by annotation. *) 106 + 107 + module Data_attr : sig 108 + (** [data-html5rw-severity] - "error", "warning", or "info" *) 109 + val severity : Jstr.t 110 + 111 + (** [data-html5rw-message] - The warning message text *) 112 + val message : Jstr.t 113 + 114 + (** [data-html5rw-code] - The error code *) 115 + val code : Jstr.t 116 + 117 + (** [data-html5rw-count] - Number of warnings on this element *) 118 + val count : Jstr.t 119 + end 120 + 121 + 122 + (** {1 CSS Classes} 123 + 124 + Constants for the CSS classes used by annotation. *) 125 + 126 + module Css_class : sig 127 + (** [html5rw-error] - Element has at least one error *) 128 + val error : Jstr.t 129 + 130 + (** [html5rw-warning] - Element has warnings but no errors *) 131 + val warning : Jstr.t 132 + 133 + (** [html5rw-info] - Element has only info messages *) 134 + val info : Jstr.t 135 + 136 + (** [html5rw-has-issues] - Element has any validation messages *) 137 + val has_issues : Jstr.t 138 + 139 + (** [html5rw-highlighted] - Element is currently highlighted *) 140 + val highlighted : Jstr.t 141 + 142 + (** [html5rw-tooltip] - The tooltip container element *) 143 + val tooltip : Jstr.t 144 + 145 + (** [html5rw-tooltip-visible] - Tooltip is currently visible *) 146 + val tooltip_visible : Jstr.t 147 + end 148 + 149 + 150 + (** {1 CSS Injection} 151 + 152 + Optionally inject default styles for annotations. *) 153 + 154 + (** Inject default CSS styles for annotations and tooltips. 155 + 156 + Adds a [<style>] element to the document head with styles for: 157 + - Annotation classes (outlines, backgrounds) 158 + - Tooltip positioning and appearance 159 + - Highlight animation 160 + 161 + @param theme Light or dark theme. [`Auto] uses [prefers-color-scheme]. 162 + @return The injected style element (can be removed later). *) 163 + val inject_default_styles : theme:[ `Light | `Dark | `Auto ] -> Brr.El.t 164 + 165 + (** Remove the injected style element. *) 166 + val remove_injected_styles : Brr.El.t -> unit
+208
lib/js/htmlrw_js_dom.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Brr 7 + 8 + (* Helper to compare elements using JavaScript strict equality *) 9 + let el_equal a b = 10 + Jv.strict_equal (El.to_jv a) (El.to_jv b) 11 + 12 + (* A location-keyed map for finding elements by line/column *) 13 + module LocMap = Map.Make(struct 14 + type t = int * int 15 + let compare = compare 16 + end) 17 + 18 + type t = { 19 + root : El.t; 20 + html_source : string; 21 + loc_to_el : El.t LocMap.t; 22 + (* Mapping from (line, column) to browser elements *) 23 + } 24 + 25 + let outer_html el = 26 + Jstr.to_string (Jv.get (El.to_jv el) "outerHTML" |> Jv.to_jstr) 27 + 28 + let inner_html el = 29 + Jstr.to_string (Jv.get (El.to_jv el) "innerHTML" |> Jv.to_jstr) 30 + 31 + let iter_elements f root = 32 + let rec walk el = 33 + f el; 34 + List.iter walk (El.children ~only_els:true el) 35 + in 36 + walk root 37 + 38 + let fold_elements f acc root = 39 + let rec walk acc el = 40 + let acc = f acc el in 41 + List.fold_left walk acc (El.children ~only_els:true el) 42 + in 43 + walk acc root 44 + 45 + let filter_elements pred root = 46 + fold_elements (fun acc el -> 47 + if pred el then el :: acc else acc 48 + ) [] root |> List.rev 49 + 50 + (* Build element map by walking browser DOM and parsed DOM in parallel *) 51 + let create root = 52 + let raw_html = outer_html root in 53 + (* Prepend DOCTYPE if not present - outerHTML doesn't include it *) 54 + let html = 55 + let lower = String.lowercase_ascii raw_html in 56 + if String.length lower >= 9 && String.sub lower 0 9 = "<!doctype" then 57 + raw_html 58 + else 59 + "<!DOCTYPE html>" ^ raw_html 60 + in 61 + 62 + (* Parse the HTML to get a tree with locations *) 63 + let reader = Bytesrw.Bytes.Reader.of_string html in 64 + let parsed = Html5rw.parse ~collect_errors:false reader in 65 + 66 + (* Walk both trees in parallel to build the mapping. 67 + Browser elements are in document order, and so are Html5rw nodes. *) 68 + let browser_elements = fold_elements (fun acc el -> el :: acc) [] root |> List.rev in 69 + 70 + (* Extract elements from Html5rw DOM in document order *) 71 + let rec extract_html5rw_elements acc node = 72 + if Html5rw.is_element node then 73 + let children = node.Html5rw.Dom.children in 74 + let acc = node :: acc in 75 + List.fold_left extract_html5rw_elements acc children 76 + else 77 + let children = node.Html5rw.Dom.children in 78 + List.fold_left extract_html5rw_elements acc children 79 + in 80 + let html5rw_elements = extract_html5rw_elements [] (Html5rw.root parsed) |> List.rev in 81 + 82 + (* Build the location map by matching elements *) 83 + let loc_to_el = 84 + let rec match_elements loc_map browser_els html5rw_els = 85 + match browser_els, html5rw_els with 86 + | [], _ | _, [] -> loc_map 87 + | b_el :: b_rest, h_el :: h_rest -> 88 + let b_tag = String.lowercase_ascii (Jstr.to_string (El.tag_name b_el)) in 89 + let h_tag = String.lowercase_ascii h_el.Html5rw.Dom.name in 90 + if b_tag = h_tag then 91 + (* Tags match - record the mapping if we have a location *) 92 + let loc_map = 93 + match h_el.Html5rw.Dom.location with 94 + | Some loc -> LocMap.add (loc.line, loc.column) b_el loc_map 95 + | None -> loc_map 96 + in 97 + match_elements loc_map b_rest h_rest 98 + else 99 + (* Tags don't match - try to resync by skipping one side *) 100 + (* This handles cases where browser might have implicit elements *) 101 + match_elements loc_map b_rest html5rw_els 102 + in 103 + match_elements LocMap.empty browser_elements html5rw_elements 104 + in 105 + 106 + { root; html_source = html; loc_to_el }, html 107 + 108 + let find_by_location t ~line ~column = 109 + LocMap.find_opt (line, column) t.loc_to_el 110 + 111 + let find_by_location_and_tag t ~line ~column ~tag = 112 + match LocMap.find_opt (line, column) t.loc_to_el with 113 + | Some el when String.lowercase_ascii (Jstr.to_string (El.tag_name el)) = 114 + String.lowercase_ascii tag -> 115 + Some el 116 + | _ -> None 117 + 118 + let find_for_message t msg = 119 + (* Try to find element by location first *) 120 + match msg.Htmlrw_check.location with 121 + | Some loc -> 122 + (match msg.Htmlrw_check.element with 123 + | Some tag -> find_by_location_and_tag t ~line:loc.line ~column:loc.column ~tag 124 + | None -> find_by_location t ~line:loc.line ~column:loc.column) 125 + | None -> 126 + (* No location - try to find by element name if we have one *) 127 + match msg.Htmlrw_check.element with 128 + | Some tag -> 129 + (* Find first element with this tag *) 130 + let matches = filter_elements (fun el -> 131 + String.lowercase_ascii (Jstr.to_string (El.tag_name el)) = 132 + String.lowercase_ascii tag 133 + ) t.root in 134 + (match matches with 135 + | el :: _ -> Some el 136 + | [] -> None) 137 + | None -> None 138 + 139 + let html_source t = t.html_source 140 + 141 + let root_element t = t.root 142 + 143 + let selector_path ?root el = 144 + let stop_at = match root with 145 + | Some r -> Some r 146 + | None -> None 147 + in 148 + let rec build_path el acc = 149 + (* Stop if we've reached the root *) 150 + let should_stop = match stop_at with 151 + | Some r -> el_equal el r 152 + | None -> String.lowercase_ascii (Jstr.to_string (El.tag_name el)) = "body" 153 + in 154 + if should_stop then 155 + acc 156 + else 157 + let tag = String.lowercase_ascii (Jstr.to_string (El.tag_name el)) in 158 + let segment = 159 + match El.parent el with 160 + | None -> tag 161 + | Some parent -> 162 + let siblings = El.children ~only_els:true parent in 163 + let same_tag = List.filter (fun sib -> 164 + String.lowercase_ascii (Jstr.to_string (El.tag_name sib)) = tag 165 + ) siblings in 166 + if List.length same_tag <= 1 then 167 + tag 168 + else 169 + let idx = 170 + let rec find_idx i = function 171 + | [] -> 1 172 + | sib :: rest -> 173 + if el_equal sib el then i 174 + else find_idx (i + 1) rest 175 + in 176 + find_idx 1 same_tag 177 + in 178 + Printf.sprintf "%s:nth-of-type(%d)" tag idx 179 + in 180 + let new_acc = segment :: acc in 181 + match El.parent el with 182 + | None -> new_acc 183 + | Some parent -> build_path parent new_acc 184 + in 185 + String.concat " > " (build_path el []) 186 + 187 + let short_selector ?root el = 188 + (* Try ID first *) 189 + match El.at (Jstr.v "id") el with 190 + | Some id when not (Jstr.is_empty id) -> 191 + "#" ^ Jstr.to_string id 192 + | _ -> 193 + (* Try parent ID + short path *) 194 + let rec find_id_ancestor el depth = 195 + if depth > 3 then None 196 + else match El.parent el with 197 + | None -> None 198 + | Some parent -> 199 + match El.at (Jstr.v "id") parent with 200 + | Some id when not (Jstr.is_empty id) -> Some (parent, id) 201 + | _ -> find_id_ancestor parent (depth + 1) 202 + in 203 + match find_id_ancestor el 0 with 204 + | Some (ancestor, id) -> 205 + let path = selector_path ~root:ancestor el in 206 + "#" ^ Jstr.to_string id ^ " > " ^ path 207 + | None -> 208 + selector_path ?root el
+111
lib/js/htmlrw_js_dom.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Browser DOM utilities for mapping validation results to live elements. 7 + 8 + This module bridges the gap between HTML string validation (which produces 9 + line/column locations) and live DOM manipulation (which needs element 10 + references). It builds mappings between source positions and DOM elements 11 + by walking both the serialized HTML and the DOM tree in parallel. *) 12 + 13 + 14 + (** {1 Element Mapping} 15 + 16 + When we validate [element.outerHTML], we get messages with line/column 17 + positions. To annotate the original DOM, we need to map those positions 18 + back to the live elements. *) 19 + 20 + (** An element map associates source locations with DOM elements. *) 21 + type t 22 + 23 + (** Build an element map by walking a DOM element and its serialization. 24 + 25 + This function: 26 + 1. Serializes the element to HTML via [outerHTML] 27 + 2. Parses that HTML with Html5rw to get the parse tree with locations 28 + 3. Walks both trees in parallel to build a bidirectional mapping 29 + 30 + @param root The DOM element to map. 31 + @return The element map and the HTML source string. *) 32 + val create : Brr.El.t -> t * string 33 + 34 + (** Find the DOM element corresponding to a source location. 35 + 36 + @param line 1-indexed line number 37 + @param column 1-indexed column number 38 + @return The element at or containing that position, or [None]. *) 39 + val find_by_location : t -> line:int -> column:int -> Brr.El.t option 40 + 41 + (** Find the DOM element corresponding to an element name at a location. 42 + 43 + More precise than {!find_by_location} when the validator provides 44 + the element name along with the location. 45 + 46 + @param line 1-indexed line number 47 + @param column 1-indexed column number 48 + @param tag Element tag name (lowercase) 49 + @return The matching element, or [None]. *) 50 + val find_by_location_and_tag : 51 + t -> line:int -> column:int -> tag:string -> Brr.El.t option 52 + 53 + (** Find the DOM element for a validation message. 54 + 55 + Uses the message's location and element fields to find the best match. 56 + This is the primary function used by the annotation system. *) 57 + val find_for_message : t -> Htmlrw_check.message -> Brr.El.t option 58 + 59 + (** The HTML source string that was used to build this map. *) 60 + val html_source : t -> string 61 + 62 + (** The root element this map was built from. *) 63 + val root_element : t -> Brr.El.t 64 + 65 + 66 + (** {1 CSS Selector Generation} *) 67 + 68 + (** Build a CSS selector path that uniquely identifies an element. 69 + 70 + The selector uses child combinators and [:nth-child] to be specific: 71 + ["body > div.main:nth-child(2) > p > img:nth-child(1)"] 72 + 73 + @param root Optional root element; selector will be relative to this. 74 + Defaults to [document.body]. 75 + @param el The element to build a selector for. 76 + @return A CSS selector string. *) 77 + val selector_path : ?root:Brr.El.t -> Brr.El.t -> string 78 + 79 + (** Build a shorter selector using IDs and classes when available. 80 + 81 + Tries to find the shortest unique selector: 82 + 1. If element has an ID: ["#myId"] 83 + 2. If parent has ID: ["#parentId > .myClass"] 84 + 3. Falls back to full path from {!selector_path} 85 + 86 + @param root Optional root element. 87 + @param el The element to build a selector for. *) 88 + val short_selector : ?root:Brr.El.t -> Brr.El.t -> string 89 + 90 + 91 + (** {1 DOM Iteration} *) 92 + 93 + (** Iterate over all elements in document order (depth-first pre-order). *) 94 + val iter_elements : (Brr.El.t -> unit) -> Brr.El.t -> unit 95 + 96 + (** Fold over all elements in document order. *) 97 + val fold_elements : ('a -> Brr.El.t -> 'a) -> 'a -> Brr.El.t -> 'a 98 + 99 + (** Find all elements matching a predicate. *) 100 + val filter_elements : (Brr.El.t -> bool) -> Brr.El.t -> Brr.El.t list 101 + 102 + 103 + (** {1 Serialization} *) 104 + 105 + (** Get the outer HTML of an element. 106 + 107 + This is a wrapper around the browser's [outerHTML] property. *) 108 + val outer_html : Brr.El.t -> string 109 + 110 + (** Get the inner HTML of an element. *) 111 + val inner_html : Brr.El.t -> string
+9
lib/js/htmlrw_js_main.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* Entry point for the standalone JavaScript build. 7 + This registers the API on window.html5rw when the script loads. *) 8 + 9 + let () = Htmlrw_js.register_global_api ()
+56
lib/js/htmlrw_js_main.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Entry point for the standalone JavaScript build. 7 + 8 + This module is compiled to [htmlrw.js] and automatically registers 9 + the validation API on [window.html5rw] when loaded. 10 + 11 + {2 Browser Usage} 12 + 13 + {v 14 + <script src="htmlrw.js"></script> 15 + <script> 16 + // API is available immediately after loading 17 + const result = html5rw.validateElement(document.body); 18 + 19 + if (result.errorCount > 0) { 20 + console.log("Found", result.errorCount, "errors"); 21 + 22 + // Show the warning panel 23 + html5rw.showPanel(result); 24 + } 25 + </script> 26 + v} 27 + 28 + {2 Module Bundler Usage} 29 + 30 + If using a bundler that supports CommonJS or ES modules, you can 31 + import the module instead: 32 + 33 + {v 34 + import { validateElement, showPanel } from './htmlrw.js'; 35 + 36 + const result = validateElement(document.body); 37 + if (result.hasErrors) { 38 + showPanel(result); 39 + } 40 + v} 41 + 42 + The module exports are set up to work with both import styles. 43 + 44 + {2 API Reference} 45 + 46 + See {!Htmlrw_js} for the full API documentation. The JavaScript API 47 + mirrors the OCaml API with camelCase naming: 48 + 49 + - [html5rw.validateString(html)] - Validate an HTML string 50 + - [html5rw.validateElement(el)] - Validate a DOM element 51 + - [html5rw.validateAndAnnotate(el, config?)] - Validate and annotate 52 + - [html5rw.showPanel(result, config?)] - Show the warning panel 53 + - [html5rw.hidePanel()] - Hide the warning panel 54 + - [html5rw.clearAnnotations(el)] - Clear annotations from an element *) 55 + 56 + (* This module has no values; its side effect is registering the API *)
+172
lib/js/htmlrw_js_types.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Brr 7 + 8 + (* Helper to compare elements using JavaScript strict equality *) 9 + let el_equal a b = 10 + Jv.strict_equal (El.to_jv a) (El.to_jv b) 11 + 12 + type element_ref = { 13 + element : El.t option; 14 + selector : string; 15 + } 16 + 17 + type browser_message = { 18 + message : Htmlrw_check.message; 19 + element_ref : element_ref option; 20 + } 21 + 22 + type result = { 23 + messages : browser_message list; 24 + core_result : Htmlrw_check.t; 25 + source_element : El.t option; 26 + } 27 + 28 + type annotation_config = { 29 + add_data_attrs : bool; 30 + add_classes : bool; 31 + show_tooltips : bool; 32 + tooltip_position : [ `Above | `Below | `Auto ]; 33 + highlight_on_hover : bool; 34 + } 35 + 36 + let default_annotation_config = { 37 + add_data_attrs = true; 38 + add_classes = true; 39 + show_tooltips = true; 40 + tooltip_position = `Auto; 41 + highlight_on_hover = true; 42 + } 43 + 44 + type panel_config = { 45 + initial_position : [ `TopRight | `TopLeft | `BottomRight | `BottomLeft | `Custom of int * int ]; 46 + draggable : bool; 47 + resizable : bool; 48 + collapsible : bool; 49 + start_collapsed : bool; 50 + max_height : int option; 51 + group_by_severity : bool; 52 + click_to_highlight : bool; 53 + show_selector_path : bool; 54 + theme : [ `Light | `Dark | `Auto ]; 55 + } 56 + 57 + let default_panel_config = { 58 + initial_position = `TopRight; 59 + draggable = true; 60 + resizable = true; 61 + collapsible = true; 62 + start_collapsed = false; 63 + max_height = Some 400; 64 + group_by_severity = true; 65 + click_to_highlight = true; 66 + show_selector_path = true; 67 + theme = `Auto; 68 + } 69 + 70 + let selector_of_element el = 71 + let rec build_path el acc = 72 + let tag = Jstr.to_string (El.tag_name el) in 73 + let id = El.at (Jstr.v "id") el in 74 + let segment = 75 + match id with 76 + | Some id_val when not (Jstr.is_empty id_val) -> 77 + (* If element has an ID, use it directly *) 78 + "#" ^ Jstr.to_string id_val 79 + | _ -> 80 + (* Otherwise use tag name with nth-child if needed *) 81 + match El.parent el with 82 + | None -> tag 83 + | Some parent -> 84 + let siblings = El.children ~only_els:true parent in 85 + let same_tag = List.filter (fun sib -> 86 + String.lowercase_ascii (Jstr.to_string (El.tag_name sib)) = 87 + String.lowercase_ascii tag 88 + ) siblings in 89 + if List.length same_tag <= 1 then 90 + tag 91 + else 92 + let idx = 93 + let rec find_idx i = function 94 + | [] -> 1 95 + | sib :: rest -> 96 + if el_equal sib el then i 97 + else find_idx (i + 1) rest 98 + in 99 + find_idx 1 same_tag 100 + in 101 + Printf.sprintf "%s:nth-of-type(%d)" tag idx 102 + in 103 + let new_acc = segment :: acc in 104 + (* Stop if we hit an ID (absolute reference) or no parent *) 105 + if String.length segment > 0 && segment.[0] = '#' then 106 + new_acc 107 + else 108 + match El.parent el with 109 + | None -> new_acc 110 + | Some parent -> 111 + if String.lowercase_ascii (Jstr.to_string (El.tag_name parent)) = "html" then 112 + new_acc 113 + else 114 + build_path parent new_acc 115 + in 116 + String.concat " > " (build_path el []) 117 + 118 + let browser_message_to_jv bm = 119 + let msg = bm.message in 120 + let obj = Jv.obj [||] in 121 + Jv.set obj "severity" (Jv.of_string (Htmlrw_check.severity_to_string msg.severity)); 122 + Jv.set obj "message" (Jv.of_string msg.text); 123 + Jv.set obj "errorCode" (Jv.of_string (Htmlrw_check.error_code_to_string msg.error_code)); 124 + (match msg.element with 125 + | Some el -> Jv.set obj "elementName" (Jv.of_string el) 126 + | None -> ()); 127 + (match msg.attribute with 128 + | Some attr -> Jv.set obj "attribute" (Jv.of_string attr) 129 + | None -> ()); 130 + (match msg.location with 131 + | Some loc -> 132 + Jv.set obj "line" (Jv.of_int loc.line); 133 + Jv.set obj "column" (Jv.of_int loc.column) 134 + | None -> ()); 135 + (match bm.element_ref with 136 + | Some ref -> 137 + Jv.set obj "selector" (Jv.of_string ref.selector); 138 + (match ref.element with 139 + | Some el -> Jv.set obj "element" (El.to_jv el) 140 + | None -> ()) 141 + | None -> ()); 142 + obj 143 + 144 + let result_to_jv result = 145 + let warnings_arr = 146 + Jv.of_list browser_message_to_jv result.messages 147 + in 148 + let error_count = 149 + List.length (List.filter (fun bm -> 150 + bm.message.severity = Htmlrw_check.Error 151 + ) result.messages) 152 + in 153 + let warning_count = 154 + List.length (List.filter (fun bm -> 155 + bm.message.severity = Htmlrw_check.Warning 156 + ) result.messages) 157 + in 158 + let info_count = 159 + List.length (List.filter (fun bm -> 160 + bm.message.severity = Htmlrw_check.Info 161 + ) result.messages) 162 + in 163 + let obj = Jv.obj [||] in 164 + Jv.set obj "warnings" warnings_arr; 165 + Jv.set obj "errorCount" (Jv.of_int error_count); 166 + Jv.set obj "warningCount" (Jv.of_int warning_count); 167 + Jv.set obj "infoCount" (Jv.of_int info_count); 168 + Jv.set obj "hasErrors" (Jv.of_bool (error_count > 0)); 169 + (match result.source_element with 170 + | Some el -> Jv.set obj "sourceElement" (El.to_jv el) 171 + | None -> ()); 172 + obj
+125
lib/js/htmlrw_js_types.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Browser-specific types for HTML5rw JavaScript validation. 7 + 8 + Core validation types ({!Htmlrw_check.severity}, {!Htmlrw_check.message}, etc.) 9 + are reused from the main library. This module adds only the browser-specific 10 + types needed for DOM element references, annotation, and UI. *) 11 + 12 + 13 + (** {1 Element References} 14 + 15 + Since we validate HTML strings but want to annotate live DOM elements, 16 + we need to map validation messages back to browser elements. *) 17 + 18 + (** A reference to a DOM element, providing both programmatic access 19 + and a serializable CSS selector. *) 20 + type element_ref = { 21 + element : Brr.El.t option; 22 + (** The live DOM element, if still attached to the document. 23 + May be [None] if validation was performed on a raw HTML string 24 + without a source element. *) 25 + 26 + selector : string; 27 + (** A CSS selector path that uniquely identifies this element. 28 + Format: ["body > div.container > p:nth-child(3) > img"] 29 + Useful for logging and re-finding elements. *) 30 + } 31 + 32 + (** A validation message paired with its DOM element reference. *) 33 + type browser_message = { 34 + message : Htmlrw_check.message; 35 + (** The core validation message with severity, text, error code, etc. *) 36 + 37 + element_ref : element_ref option; 38 + (** Reference to the problematic DOM element, if identifiable. 39 + [None] for document-level issues like missing DOCTYPE. *) 40 + } 41 + 42 + (** Browser validation result. *) 43 + type result = { 44 + messages : browser_message list; 45 + (** All validation messages with element references. *) 46 + 47 + core_result : Htmlrw_check.t; 48 + (** The underlying validation result from the core library. 49 + Use for access to {!Htmlrw_check.errors}, {!Htmlrw_check.has_errors}, etc. *) 50 + 51 + source_element : Brr.El.t option; 52 + (** The root element that was validated, if validation started from an element. *) 53 + } 54 + 55 + 56 + (** {1 Annotation Configuration} *) 57 + 58 + (** Configuration for how warnings are displayed on annotated elements. *) 59 + type annotation_config = { 60 + add_data_attrs : bool; 61 + (** Add [data-html5rw-*] attributes to elements: 62 + - [data-html5rw-severity]: ["error"], ["warning"], or ["info"] 63 + - [data-html5rw-message]: The warning message text 64 + - [data-html5rw-code]: The error code *) 65 + 66 + add_classes : bool; 67 + (** Add CSS classes: [html5rw-error], [html5rw-warning], [html5rw-info], 68 + and [html5rw-has-issues] on any element with warnings. *) 69 + 70 + show_tooltips : bool; 71 + (** Create tooltip overlays that appear on hover. *) 72 + 73 + tooltip_position : [ `Above | `Below | `Auto ]; 74 + (** Tooltip position. [`Auto] chooses based on viewport. *) 75 + 76 + highlight_on_hover : bool; 77 + (** Highlight elements when hovering over warnings in the panel. *) 78 + } 79 + 80 + (** Default: all annotation features enabled, tooltips auto-positioned. *) 81 + val default_annotation_config : annotation_config 82 + 83 + 84 + (** {1 Panel Configuration} *) 85 + 86 + (** Configuration for the floating warning panel. *) 87 + type panel_config = { 88 + initial_position : [ `TopRight | `TopLeft | `BottomRight | `BottomLeft | `Custom of int * int ]; 89 + (** Where the panel appears initially. *) 90 + 91 + draggable : bool; 92 + resizable : bool; 93 + collapsible : bool; 94 + start_collapsed : bool; 95 + 96 + max_height : int option; 97 + (** Maximum height in pixels before scrolling. *) 98 + 99 + group_by_severity : bool; 100 + (** Group warnings: errors first, then warnings, then info. *) 101 + 102 + click_to_highlight : bool; 103 + (** Clicking a warning scrolls to and highlights the element. *) 104 + 105 + show_selector_path : bool; 106 + (** Show the CSS selector path in each warning row. *) 107 + 108 + theme : [ `Light | `Dark | `Auto ]; 109 + (** Color scheme. [`Auto] follows [prefers-color-scheme]. *) 110 + } 111 + 112 + (** Default panel configuration. *) 113 + val default_panel_config : panel_config 114 + 115 + 116 + (** {1 Conversions} *) 117 + 118 + (** Build a CSS selector path for an element. *) 119 + val selector_of_element : Brr.El.t -> string 120 + 121 + (** Convert a browser message to a JavaScript object. *) 122 + val browser_message_to_jv : browser_message -> Jv.t 123 + 124 + (** Convert a result to a JavaScript object. *) 125 + val result_to_jv : result -> Jv.t
+426
lib/js/htmlrw_js_ui.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Brr 7 + open Htmlrw_js_types 8 + 9 + module Css_class = struct 10 + let panel = Jstr.v "html5rw-panel" 11 + let panel_header = Jstr.v "html5rw-panel-header" 12 + let panel_content = Jstr.v "html5rw-panel-content" 13 + let panel_collapsed = Jstr.v "html5rw-panel-collapsed" 14 + let panel_dragging = Jstr.v "html5rw-panel-dragging" 15 + let warning_list = Jstr.v "html5rw-warning-list" 16 + let warning_row = Jstr.v "html5rw-warning-row" 17 + let warning_row_error = Jstr.v "html5rw-warning-row-error" 18 + let warning_row_warning = Jstr.v "html5rw-warning-row-warning" 19 + let warning_row_info = Jstr.v "html5rw-warning-row-info" 20 + let severity_badge = Jstr.v "html5rw-severity-badge" 21 + let message_text = Jstr.v "html5rw-message-text" 22 + let selector_path = Jstr.v "html5rw-selector-path" 23 + let collapse_btn = Jstr.v "html5rw-collapse-btn" 24 + let close_btn = Jstr.v "html5rw-close-btn" 25 + let summary_badge = Jstr.v "html5rw-summary-badge" 26 + let error_count = Jstr.v "html5rw-error-count" 27 + let warning_count = Jstr.v "html5rw-warning-count" 28 + let theme_light = Jstr.v "html5rw-theme-light" 29 + let theme_dark = Jstr.v "html5rw-theme-dark" 30 + end 31 + 32 + type t = { 33 + root : El.t; 34 + header : El.t; 35 + content : El.t; 36 + badge : El.t; 37 + config : panel_config; 38 + mutable result : result; 39 + mutable collapsed : bool; 40 + mutable highlighted : El.t option; 41 + mutable on_warning_click : (browser_message -> unit) option; 42 + mutable on_collapse_toggle : (bool -> unit) option; 43 + mutable on_close : (unit -> unit) option; 44 + mutable on_move : (int * int -> unit) option; 45 + } 46 + 47 + let _current_panel : t option ref = ref None 48 + 49 + let current () = !_current_panel 50 + let root_element t = t.root 51 + let header_element t = t.header 52 + let content_element t = t.content 53 + let badge_element t = t.badge 54 + 55 + let is_visible t = 56 + let display = El.computed_style (Jstr.v "display") t.root in 57 + not (Jstr.equal display (Jstr.v "none")) 58 + 59 + let is_collapsed t = t.collapsed 60 + 61 + let position t = 62 + let x = int_of_float (El.bound_x t.root) in 63 + let y = int_of_float (El.bound_y t.root) in 64 + (x, y) 65 + 66 + let set_position t x y = 67 + El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) t.root; 68 + El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) t.root; 69 + El.set_inline_style (Jstr.v "right") (Jstr.v "auto") t.root 70 + 71 + let highlighted_element t = t.highlighted 72 + 73 + let clear_highlight t = 74 + match t.highlighted with 75 + | Some el -> 76 + Htmlrw_js_annotate.unhighlight_element el; 77 + t.highlighted <- None 78 + | None -> () 79 + 80 + let navigate_to_element t bm = 81 + clear_highlight t; 82 + match bm.element_ref with 83 + | Some { element = Some el; _ } -> 84 + Htmlrw_js_annotate.highlight_element el; 85 + t.highlighted <- Some el 86 + | _ -> () 87 + 88 + let severity_row_class = function 89 + | Htmlrw_check.Error -> Css_class.warning_row_error 90 + | Htmlrw_check.Warning -> Css_class.warning_row_warning 91 + | Htmlrw_check.Info -> Css_class.warning_row_info 92 + 93 + let create_warning_row ~config t bm = 94 + let msg = bm.message in 95 + let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in 96 + 97 + let badge = El.v (Jstr.v "span") ~at:[At.class' Css_class.severity_badge] [ 98 + El.txt' (String.uppercase_ascii sev) 99 + ] in 100 + 101 + let text = El.v (Jstr.v "span") ~at:[At.class' Css_class.message_text] [ 102 + El.txt' msg.Htmlrw_check.text 103 + ] in 104 + 105 + let children = [badge; text] in 106 + let children = 107 + if config.show_selector_path then 108 + match bm.element_ref with 109 + | Some ref -> 110 + let path = El.v (Jstr.v "span") ~at:[At.class' Css_class.selector_path] [ 111 + El.txt' ref.selector 112 + ] in 113 + children @ [path] 114 + | None -> children 115 + else 116 + children 117 + in 118 + 119 + let row = El.v (Jstr.v "div") ~at:[ 120 + At.class' Css_class.warning_row; 121 + At.class' (severity_row_class msg.Htmlrw_check.severity); 122 + ] children in 123 + 124 + if config.click_to_highlight then begin 125 + ignore (Ev.listen Ev.click (fun _ -> 126 + navigate_to_element t bm; 127 + match t.on_warning_click with 128 + | Some f -> f bm 129 + | None -> () 130 + ) (El.as_target row)) 131 + end; 132 + 133 + row 134 + 135 + let build_content ~config t = 136 + let messages = 137 + if config.group_by_severity then 138 + let errors, warnings, infos = List.fold_left (fun (e, w, i) bm -> 139 + match bm.message.Htmlrw_check.severity with 140 + | Htmlrw_check.Error -> (bm :: e, w, i) 141 + | Htmlrw_check.Warning -> (e, bm :: w, i) 142 + | Htmlrw_check.Info -> (e, w, bm :: i) 143 + ) ([], [], []) t.result.messages in 144 + List.rev errors @ List.rev warnings @ List.rev infos 145 + else 146 + t.result.messages 147 + in 148 + 149 + let rows = List.map (create_warning_row ~config t) messages in 150 + let list = El.v (Jstr.v "div") ~at:[At.class' Css_class.warning_list] rows in 151 + 152 + (match config.max_height with 153 + | Some h -> 154 + El.set_inline_style (Jstr.v "maxHeight") (Jstr.v (Printf.sprintf "%dpx" h)) list; 155 + El.set_inline_style (Jstr.v "overflowY") (Jstr.v "auto") list 156 + | None -> ()); 157 + list 158 + 159 + let update t result = 160 + t.result <- result; 161 + let list = build_content ~config:t.config t in 162 + El.set_children t.content [list]; 163 + let error_count = List.length (List.filter (fun bm -> 164 + bm.message.Htmlrw_check.severity = Htmlrw_check.Error 165 + ) result.messages) in 166 + let warning_count = List.length (List.filter (fun bm -> 167 + bm.message.Htmlrw_check.severity = Htmlrw_check.Warning 168 + ) result.messages) in 169 + El.set_children t.badge [ 170 + El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count) 171 + ] 172 + 173 + let collapse t = 174 + t.collapsed <- true; 175 + El.set_class Css_class.panel_collapsed true t.root; 176 + match t.on_collapse_toggle with Some f -> f true | None -> () 177 + 178 + let expand t = 179 + t.collapsed <- false; 180 + El.set_class Css_class.panel_collapsed false t.root; 181 + match t.on_collapse_toggle with Some f -> f false | None -> () 182 + 183 + let toggle_collapsed t = 184 + if t.collapsed then expand t else collapse t 185 + 186 + let show t = 187 + El.set_inline_style (Jstr.v "display") (Jstr.v "block") t.root 188 + 189 + let hide t = 190 + El.set_inline_style (Jstr.v "display") (Jstr.v "none") t.root 191 + 192 + let destroy t = 193 + El.remove t.root; 194 + if !_current_panel = Some t then _current_panel := None 195 + 196 + let hide_current () = 197 + match !_current_panel with Some t -> destroy t | None -> () 198 + 199 + let create ~config result = 200 + hide_current (); 201 + 202 + let _doc = G.document in 203 + 204 + let title = El.v (Jstr.v "span") [El.txt' "HTML5 Validation"] in 205 + 206 + let collapse_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.collapse_btn] [ 207 + El.txt' "_" 208 + ] in 209 + 210 + let close_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.close_btn] [ 211 + El.txt' "x" 212 + ] in 213 + 214 + let header = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_header] [ 215 + title; collapse_btn; close_btn 216 + ] in 217 + 218 + let error_count = List.length (List.filter (fun bm -> 219 + bm.message.Htmlrw_check.severity = Htmlrw_check.Error 220 + ) result.messages) in 221 + let warning_count = List.length (List.filter (fun bm -> 222 + bm.message.Htmlrw_check.severity = Htmlrw_check.Warning 223 + ) result.messages) in 224 + 225 + let badge = El.v (Jstr.v "div") ~at:[At.class' Css_class.summary_badge] [ 226 + El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count) 227 + ] in 228 + 229 + let content = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_content] [] in 230 + 231 + let theme_class = match config.theme with 232 + | `Light -> Css_class.theme_light 233 + | `Dark -> Css_class.theme_dark 234 + | `Auto -> Css_class.theme_light 235 + in 236 + 237 + let root = El.v (Jstr.v "div") ~at:[ 238 + At.class' Css_class.panel; 239 + At.class' theme_class; 240 + ] [header; badge; content] in 241 + 242 + (match config.initial_position with 243 + | `TopRight -> 244 + El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root; 245 + El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root 246 + | `TopLeft -> 247 + El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root; 248 + El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root 249 + | `BottomRight -> 250 + El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root; 251 + El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root 252 + | `BottomLeft -> 253 + El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root; 254 + El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root 255 + | `Custom (x, y) -> 256 + El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) root; 257 + El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) root); 258 + 259 + let t = { 260 + root; header; content; badge; config; result; 261 + collapsed = config.start_collapsed; 262 + highlighted = None; 263 + on_warning_click = None; 264 + on_collapse_toggle = None; 265 + on_close = None; 266 + on_move = None; 267 + } in 268 + 269 + update t result; 270 + 271 + ignore (Ev.listen Ev.click (fun _ -> toggle_collapsed t) (El.as_target collapse_btn)); 272 + 273 + ignore (Ev.listen Ev.click (fun _ -> 274 + destroy t; 275 + match t.on_close with Some f -> f () | None -> () 276 + ) (El.as_target close_btn)); 277 + 278 + if config.draggable then begin 279 + let dragging = ref false in 280 + let offset_x = ref 0.0 in 281 + let offset_y = ref 0.0 in 282 + 283 + ignore (Ev.listen Ev.mousedown (fun ev -> 284 + let m = Ev.as_type ev in 285 + dragging := true; 286 + offset_x := Ev.Mouse.client_x m -. El.bound_x root; 287 + offset_y := Ev.Mouse.client_y m -. El.bound_y root; 288 + El.set_class Css_class.panel_dragging true root 289 + ) (El.as_target header)); 290 + 291 + ignore (Ev.listen Ev.mousemove (fun ev -> 292 + if !dragging then begin 293 + let m = Ev.as_type ev in 294 + let x = int_of_float (Ev.Mouse.client_x m -. !offset_x) in 295 + let y = int_of_float (Ev.Mouse.client_y m -. !offset_y) in 296 + set_position t x y; 297 + match t.on_move with Some f -> f (x, y) | None -> () 298 + end 299 + ) (Window.as_target G.window)); 300 + 301 + ignore (Ev.listen Ev.mouseup (fun _ -> 302 + dragging := false; 303 + El.set_class Css_class.panel_dragging false root 304 + ) (Window.as_target G.window)) 305 + end; 306 + 307 + if config.start_collapsed then 308 + El.set_class Css_class.panel_collapsed true root; 309 + 310 + El.append_children (Document.body G.document) [root]; 311 + 312 + _current_panel := Some t; 313 + t 314 + 315 + let on_warning_click t f = t.on_warning_click <- Some f 316 + let on_collapse_toggle t f = t.on_collapse_toggle <- Some f 317 + let on_close t f = t.on_close <- Some f 318 + let on_move t f = t.on_move <- Some f 319 + 320 + let inject_default_styles ~theme = 321 + let theme_vars = match theme with 322 + | `Light -> {| 323 + --html5rw-panel-bg: #ffffff; 324 + --html5rw-panel-text: #333333; 325 + --html5rw-panel-border: #dddddd; 326 + --html5rw-panel-header-bg: #f5f5f5; 327 + |} 328 + | `Dark -> {| 329 + --html5rw-panel-bg: #2d3436; 330 + --html5rw-panel-text: #dfe6e9; 331 + --html5rw-panel-border: #636e72; 332 + --html5rw-panel-header-bg: #1e272e; 333 + |} 334 + | `Auto -> {| 335 + --html5rw-panel-bg: #ffffff; 336 + --html5rw-panel-text: #333333; 337 + --html5rw-panel-border: #dddddd; 338 + --html5rw-panel-header-bg: #f5f5f5; 339 + |} 340 + in 341 + 342 + let css = Printf.sprintf {| 343 + :root { %s } 344 + 345 + @media (prefers-color-scheme: dark) { 346 + :root { 347 + --html5rw-panel-bg: #2d3436; 348 + --html5rw-panel-text: #dfe6e9; 349 + --html5rw-panel-border: #636e72; 350 + --html5rw-panel-header-bg: #1e272e; 351 + } 352 + } 353 + 354 + .html5rw-panel { 355 + position: fixed; 356 + z-index: 99999; 357 + width: 400px; 358 + background: var(--html5rw-panel-bg); 359 + border: 1px solid var(--html5rw-panel-border); 360 + border-radius: 8px; 361 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 362 + font-family: system-ui, -apple-system, sans-serif; 363 + font-size: 13px; 364 + color: var(--html5rw-panel-text); 365 + } 366 + 367 + .html5rw-panel-header { 368 + display: flex; 369 + align-items: center; 370 + padding: 12px 16px; 371 + background: var(--html5rw-panel-header-bg); 372 + border-bottom: 1px solid var(--html5rw-panel-border); 373 + border-radius: 8px 8px 0 0; 374 + cursor: move; 375 + user-select: none; 376 + } 377 + 378 + .html5rw-panel-header span { flex: 1; font-weight: 600; } 379 + 380 + .html5rw-panel-header button { 381 + width: 24px; height: 24px; margin-left: 8px; 382 + border: none; border-radius: 4px; 383 + background: transparent; color: var(--html5rw-panel-text); 384 + cursor: pointer; font-size: 14px; line-height: 1; 385 + } 386 + 387 + .html5rw-panel-header button:hover { background: rgba(0, 0, 0, 0.1); } 388 + .html5rw-panel-content { padding: 0; } 389 + .html5rw-panel-collapsed .html5rw-panel-content { display: none; } 390 + .html5rw-panel-collapsed .html5rw-summary-badge { display: block; } 391 + .html5rw-summary-badge { display: none; padding: 12px 16px; text-align: center; font-weight: 500; } 392 + .html5rw-warning-list { max-height: 400px; overflow-y: auto; } 393 + 394 + .html5rw-warning-row { 395 + display: flex; flex-direction: column; 396 + padding: 10px 16px; 397 + border-bottom: 1px solid var(--html5rw-panel-border); 398 + cursor: pointer; transition: background 0.15s; 399 + } 400 + 401 + .html5rw-warning-row:hover { background: rgba(0, 0, 0, 0.05); } 402 + .html5rw-warning-row:last-child { border-bottom: none; } 403 + 404 + .html5rw-severity-badge { 405 + display: inline-block; padding: 2px 6px; border-radius: 3px; 406 + font-size: 10px; font-weight: 600; text-transform: uppercase; margin-right: 8px; 407 + } 408 + 409 + .html5rw-warning-row-error .html5rw-severity-badge { background: #e74c3c; color: white; } 410 + .html5rw-warning-row-warning .html5rw-severity-badge { background: #f39c12; color: white; } 411 + .html5rw-warning-row-info .html5rw-severity-badge { background: #3498db; color: white; } 412 + .html5rw-message-text { flex: 1; line-height: 1.4; } 413 + 414 + .html5rw-selector-path { 415 + display: block; margin-top: 4px; font-size: 11px; color: #888; 416 + font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 417 + } 418 + 419 + .html5rw-panel-dragging { opacity: 0.9; } 420 + |} theme_vars in 421 + 422 + let doc = G.document in 423 + let style_el = El.v (Jstr.v "style") [El.txt' css] in 424 + El.set_at (Jstr.v "data-html5rw-panel-styles") (Some (Jstr.v "true")) style_el; 425 + El.append_children (Document.head doc) [style_el]; 426 + style_el
+169
lib/js/htmlrw_js_ui.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Floating warning panel UI. 7 + 8 + This module creates and manages a draggable, floating panel that displays 9 + validation warnings. The panel supports: 10 + - Grouping by severity (errors first) 11 + - Click-to-navigate to problematic elements 12 + - Collapse/expand functionality 13 + - Light/dark themes *) 14 + 15 + open Htmlrw_js_types 16 + 17 + 18 + (** {1 Panel Management} *) 19 + 20 + (** The warning panel. *) 21 + type t 22 + 23 + (** Create and display a warning panel. 24 + 25 + The panel is appended to [document.body] and positioned according 26 + to the configuration. 27 + 28 + @param config Panel configuration. 29 + @param result Validation result to display. *) 30 + val create : config:panel_config -> result -> t 31 + 32 + (** Update the panel with new validation results. 33 + 34 + Use this to re-validate and refresh the panel without destroying it. *) 35 + val update : t -> result -> unit 36 + 37 + (** Show the panel if hidden. *) 38 + val show : t -> unit 39 + 40 + (** Hide the panel (but keep it in the DOM). *) 41 + val hide : t -> unit 42 + 43 + (** Remove the panel from the DOM entirely. *) 44 + val destroy : t -> unit 45 + 46 + (** Check if the panel is currently visible. *) 47 + val is_visible : t -> bool 48 + 49 + (** Check if the panel is currently collapsed. *) 50 + val is_collapsed : t -> bool 51 + 52 + 53 + (** {1 Panel State} *) 54 + 55 + (** Collapse the panel to just show the summary badge. *) 56 + val collapse : t -> unit 57 + 58 + (** Expand the panel to show the full warning list. *) 59 + val expand : t -> unit 60 + 61 + (** Toggle collapsed state. *) 62 + val toggle_collapsed : t -> unit 63 + 64 + (** Get the current position of the panel. *) 65 + val position : t -> int * int 66 + 67 + (** Move the panel to a new position. *) 68 + val set_position : t -> int -> int -> unit 69 + 70 + 71 + (** {1 Interaction} *) 72 + 73 + (** Scroll to and highlight an element from a warning row. 74 + 75 + This is called internally when clicking a warning, but can be 76 + invoked programmatically. *) 77 + val navigate_to_element : t -> browser_message -> unit 78 + 79 + (** Get the currently highlighted element, if any. *) 80 + val highlighted_element : t -> Brr.El.t option 81 + 82 + (** Clear the current highlight. *) 83 + val clear_highlight : t -> unit 84 + 85 + 86 + (** {1 Event Callbacks} 87 + 88 + Register callbacks for panel events. *) 89 + 90 + (** Called when a warning row is clicked. *) 91 + val on_warning_click : t -> (browser_message -> unit) -> unit 92 + 93 + (** Called when the panel is collapsed or expanded. *) 94 + val on_collapse_toggle : t -> (bool -> unit) -> unit 95 + 96 + (** Called when the panel is closed. *) 97 + val on_close : t -> (unit -> unit) -> unit 98 + 99 + (** Called when the panel is dragged to a new position. *) 100 + val on_move : t -> (int * int -> unit) -> unit 101 + 102 + 103 + (** {1 Global Panel State} 104 + 105 + For convenience, there's a single "current" panel that the 106 + JavaScript API manages. *) 107 + 108 + (** Get the current panel, if one exists. *) 109 + val current : unit -> t option 110 + 111 + (** Hide and destroy the current panel. *) 112 + val hide_current : unit -> unit 113 + 114 + 115 + (** {1 Panel Elements} 116 + 117 + Access to the panel's DOM structure for custom styling. *) 118 + 119 + (** The root panel element. *) 120 + val root_element : t -> Brr.El.t 121 + 122 + (** The header element (contains title and controls). *) 123 + val header_element : t -> Brr.El.t 124 + 125 + (** The content element (contains warning list). *) 126 + val content_element : t -> Brr.El.t 127 + 128 + (** The summary badge element (shown when collapsed). *) 129 + val badge_element : t -> Brr.El.t 130 + 131 + 132 + (** {1 CSS Classes} 133 + 134 + Classes used by the panel for custom styling. *) 135 + 136 + module Css_class : sig 137 + val panel : Jstr.t 138 + val panel_header : Jstr.t 139 + val panel_content : Jstr.t 140 + val panel_collapsed : Jstr.t 141 + val panel_dragging : Jstr.t 142 + val warning_list : Jstr.t 143 + val warning_row : Jstr.t 144 + val warning_row_error : Jstr.t 145 + val warning_row_warning : Jstr.t 146 + val warning_row_info : Jstr.t 147 + val severity_badge : Jstr.t 148 + val message_text : Jstr.t 149 + val selector_path : Jstr.t 150 + val collapse_btn : Jstr.t 151 + val close_btn : Jstr.t 152 + val summary_badge : Jstr.t 153 + val error_count : Jstr.t 154 + val warning_count : Jstr.t 155 + val theme_light : Jstr.t 156 + val theme_dark : Jstr.t 157 + end 158 + 159 + 160 + (** {1 CSS Injection} *) 161 + 162 + (** Inject default CSS styles for the panel. 163 + 164 + Styles include layout, colors, shadows, and animations. 165 + The styles are scoped to the panel's CSS classes. 166 + 167 + @param theme Color scheme to use. 168 + @return The injected style element. *) 169 + val inject_default_styles : theme:[ `Light | `Dark | `Auto ] -> Brr.El.t
+151
lib/js/htmlrw_js_worker.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* Web Worker entry point for background HTML validation. 7 + 8 + This runs in a separate thread and communicates via postMessage. 9 + It only does string-based validation since workers can't access the DOM. 10 + *) 11 + 12 + [@@@warning "-33"] (* Suppress unused open - we only need Jv from Brr *) 13 + open Brr 14 + 15 + let console_log msg = 16 + ignore (Jv.call (Jv.get Jv.global "console") "log" [| Jv.of_string msg |]) 17 + 18 + let console_error msg = 19 + ignore (Jv.call (Jv.get Jv.global "console") "error" [| Jv.of_string msg |]) 20 + 21 + let ensure_doctype html = 22 + let lower = String.lowercase_ascii html in 23 + if String.length lower >= 9 && String.sub lower 0 9 = "<!doctype" then 24 + html 25 + else 26 + "<!DOCTYPE html>" ^ html 27 + 28 + (* Debug: dump tree structure to see what parser built *) 29 + let dump_tree_structure html = 30 + let doc = Html5rw.parse (Bytesrw.Bytes.Reader.of_string html) in 31 + let root = Html5rw.root doc in 32 + let buf = Buffer.create 1024 in 33 + let rec dump indent node = 34 + let prefix = String.make (indent * 2) ' ' in 35 + let name = node.Html5rw.Dom.name in 36 + if name = "#text" then begin 37 + let text = String.trim node.Html5rw.Dom.data in 38 + if String.length text > 0 then 39 + Buffer.add_string buf (Printf.sprintf "%s#text: \"%s\"\n" prefix 40 + (if String.length text > 30 then String.sub text 0 30 ^ "..." else text)) 41 + end else if name = "#comment" then 42 + () 43 + else begin 44 + Buffer.add_string buf (Printf.sprintf "%s<%s>\n" prefix name); 45 + if indent < 5 then (* only show first 5 levels *) 46 + List.iter (dump (indent + 1)) node.Html5rw.Dom.children 47 + end 48 + in 49 + dump 0 root; 50 + Buffer.contents buf 51 + 52 + let handle_message msg_data = 53 + console_log "[html5rw worker] Message received"; 54 + let response = Jv.obj [||] in 55 + try 56 + let id = Jv.get msg_data "id" |> Jv.to_int in 57 + let raw_html = Jv.get msg_data "html" |> Jv.to_string in 58 + let html = ensure_doctype raw_html in 59 + console_log (Printf.sprintf "[html5rw worker] Validating %d bytes (id=%d)" (String.length html) id); 60 + (* Log first 500 chars of HTML for debugging *) 61 + let preview = if String.length html > 500 then String.sub html 0 500 ^ "..." else html in 62 + console_log (Printf.sprintf "[html5rw worker] HTML preview:\n%s" preview); 63 + 64 + Jv.set response "id" (Jv.of_int id); 65 + 66 + (try 67 + (* Run validation *) 68 + let core_result = Htmlrw_check.check_string html in 69 + let messages = Htmlrw_check.messages core_result in 70 + 71 + (* Convert messages to JS-friendly format *) 72 + let warnings = Jv.of_list (fun msg -> 73 + let obj = Jv.obj [||] in 74 + Jv.set obj "severity" (Jv.of_string (Htmlrw_check.severity_to_string msg.Htmlrw_check.severity)); 75 + Jv.set obj "message" (Jv.of_string msg.Htmlrw_check.text); 76 + Jv.set obj "errorCode" (Jv.of_string (Htmlrw_check.error_code_to_string msg.Htmlrw_check.error_code)); 77 + (match msg.Htmlrw_check.element with 78 + | Some el -> Jv.set obj "elementName" (Jv.of_string el) 79 + | None -> ()); 80 + (match msg.Htmlrw_check.attribute with 81 + | Some attr -> Jv.set obj "attribute" (Jv.of_string attr) 82 + | None -> ()); 83 + (match msg.Htmlrw_check.location with 84 + | Some loc -> 85 + Jv.set obj "line" (Jv.of_int loc.line); 86 + Jv.set obj "column" (Jv.of_int loc.column) 87 + | None -> ()); 88 + obj 89 + ) messages in 90 + 91 + let error_count = List.length (List.filter (fun m -> 92 + m.Htmlrw_check.severity = Htmlrw_check.Error) messages) in 93 + let warning_count = List.length (List.filter (fun m -> 94 + m.Htmlrw_check.severity = Htmlrw_check.Warning) messages) in 95 + let info_count = List.length (List.filter (fun m -> 96 + m.Htmlrw_check.severity = Htmlrw_check.Info) messages) in 97 + 98 + Jv.set response "warnings" warnings; 99 + Jv.set response "errorCount" (Jv.of_int error_count); 100 + Jv.set response "warningCount" (Jv.of_int warning_count); 101 + Jv.set response "infoCount" (Jv.of_int info_count); 102 + Jv.set response "hasErrors" (Jv.of_bool (error_count > 0)); 103 + (* Add tree structure for debugging *) 104 + let tree_dump = dump_tree_structure html in 105 + Jv.set response "treeStructure" (Jv.of_string tree_dump); 106 + Jv.set response "htmlPreview" (Jv.of_string preview); 107 + console_log (Printf.sprintf "[html5rw worker] Tree structure:\n%s" tree_dump) 108 + with exn -> 109 + (* Return error on parse failure *) 110 + let error_obj = Jv.obj [||] in 111 + Jv.set error_obj "severity" (Jv.of_string "error"); 112 + Jv.set error_obj "message" (Jv.of_string (Printf.sprintf "Parse error: %s" (Printexc.to_string exn))); 113 + Jv.set error_obj "errorCode" (Jv.of_string "parse-error"); 114 + Jv.set response "warnings" (Jv.of_list Fun.id [error_obj]); 115 + Jv.set response "errorCount" (Jv.of_int 1); 116 + Jv.set response "warningCount" (Jv.of_int 0); 117 + Jv.set response "infoCount" (Jv.of_int 0); 118 + Jv.set response "hasErrors" (Jv.of_bool true); 119 + Jv.set response "parseError" (Jv.of_string (Printexc.to_string exn))); 120 + 121 + console_log "[html5rw worker] Validation complete, posting response"; 122 + (* Post result back to main thread *) 123 + let self = Jv.get Jv.global "self" in 124 + ignore (Jv.call self "postMessage" [| response |]) 125 + with exn -> 126 + (* Outer error handler - catches message parsing errors *) 127 + console_error (Printf.sprintf "[html5rw worker] Fatal error: %s" (Printexc.to_string exn)); 128 + let error_obj = Jv.obj [||] in 129 + Jv.set error_obj "severity" (Jv.of_string "error"); 130 + Jv.set error_obj "message" (Jv.of_string (Printf.sprintf "Worker error: %s" (Printexc.to_string exn))); 131 + Jv.set error_obj "errorCode" (Jv.of_string "worker-error"); 132 + Jv.set response "id" (Jv.of_int (-1)); 133 + Jv.set response "warnings" (Jv.of_list Fun.id [error_obj]); 134 + Jv.set response "errorCount" (Jv.of_int 1); 135 + Jv.set response "warningCount" (Jv.of_int 0); 136 + Jv.set response "infoCount" (Jv.of_int 0); 137 + Jv.set response "hasErrors" (Jv.of_bool true); 138 + Jv.set response "fatalError" (Jv.of_string (Printexc.to_string exn)); 139 + let self = Jv.get Jv.global "self" in 140 + ignore (Jv.call self "postMessage" [| response |]) 141 + 142 + let () = 143 + console_log "[html5rw worker] Worker script starting..."; 144 + (* Set up message handler *) 145 + let self = Jv.get Jv.global "self" in 146 + let handler = Jv.callback ~arity:1 (fun ev -> 147 + let data = Jv.get ev "data" in 148 + handle_message data 149 + ) in 150 + ignore (Jv.call self "addEventListener" [| Jv.of_string "message"; handler |]); 151 + console_log "[html5rw worker] Message handler registered, ready for messages"