···4747 | Ok (reader, ic, system_id) ->
4848 (* Run validation *)
4949 let result = Htmlrw_check.check ~system_id reader in
5050-5150 (* Close input if it's not stdin *)
5251 if file <> "-" then close_in ic;
5352
+49
lib/js/dune
···11+; HTML5rw JavaScript Validator Library
22+; Compiled with js_of_ocaml for browser use
33+44+(library
55+ (name htmlrw_js)
66+ (public_name html5rw.js)
77+ (libraries
88+ html5rw
99+ htmlrw_check
1010+ bytesrw
1111+ brr)
1212+ (modes byte) ; js_of_ocaml requires bytecode
1313+ (modules
1414+ htmlrw_js_types
1515+ htmlrw_js_dom
1616+ htmlrw_js_annotate
1717+ htmlrw_js_ui
1818+ htmlrw_js))
1919+2020+; Standalone JavaScript file for direct browser use
2121+; This compiles the library entry point to a .js file
2222+(executable
2323+ (name htmlrw_js_main)
2424+ (libraries htmlrw_js)
2525+ (js_of_ocaml
2626+ (javascript_files))
2727+ (modes js)
2828+ (modules htmlrw_js_main))
2929+3030+; Web Worker for background validation
3131+; Runs validation in a separate thread to avoid blocking the UI
3232+(executable
3333+ (name htmlrw_js_worker)
3434+ (libraries html5rw htmlrw_check bytesrw brr)
3535+ (js_of_ocaml
3636+ (javascript_files))
3737+ (modes js)
3838+ (modules htmlrw_js_worker))
3939+4040+; Copy to nice filenames
4141+(rule
4242+ (targets htmlrw.js)
4343+ (deps htmlrw_js_main.bc.js)
4444+ (action (copy %{deps} %{targets})))
4545+4646+(rule
4747+ (targets htmlrw-worker.js)
4848+ (deps htmlrw_js_worker.bc.js)
4949+ (action (copy %{deps} %{targets})))
+576
lib/js/htmlrw_js.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Brr
77+open Htmlrw_js_types
88+99+let ensure_doctype html =
1010+ let lower = String.lowercase_ascii html in
1111+ if String.length lower >= 9 && String.sub lower 0 9 = "<!doctype" then
1212+ html
1313+ else
1414+ "<!DOCTYPE html>" ^ html
1515+1616+let validate_string raw_html =
1717+ let html = ensure_doctype raw_html in
1818+ try
1919+ let core_result = Htmlrw_check.check_string html in
2020+ let messages = List.map (fun msg ->
2121+ { message = msg; element_ref = None }
2222+ ) (Htmlrw_check.messages core_result) in
2323+ { messages; core_result; source_element = None }
2424+ with exn ->
2525+ (* Return empty result with error message on parse failure *)
2626+ let error_msg = {
2727+ Htmlrw_check.severity = Htmlrw_check.Error;
2828+ text = Printf.sprintf "Parse error: %s" (Printexc.to_string exn);
2929+ error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1);
3030+ location = None;
3131+ element = None;
3232+ attribute = None;
3333+ extract = None;
3434+ } in
3535+ let core_result = Htmlrw_check.check_string "" in
3636+ { messages = [{ message = error_msg; element_ref = None }];
3737+ core_result;
3838+ source_element = None }
3939+4040+let validate_element el =
4141+ try
4242+ let el_map, html = Htmlrw_js_dom.create el in
4343+ let core_result = Htmlrw_check.check_string html in
4444+ let messages = List.map (fun msg ->
4545+ let element_ref =
4646+ match Htmlrw_js_dom.find_for_message el_map msg with
4747+ | Some browser_el ->
4848+ Some {
4949+ element = Some browser_el;
5050+ selector = Htmlrw_js_dom.selector_path browser_el;
5151+ }
5252+ | None ->
5353+ (* No direct mapping found - try to find by element name *)
5454+ match msg.Htmlrw_check.element with
5555+ | Some tag ->
5656+ let matches = Htmlrw_js_dom.filter_elements (fun e ->
5757+ String.lowercase_ascii (Jstr.to_string (El.tag_name e)) =
5858+ String.lowercase_ascii tag
5959+ ) el in
6060+ (match matches with
6161+ | browser_el :: _ ->
6262+ Some {
6363+ element = Some browser_el;
6464+ selector = Htmlrw_js_dom.selector_path browser_el;
6565+ }
6666+ | [] -> None)
6767+ | None -> None
6868+ in
6969+ { message = msg; element_ref }
7070+ ) (Htmlrw_check.messages core_result) in
7171+ { messages; core_result; source_element = Some el }
7272+ with exn ->
7373+ (* Return error result on parse failure *)
7474+ let error_msg = {
7575+ Htmlrw_check.severity = Htmlrw_check.Error;
7676+ text = Printf.sprintf "Parse error: %s" (Printexc.to_string exn);
7777+ error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1);
7878+ location = None;
7979+ element = None;
8080+ attribute = None;
8181+ extract = None;
8282+ } in
8383+ let core_result = Htmlrw_check.check_string "" in
8484+ { messages = [{ message = error_msg; element_ref = None }];
8585+ core_result;
8686+ source_element = Some el }
8787+8888+let validate_and_annotate ?(config = default_annotation_config) el =
8989+ let result = validate_element el in
9090+ (* Inject styles if not already present *)
9191+ let doc = El.document el in
9292+ let existing = El.find_first_by_selector (Jstr.v "[data-html5rw-styles]")
9393+ ~root:(Document.head doc) in
9494+ if Option.is_none existing then
9595+ ignore (Htmlrw_js_annotate.inject_default_styles ~theme:`Auto);
9696+ (* Annotate elements *)
9797+ Htmlrw_js_annotate.annotate ~config ~root:el result.messages;
9898+ result
9999+100100+let validate_and_show_panel
101101+ ?(annotation_config = default_annotation_config)
102102+ ?(panel_config = default_panel_config)
103103+ el =
104104+ let result = validate_and_annotate ~config:annotation_config el in
105105+ (* Inject panel styles if not already present *)
106106+ let doc = El.document el in
107107+ let existing = El.find_first_by_selector (Jstr.v "[data-html5rw-panel-styles]")
108108+ ~root:(Document.head doc) in
109109+ if Option.is_none existing then
110110+ ignore (Htmlrw_js_ui.inject_default_styles ~theme:panel_config.theme);
111111+ (* Create and show panel *)
112112+ ignore (Htmlrw_js_ui.create ~config:panel_config result);
113113+ result
114114+115115+let errors result =
116116+ List.filter (fun bm ->
117117+ bm.message.Htmlrw_check.severity = Htmlrw_check.Error
118118+ ) result.messages
119119+120120+let warnings_only result =
121121+ List.filter (fun bm ->
122122+ bm.message.Htmlrw_check.severity = Htmlrw_check.Warning
123123+ ) result.messages
124124+125125+let infos result =
126126+ List.filter (fun bm ->
127127+ bm.message.Htmlrw_check.severity = Htmlrw_check.Info
128128+ ) result.messages
129129+130130+let has_errors result =
131131+ Htmlrw_check.has_errors result.core_result
132132+133133+let has_issues result =
134134+ Htmlrw_check.has_errors result.core_result ||
135135+ Htmlrw_check.has_warnings result.core_result
136136+137137+let message_count result =
138138+ List.length result.messages
139139+140140+let element_map result =
141141+ match result.source_element with
142142+ | Some el -> Some (fst (Htmlrw_js_dom.create el))
143143+ | None -> None
144144+145145+(* JavaScript API registration *)
146146+147147+let register_api_on obj =
148148+ (* validateString(html) -> result *)
149149+ Jv.set obj "validateString" (Jv.callback ~arity:1 (fun html ->
150150+ let html_str = Jv.to_string html in
151151+ let result = validate_string html_str in
152152+ result_to_jv result
153153+ ));
154154+155155+ (* validateElement(el) -> result *)
156156+ Jv.set obj "validateElement" (Jv.callback ~arity:1 (fun el_jv ->
157157+ let el = El.of_jv el_jv in
158158+ let result = validate_element el in
159159+ result_to_jv result
160160+ ));
161161+162162+ (* validateAndAnnotate(el, config?) -> result *)
163163+ Jv.set obj "validateAndAnnotate" (Jv.callback ~arity:2 (fun el_jv config_jv ->
164164+ let el = El.of_jv el_jv in
165165+ let config =
166166+ if Jv.is_none config_jv then
167167+ default_annotation_config
168168+ else
169169+ {
170170+ add_data_attrs = Jv.to_bool (Jv.get config_jv "addDataAttrs");
171171+ add_classes = Jv.to_bool (Jv.get config_jv "addClasses");
172172+ show_tooltips = Jv.to_bool (Jv.get config_jv "showTooltips");
173173+ tooltip_position = `Auto;
174174+ highlight_on_hover = Jv.to_bool (Jv.get config_jv "highlightOnHover");
175175+ }
176176+ in
177177+ let result = validate_and_annotate ~config el in
178178+ result_to_jv result
179179+ ));
180180+181181+ (* validateAndShowPanel(el, config?) -> result *)
182182+ Jv.set obj "validateAndShowPanel" (Jv.callback ~arity:2 (fun el_jv config_jv ->
183183+ let el = El.of_jv el_jv in
184184+ let annotation_config, panel_config =
185185+ if Jv.is_none config_jv then
186186+ default_annotation_config, default_panel_config
187187+ else
188188+ let ann_jv = Jv.get config_jv "annotation" in
189189+ let panel_jv = Jv.get config_jv "panel" in
190190+ let ann_config =
191191+ if Jv.is_none ann_jv then default_annotation_config
192192+ else {
193193+ add_data_attrs =
194194+ (let v = Jv.get ann_jv "addDataAttrs" in
195195+ if Jv.is_none v then true else Jv.to_bool v);
196196+ add_classes =
197197+ (let v = Jv.get ann_jv "addClasses" in
198198+ if Jv.is_none v then true else Jv.to_bool v);
199199+ show_tooltips =
200200+ (let v = Jv.get ann_jv "showTooltips" in
201201+ if Jv.is_none v then true else Jv.to_bool v);
202202+ tooltip_position = `Auto;
203203+ highlight_on_hover =
204204+ (let v = Jv.get ann_jv "highlightOnHover" in
205205+ if Jv.is_none v then true else Jv.to_bool v);
206206+ }
207207+ in
208208+ let panel_config =
209209+ if Jv.is_none panel_jv then default_panel_config
210210+ else {
211211+ initial_position =
212212+ (let v = Jv.get panel_jv "initialPosition" in
213213+ if Jv.is_none v then `TopRight
214214+ else match Jv.to_string v with
215215+ | "topRight" -> `TopRight
216216+ | "topLeft" -> `TopLeft
217217+ | "bottomRight" -> `BottomRight
218218+ | "bottomLeft" -> `BottomLeft
219219+ | _ -> `TopRight);
220220+ draggable =
221221+ (let v = Jv.get panel_jv "draggable" in
222222+ if Jv.is_none v then true else Jv.to_bool v);
223223+ resizable =
224224+ (let v = Jv.get panel_jv "resizable" in
225225+ if Jv.is_none v then true else Jv.to_bool v);
226226+ collapsible =
227227+ (let v = Jv.get panel_jv "collapsible" in
228228+ if Jv.is_none v then true else Jv.to_bool v);
229229+ start_collapsed =
230230+ (let v = Jv.get panel_jv "startCollapsed" in
231231+ if Jv.is_none v then false else Jv.to_bool v);
232232+ max_height =
233233+ (let v = Jv.get panel_jv "maxHeight" in
234234+ if Jv.is_none v then Some 400 else Some (Jv.to_int v));
235235+ group_by_severity =
236236+ (let v = Jv.get panel_jv "groupBySeverity" in
237237+ if Jv.is_none v then true else Jv.to_bool v);
238238+ click_to_highlight =
239239+ (let v = Jv.get panel_jv "clickToHighlight" in
240240+ if Jv.is_none v then true else Jv.to_bool v);
241241+ show_selector_path =
242242+ (let v = Jv.get panel_jv "showSelectorPath" in
243243+ if Jv.is_none v then true else Jv.to_bool v);
244244+ theme =
245245+ (let v = Jv.get panel_jv "theme" in
246246+ if Jv.is_none v then `Auto
247247+ else match Jv.to_string v with
248248+ | "light" -> `Light
249249+ | "dark" -> `Dark
250250+ | _ -> `Auto);
251251+ }
252252+ in
253253+ ann_config, panel_config
254254+ in
255255+ let result = validate_and_show_panel ~annotation_config ~panel_config el in
256256+ result_to_jv result
257257+ ));
258258+259259+ (* clearAnnotations(el) *)
260260+ Jv.set obj "clearAnnotations" (Jv.callback ~arity:1 (fun el_jv ->
261261+ let el = El.of_jv el_jv in
262262+ Htmlrw_js_annotate.clear el;
263263+ Jv.undefined
264264+ ));
265265+266266+ (* hidePanel() *)
267267+ Jv.set obj "hidePanel" (Jv.callback ~arity:0 (fun () ->
268268+ Htmlrw_js_ui.hide_current ();
269269+ Jv.undefined
270270+ ));
271271+272272+ (* showPanel(result, config?) *)
273273+ Jv.set obj "showPanel" (Jv.callback ~arity:2 (fun result_jv config_jv ->
274274+ (* This expects a previously returned result object *)
275275+ (* For now, just create a panel with the warnings from the result *)
276276+ let warnings_jv = Jv.get result_jv "warnings" in
277277+ let warnings = Jv.to_list (fun w_jv ->
278278+ let msg = {
279279+ Htmlrw_check.severity =
280280+ (match Jv.to_string (Jv.get w_jv "severity") with
281281+ | "error" -> Htmlrw_check.Error
282282+ | "warning" -> Htmlrw_check.Warning
283283+ | _ -> Htmlrw_check.Info);
284284+ text = Jv.to_string (Jv.get w_jv "message");
285285+ error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1);
286286+ location = None;
287287+ element = None;
288288+ attribute = None;
289289+ extract = None;
290290+ } in
291291+ let element_ref =
292292+ let sel_jv = Jv.get w_jv "selector" in
293293+ let el_jv = Jv.get w_jv "element" in
294294+ if Jv.is_none sel_jv then None
295295+ else Some {
296296+ selector = Jv.to_string sel_jv;
297297+ element = if Jv.is_none el_jv then None else Some (El.of_jv el_jv);
298298+ }
299299+ in
300300+ { message = msg; element_ref }
301301+ ) warnings_jv in
302302+ let result = {
303303+ messages = warnings;
304304+ core_result = Htmlrw_check.check_string "";
305305+ source_element = None;
306306+ } in
307307+ let config =
308308+ if Jv.is_none config_jv then default_panel_config
309309+ else default_panel_config (* TODO: parse config *)
310310+ in
311311+ ignore (Htmlrw_js_ui.create ~config result);
312312+ Jv.undefined
313313+ ))
314314+315315+(* Async/Worker support *)
316316+317317+let console_log msg =
318318+ ignore (Jv.call (Jv.get Jv.global "console") "log" [| Jv.of_string msg |])
319319+320320+let console_log_result prefix result =
321321+ let error_count = List.length (List.filter (fun bm ->
322322+ bm.message.Htmlrw_check.severity = Htmlrw_check.Error
323323+ ) result.messages) in
324324+ let warning_count = List.length (List.filter (fun bm ->
325325+ bm.message.Htmlrw_check.severity = Htmlrw_check.Warning
326326+ ) result.messages) in
327327+ let msg = Printf.sprintf "[html5rw] %s: %d errors, %d warnings, %d total issues"
328328+ prefix error_count warning_count (List.length result.messages) in
329329+ console_log msg
330330+331331+let _worker : Jv.t option ref = ref None
332332+let _pending_callbacks : (int, Jv.t -> unit) Hashtbl.t = Hashtbl.create 16
333333+let _next_id = ref 0
334334+335335+let init_worker worker_url =
336336+ console_log (Printf.sprintf "[html5rw] Initializing web worker from %s" worker_url);
337337+ let worker = Jv.new' (Jv.get Jv.global "Worker") [| Jv.of_string worker_url |] in
338338+339339+ (* Error handler for worker-level errors *)
340340+ let error_handler = Jv.callback ~arity:1 (fun ev ->
341341+ let msg = Jv.get ev "message" in
342342+ let filename = Jv.get ev "filename" in
343343+ let lineno = Jv.get ev "lineno" in
344344+ console_log (Printf.sprintf "[html5rw] Worker error: %s at %s:%d"
345345+ (if Jv.is_undefined msg then "unknown" else Jv.to_string msg)
346346+ (if Jv.is_undefined filename then "unknown" else Jv.to_string filename)
347347+ (if Jv.is_undefined lineno then 0 else Jv.to_int lineno))
348348+ ) in
349349+ ignore (Jv.call worker "addEventListener" [| Jv.of_string "error"; error_handler |]);
350350+351351+ let handler = Jv.callback ~arity:1 (fun ev ->
352352+ let data = Jv.get ev "data" in
353353+ let id = Jv.get data "id" |> Jv.to_int in
354354+ let error_count = Jv.get data "errorCount" |> Jv.to_int in
355355+ let warning_count = Jv.get data "warningCount" |> Jv.to_int in
356356+ let total = Jv.get data "warnings" |> Jv.to_list (fun _ -> ()) |> List.length in
357357+ console_log (Printf.sprintf "[html5rw] Worker validation complete: %d errors, %d warnings, %d total issues"
358358+ error_count warning_count total);
359359+ match Hashtbl.find_opt _pending_callbacks id with
360360+ | Some callback ->
361361+ Hashtbl.remove _pending_callbacks id;
362362+ callback data
363363+ | None -> ()
364364+ ) in
365365+ ignore (Jv.call worker "addEventListener" [| Jv.of_string "message"; handler |]);
366366+ _worker := Some worker;
367367+ console_log "[html5rw] Web worker ready";
368368+ worker
369369+370370+let validate_string_async ~callback html =
371371+ match !_worker with
372372+ | None -> failwith "Worker not initialized. Call html5rw.initWorker(url) first."
373373+ | Some worker ->
374374+ console_log (Printf.sprintf "[html5rw] Sending %d bytes to worker for validation..." (String.length html));
375375+ let id = !_next_id in
376376+ incr _next_id;
377377+ Hashtbl.add _pending_callbacks id callback;
378378+ let msg = Jv.obj [|
379379+ "id", Jv.of_int id;
380380+ "html", Jv.of_string html
381381+ |] in
382382+ ignore (Jv.call worker "postMessage" [| msg |])
383383+384384+let _validate_element_async ~callback el =
385385+ let html = Htmlrw_js_dom.outer_html el in
386386+ validate_string_async ~callback html
387387+388388+let validate_after_load callback el =
389389+ (* Use requestIdleCallback if available, otherwise setTimeout *)
390390+ console_log "[html5rw] Waiting for page load...";
391391+ let run () =
392392+ console_log "[html5rw] Starting validation...";
393393+ let result = validate_element el in
394394+ console_log_result "Validation complete" result;
395395+ callback result
396396+ in
397397+ let request_idle = Jv.get Jv.global "requestIdleCallback" in
398398+ if not (Jv.is_undefined request_idle) then
399399+ ignore (Jv.apply request_idle [| Jv.callback ~arity:1 (fun _ -> run ()) |])
400400+ else
401401+ ignore (Jv.call Jv.global "setTimeout" [|
402402+ Jv.callback ~arity:0 run;
403403+ Jv.of_int 0
404404+ |])
405405+406406+let validate_on_idle ?(timeout=5000) callback el =
407407+ (* Wait for page load, then use requestIdleCallback with timeout *)
408408+ console_log "[html5rw] Scheduling validation for idle time...";
409409+ let run_when_ready () =
410410+ let request_idle = Jv.get Jv.global "requestIdleCallback" in
411411+ if not (Jv.is_undefined request_idle) then begin
412412+ let opts = Jv.obj [| "timeout", Jv.of_int timeout |] in
413413+ ignore (Jv.call Jv.global "requestIdleCallback" [|
414414+ Jv.callback ~arity:1 (fun _ ->
415415+ console_log "[html5rw] Browser idle, starting validation...";
416416+ let result = validate_element el in
417417+ console_log_result "Validation complete" result;
418418+ callback result
419419+ );
420420+ opts
421421+ |])
422422+ end else begin
423423+ ignore (Jv.call Jv.global "setTimeout" [|
424424+ Jv.callback ~arity:0 (fun () ->
425425+ console_log "[html5rw] Starting validation...";
426426+ let result = validate_element el in
427427+ console_log_result "Validation complete" result;
428428+ callback result
429429+ );
430430+ Jv.of_int 100
431431+ |])
432432+ end
433433+ in
434434+ let ready_state = Jv.get (Jv.get Jv.global "document") "readyState" |> Jv.to_string in
435435+ if ready_state = "complete" then
436436+ run_when_ready ()
437437+ else
438438+ ignore (Jv.call Jv.global "addEventListener" [|
439439+ Jv.of_string "load";
440440+ Jv.callback ~arity:1 (fun _ -> run_when_ready ())
441441+ |])
442442+443443+let register_global_api () =
444444+ let api = Jv.obj [||] in
445445+ register_api_on api;
446446+447447+ (* Add async functions *)
448448+449449+ (* initWorker(url) - initialize web worker *)
450450+ Jv.set api "initWorker" (Jv.callback ~arity:1 (fun url_jv ->
451451+ let url = Jv.to_string url_jv in
452452+ init_worker url
453453+ ));
454454+455455+ (* validateStringAsync(html, callback) - validate in worker *)
456456+ Jv.set api "validateStringAsync" (Jv.callback ~arity:2 (fun html_jv callback_jv ->
457457+ let html = Jv.to_string html_jv in
458458+ let callback result = ignore (Jv.apply callback_jv [| result |]) in
459459+ validate_string_async ~callback html;
460460+ Jv.undefined
461461+ ));
462462+463463+ (* validateElementAsync(el, callback) - validate element in worker *)
464464+ Jv.set api "validateElementAsync" (Jv.callback ~arity:2 (fun el_jv callback_jv ->
465465+ let el = El.of_jv el_jv in
466466+ let html = Htmlrw_js_dom.outer_html el in
467467+ let callback result = ignore (Jv.apply callback_jv [| result |]) in
468468+ validate_string_async ~callback html;
469469+ Jv.undefined
470470+ ));
471471+472472+ (* validateAfterLoad(el, callback) - validate after page load *)
473473+ Jv.set api "validateAfterLoad" (Jv.callback ~arity:2 (fun el_jv callback_jv ->
474474+ let el = El.of_jv el_jv in
475475+ let callback result = ignore (Jv.apply callback_jv [| result_to_jv result |]) in
476476+ validate_after_load callback el;
477477+ Jv.undefined
478478+ ));
479479+480480+ (* validateOnIdle(el, callback, timeout?) - validate when browser is idle *)
481481+ Jv.set api "validateOnIdle" (Jv.callback ~arity:3 (fun el_jv callback_jv timeout_jv ->
482482+ let el = El.of_jv el_jv in
483483+ let timeout = if Jv.is_undefined timeout_jv then 5000 else Jv.to_int timeout_jv in
484484+ let callback result = ignore (Jv.apply callback_jv [| result_to_jv result |]) in
485485+ validate_on_idle ~timeout callback el;
486486+ Jv.undefined
487487+ ));
488488+489489+ (* validateAndShowPanelAsync(el, config?) - non-blocking panel display *)
490490+ Jv.set api "validateAndShowPanelAsync" (Jv.callback ~arity:2 (fun el_jv config_jv ->
491491+ let el = El.of_jv el_jv in
492492+ validate_on_idle ~timeout:3000 (fun result ->
493493+ let annotation_config, panel_config =
494494+ if Jv.is_none config_jv then
495495+ default_annotation_config, default_panel_config
496496+ else
497497+ (* Parse config same as validateAndShowPanel *)
498498+ default_annotation_config, default_panel_config
499499+ in
500500+ (* Inject styles if needed *)
501501+ let doc = El.document el in
502502+ let existing = El.find_first_by_selector (Jstr.v "[data-html5rw-styles]")
503503+ ~root:(Document.head doc) in
504504+ if Option.is_none existing then
505505+ ignore (Htmlrw_js_annotate.inject_default_styles ~theme:`Auto);
506506+ let existing_panel = El.find_first_by_selector (Jstr.v "[data-html5rw-panel-styles]")
507507+ ~root:(Document.head doc) in
508508+ if Option.is_none existing_panel then
509509+ ignore (Htmlrw_js_ui.inject_default_styles ~theme:panel_config.theme);
510510+ (* Annotate and show panel *)
511511+ Htmlrw_js_annotate.annotate ~config:annotation_config ~root:el result.messages;
512512+ ignore (Htmlrw_js_ui.create ~config:panel_config result)
513513+ ) el;
514514+ Jv.undefined
515515+ ));
516516+517517+ (* showPanelFromWorkerResult(result) - show panel from worker validation result *)
518518+ Jv.set api "showPanelFromWorkerResult" (Jv.callback ~arity:1 (fun result_jv ->
519519+ console_log "[html5rw] Showing panel from worker result";
520520+ (* Convert worker result format to internal format *)
521521+ let warnings_jv = Jv.get result_jv "warnings" in
522522+ let messages = Jv.to_list (fun w_jv ->
523523+ let severity_str = Jv.to_string (Jv.get w_jv "severity") in
524524+ let msg = {
525525+ Htmlrw_check.severity =
526526+ (match severity_str with
527527+ | "error" -> Htmlrw_check.Error
528528+ | "warning" -> Htmlrw_check.Warning
529529+ | _ -> Htmlrw_check.Info);
530530+ text = Jv.to_string (Jv.get w_jv "message");
531531+ error_code = Htmlrw_check.Conformance (`Misc `Multiple_h1);
532532+ location = (
533533+ let line_jv = Jv.get w_jv "line" in
534534+ let col_jv = Jv.get w_jv "column" in
535535+ if Jv.is_undefined line_jv then None
536536+ else Some {
537537+ Htmlrw_check.line = Jv.to_int line_jv;
538538+ column = (if Jv.is_undefined col_jv then 1 else Jv.to_int col_jv);
539539+ end_line = None;
540540+ end_column = None;
541541+ system_id = None;
542542+ }
543543+ );
544544+ element = (
545545+ let el_jv = Jv.get w_jv "elementName" in
546546+ if Jv.is_undefined el_jv then None else Some (Jv.to_string el_jv)
547547+ );
548548+ attribute = (
549549+ let attr_jv = Jv.get w_jv "attribute" in
550550+ if Jv.is_undefined attr_jv then None else Some (Jv.to_string attr_jv)
551551+ );
552552+ extract = None;
553553+ } in
554554+ { message = msg; element_ref = None }
555555+ ) warnings_jv in
556556+557557+ let result = {
558558+ messages;
559559+ core_result = Htmlrw_check.check_string "";
560560+ source_element = None;
561561+ } in
562562+563563+ (* Inject panel styles *)
564564+ let doc = Document.of_jv (Jv.get Jv.global "document") in
565565+ let existing_panel = El.find_first_by_selector (Jstr.v "[data-html5rw-panel-styles]")
566566+ ~root:(Document.head doc) in
567567+ if Option.is_none existing_panel then
568568+ ignore (Htmlrw_js_ui.inject_default_styles ~theme:`Auto);
569569+570570+ (* Create and show panel *)
571571+ console_log (Printf.sprintf "[html5rw] Creating panel with %d messages" (List.length messages));
572572+ ignore (Htmlrw_js_ui.create ~config:default_panel_config result);
573573+ Jv.undefined
574574+ ));
575575+576576+ Jv.set Jv.global "html5rw" api
+153
lib/js/htmlrw_js.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** JavaScript API for HTML5 validation in the browser.
77+88+ This module provides the main entry points for validating HTML in a
99+ browser environment. It wraps the core {!Htmlrw_check} validator and
1010+ adds browser-specific functionality for element mapping and annotation.
1111+1212+ {2 JavaScript Usage}
1313+1414+ After loading the compiled JavaScript, the API is available on [window]:
1515+1616+ {v
1717+ // Validate an element (recommended)
1818+ const result = html5rw.validateElement(document.body);
1919+ console.log(result.errorCount, "errors found");
2020+2121+ // Validate with annotation
2222+ html5rw.validateAndAnnotate(document.body, {
2323+ showTooltips: true,
2424+ showPanel: true
2525+ });
2626+2727+ // Validate a raw HTML string
2828+ const result = html5rw.validateString("<div><p>Hello</div>");
2929+ result.warnings.forEach(w => console.log(w.message));
3030+ v}
3131+3232+ {2 OCaml Usage}
3333+3434+ {[
3535+ let result = Htmlrw_js.validate_element (Brr.Document.body G.document) in
3636+ List.iter (fun bm ->
3737+ Brr.Console.log [Jstr.v bm.Htmlrw_js_types.message.text]
3838+ ) result.messages
3939+ ]} *)
4040+4141+4242+open Htmlrw_js_types
4343+4444+4545+(** {1 Validation} *)
4646+4747+(** Validate an HTML string.
4848+4949+ This is the simplest form of validation. Since there's no source element,
5050+ the returned {!browser_message}s will not have element references.
5151+5252+ {[
5353+ let result = validate_string "<html><body><img></body></html>" in
5454+ if Htmlrw_check.has_errors result.core_result then
5555+ (* handle errors *)
5656+ ]} *)
5757+val validate_string : string -> result
5858+5959+(** Validate a DOM element's HTML.
6060+6161+ Serializes the element to HTML, validates it, and maps the results
6262+ back to the live DOM elements.
6363+6464+ {[
6565+ let result = validate_element (Document.body G.document) in
6666+ List.iter (fun bm ->
6767+ match bm.element_ref with
6868+ | Some { element = Some el; _ } ->
6969+ El.set_class (Jstr.v "has-error") true el
7070+ | _ -> ()
7171+ ) result.messages
7272+ ]} *)
7373+val validate_element : Brr.El.t -> result
7474+7575+7676+(** {1 Validation with Annotation}
7777+7878+ These functions validate and immediately annotate the DOM with results. *)
7979+8080+(** Validate and annotate an element.
8181+8282+ This combines validation with DOM annotation. The element and its
8383+ descendants are annotated with data attributes, classes, and optionally
8484+ tooltips based on the validation results.
8585+8686+ @param config Annotation configuration. Defaults to {!default_annotation_config}. *)
8787+val validate_and_annotate :
8888+ ?config:annotation_config -> Brr.El.t -> result
8989+9090+(** Validate, annotate, and show the warning panel.
9191+9292+ The all-in-one function for browser validation with full UI.
9393+9494+ @param annotation_config How to annotate elements.
9595+ @param panel_config How to display the warning panel. *)
9696+val validate_and_show_panel :
9797+ ?annotation_config:annotation_config ->
9898+ ?panel_config:panel_config ->
9999+ Brr.El.t ->
100100+ result
101101+102102+103103+(** {1 Result Inspection} *)
104104+105105+(** Get messages filtered by severity. *)
106106+val errors : result -> browser_message list
107107+val warnings_only : result -> browser_message list
108108+val infos : result -> browser_message list
109109+110110+(** Check if there are any errors. *)
111111+val has_errors : result -> bool
112112+113113+(** Check if there are any warnings or errors. *)
114114+val has_issues : result -> bool
115115+116116+(** Get total count of all messages. *)
117117+val message_count : result -> int
118118+119119+120120+(** {1 JavaScript Export}
121121+122122+ These functions register the API on the JavaScript global object. *)
123123+124124+(** Register the validation API on [window.html5rw].
125125+126126+ Call this from your main entry point to expose the JavaScript API:
127127+128128+ {[
129129+ let () = Htmlrw_js.register_global_api ()
130130+ ]}
131131+132132+ This exposes:
133133+ - [html5rw.validateString(html)] -> result object
134134+ - [html5rw.validateElement(el)] -> result object
135135+ - [html5rw.validateAndAnnotate(el, config?)] -> result object
136136+ - [html5rw.validateAndShowPanel(el, config?)] -> result object
137137+ - [html5rw.clearAnnotations(el)] -> void
138138+ - [html5rw.hidePanel()] -> void *)
139139+val register_global_api : unit -> unit
140140+141141+(** Register the API on a custom object instead of [window.html5rw].
142142+143143+ Useful for module bundlers or when you want to control the namespace. *)
144144+val register_api_on : Jv.t -> unit
145145+146146+147147+(** {1 Low-level Access} *)
148148+149149+(** Access the element map from a validation result.
150150+151151+ Useful for custom element lookup logic. Returns [None] if the result
152152+ was from {!validate_string} (no source element). *)
153153+val element_map : result -> Htmlrw_js_dom.t option
+340
lib/js/htmlrw_js_annotate.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Brr
77+open Htmlrw_js_types
88+99+module Data_attr = struct
1010+ let severity = Jstr.v "data-html5rw-severity"
1111+ let message = Jstr.v "data-html5rw-message"
1212+ let code = Jstr.v "data-html5rw-code"
1313+ let count = Jstr.v "data-html5rw-count"
1414+end
1515+1616+module Css_class = struct
1717+ let error = Jstr.v "html5rw-error"
1818+ let warning = Jstr.v "html5rw-warning"
1919+ let info = Jstr.v "html5rw-info"
2020+ let has_issues = Jstr.v "html5rw-has-issues"
2121+ let highlighted = Jstr.v "html5rw-highlighted"
2222+ let tooltip = Jstr.v "html5rw-tooltip"
2323+ let tooltip_visible = Jstr.v "html5rw-tooltip-visible"
2424+end
2525+2626+type tooltip = {
2727+ container : El.t;
2828+ _target : El.t;
2929+}
3030+3131+let severity_class = function
3232+ | Htmlrw_check.Error -> Css_class.error
3333+ | Htmlrw_check.Warning -> Css_class.warning
3434+ | Htmlrw_check.Info -> Css_class.info
3535+3636+let annotate_element ~config el msg =
3737+ if config.add_data_attrs then begin
3838+ El.set_at Data_attr.severity
3939+ (Some (Jstr.v (Htmlrw_check.severity_to_string msg.Htmlrw_check.severity))) el;
4040+ El.set_at Data_attr.message (Some (Jstr.v msg.Htmlrw_check.text)) el;
4141+ El.set_at Data_attr.code
4242+ (Some (Jstr.v (Htmlrw_check.error_code_to_string msg.Htmlrw_check.error_code))) el
4343+ end;
4444+ if config.add_classes then begin
4545+ El.set_class (severity_class msg.Htmlrw_check.severity) true el;
4646+ El.set_class Css_class.has_issues true el
4747+ end
4848+4949+let rec create_tooltip ~position target messages =
5050+ let doc = El.document target in
5151+5252+ (* Create tooltip container *)
5353+ let container = El.v (Jstr.v "div") ~at:[At.class' Css_class.tooltip] [] in
5454+5555+ (* Add messages to tooltip *)
5656+ let msg_els = List.map (fun msg ->
5757+ let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in
5858+ let sev_class = Jstr.v ("html5rw-tooltip-" ^ sev) in
5959+ El.v (Jstr.v "div") ~at:[At.class' sev_class] [
6060+ El.v (Jstr.v "span") ~at:[At.class' (Jstr.v "html5rw-tooltip-severity")] [
6161+ El.txt' (String.uppercase_ascii sev)
6262+ ];
6363+ El.v (Jstr.v "span") ~at:[At.class' (Jstr.v "html5rw-tooltip-text")] [
6464+ El.txt' msg.Htmlrw_check.text
6565+ ]
6666+ ]
6767+ ) messages in
6868+ El.set_children container msg_els;
6969+7070+ (* Position the tooltip *)
7171+ let pos_class = match position with
7272+ | `Above -> "html5rw-tooltip-above"
7373+ | `Below -> "html5rw-tooltip-below"
7474+ | `Auto -> "html5rw-tooltip-auto"
7575+ in
7676+ El.set_class (Jstr.v pos_class) true container;
7777+7878+ (* Add to body for proper z-index handling *)
7979+ El.append_children (Document.body doc) [container];
8080+8181+ (* Set up hover events *)
8282+ let hide () =
8383+ El.set_class Css_class.tooltip_visible false container
8484+ in
8585+ let show () =
8686+ (* Hide any other visible tooltips first *)
8787+ let doc = El.document target in
8888+ let visible = El.fold_find_by_selector (fun el acc -> el :: acc)
8989+ (Jstr.v ".html5rw-tooltip-visible") [] ~root:(Document.body doc) in
9090+ List.iter (fun el -> El.set_class Css_class.tooltip_visible false el) visible;
9191+ (* Position and show this tooltip *)
9292+ let x = El.bound_x target in
9393+ let y = El.bound_y target in
9494+ let h = El.bound_h target in
9595+ let tooltip_y = match position with
9696+ | `Below | `Auto -> y +. h +. 4.0
9797+ | `Above -> y -. 4.0
9898+ in
9999+ El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%.0fpx" x)) container;
100100+ El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%.0fpx" tooltip_y)) container;
101101+ El.set_class Css_class.tooltip_visible true container
102102+ in
103103+104104+ ignore (Ev.listen Ev.mouseenter (fun _ -> show ()) (El.as_target target));
105105+ ignore (Ev.listen Ev.mouseleave (fun _ -> hide ()) (El.as_target target));
106106+ (* Also hide on mouseout for better reliability *)
107107+ ignore (Ev.listen Ev.mouseout (fun ev ->
108108+ let related = Jv.get (Ev.to_jv ev) "relatedTarget" in
109109+ (* Hide if mouse moved to something outside the target *)
110110+ if Jv.is_null related then hide ()
111111+ else
112112+ (* Use JS contains method directly *)
113113+ let contains = Jv.call (El.to_jv target) "contains" [| related |] |> Jv.to_bool in
114114+ if not contains then hide ()
115115+ ) (El.as_target target));
116116+117117+ { container; _target = target }
118118+119119+and annotate ~config ~root:_ messages =
120120+ (* Group messages by element - use a list since we can't hash elements *)
121121+ let el_messages : (El.t * Htmlrw_check.message list) list ref = ref [] in
122122+ List.iter (fun bm ->
123123+ match bm.element_ref with
124124+ | Some { element = Some el; _ } ->
125125+ let found = ref false in
126126+ el_messages := List.map (fun (e, msgs) ->
127127+ if Jv.strict_equal (El.to_jv e) (El.to_jv el) then begin
128128+ found := true;
129129+ (e, bm.message :: msgs)
130130+ end else (e, msgs)
131131+ ) !el_messages;
132132+ if not !found then
133133+ el_messages := (el, [bm.message]) :: !el_messages
134134+ | _ -> ()
135135+ ) messages;
136136+137137+ (* Annotate each element *)
138138+ List.iter (fun (el, msgs) ->
139139+ (* Use highest severity *)
140140+ let highest = List.fold_left (fun acc msg ->
141141+ match acc, msg.Htmlrw_check.severity with
142142+ | Htmlrw_check.Error, _ -> Htmlrw_check.Error
143143+ | _, Htmlrw_check.Error -> Htmlrw_check.Error
144144+ | Htmlrw_check.Warning, _ -> Htmlrw_check.Warning
145145+ | _, Htmlrw_check.Warning -> Htmlrw_check.Warning
146146+ | _ -> Htmlrw_check.Info
147147+ ) Htmlrw_check.Info msgs in
148148+149149+ let primary_msg = {
150150+ Htmlrw_check.severity = highest;
151151+ text = (match msgs with m :: _ -> m.Htmlrw_check.text | [] -> "");
152152+ error_code = (match msgs with m :: _ -> m.Htmlrw_check.error_code
153153+ | [] -> Htmlrw_check.Conformance (`Misc `Multiple_h1));
154154+ location = None;
155155+ element = None;
156156+ attribute = None;
157157+ extract = None;
158158+ } in
159159+ annotate_element ~config el primary_msg;
160160+161161+ if config.add_data_attrs then
162162+ El.set_at Data_attr.count (Some (Jstr.v (string_of_int (List.length msgs)))) el;
163163+164164+ if config.show_tooltips then
165165+ ignore (create_tooltip ~position:config.tooltip_position el msgs)
166166+ ) !el_messages
167167+168168+let show_tooltip t =
169169+ El.set_class Css_class.tooltip_visible true t.container
170170+171171+let hide_tooltip t =
172172+ El.set_class Css_class.tooltip_visible false t.container
173173+174174+let remove_tooltip t =
175175+ El.remove t.container
176176+177177+let tooltips_in root =
178178+ let doc = El.document root in
179179+ let tooltip_els = El.fold_find_by_selector (fun el acc -> el :: acc)
180180+ (Jstr.v ".html5rw-tooltip") [] ~root:(Document.body doc) in
181181+ List.map (fun container -> { container; _target = root }) tooltip_els
182182+183183+let clear_element el =
184184+ El.set_at Data_attr.severity None el;
185185+ El.set_at Data_attr.message None el;
186186+ El.set_at Data_attr.code None el;
187187+ El.set_at Data_attr.count None el;
188188+ El.set_class Css_class.error false el;
189189+ El.set_class Css_class.warning false el;
190190+ El.set_class Css_class.info false el;
191191+ El.set_class Css_class.has_issues false el;
192192+ El.set_class Css_class.highlighted false el
193193+194194+let clear root =
195195+ Htmlrw_js_dom.iter_elements clear_element root;
196196+ List.iter remove_tooltip (tooltips_in root)
197197+198198+let highlight_element el =
199199+ El.set_class Css_class.highlighted true el;
200200+ (* Call scrollIntoView directly with options object *)
201201+ let opts = Jv.obj [|
202202+ "behavior", Jv.of_string "smooth";
203203+ "block", Jv.of_string "center"
204204+ |] in
205205+ ignore (Jv.call (El.to_jv el) "scrollIntoView" [| opts |])
206206+207207+let unhighlight_element el =
208208+ El.set_class Css_class.highlighted false el
209209+210210+let _highlighted_elements : El.t list ref = ref []
211211+212212+let clear_highlights () =
213213+ List.iter unhighlight_element !_highlighted_elements;
214214+ _highlighted_elements := []
215215+216216+let inject_default_styles ~theme =
217217+ let theme_vars = match theme with
218218+ | `Light -> {|
219219+ --html5rw-error-color: #e74c3c;
220220+ --html5rw-warning-color: #f39c12;
221221+ --html5rw-info-color: #3498db;
222222+ --html5rw-bg: #ffffff;
223223+ --html5rw-text: #333333;
224224+ --html5rw-border: #dddddd;
225225+ |}
226226+ | `Dark -> {|
227227+ --html5rw-error-color: #ff6b6b;
228228+ --html5rw-warning-color: #feca57;
229229+ --html5rw-info-color: #54a0ff;
230230+ --html5rw-bg: #2d3436;
231231+ --html5rw-text: #dfe6e9;
232232+ --html5rw-border: #636e72;
233233+ |}
234234+ | `Auto -> {|
235235+ --html5rw-error-color: #e74c3c;
236236+ --html5rw-warning-color: #f39c12;
237237+ --html5rw-info-color: #3498db;
238238+ --html5rw-bg: #ffffff;
239239+ --html5rw-text: #333333;
240240+ --html5rw-border: #dddddd;
241241+ |}
242242+ in
243243+ let css = Printf.sprintf {|
244244+ :root { %s }
245245+246246+ @media (prefers-color-scheme: dark) {
247247+ :root {
248248+ --html5rw-error-color: #ff6b6b;
249249+ --html5rw-warning-color: #feca57;
250250+ --html5rw-info-color: #54a0ff;
251251+ --html5rw-bg: #2d3436;
252252+ --html5rw-text: #dfe6e9;
253253+ --html5rw-border: #636e72;
254254+ }
255255+ }
256256+257257+ .html5rw-error {
258258+ outline: 2px solid var(--html5rw-error-color) !important;
259259+ outline-offset: 2px;
260260+ }
261261+262262+ .html5rw-warning {
263263+ outline: 2px solid var(--html5rw-warning-color) !important;
264264+ outline-offset: 2px;
265265+ }
266266+267267+ .html5rw-info {
268268+ outline: 2px solid var(--html5rw-info-color) !important;
269269+ outline-offset: 2px;
270270+ }
271271+272272+ .html5rw-highlighted {
273273+ background-color: rgba(52, 152, 219, 0.3) !important;
274274+ animation: html5rw-pulse 1s ease-in-out;
275275+ }
276276+277277+ @keyframes html5rw-pulse {
278278+ 0%%, 100%% { background-color: rgba(52, 152, 219, 0.3); }
279279+ 50%% { background-color: rgba(52, 152, 219, 0.5); }
280280+ }
281281+282282+ .html5rw-tooltip {
283283+ position: fixed;
284284+ z-index: 100000;
285285+ background: var(--html5rw-bg);
286286+ border: 1px solid var(--html5rw-border);
287287+ border-radius: 6px;
288288+ padding: 8px 12px;
289289+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
290290+ max-width: 400px;
291291+ font-family: system-ui, -apple-system, sans-serif;
292292+ font-size: 13px;
293293+ color: var(--html5rw-text);
294294+ opacity: 0;
295295+ visibility: hidden;
296296+ transition: opacity 0.2s, visibility 0.2s;
297297+ pointer-events: none;
298298+ }
299299+300300+ .html5rw-tooltip-visible {
301301+ opacity: 1;
302302+ visibility: visible;
303303+ }
304304+305305+ .html5rw-tooltip-error .html5rw-tooltip-severity {
306306+ color: var(--html5rw-error-color);
307307+ font-weight: 600;
308308+ margin-right: 8px;
309309+ }
310310+311311+ .html5rw-tooltip-warning .html5rw-tooltip-severity {
312312+ color: var(--html5rw-warning-color);
313313+ font-weight: 600;
314314+ margin-right: 8px;
315315+ }
316316+317317+ .html5rw-tooltip-info .html5rw-tooltip-severity {
318318+ color: var(--html5rw-info-color);
319319+ font-weight: 600;
320320+ margin-right: 8px;
321321+ }
322322+323323+ .html5rw-tooltip > div {
324324+ margin-bottom: 4px;
325325+ }
326326+327327+ .html5rw-tooltip > div:last-child {
328328+ margin-bottom: 0;
329329+ }
330330+ |} theme_vars in
331331+332332+ let doc = G.document in
333333+ let style_el = El.v (Jstr.v "style") [] in
334334+ El.set_children style_el [El.txt' css];
335335+ El.set_at (Jstr.v "data-html5rw-styles") (Some (Jstr.v "true")) style_el;
336336+ El.append_children (Document.head doc) [style_el];
337337+ style_el
338338+339339+let remove_injected_styles style_el =
340340+ El.remove style_el
+166
lib/js/htmlrw_js_annotate.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** DOM annotation for validation warnings.
77+88+ This module applies validation results to the live DOM by adding
99+ data attributes, CSS classes, and tooltip overlays to elements
1010+ that have warnings. *)
1111+1212+open Htmlrw_js_types
1313+1414+1515+(** {1 Annotation} *)
1616+1717+(** Annotate elements in a subtree based on validation results.
1818+1919+ For each message with an element reference, this function:
2020+ 1. Adds data attributes ([data-html5rw-severity], etc.) if configured
2121+ 2. Adds CSS classes ([html5rw-error], etc.) if configured
2222+ 3. Creates tooltip elements if configured
2323+2424+ @param config Annotation configuration.
2525+ @param root The root element to annotate within.
2626+ @param messages The validation messages with element references. *)
2727+val annotate :
2828+ config:annotation_config ->
2929+ root:Brr.El.t ->
3030+ browser_message list ->
3131+ unit
3232+3333+(** Annotate a single element with a message.
3434+3535+ Lower-level function for custom annotation logic. *)
3636+val annotate_element :
3737+ config:annotation_config ->
3838+ Brr.El.t ->
3939+ Htmlrw_check.message ->
4040+ unit
4141+4242+4343+(** {1 Clearing Annotations} *)
4444+4545+(** Remove all annotations from a subtree.
4646+4747+ This removes:
4848+ - All [data-html5rw-*] attributes
4949+ - All [html5rw-*] CSS classes
5050+ - All tooltip elements created by this module *)
5151+val clear : Brr.El.t -> unit
5252+5353+(** Remove annotations from a single element (not descendants). *)
5454+val clear_element : Brr.El.t -> unit
5555+5656+5757+(** {1 Tooltips} *)
5858+5959+(** Tooltip state for an element. *)
6060+type tooltip
6161+6262+(** Create a tooltip for an element.
6363+6464+ The tooltip is not immediately visible; it appears on hover
6565+ if CSS is set up correctly, or can be shown programmatically.
6666+6767+ @param position Where to position the tooltip.
6868+ @param el The element to attach the tooltip to.
6969+ @param messages All messages for this element (may be multiple). *)
7070+val create_tooltip :
7171+ position:[ `Above | `Below | `Auto ] ->
7272+ Brr.El.t ->
7373+ Htmlrw_check.message list ->
7474+ tooltip
7575+7676+(** Show a tooltip immediately. *)
7777+val show_tooltip : tooltip -> unit
7878+7979+(** Hide a tooltip. *)
8080+val hide_tooltip : tooltip -> unit
8181+8282+(** Remove a tooltip from the DOM. *)
8383+val remove_tooltip : tooltip -> unit
8484+8585+(** Get all tooltips created in a subtree. *)
8686+val tooltips_in : Brr.El.t -> tooltip list
8787+8888+8989+(** {1 Highlighting} *)
9090+9191+(** Highlight an element (for click-to-navigate in the panel).
9292+9393+ Adds a temporary visual highlight and scrolls the element into view. *)
9494+val highlight_element : Brr.El.t -> unit
9595+9696+(** Remove highlight from an element. *)
9797+val unhighlight_element : Brr.El.t -> unit
9898+9999+(** Remove all highlights. *)
100100+val clear_highlights : unit -> unit
101101+102102+103103+(** {1 Data Attributes}
104104+105105+ Constants for the data attributes used by annotation. *)
106106+107107+module Data_attr : sig
108108+ (** [data-html5rw-severity] - "error", "warning", or "info" *)
109109+ val severity : Jstr.t
110110+111111+ (** [data-html5rw-message] - The warning message text *)
112112+ val message : Jstr.t
113113+114114+ (** [data-html5rw-code] - The error code *)
115115+ val code : Jstr.t
116116+117117+ (** [data-html5rw-count] - Number of warnings on this element *)
118118+ val count : Jstr.t
119119+end
120120+121121+122122+(** {1 CSS Classes}
123123+124124+ Constants for the CSS classes used by annotation. *)
125125+126126+module Css_class : sig
127127+ (** [html5rw-error] - Element has at least one error *)
128128+ val error : Jstr.t
129129+130130+ (** [html5rw-warning] - Element has warnings but no errors *)
131131+ val warning : Jstr.t
132132+133133+ (** [html5rw-info] - Element has only info messages *)
134134+ val info : Jstr.t
135135+136136+ (** [html5rw-has-issues] - Element has any validation messages *)
137137+ val has_issues : Jstr.t
138138+139139+ (** [html5rw-highlighted] - Element is currently highlighted *)
140140+ val highlighted : Jstr.t
141141+142142+ (** [html5rw-tooltip] - The tooltip container element *)
143143+ val tooltip : Jstr.t
144144+145145+ (** [html5rw-tooltip-visible] - Tooltip is currently visible *)
146146+ val tooltip_visible : Jstr.t
147147+end
148148+149149+150150+(** {1 CSS Injection}
151151+152152+ Optionally inject default styles for annotations. *)
153153+154154+(** Inject default CSS styles for annotations and tooltips.
155155+156156+ Adds a [<style>] element to the document head with styles for:
157157+ - Annotation classes (outlines, backgrounds)
158158+ - Tooltip positioning and appearance
159159+ - Highlight animation
160160+161161+ @param theme Light or dark theme. [`Auto] uses [prefers-color-scheme].
162162+ @return The injected style element (can be removed later). *)
163163+val inject_default_styles : theme:[ `Light | `Dark | `Auto ] -> Brr.El.t
164164+165165+(** Remove the injected style element. *)
166166+val remove_injected_styles : Brr.El.t -> unit
+208
lib/js/htmlrw_js_dom.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Brr
77+88+(* Helper to compare elements using JavaScript strict equality *)
99+let el_equal a b =
1010+ Jv.strict_equal (El.to_jv a) (El.to_jv b)
1111+1212+(* A location-keyed map for finding elements by line/column *)
1313+module LocMap = Map.Make(struct
1414+ type t = int * int
1515+ let compare = compare
1616+end)
1717+1818+type t = {
1919+ root : El.t;
2020+ html_source : string;
2121+ loc_to_el : El.t LocMap.t;
2222+ (* Mapping from (line, column) to browser elements *)
2323+}
2424+2525+let outer_html el =
2626+ Jstr.to_string (Jv.get (El.to_jv el) "outerHTML" |> Jv.to_jstr)
2727+2828+let inner_html el =
2929+ Jstr.to_string (Jv.get (El.to_jv el) "innerHTML" |> Jv.to_jstr)
3030+3131+let iter_elements f root =
3232+ let rec walk el =
3333+ f el;
3434+ List.iter walk (El.children ~only_els:true el)
3535+ in
3636+ walk root
3737+3838+let fold_elements f acc root =
3939+ let rec walk acc el =
4040+ let acc = f acc el in
4141+ List.fold_left walk acc (El.children ~only_els:true el)
4242+ in
4343+ walk acc root
4444+4545+let filter_elements pred root =
4646+ fold_elements (fun acc el ->
4747+ if pred el then el :: acc else acc
4848+ ) [] root |> List.rev
4949+5050+(* Build element map by walking browser DOM and parsed DOM in parallel *)
5151+let create root =
5252+ let raw_html = outer_html root in
5353+ (* Prepend DOCTYPE if not present - outerHTML doesn't include it *)
5454+ let html =
5555+ let lower = String.lowercase_ascii raw_html in
5656+ if String.length lower >= 9 && String.sub lower 0 9 = "<!doctype" then
5757+ raw_html
5858+ else
5959+ "<!DOCTYPE html>" ^ raw_html
6060+ in
6161+6262+ (* Parse the HTML to get a tree with locations *)
6363+ let reader = Bytesrw.Bytes.Reader.of_string html in
6464+ let parsed = Html5rw.parse ~collect_errors:false reader in
6565+6666+ (* Walk both trees in parallel to build the mapping.
6767+ Browser elements are in document order, and so are Html5rw nodes. *)
6868+ let browser_elements = fold_elements (fun acc el -> el :: acc) [] root |> List.rev in
6969+7070+ (* Extract elements from Html5rw DOM in document order *)
7171+ let rec extract_html5rw_elements acc node =
7272+ if Html5rw.is_element node then
7373+ let children = node.Html5rw.Dom.children in
7474+ let acc = node :: acc in
7575+ List.fold_left extract_html5rw_elements acc children
7676+ else
7777+ let children = node.Html5rw.Dom.children in
7878+ List.fold_left extract_html5rw_elements acc children
7979+ in
8080+ let html5rw_elements = extract_html5rw_elements [] (Html5rw.root parsed) |> List.rev in
8181+8282+ (* Build the location map by matching elements *)
8383+ let loc_to_el =
8484+ let rec match_elements loc_map browser_els html5rw_els =
8585+ match browser_els, html5rw_els with
8686+ | [], _ | _, [] -> loc_map
8787+ | b_el :: b_rest, h_el :: h_rest ->
8888+ let b_tag = String.lowercase_ascii (Jstr.to_string (El.tag_name b_el)) in
8989+ let h_tag = String.lowercase_ascii h_el.Html5rw.Dom.name in
9090+ if b_tag = h_tag then
9191+ (* Tags match - record the mapping if we have a location *)
9292+ let loc_map =
9393+ match h_el.Html5rw.Dom.location with
9494+ | Some loc -> LocMap.add (loc.line, loc.column) b_el loc_map
9595+ | None -> loc_map
9696+ in
9797+ match_elements loc_map b_rest h_rest
9898+ else
9999+ (* Tags don't match - try to resync by skipping one side *)
100100+ (* This handles cases where browser might have implicit elements *)
101101+ match_elements loc_map b_rest html5rw_els
102102+ in
103103+ match_elements LocMap.empty browser_elements html5rw_elements
104104+ in
105105+106106+ { root; html_source = html; loc_to_el }, html
107107+108108+let find_by_location t ~line ~column =
109109+ LocMap.find_opt (line, column) t.loc_to_el
110110+111111+let find_by_location_and_tag t ~line ~column ~tag =
112112+ match LocMap.find_opt (line, column) t.loc_to_el with
113113+ | Some el when String.lowercase_ascii (Jstr.to_string (El.tag_name el)) =
114114+ String.lowercase_ascii tag ->
115115+ Some el
116116+ | _ -> None
117117+118118+let find_for_message t msg =
119119+ (* Try to find element by location first *)
120120+ match msg.Htmlrw_check.location with
121121+ | Some loc ->
122122+ (match msg.Htmlrw_check.element with
123123+ | Some tag -> find_by_location_and_tag t ~line:loc.line ~column:loc.column ~tag
124124+ | None -> find_by_location t ~line:loc.line ~column:loc.column)
125125+ | None ->
126126+ (* No location - try to find by element name if we have one *)
127127+ match msg.Htmlrw_check.element with
128128+ | Some tag ->
129129+ (* Find first element with this tag *)
130130+ let matches = filter_elements (fun el ->
131131+ String.lowercase_ascii (Jstr.to_string (El.tag_name el)) =
132132+ String.lowercase_ascii tag
133133+ ) t.root in
134134+ (match matches with
135135+ | el :: _ -> Some el
136136+ | [] -> None)
137137+ | None -> None
138138+139139+let html_source t = t.html_source
140140+141141+let root_element t = t.root
142142+143143+let selector_path ?root el =
144144+ let stop_at = match root with
145145+ | Some r -> Some r
146146+ | None -> None
147147+ in
148148+ let rec build_path el acc =
149149+ (* Stop if we've reached the root *)
150150+ let should_stop = match stop_at with
151151+ | Some r -> el_equal el r
152152+ | None -> String.lowercase_ascii (Jstr.to_string (El.tag_name el)) = "body"
153153+ in
154154+ if should_stop then
155155+ acc
156156+ else
157157+ let tag = String.lowercase_ascii (Jstr.to_string (El.tag_name el)) in
158158+ let segment =
159159+ match El.parent el with
160160+ | None -> tag
161161+ | Some parent ->
162162+ let siblings = El.children ~only_els:true parent in
163163+ let same_tag = List.filter (fun sib ->
164164+ String.lowercase_ascii (Jstr.to_string (El.tag_name sib)) = tag
165165+ ) siblings in
166166+ if List.length same_tag <= 1 then
167167+ tag
168168+ else
169169+ let idx =
170170+ let rec find_idx i = function
171171+ | [] -> 1
172172+ | sib :: rest ->
173173+ if el_equal sib el then i
174174+ else find_idx (i + 1) rest
175175+ in
176176+ find_idx 1 same_tag
177177+ in
178178+ Printf.sprintf "%s:nth-of-type(%d)" tag idx
179179+ in
180180+ let new_acc = segment :: acc in
181181+ match El.parent el with
182182+ | None -> new_acc
183183+ | Some parent -> build_path parent new_acc
184184+ in
185185+ String.concat " > " (build_path el [])
186186+187187+let short_selector ?root el =
188188+ (* Try ID first *)
189189+ match El.at (Jstr.v "id") el with
190190+ | Some id when not (Jstr.is_empty id) ->
191191+ "#" ^ Jstr.to_string id
192192+ | _ ->
193193+ (* Try parent ID + short path *)
194194+ let rec find_id_ancestor el depth =
195195+ if depth > 3 then None
196196+ else match El.parent el with
197197+ | None -> None
198198+ | Some parent ->
199199+ match El.at (Jstr.v "id") parent with
200200+ | Some id when not (Jstr.is_empty id) -> Some (parent, id)
201201+ | _ -> find_id_ancestor parent (depth + 1)
202202+ in
203203+ match find_id_ancestor el 0 with
204204+ | Some (ancestor, id) ->
205205+ let path = selector_path ~root:ancestor el in
206206+ "#" ^ Jstr.to_string id ^ " > " ^ path
207207+ | None ->
208208+ selector_path ?root el
+111
lib/js/htmlrw_js_dom.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** Browser DOM utilities for mapping validation results to live elements.
77+88+ This module bridges the gap between HTML string validation (which produces
99+ line/column locations) and live DOM manipulation (which needs element
1010+ references). It builds mappings between source positions and DOM elements
1111+ by walking both the serialized HTML and the DOM tree in parallel. *)
1212+1313+1414+(** {1 Element Mapping}
1515+1616+ When we validate [element.outerHTML], we get messages with line/column
1717+ positions. To annotate the original DOM, we need to map those positions
1818+ back to the live elements. *)
1919+2020+(** An element map associates source locations with DOM elements. *)
2121+type t
2222+2323+(** Build an element map by walking a DOM element and its serialization.
2424+2525+ This function:
2626+ 1. Serializes the element to HTML via [outerHTML]
2727+ 2. Parses that HTML with Html5rw to get the parse tree with locations
2828+ 3. Walks both trees in parallel to build a bidirectional mapping
2929+3030+ @param root The DOM element to map.
3131+ @return The element map and the HTML source string. *)
3232+val create : Brr.El.t -> t * string
3333+3434+(** Find the DOM element corresponding to a source location.
3535+3636+ @param line 1-indexed line number
3737+ @param column 1-indexed column number
3838+ @return The element at or containing that position, or [None]. *)
3939+val find_by_location : t -> line:int -> column:int -> Brr.El.t option
4040+4141+(** Find the DOM element corresponding to an element name at a location.
4242+4343+ More precise than {!find_by_location} when the validator provides
4444+ the element name along with the location.
4545+4646+ @param line 1-indexed line number
4747+ @param column 1-indexed column number
4848+ @param tag Element tag name (lowercase)
4949+ @return The matching element, or [None]. *)
5050+val find_by_location_and_tag :
5151+ t -> line:int -> column:int -> tag:string -> Brr.El.t option
5252+5353+(** Find the DOM element for a validation message.
5454+5555+ Uses the message's location and element fields to find the best match.
5656+ This is the primary function used by the annotation system. *)
5757+val find_for_message : t -> Htmlrw_check.message -> Brr.El.t option
5858+5959+(** The HTML source string that was used to build this map. *)
6060+val html_source : t -> string
6161+6262+(** The root element this map was built from. *)
6363+val root_element : t -> Brr.El.t
6464+6565+6666+(** {1 CSS Selector Generation} *)
6767+6868+(** Build a CSS selector path that uniquely identifies an element.
6969+7070+ The selector uses child combinators and [:nth-child] to be specific:
7171+ ["body > div.main:nth-child(2) > p > img:nth-child(1)"]
7272+7373+ @param root Optional root element; selector will be relative to this.
7474+ Defaults to [document.body].
7575+ @param el The element to build a selector for.
7676+ @return A CSS selector string. *)
7777+val selector_path : ?root:Brr.El.t -> Brr.El.t -> string
7878+7979+(** Build a shorter selector using IDs and classes when available.
8080+8181+ Tries to find the shortest unique selector:
8282+ 1. If element has an ID: ["#myId"]
8383+ 2. If parent has ID: ["#parentId > .myClass"]
8484+ 3. Falls back to full path from {!selector_path}
8585+8686+ @param root Optional root element.
8787+ @param el The element to build a selector for. *)
8888+val short_selector : ?root:Brr.El.t -> Brr.El.t -> string
8989+9090+9191+(** {1 DOM Iteration} *)
9292+9393+(** Iterate over all elements in document order (depth-first pre-order). *)
9494+val iter_elements : (Brr.El.t -> unit) -> Brr.El.t -> unit
9595+9696+(** Fold over all elements in document order. *)
9797+val fold_elements : ('a -> Brr.El.t -> 'a) -> 'a -> Brr.El.t -> 'a
9898+9999+(** Find all elements matching a predicate. *)
100100+val filter_elements : (Brr.El.t -> bool) -> Brr.El.t -> Brr.El.t list
101101+102102+103103+(** {1 Serialization} *)
104104+105105+(** Get the outer HTML of an element.
106106+107107+ This is a wrapper around the browser's [outerHTML] property. *)
108108+val outer_html : Brr.El.t -> string
109109+110110+(** Get the inner HTML of an element. *)
111111+val inner_html : Brr.El.t -> string
+9
lib/js/htmlrw_js_main.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(* Entry point for the standalone JavaScript build.
77+ This registers the API on window.html5rw when the script loads. *)
88+99+let () = Htmlrw_js.register_global_api ()
+56
lib/js/htmlrw_js_main.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** Entry point for the standalone JavaScript build.
77+88+ This module is compiled to [htmlrw.js] and automatically registers
99+ the validation API on [window.html5rw] when loaded.
1010+1111+ {2 Browser Usage}
1212+1313+ {v
1414+ <script src="htmlrw.js"></script>
1515+ <script>
1616+ // API is available immediately after loading
1717+ const result = html5rw.validateElement(document.body);
1818+1919+ if (result.errorCount > 0) {
2020+ console.log("Found", result.errorCount, "errors");
2121+2222+ // Show the warning panel
2323+ html5rw.showPanel(result);
2424+ }
2525+ </script>
2626+ v}
2727+2828+ {2 Module Bundler Usage}
2929+3030+ If using a bundler that supports CommonJS or ES modules, you can
3131+ import the module instead:
3232+3333+ {v
3434+ import { validateElement, showPanel } from './htmlrw.js';
3535+3636+ const result = validateElement(document.body);
3737+ if (result.hasErrors) {
3838+ showPanel(result);
3939+ }
4040+ v}
4141+4242+ The module exports are set up to work with both import styles.
4343+4444+ {2 API Reference}
4545+4646+ See {!Htmlrw_js} for the full API documentation. The JavaScript API
4747+ mirrors the OCaml API with camelCase naming:
4848+4949+ - [html5rw.validateString(html)] - Validate an HTML string
5050+ - [html5rw.validateElement(el)] - Validate a DOM element
5151+ - [html5rw.validateAndAnnotate(el, config?)] - Validate and annotate
5252+ - [html5rw.showPanel(result, config?)] - Show the warning panel
5353+ - [html5rw.hidePanel()] - Hide the warning panel
5454+ - [html5rw.clearAnnotations(el)] - Clear annotations from an element *)
5555+5656+(* This module has no values; its side effect is registering the API *)
+172
lib/js/htmlrw_js_types.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Brr
77+88+(* Helper to compare elements using JavaScript strict equality *)
99+let el_equal a b =
1010+ Jv.strict_equal (El.to_jv a) (El.to_jv b)
1111+1212+type element_ref = {
1313+ element : El.t option;
1414+ selector : string;
1515+}
1616+1717+type browser_message = {
1818+ message : Htmlrw_check.message;
1919+ element_ref : element_ref option;
2020+}
2121+2222+type result = {
2323+ messages : browser_message list;
2424+ core_result : Htmlrw_check.t;
2525+ source_element : El.t option;
2626+}
2727+2828+type annotation_config = {
2929+ add_data_attrs : bool;
3030+ add_classes : bool;
3131+ show_tooltips : bool;
3232+ tooltip_position : [ `Above | `Below | `Auto ];
3333+ highlight_on_hover : bool;
3434+}
3535+3636+let default_annotation_config = {
3737+ add_data_attrs = true;
3838+ add_classes = true;
3939+ show_tooltips = true;
4040+ tooltip_position = `Auto;
4141+ highlight_on_hover = true;
4242+}
4343+4444+type panel_config = {
4545+ initial_position : [ `TopRight | `TopLeft | `BottomRight | `BottomLeft | `Custom of int * int ];
4646+ draggable : bool;
4747+ resizable : bool;
4848+ collapsible : bool;
4949+ start_collapsed : bool;
5050+ max_height : int option;
5151+ group_by_severity : bool;
5252+ click_to_highlight : bool;
5353+ show_selector_path : bool;
5454+ theme : [ `Light | `Dark | `Auto ];
5555+}
5656+5757+let default_panel_config = {
5858+ initial_position = `TopRight;
5959+ draggable = true;
6060+ resizable = true;
6161+ collapsible = true;
6262+ start_collapsed = false;
6363+ max_height = Some 400;
6464+ group_by_severity = true;
6565+ click_to_highlight = true;
6666+ show_selector_path = true;
6767+ theme = `Auto;
6868+}
6969+7070+let selector_of_element el =
7171+ let rec build_path el acc =
7272+ let tag = Jstr.to_string (El.tag_name el) in
7373+ let id = El.at (Jstr.v "id") el in
7474+ let segment =
7575+ match id with
7676+ | Some id_val when not (Jstr.is_empty id_val) ->
7777+ (* If element has an ID, use it directly *)
7878+ "#" ^ Jstr.to_string id_val
7979+ | _ ->
8080+ (* Otherwise use tag name with nth-child if needed *)
8181+ match El.parent el with
8282+ | None -> tag
8383+ | Some parent ->
8484+ let siblings = El.children ~only_els:true parent in
8585+ let same_tag = List.filter (fun sib ->
8686+ String.lowercase_ascii (Jstr.to_string (El.tag_name sib)) =
8787+ String.lowercase_ascii tag
8888+ ) siblings in
8989+ if List.length same_tag <= 1 then
9090+ tag
9191+ else
9292+ let idx =
9393+ let rec find_idx i = function
9494+ | [] -> 1
9595+ | sib :: rest ->
9696+ if el_equal sib el then i
9797+ else find_idx (i + 1) rest
9898+ in
9999+ find_idx 1 same_tag
100100+ in
101101+ Printf.sprintf "%s:nth-of-type(%d)" tag idx
102102+ in
103103+ let new_acc = segment :: acc in
104104+ (* Stop if we hit an ID (absolute reference) or no parent *)
105105+ if String.length segment > 0 && segment.[0] = '#' then
106106+ new_acc
107107+ else
108108+ match El.parent el with
109109+ | None -> new_acc
110110+ | Some parent ->
111111+ if String.lowercase_ascii (Jstr.to_string (El.tag_name parent)) = "html" then
112112+ new_acc
113113+ else
114114+ build_path parent new_acc
115115+ in
116116+ String.concat " > " (build_path el [])
117117+118118+let browser_message_to_jv bm =
119119+ let msg = bm.message in
120120+ let obj = Jv.obj [||] in
121121+ Jv.set obj "severity" (Jv.of_string (Htmlrw_check.severity_to_string msg.severity));
122122+ Jv.set obj "message" (Jv.of_string msg.text);
123123+ Jv.set obj "errorCode" (Jv.of_string (Htmlrw_check.error_code_to_string msg.error_code));
124124+ (match msg.element with
125125+ | Some el -> Jv.set obj "elementName" (Jv.of_string el)
126126+ | None -> ());
127127+ (match msg.attribute with
128128+ | Some attr -> Jv.set obj "attribute" (Jv.of_string attr)
129129+ | None -> ());
130130+ (match msg.location with
131131+ | Some loc ->
132132+ Jv.set obj "line" (Jv.of_int loc.line);
133133+ Jv.set obj "column" (Jv.of_int loc.column)
134134+ | None -> ());
135135+ (match bm.element_ref with
136136+ | Some ref ->
137137+ Jv.set obj "selector" (Jv.of_string ref.selector);
138138+ (match ref.element with
139139+ | Some el -> Jv.set obj "element" (El.to_jv el)
140140+ | None -> ())
141141+ | None -> ());
142142+ obj
143143+144144+let result_to_jv result =
145145+ let warnings_arr =
146146+ Jv.of_list browser_message_to_jv result.messages
147147+ in
148148+ let error_count =
149149+ List.length (List.filter (fun bm ->
150150+ bm.message.severity = Htmlrw_check.Error
151151+ ) result.messages)
152152+ in
153153+ let warning_count =
154154+ List.length (List.filter (fun bm ->
155155+ bm.message.severity = Htmlrw_check.Warning
156156+ ) result.messages)
157157+ in
158158+ let info_count =
159159+ List.length (List.filter (fun bm ->
160160+ bm.message.severity = Htmlrw_check.Info
161161+ ) result.messages)
162162+ in
163163+ let obj = Jv.obj [||] in
164164+ Jv.set obj "warnings" warnings_arr;
165165+ Jv.set obj "errorCount" (Jv.of_int error_count);
166166+ Jv.set obj "warningCount" (Jv.of_int warning_count);
167167+ Jv.set obj "infoCount" (Jv.of_int info_count);
168168+ Jv.set obj "hasErrors" (Jv.of_bool (error_count > 0));
169169+ (match result.source_element with
170170+ | Some el -> Jv.set obj "sourceElement" (El.to_jv el)
171171+ | None -> ());
172172+ obj
+125
lib/js/htmlrw_js_types.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** Browser-specific types for HTML5rw JavaScript validation.
77+88+ Core validation types ({!Htmlrw_check.severity}, {!Htmlrw_check.message}, etc.)
99+ are reused from the main library. This module adds only the browser-specific
1010+ types needed for DOM element references, annotation, and UI. *)
1111+1212+1313+(** {1 Element References}
1414+1515+ Since we validate HTML strings but want to annotate live DOM elements,
1616+ we need to map validation messages back to browser elements. *)
1717+1818+(** A reference to a DOM element, providing both programmatic access
1919+ and a serializable CSS selector. *)
2020+type element_ref = {
2121+ element : Brr.El.t option;
2222+ (** The live DOM element, if still attached to the document.
2323+ May be [None] if validation was performed on a raw HTML string
2424+ without a source element. *)
2525+2626+ selector : string;
2727+ (** A CSS selector path that uniquely identifies this element.
2828+ Format: ["body > div.container > p:nth-child(3) > img"]
2929+ Useful for logging and re-finding elements. *)
3030+}
3131+3232+(** A validation message paired with its DOM element reference. *)
3333+type browser_message = {
3434+ message : Htmlrw_check.message;
3535+ (** The core validation message with severity, text, error code, etc. *)
3636+3737+ element_ref : element_ref option;
3838+ (** Reference to the problematic DOM element, if identifiable.
3939+ [None] for document-level issues like missing DOCTYPE. *)
4040+}
4141+4242+(** Browser validation result. *)
4343+type result = {
4444+ messages : browser_message list;
4545+ (** All validation messages with element references. *)
4646+4747+ core_result : Htmlrw_check.t;
4848+ (** The underlying validation result from the core library.
4949+ Use for access to {!Htmlrw_check.errors}, {!Htmlrw_check.has_errors}, etc. *)
5050+5151+ source_element : Brr.El.t option;
5252+ (** The root element that was validated, if validation started from an element. *)
5353+}
5454+5555+5656+(** {1 Annotation Configuration} *)
5757+5858+(** Configuration for how warnings are displayed on annotated elements. *)
5959+type annotation_config = {
6060+ add_data_attrs : bool;
6161+ (** Add [data-html5rw-*] attributes to elements:
6262+ - [data-html5rw-severity]: ["error"], ["warning"], or ["info"]
6363+ - [data-html5rw-message]: The warning message text
6464+ - [data-html5rw-code]: The error code *)
6565+6666+ add_classes : bool;
6767+ (** Add CSS classes: [html5rw-error], [html5rw-warning], [html5rw-info],
6868+ and [html5rw-has-issues] on any element with warnings. *)
6969+7070+ show_tooltips : bool;
7171+ (** Create tooltip overlays that appear on hover. *)
7272+7373+ tooltip_position : [ `Above | `Below | `Auto ];
7474+ (** Tooltip position. [`Auto] chooses based on viewport. *)
7575+7676+ highlight_on_hover : bool;
7777+ (** Highlight elements when hovering over warnings in the panel. *)
7878+}
7979+8080+(** Default: all annotation features enabled, tooltips auto-positioned. *)
8181+val default_annotation_config : annotation_config
8282+8383+8484+(** {1 Panel Configuration} *)
8585+8686+(** Configuration for the floating warning panel. *)
8787+type panel_config = {
8888+ initial_position : [ `TopRight | `TopLeft | `BottomRight | `BottomLeft | `Custom of int * int ];
8989+ (** Where the panel appears initially. *)
9090+9191+ draggable : bool;
9292+ resizable : bool;
9393+ collapsible : bool;
9494+ start_collapsed : bool;
9595+9696+ max_height : int option;
9797+ (** Maximum height in pixels before scrolling. *)
9898+9999+ group_by_severity : bool;
100100+ (** Group warnings: errors first, then warnings, then info. *)
101101+102102+ click_to_highlight : bool;
103103+ (** Clicking a warning scrolls to and highlights the element. *)
104104+105105+ show_selector_path : bool;
106106+ (** Show the CSS selector path in each warning row. *)
107107+108108+ theme : [ `Light | `Dark | `Auto ];
109109+ (** Color scheme. [`Auto] follows [prefers-color-scheme]. *)
110110+}
111111+112112+(** Default panel configuration. *)
113113+val default_panel_config : panel_config
114114+115115+116116+(** {1 Conversions} *)
117117+118118+(** Build a CSS selector path for an element. *)
119119+val selector_of_element : Brr.El.t -> string
120120+121121+(** Convert a browser message to a JavaScript object. *)
122122+val browser_message_to_jv : browser_message -> Jv.t
123123+124124+(** Convert a result to a JavaScript object. *)
125125+val result_to_jv : result -> Jv.t
+426
lib/js/htmlrw_js_ui.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Brr
77+open Htmlrw_js_types
88+99+module Css_class = struct
1010+ let panel = Jstr.v "html5rw-panel"
1111+ let panel_header = Jstr.v "html5rw-panel-header"
1212+ let panel_content = Jstr.v "html5rw-panel-content"
1313+ let panel_collapsed = Jstr.v "html5rw-panel-collapsed"
1414+ let panel_dragging = Jstr.v "html5rw-panel-dragging"
1515+ let warning_list = Jstr.v "html5rw-warning-list"
1616+ let warning_row = Jstr.v "html5rw-warning-row"
1717+ let warning_row_error = Jstr.v "html5rw-warning-row-error"
1818+ let warning_row_warning = Jstr.v "html5rw-warning-row-warning"
1919+ let warning_row_info = Jstr.v "html5rw-warning-row-info"
2020+ let severity_badge = Jstr.v "html5rw-severity-badge"
2121+ let message_text = Jstr.v "html5rw-message-text"
2222+ let selector_path = Jstr.v "html5rw-selector-path"
2323+ let collapse_btn = Jstr.v "html5rw-collapse-btn"
2424+ let close_btn = Jstr.v "html5rw-close-btn"
2525+ let summary_badge = Jstr.v "html5rw-summary-badge"
2626+ let error_count = Jstr.v "html5rw-error-count"
2727+ let warning_count = Jstr.v "html5rw-warning-count"
2828+ let theme_light = Jstr.v "html5rw-theme-light"
2929+ let theme_dark = Jstr.v "html5rw-theme-dark"
3030+end
3131+3232+type t = {
3333+ root : El.t;
3434+ header : El.t;
3535+ content : El.t;
3636+ badge : El.t;
3737+ config : panel_config;
3838+ mutable result : result;
3939+ mutable collapsed : bool;
4040+ mutable highlighted : El.t option;
4141+ mutable on_warning_click : (browser_message -> unit) option;
4242+ mutable on_collapse_toggle : (bool -> unit) option;
4343+ mutable on_close : (unit -> unit) option;
4444+ mutable on_move : (int * int -> unit) option;
4545+}
4646+4747+let _current_panel : t option ref = ref None
4848+4949+let current () = !_current_panel
5050+let root_element t = t.root
5151+let header_element t = t.header
5252+let content_element t = t.content
5353+let badge_element t = t.badge
5454+5555+let is_visible t =
5656+ let display = El.computed_style (Jstr.v "display") t.root in
5757+ not (Jstr.equal display (Jstr.v "none"))
5858+5959+let is_collapsed t = t.collapsed
6060+6161+let position t =
6262+ let x = int_of_float (El.bound_x t.root) in
6363+ let y = int_of_float (El.bound_y t.root) in
6464+ (x, y)
6565+6666+let set_position t x y =
6767+ El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) t.root;
6868+ El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) t.root;
6969+ El.set_inline_style (Jstr.v "right") (Jstr.v "auto") t.root
7070+7171+let highlighted_element t = t.highlighted
7272+7373+let clear_highlight t =
7474+ match t.highlighted with
7575+ | Some el ->
7676+ Htmlrw_js_annotate.unhighlight_element el;
7777+ t.highlighted <- None
7878+ | None -> ()
7979+8080+let navigate_to_element t bm =
8181+ clear_highlight t;
8282+ match bm.element_ref with
8383+ | Some { element = Some el; _ } ->
8484+ Htmlrw_js_annotate.highlight_element el;
8585+ t.highlighted <- Some el
8686+ | _ -> ()
8787+8888+let severity_row_class = function
8989+ | Htmlrw_check.Error -> Css_class.warning_row_error
9090+ | Htmlrw_check.Warning -> Css_class.warning_row_warning
9191+ | Htmlrw_check.Info -> Css_class.warning_row_info
9292+9393+let create_warning_row ~config t bm =
9494+ let msg = bm.message in
9595+ let sev = Htmlrw_check.severity_to_string msg.Htmlrw_check.severity in
9696+9797+ let badge = El.v (Jstr.v "span") ~at:[At.class' Css_class.severity_badge] [
9898+ El.txt' (String.uppercase_ascii sev)
9999+ ] in
100100+101101+ let text = El.v (Jstr.v "span") ~at:[At.class' Css_class.message_text] [
102102+ El.txt' msg.Htmlrw_check.text
103103+ ] in
104104+105105+ let children = [badge; text] in
106106+ let children =
107107+ if config.show_selector_path then
108108+ match bm.element_ref with
109109+ | Some ref ->
110110+ let path = El.v (Jstr.v "span") ~at:[At.class' Css_class.selector_path] [
111111+ El.txt' ref.selector
112112+ ] in
113113+ children @ [path]
114114+ | None -> children
115115+ else
116116+ children
117117+ in
118118+119119+ let row = El.v (Jstr.v "div") ~at:[
120120+ At.class' Css_class.warning_row;
121121+ At.class' (severity_row_class msg.Htmlrw_check.severity);
122122+ ] children in
123123+124124+ if config.click_to_highlight then begin
125125+ ignore (Ev.listen Ev.click (fun _ ->
126126+ navigate_to_element t bm;
127127+ match t.on_warning_click with
128128+ | Some f -> f bm
129129+ | None -> ()
130130+ ) (El.as_target row))
131131+ end;
132132+133133+ row
134134+135135+let build_content ~config t =
136136+ let messages =
137137+ if config.group_by_severity then
138138+ let errors, warnings, infos = List.fold_left (fun (e, w, i) bm ->
139139+ match bm.message.Htmlrw_check.severity with
140140+ | Htmlrw_check.Error -> (bm :: e, w, i)
141141+ | Htmlrw_check.Warning -> (e, bm :: w, i)
142142+ | Htmlrw_check.Info -> (e, w, bm :: i)
143143+ ) ([], [], []) t.result.messages in
144144+ List.rev errors @ List.rev warnings @ List.rev infos
145145+ else
146146+ t.result.messages
147147+ in
148148+149149+ let rows = List.map (create_warning_row ~config t) messages in
150150+ let list = El.v (Jstr.v "div") ~at:[At.class' Css_class.warning_list] rows in
151151+152152+ (match config.max_height with
153153+ | Some h ->
154154+ El.set_inline_style (Jstr.v "maxHeight") (Jstr.v (Printf.sprintf "%dpx" h)) list;
155155+ El.set_inline_style (Jstr.v "overflowY") (Jstr.v "auto") list
156156+ | None -> ());
157157+ list
158158+159159+let update t result =
160160+ t.result <- result;
161161+ let list = build_content ~config:t.config t in
162162+ El.set_children t.content [list];
163163+ let error_count = List.length (List.filter (fun bm ->
164164+ bm.message.Htmlrw_check.severity = Htmlrw_check.Error
165165+ ) result.messages) in
166166+ let warning_count = List.length (List.filter (fun bm ->
167167+ bm.message.Htmlrw_check.severity = Htmlrw_check.Warning
168168+ ) result.messages) in
169169+ El.set_children t.badge [
170170+ El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count)
171171+ ]
172172+173173+let collapse t =
174174+ t.collapsed <- true;
175175+ El.set_class Css_class.panel_collapsed true t.root;
176176+ match t.on_collapse_toggle with Some f -> f true | None -> ()
177177+178178+let expand t =
179179+ t.collapsed <- false;
180180+ El.set_class Css_class.panel_collapsed false t.root;
181181+ match t.on_collapse_toggle with Some f -> f false | None -> ()
182182+183183+let toggle_collapsed t =
184184+ if t.collapsed then expand t else collapse t
185185+186186+let show t =
187187+ El.set_inline_style (Jstr.v "display") (Jstr.v "block") t.root
188188+189189+let hide t =
190190+ El.set_inline_style (Jstr.v "display") (Jstr.v "none") t.root
191191+192192+let destroy t =
193193+ El.remove t.root;
194194+ if !_current_panel = Some t then _current_panel := None
195195+196196+let hide_current () =
197197+ match !_current_panel with Some t -> destroy t | None -> ()
198198+199199+let create ~config result =
200200+ hide_current ();
201201+202202+ let _doc = G.document in
203203+204204+ let title = El.v (Jstr.v "span") [El.txt' "HTML5 Validation"] in
205205+206206+ let collapse_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.collapse_btn] [
207207+ El.txt' "_"
208208+ ] in
209209+210210+ let close_btn = El.v (Jstr.v "button") ~at:[At.class' Css_class.close_btn] [
211211+ El.txt' "x"
212212+ ] in
213213+214214+ let header = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_header] [
215215+ title; collapse_btn; close_btn
216216+ ] in
217217+218218+ let error_count = List.length (List.filter (fun bm ->
219219+ bm.message.Htmlrw_check.severity = Htmlrw_check.Error
220220+ ) result.messages) in
221221+ let warning_count = List.length (List.filter (fun bm ->
222222+ bm.message.Htmlrw_check.severity = Htmlrw_check.Warning
223223+ ) result.messages) in
224224+225225+ let badge = El.v (Jstr.v "div") ~at:[At.class' Css_class.summary_badge] [
226226+ El.txt' (Printf.sprintf "%d errors, %d warnings" error_count warning_count)
227227+ ] in
228228+229229+ let content = El.v (Jstr.v "div") ~at:[At.class' Css_class.panel_content] [] in
230230+231231+ let theme_class = match config.theme with
232232+ | `Light -> Css_class.theme_light
233233+ | `Dark -> Css_class.theme_dark
234234+ | `Auto -> Css_class.theme_light
235235+ in
236236+237237+ let root = El.v (Jstr.v "div") ~at:[
238238+ At.class' Css_class.panel;
239239+ At.class' theme_class;
240240+ ] [header; badge; content] in
241241+242242+ (match config.initial_position with
243243+ | `TopRight ->
244244+ El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root;
245245+ El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root
246246+ | `TopLeft ->
247247+ El.set_inline_style (Jstr.v "top") (Jstr.v "20px") root;
248248+ El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root
249249+ | `BottomRight ->
250250+ El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root;
251251+ El.set_inline_style (Jstr.v "right") (Jstr.v "20px") root
252252+ | `BottomLeft ->
253253+ El.set_inline_style (Jstr.v "bottom") (Jstr.v "20px") root;
254254+ El.set_inline_style (Jstr.v "left") (Jstr.v "20px") root
255255+ | `Custom (x, y) ->
256256+ El.set_inline_style (Jstr.v "left") (Jstr.v (Printf.sprintf "%dpx" x)) root;
257257+ El.set_inline_style (Jstr.v "top") (Jstr.v (Printf.sprintf "%dpx" y)) root);
258258+259259+ let t = {
260260+ root; header; content; badge; config; result;
261261+ collapsed = config.start_collapsed;
262262+ highlighted = None;
263263+ on_warning_click = None;
264264+ on_collapse_toggle = None;
265265+ on_close = None;
266266+ on_move = None;
267267+ } in
268268+269269+ update t result;
270270+271271+ ignore (Ev.listen Ev.click (fun _ -> toggle_collapsed t) (El.as_target collapse_btn));
272272+273273+ ignore (Ev.listen Ev.click (fun _ ->
274274+ destroy t;
275275+ match t.on_close with Some f -> f () | None -> ()
276276+ ) (El.as_target close_btn));
277277+278278+ if config.draggable then begin
279279+ let dragging = ref false in
280280+ let offset_x = ref 0.0 in
281281+ let offset_y = ref 0.0 in
282282+283283+ ignore (Ev.listen Ev.mousedown (fun ev ->
284284+ let m = Ev.as_type ev in
285285+ dragging := true;
286286+ offset_x := Ev.Mouse.client_x m -. El.bound_x root;
287287+ offset_y := Ev.Mouse.client_y m -. El.bound_y root;
288288+ El.set_class Css_class.panel_dragging true root
289289+ ) (El.as_target header));
290290+291291+ ignore (Ev.listen Ev.mousemove (fun ev ->
292292+ if !dragging then begin
293293+ let m = Ev.as_type ev in
294294+ let x = int_of_float (Ev.Mouse.client_x m -. !offset_x) in
295295+ let y = int_of_float (Ev.Mouse.client_y m -. !offset_y) in
296296+ set_position t x y;
297297+ match t.on_move with Some f -> f (x, y) | None -> ()
298298+ end
299299+ ) (Window.as_target G.window));
300300+301301+ ignore (Ev.listen Ev.mouseup (fun _ ->
302302+ dragging := false;
303303+ El.set_class Css_class.panel_dragging false root
304304+ ) (Window.as_target G.window))
305305+ end;
306306+307307+ if config.start_collapsed then
308308+ El.set_class Css_class.panel_collapsed true root;
309309+310310+ El.append_children (Document.body G.document) [root];
311311+312312+ _current_panel := Some t;
313313+ t
314314+315315+let on_warning_click t f = t.on_warning_click <- Some f
316316+let on_collapse_toggle t f = t.on_collapse_toggle <- Some f
317317+let on_close t f = t.on_close <- Some f
318318+let on_move t f = t.on_move <- Some f
319319+320320+let inject_default_styles ~theme =
321321+ let theme_vars = match theme with
322322+ | `Light -> {|
323323+ --html5rw-panel-bg: #ffffff;
324324+ --html5rw-panel-text: #333333;
325325+ --html5rw-panel-border: #dddddd;
326326+ --html5rw-panel-header-bg: #f5f5f5;
327327+ |}
328328+ | `Dark -> {|
329329+ --html5rw-panel-bg: #2d3436;
330330+ --html5rw-panel-text: #dfe6e9;
331331+ --html5rw-panel-border: #636e72;
332332+ --html5rw-panel-header-bg: #1e272e;
333333+ |}
334334+ | `Auto -> {|
335335+ --html5rw-panel-bg: #ffffff;
336336+ --html5rw-panel-text: #333333;
337337+ --html5rw-panel-border: #dddddd;
338338+ --html5rw-panel-header-bg: #f5f5f5;
339339+ |}
340340+ in
341341+342342+ let css = Printf.sprintf {|
343343+ :root { %s }
344344+345345+ @media (prefers-color-scheme: dark) {
346346+ :root {
347347+ --html5rw-panel-bg: #2d3436;
348348+ --html5rw-panel-text: #dfe6e9;
349349+ --html5rw-panel-border: #636e72;
350350+ --html5rw-panel-header-bg: #1e272e;
351351+ }
352352+ }
353353+354354+ .html5rw-panel {
355355+ position: fixed;
356356+ z-index: 99999;
357357+ width: 400px;
358358+ background: var(--html5rw-panel-bg);
359359+ border: 1px solid var(--html5rw-panel-border);
360360+ border-radius: 8px;
361361+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
362362+ font-family: system-ui, -apple-system, sans-serif;
363363+ font-size: 13px;
364364+ color: var(--html5rw-panel-text);
365365+ }
366366+367367+ .html5rw-panel-header {
368368+ display: flex;
369369+ align-items: center;
370370+ padding: 12px 16px;
371371+ background: var(--html5rw-panel-header-bg);
372372+ border-bottom: 1px solid var(--html5rw-panel-border);
373373+ border-radius: 8px 8px 0 0;
374374+ cursor: move;
375375+ user-select: none;
376376+ }
377377+378378+ .html5rw-panel-header span { flex: 1; font-weight: 600; }
379379+380380+ .html5rw-panel-header button {
381381+ width: 24px; height: 24px; margin-left: 8px;
382382+ border: none; border-radius: 4px;
383383+ background: transparent; color: var(--html5rw-panel-text);
384384+ cursor: pointer; font-size: 14px; line-height: 1;
385385+ }
386386+387387+ .html5rw-panel-header button:hover { background: rgba(0, 0, 0, 0.1); }
388388+ .html5rw-panel-content { padding: 0; }
389389+ .html5rw-panel-collapsed .html5rw-panel-content { display: none; }
390390+ .html5rw-panel-collapsed .html5rw-summary-badge { display: block; }
391391+ .html5rw-summary-badge { display: none; padding: 12px 16px; text-align: center; font-weight: 500; }
392392+ .html5rw-warning-list { max-height: 400px; overflow-y: auto; }
393393+394394+ .html5rw-warning-row {
395395+ display: flex; flex-direction: column;
396396+ padding: 10px 16px;
397397+ border-bottom: 1px solid var(--html5rw-panel-border);
398398+ cursor: pointer; transition: background 0.15s;
399399+ }
400400+401401+ .html5rw-warning-row:hover { background: rgba(0, 0, 0, 0.05); }
402402+ .html5rw-warning-row:last-child { border-bottom: none; }
403403+404404+ .html5rw-severity-badge {
405405+ display: inline-block; padding: 2px 6px; border-radius: 3px;
406406+ font-size: 10px; font-weight: 600; text-transform: uppercase; margin-right: 8px;
407407+ }
408408+409409+ .html5rw-warning-row-error .html5rw-severity-badge { background: #e74c3c; color: white; }
410410+ .html5rw-warning-row-warning .html5rw-severity-badge { background: #f39c12; color: white; }
411411+ .html5rw-warning-row-info .html5rw-severity-badge { background: #3498db; color: white; }
412412+ .html5rw-message-text { flex: 1; line-height: 1.4; }
413413+414414+ .html5rw-selector-path {
415415+ display: block; margin-top: 4px; font-size: 11px; color: #888;
416416+ font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
417417+ }
418418+419419+ .html5rw-panel-dragging { opacity: 0.9; }
420420+ |} theme_vars in
421421+422422+ let doc = G.document in
423423+ let style_el = El.v (Jstr.v "style") [El.txt' css] in
424424+ El.set_at (Jstr.v "data-html5rw-panel-styles") (Some (Jstr.v "true")) style_el;
425425+ El.append_children (Document.head doc) [style_el];
426426+ style_el
+169
lib/js/htmlrw_js_ui.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** Floating warning panel UI.
77+88+ This module creates and manages a draggable, floating panel that displays
99+ validation warnings. The panel supports:
1010+ - Grouping by severity (errors first)
1111+ - Click-to-navigate to problematic elements
1212+ - Collapse/expand functionality
1313+ - Light/dark themes *)
1414+1515+open Htmlrw_js_types
1616+1717+1818+(** {1 Panel Management} *)
1919+2020+(** The warning panel. *)
2121+type t
2222+2323+(** Create and display a warning panel.
2424+2525+ The panel is appended to [document.body] and positioned according
2626+ to the configuration.
2727+2828+ @param config Panel configuration.
2929+ @param result Validation result to display. *)
3030+val create : config:panel_config -> result -> t
3131+3232+(** Update the panel with new validation results.
3333+3434+ Use this to re-validate and refresh the panel without destroying it. *)
3535+val update : t -> result -> unit
3636+3737+(** Show the panel if hidden. *)
3838+val show : t -> unit
3939+4040+(** Hide the panel (but keep it in the DOM). *)
4141+val hide : t -> unit
4242+4343+(** Remove the panel from the DOM entirely. *)
4444+val destroy : t -> unit
4545+4646+(** Check if the panel is currently visible. *)
4747+val is_visible : t -> bool
4848+4949+(** Check if the panel is currently collapsed. *)
5050+val is_collapsed : t -> bool
5151+5252+5353+(** {1 Panel State} *)
5454+5555+(** Collapse the panel to just show the summary badge. *)
5656+val collapse : t -> unit
5757+5858+(** Expand the panel to show the full warning list. *)
5959+val expand : t -> unit
6060+6161+(** Toggle collapsed state. *)
6262+val toggle_collapsed : t -> unit
6363+6464+(** Get the current position of the panel. *)
6565+val position : t -> int * int
6666+6767+(** Move the panel to a new position. *)
6868+val set_position : t -> int -> int -> unit
6969+7070+7171+(** {1 Interaction} *)
7272+7373+(** Scroll to and highlight an element from a warning row.
7474+7575+ This is called internally when clicking a warning, but can be
7676+ invoked programmatically. *)
7777+val navigate_to_element : t -> browser_message -> unit
7878+7979+(** Get the currently highlighted element, if any. *)
8080+val highlighted_element : t -> Brr.El.t option
8181+8282+(** Clear the current highlight. *)
8383+val clear_highlight : t -> unit
8484+8585+8686+(** {1 Event Callbacks}
8787+8888+ Register callbacks for panel events. *)
8989+9090+(** Called when a warning row is clicked. *)
9191+val on_warning_click : t -> (browser_message -> unit) -> unit
9292+9393+(** Called when the panel is collapsed or expanded. *)
9494+val on_collapse_toggle : t -> (bool -> unit) -> unit
9595+9696+(** Called when the panel is closed. *)
9797+val on_close : t -> (unit -> unit) -> unit
9898+9999+(** Called when the panel is dragged to a new position. *)
100100+val on_move : t -> (int * int -> unit) -> unit
101101+102102+103103+(** {1 Global Panel State}
104104+105105+ For convenience, there's a single "current" panel that the
106106+ JavaScript API manages. *)
107107+108108+(** Get the current panel, if one exists. *)
109109+val current : unit -> t option
110110+111111+(** Hide and destroy the current panel. *)
112112+val hide_current : unit -> unit
113113+114114+115115+(** {1 Panel Elements}
116116+117117+ Access to the panel's DOM structure for custom styling. *)
118118+119119+(** The root panel element. *)
120120+val root_element : t -> Brr.El.t
121121+122122+(** The header element (contains title and controls). *)
123123+val header_element : t -> Brr.El.t
124124+125125+(** The content element (contains warning list). *)
126126+val content_element : t -> Brr.El.t
127127+128128+(** The summary badge element (shown when collapsed). *)
129129+val badge_element : t -> Brr.El.t
130130+131131+132132+(** {1 CSS Classes}
133133+134134+ Classes used by the panel for custom styling. *)
135135+136136+module Css_class : sig
137137+ val panel : Jstr.t
138138+ val panel_header : Jstr.t
139139+ val panel_content : Jstr.t
140140+ val panel_collapsed : Jstr.t
141141+ val panel_dragging : Jstr.t
142142+ val warning_list : Jstr.t
143143+ val warning_row : Jstr.t
144144+ val warning_row_error : Jstr.t
145145+ val warning_row_warning : Jstr.t
146146+ val warning_row_info : Jstr.t
147147+ val severity_badge : Jstr.t
148148+ val message_text : Jstr.t
149149+ val selector_path : Jstr.t
150150+ val collapse_btn : Jstr.t
151151+ val close_btn : Jstr.t
152152+ val summary_badge : Jstr.t
153153+ val error_count : Jstr.t
154154+ val warning_count : Jstr.t
155155+ val theme_light : Jstr.t
156156+ val theme_dark : Jstr.t
157157+end
158158+159159+160160+(** {1 CSS Injection} *)
161161+162162+(** Inject default CSS styles for the panel.
163163+164164+ Styles include layout, colors, shadows, and animations.
165165+ The styles are scoped to the panel's CSS classes.
166166+167167+ @param theme Color scheme to use.
168168+ @return The injected style element. *)
169169+val inject_default_styles : theme:[ `Light | `Dark | `Auto ] -> Brr.El.t
+151
lib/js/htmlrw_js_worker.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(* Web Worker entry point for background HTML validation.
77+88+ This runs in a separate thread and communicates via postMessage.
99+ It only does string-based validation since workers can't access the DOM.
1010+*)
1111+1212+[@@@warning "-33"] (* Suppress unused open - we only need Jv from Brr *)
1313+open Brr
1414+1515+let console_log msg =
1616+ ignore (Jv.call (Jv.get Jv.global "console") "log" [| Jv.of_string msg |])
1717+1818+let console_error msg =
1919+ ignore (Jv.call (Jv.get Jv.global "console") "error" [| Jv.of_string msg |])
2020+2121+let ensure_doctype html =
2222+ let lower = String.lowercase_ascii html in
2323+ if String.length lower >= 9 && String.sub lower 0 9 = "<!doctype" then
2424+ html
2525+ else
2626+ "<!DOCTYPE html>" ^ html
2727+2828+(* Debug: dump tree structure to see what parser built *)
2929+let dump_tree_structure html =
3030+ let doc = Html5rw.parse (Bytesrw.Bytes.Reader.of_string html) in
3131+ let root = Html5rw.root doc in
3232+ let buf = Buffer.create 1024 in
3333+ let rec dump indent node =
3434+ let prefix = String.make (indent * 2) ' ' in
3535+ let name = node.Html5rw.Dom.name in
3636+ if name = "#text" then begin
3737+ let text = String.trim node.Html5rw.Dom.data in
3838+ if String.length text > 0 then
3939+ Buffer.add_string buf (Printf.sprintf "%s#text: \"%s\"\n" prefix
4040+ (if String.length text > 30 then String.sub text 0 30 ^ "..." else text))
4141+ end else if name = "#comment" then
4242+ ()
4343+ else begin
4444+ Buffer.add_string buf (Printf.sprintf "%s<%s>\n" prefix name);
4545+ if indent < 5 then (* only show first 5 levels *)
4646+ List.iter (dump (indent + 1)) node.Html5rw.Dom.children
4747+ end
4848+ in
4949+ dump 0 root;
5050+ Buffer.contents buf
5151+5252+let handle_message msg_data =
5353+ console_log "[html5rw worker] Message received";
5454+ let response = Jv.obj [||] in
5555+ try
5656+ let id = Jv.get msg_data "id" |> Jv.to_int in
5757+ let raw_html = Jv.get msg_data "html" |> Jv.to_string in
5858+ let html = ensure_doctype raw_html in
5959+ console_log (Printf.sprintf "[html5rw worker] Validating %d bytes (id=%d)" (String.length html) id);
6060+ (* Log first 500 chars of HTML for debugging *)
6161+ let preview = if String.length html > 500 then String.sub html 0 500 ^ "..." else html in
6262+ console_log (Printf.sprintf "[html5rw worker] HTML preview:\n%s" preview);
6363+6464+ Jv.set response "id" (Jv.of_int id);
6565+6666+ (try
6767+ (* Run validation *)
6868+ let core_result = Htmlrw_check.check_string html in
6969+ let messages = Htmlrw_check.messages core_result in
7070+7171+ (* Convert messages to JS-friendly format *)
7272+ let warnings = Jv.of_list (fun msg ->
7373+ let obj = Jv.obj [||] in
7474+ Jv.set obj "severity" (Jv.of_string (Htmlrw_check.severity_to_string msg.Htmlrw_check.severity));
7575+ Jv.set obj "message" (Jv.of_string msg.Htmlrw_check.text);
7676+ Jv.set obj "errorCode" (Jv.of_string (Htmlrw_check.error_code_to_string msg.Htmlrw_check.error_code));
7777+ (match msg.Htmlrw_check.element with
7878+ | Some el -> Jv.set obj "elementName" (Jv.of_string el)
7979+ | None -> ());
8080+ (match msg.Htmlrw_check.attribute with
8181+ | Some attr -> Jv.set obj "attribute" (Jv.of_string attr)
8282+ | None -> ());
8383+ (match msg.Htmlrw_check.location with
8484+ | Some loc ->
8585+ Jv.set obj "line" (Jv.of_int loc.line);
8686+ Jv.set obj "column" (Jv.of_int loc.column)
8787+ | None -> ());
8888+ obj
8989+ ) messages in
9090+9191+ let error_count = List.length (List.filter (fun m ->
9292+ m.Htmlrw_check.severity = Htmlrw_check.Error) messages) in
9393+ let warning_count = List.length (List.filter (fun m ->
9494+ m.Htmlrw_check.severity = Htmlrw_check.Warning) messages) in
9595+ let info_count = List.length (List.filter (fun m ->
9696+ m.Htmlrw_check.severity = Htmlrw_check.Info) messages) in
9797+9898+ Jv.set response "warnings" warnings;
9999+ Jv.set response "errorCount" (Jv.of_int error_count);
100100+ Jv.set response "warningCount" (Jv.of_int warning_count);
101101+ Jv.set response "infoCount" (Jv.of_int info_count);
102102+ Jv.set response "hasErrors" (Jv.of_bool (error_count > 0));
103103+ (* Add tree structure for debugging *)
104104+ let tree_dump = dump_tree_structure html in
105105+ Jv.set response "treeStructure" (Jv.of_string tree_dump);
106106+ Jv.set response "htmlPreview" (Jv.of_string preview);
107107+ console_log (Printf.sprintf "[html5rw worker] Tree structure:\n%s" tree_dump)
108108+ with exn ->
109109+ (* Return error on parse failure *)
110110+ let error_obj = Jv.obj [||] in
111111+ Jv.set error_obj "severity" (Jv.of_string "error");
112112+ Jv.set error_obj "message" (Jv.of_string (Printf.sprintf "Parse error: %s" (Printexc.to_string exn)));
113113+ Jv.set error_obj "errorCode" (Jv.of_string "parse-error");
114114+ Jv.set response "warnings" (Jv.of_list Fun.id [error_obj]);
115115+ Jv.set response "errorCount" (Jv.of_int 1);
116116+ Jv.set response "warningCount" (Jv.of_int 0);
117117+ Jv.set response "infoCount" (Jv.of_int 0);
118118+ Jv.set response "hasErrors" (Jv.of_bool true);
119119+ Jv.set response "parseError" (Jv.of_string (Printexc.to_string exn)));
120120+121121+ console_log "[html5rw worker] Validation complete, posting response";
122122+ (* Post result back to main thread *)
123123+ let self = Jv.get Jv.global "self" in
124124+ ignore (Jv.call self "postMessage" [| response |])
125125+ with exn ->
126126+ (* Outer error handler - catches message parsing errors *)
127127+ console_error (Printf.sprintf "[html5rw worker] Fatal error: %s" (Printexc.to_string exn));
128128+ let error_obj = Jv.obj [||] in
129129+ Jv.set error_obj "severity" (Jv.of_string "error");
130130+ Jv.set error_obj "message" (Jv.of_string (Printf.sprintf "Worker error: %s" (Printexc.to_string exn)));
131131+ Jv.set error_obj "errorCode" (Jv.of_string "worker-error");
132132+ Jv.set response "id" (Jv.of_int (-1));
133133+ Jv.set response "warnings" (Jv.of_list Fun.id [error_obj]);
134134+ Jv.set response "errorCount" (Jv.of_int 1);
135135+ Jv.set response "warningCount" (Jv.of_int 0);
136136+ Jv.set response "infoCount" (Jv.of_int 0);
137137+ Jv.set response "hasErrors" (Jv.of_bool true);
138138+ Jv.set response "fatalError" (Jv.of_string (Printexc.to_string exn));
139139+ let self = Jv.get Jv.global "self" in
140140+ ignore (Jv.call self "postMessage" [| response |])
141141+142142+let () =
143143+ console_log "[html5rw worker] Worker script starting...";
144144+ (* Set up message handler *)
145145+ let self = Jv.get Jv.global "self" in
146146+ let handler = Jv.callback ~arity:1 (fun ev ->
147147+ let data = Jv.get ev "data" in
148148+ handle_message data
149149+ ) in
150150+ ignore (Jv.call self "addEventListener" [| Jv.of_string "message"; handler |]);
151151+ console_log "[html5rw worker] Message handler registered, ready for messages"