(** Ruby element content model validation checker. Validates that: - Ruby contains at least one rt element - Ruby contains phrasing content before rt elements *) type ruby_info = { mutable has_rt : bool; mutable has_content_before_rt : bool; mutable saw_rt : bool; (* Whether we've seen rt yet *) mutable depth : int; (* Track nesting level *) } type state = { mutable ruby_stack : ruby_info list; (* Stack for nested ruby elements *) mutable in_template : int; } let create () = { ruby_stack = []; in_template = 0; } let reset state = state.ruby_stack <- []; state.in_template <- 0 (** Check if element is phrasing content that can appear before rt *) let is_phrasing_content tag = match tag with | Tag.Html `Rt | Tag.Html `Rp -> false | _ -> true let start_element state ~element _collector = match element.Element.tag with | Tag.Html `Template -> state.in_template <- state.in_template + 1 | Tag.Html `Ruby when state.in_template = 0 -> (* Push new ruby context *) let info = { has_rt = false; has_content_before_rt = false; saw_rt = false; depth = 1; (* Set depth to 1 for the ruby element itself *) } in state.ruby_stack <- info :: state.ruby_stack | tag when state.in_template = 0 -> (match state.ruby_stack with | info :: _ -> (* Inside a ruby element *) if info.depth = 1 then begin (* Direct children of ruby *) match tag with | Tag.Html `Rt -> info.has_rt <- true; info.saw_rt <- true | _ when is_phrasing_content tag -> if not info.saw_rt then info.has_content_before_rt <- true | _ -> () end; info.depth <- info.depth + 1 | [] -> ()) | _ -> () (* In template or non-HTML element *) let end_element state ~tag collector = match tag with | Tag.Html `Template when state.in_template > 0 -> state.in_template <- state.in_template - 1 | Tag.Html `Ruby when state.in_template = 0 -> (match state.ruby_stack with | info :: rest -> info.depth <- info.depth - 1; (* Check if this is the closing ruby tag (depth becomes 0 when ruby closes) *) if info.depth <= 0 then begin (* Closing ruby element - validate *) if not info.has_rt then (* Empty ruby or ruby without any rt - needs rp or rt *) Message_collector.add_typed collector (`Element (`Missing_child_one_of (`Parent "ruby", `Children ["rp"; "rt"]))) else if not info.has_content_before_rt then (* Has rt but missing content before it - needs content *) Message_collector.add_typed collector (`Element (`Missing_child (`Parent "ruby", `Child "rt"))); state.ruby_stack <- rest end | [] -> ()) | _ when state.in_template = 0 -> (match state.ruby_stack with | info :: _ -> info.depth <- info.depth - 1 | [] -> ()) | _ -> () (* In template or non-HTML element *) let characters state text _collector = (* Text content counts as phrasing content before rt *) if state.in_template > 0 then () else begin match state.ruby_stack with | info :: _ -> if info.depth = 1 then begin (* Direct text child of ruby *) let has_non_whitespace = String.exists (fun c -> c <> ' ' && c <> '\t' && c <> '\n' && c <> '\r' ) text in if has_non_whitespace && not info.saw_rt then info.has_content_before_rt <- true end | [] -> () end let checker = Checker.make ~create ~reset ~start_element ~end_element ~characters ()