···2727 mutable children_in_picture : string list;
2828 mutable last_was_img : bool;
2929 mutable has_source_after_img : bool;
3030+ mutable has_always_matching_source : bool; (* source without media/type *)
3131+ mutable source_after_always_matching : bool; (* source after always-matching source *)
3032}
31333234let create () = {
···3638 children_in_picture = [];
3739 last_was_img = false;
3840 has_source_after_img = false;
4141+ has_always_matching_source = false;
4242+ source_after_always_matching = false;
3943}
40444145let reset state =
···4448 state.picture_depth <- 0;
4549 state.children_in_picture <- [];
4650 state.last_was_img <- false;
4747- state.has_source_after_img <- false
5151+ state.has_source_after_img <- false;
5252+ state.has_always_matching_source <- false;
5353+ state.source_after_always_matching <- false
48544955(** Check if an attribute list contains a specific attribute. *)
5056let has_attr name attrs =
···109115 state.picture_depth <- 0; (* Will be incremented to 1 at end of function *)
110116 state.children_in_picture <- [];
111117 state.last_was_img <- false;
112112- state.has_source_after_img <- false
118118+ state.has_source_after_img <- false;
119119+ state.has_always_matching_source <- false;
120120+ state.source_after_always_matching <- false
113121114122 | "source" when state.in_picture && state.picture_depth = 1 ->
115123 check_source_attrs_in_picture attrs collector;
116124 state.children_in_picture <- "source" :: state.children_in_picture;
117125 if state.last_was_img then
118118- state.has_source_after_img <- true
126126+ state.has_source_after_img <- true;
127127+ (* Check for always-matching source followed by another source *)
128128+ if state.has_always_matching_source then
129129+ state.source_after_always_matching <- true;
130130+ (* A source is "always matching" if it has:
131131+ - no media and no type attribute, OR
132132+ - media attribute with empty/whitespace-only value, OR
133133+ - media="all" (with optional whitespace) *)
134134+ let media_value = List.find_map (fun (attr_name, v) ->
135135+ if String.lowercase_ascii attr_name = "media" then Some v else None
136136+ ) attrs in
137137+ let has_type = has_attr "type" attrs in
138138+ let is_always_matching = match media_value with
139139+ | None -> not has_type (* no media, check if no type either *)
140140+ | Some v ->
141141+ let trimmed = String.trim v in
142142+ trimmed = "" || String.lowercase_ascii trimmed = "all"
143143+ in
144144+ if is_always_matching then
145145+ state.has_always_matching_source <- true
119146120147 | "img" when state.in_picture && state.picture_depth = 1 ->
121148 check_img_attrs attrs collector;
···162189 (* Check for source after img *)
163190 if state.has_source_after_img then
164191 report_disallowed_child "picture" "source" collector;
192192+ (* Check for source after always-matching source *)
193193+ if state.source_after_always_matching then
194194+ Message_collector.add_error collector
195195+ ~message:"A \xe2\x80\x9csource\xe2\x80\x9d element that matches all media types cannot be followed by another \xe2\x80\x9csource\xe2\x80\x9d element."
196196+ ~code:"always-matching-source"
197197+ ~element:"source" ();
165198166199 state.in_picture <- false
167200 end
···11+(** Srcset and sizes attribute validation checker. *)
22+33+(** Valid CSS length units for sizes attribute *)
44+let valid_length_units = [
55+ "em"; "ex"; "ch"; "rem"; "cap"; "ic";
66+ "vw"; "svw"; "lvw"; "dvw"; "vh"; "svh"; "lvh"; "dvh";
77+ "vi"; "svi"; "lvi"; "dvi"; "vb"; "svb"; "lvb"; "dvb";
88+ "vmin"; "svmin"; "lvmin"; "dvmin"; "vmax"; "svmax"; "lvmax"; "dvmax";
99+ "cm"; "mm"; "q"; "in"; "pc"; "pt"; "px"
1010+]
1111+1212+type state = unit
1313+1414+let create () = ()
1515+let reset _state = ()
1616+1717+(** Get attribute value *)
1818+let get_attr name attrs =
1919+ List.find_map (fun (n, v) ->
2020+ if String.lowercase_ascii n = name then Some v else None
2121+ ) attrs
2222+2323+(** Check if string contains only whitespace *)
2424+let is_whitespace_only s =
2525+ String.for_all (fun c -> c = ' ' || c = '\t' || c = '\n' || c = '\r') s
2626+2727+(** Invalid units that are not CSS lengths but might be confused for them *)
2828+let invalid_size_units = [
2929+ "deg"; "grad"; "rad"; "turn"; (* angle units *)
3030+ "s"; "ms"; (* time units *)
3131+ "hz"; "khz"; (* frequency units *)
3232+ "dpi"; "dpcm"; "dppx"; (* resolution units *)
3333+ "%" (* percentage - not valid in sizes *)
3434+]
3535+3636+(** Strip CSS comments from a value *)
3737+let strip_css_comments s =
3838+ let buf = Buffer.create (String.length s) in
3939+ let len = String.length s in
4040+ let i = ref 0 in
4141+ while !i < len do
4242+ if !i + 1 < len && s.[!i] = '/' && s.[!i + 1] = '*' then begin
4343+ (* Start of comment, find end *)
4444+ i := !i + 2;
4545+ while !i + 1 < len && not (s.[!i] = '*' && s.[!i + 1] = '/') do
4646+ incr i
4747+ done;
4848+ if !i + 1 < len then i := !i + 2
4949+ end else begin
5050+ Buffer.add_char buf s.[!i];
5151+ incr i
5252+ end
5353+ done;
5454+ Buffer.contents buf
5555+5656+(** Check if a size value has a valid CSS length unit and non-negative value *)
5757+type size_check_result = Valid | InvalidUnit | NegativeValue
5858+5959+let check_size_value size_value =
6060+ let trimmed = String.trim (strip_css_comments size_value) in
6161+ if trimmed = "" then InvalidUnit
6262+ else if trimmed = "auto" then Valid (* "auto" is valid *)
6363+ else begin
6464+ let lower = String.lowercase_ascii trimmed in
6565+ (* Check for invalid units first *)
6666+ let has_invalid = List.exists (fun unit ->
6767+ let len = String.length unit in
6868+ String.length lower > len &&
6969+ String.sub lower (String.length lower - len) len = unit
7070+ ) invalid_size_units in
7171+ if has_invalid then InvalidUnit
7272+ else begin
7373+ (* Check for valid CSS length units *)
7474+ let has_valid_unit = List.exists (fun unit ->
7575+ let len = String.length unit in
7676+ String.length lower > len &&
7777+ String.sub lower (String.length lower - len) len = unit
7878+ ) valid_length_units in
7979+ if has_valid_unit then begin
8080+ (* Check if it's negative (starts with - but not -0) *)
8181+ if String.length trimmed > 0 && trimmed.[0] = '-' then begin
8282+ (* Check if it's -0 which is valid *)
8383+ let after_minus = String.sub trimmed 1 (String.length trimmed - 1) in
8484+ let after_minus_stripped = String.trim (strip_css_comments after_minus) in
8585+ try
8686+ let num_str = Str.global_replace (Str.regexp "[a-zA-Z]+$") "" after_minus_stripped in
8787+ let f = float_of_string num_str in
8888+ if f = 0.0 then Valid else NegativeValue
8989+ with _ -> NegativeValue
9090+ end else
9191+ Valid
9292+ end
9393+ (* Could be calc() or other CSS functions - allow those *)
9494+ else if String.contains trimmed '(' then Valid
9595+ else begin
9696+ (* Check if it's a zero value (0, -0, +0) - these are valid without units *)
9797+ let stripped =
9898+ let s = trimmed in
9999+ let s = if String.length s > 0 && (s.[0] = '+' || s.[0] = '-') then String.sub s 1 (String.length s - 1) else s in
100100+ s
101101+ in
102102+ (* Check if it's zero or a numeric value starting with 0 *)
103103+ try
104104+ let f = float_of_string stripped in
105105+ if f = 0.0 then Valid else InvalidUnit
106106+ with _ -> InvalidUnit
107107+ end
108108+ end
109109+ end
110110+111111+let has_valid_size_unit size_value =
112112+ match check_size_value size_value with
113113+ | Valid -> true
114114+ | InvalidUnit | NegativeValue -> false
115115+116116+(** Check if a sizes entry has a media condition (starts with '(') *)
117117+let has_media_condition entry =
118118+ let trimmed = String.trim entry in
119119+ String.length trimmed > 0 && trimmed.[0] = '('
120120+121121+(** Extract the size value from a sizes entry (after media condition if any) *)
122122+let extract_size_value entry =
123123+ let trimmed = String.trim entry in
124124+ if not (has_media_condition trimmed) then
125125+ trimmed
126126+ else begin
127127+ (* Find matching closing paren, then get the size value after it *)
128128+ let len = String.length trimmed in
129129+ let rec find_close_paren i depth =
130130+ if i >= len then len
131131+ else match trimmed.[i] with
132132+ | '(' -> find_close_paren (i + 1) (depth + 1)
133133+ | ')' -> if depth = 1 then i + 1 else find_close_paren (i + 1) (depth - 1)
134134+ | _ -> find_close_paren (i + 1) depth
135135+ in
136136+ let after_paren = find_close_paren 0 0 in
137137+ if after_paren >= len then ""
138138+ else String.trim (String.sub trimmed after_paren (len - after_paren))
139139+ end
140140+141141+(** Validate sizes attribute value *)
142142+let validate_sizes value element_name collector =
143143+ (* Empty sizes is invalid *)
144144+ if String.trim value = "" then begin
145145+ Message_collector.add_error collector
146146+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c\xe2\x80\x9d for attribute \xe2\x80\x9csizes\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad source size list: Must not be empty." element_name)
147147+ ~code:"bad-sizes-value"
148148+ ~element:element_name ~attribute:"sizes" ();
149149+ false
150150+ end else begin
151151+ (* Split on comma and check each entry *)
152152+ let entries = String.split_on_char ',' value in
153153+ let first_entry = String.trim (List.hd entries) in
154154+155155+ (* Check if starts with comma (empty first entry) *)
156156+ if first_entry = "" then begin
157157+ Message_collector.add_error collector
158158+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csizes\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad source size list: Starts with empty source size." value element_name)
159159+ ~code:"bad-sizes-value"
160160+ ~element:element_name ~attribute:"sizes" ();
161161+ false
162162+ end else begin
163163+ (* Check for trailing comma *)
164164+ let last_entry = String.trim (List.nth entries (List.length entries - 1)) in
165165+ if List.length entries > 1 && last_entry = "" then begin
166166+ Message_collector.add_error collector
167167+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csizes\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad source size list: Ends with trailing comma." value element_name)
168168+ ~code:"bad-sizes-value"
169169+ ~element:element_name ~attribute:"sizes" ();
170170+ false
171171+ end else begin
172172+ let valid = ref true in
173173+174174+ (* Check for default-first pattern: unconditional value before conditional ones *)
175175+ let non_empty_entries = List.filter (fun e -> String.trim e <> "") entries in
176176+ if List.length non_empty_entries > 1 then begin
177177+ let first = List.hd non_empty_entries in
178178+ let rest = List.tl non_empty_entries in
179179+ (* If first entry has no media condition but later ones do, that's invalid *)
180180+ if not (has_media_condition first) && List.exists has_media_condition rest then begin
181181+ Message_collector.add_error collector
182182+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csizes\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad source size list: Default size must be last." value element_name)
183183+ ~code:"bad-sizes-value"
184184+ ~element:element_name ~attribute:"sizes" ();
185185+ valid := false
186186+ end
187187+ end;
188188+189189+ (* Validate each entry's size value has valid unit and is not negative *)
190190+ List.iter (fun entry ->
191191+ let trimmed = String.trim entry in
192192+ if trimmed <> "" then begin
193193+ let size_val = extract_size_value trimmed in
194194+ if size_val <> "" then begin
195195+ match check_size_value size_val with
196196+ | Valid -> ()
197197+ | NegativeValue ->
198198+ Message_collector.add_error collector
199199+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csizes\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Source size value cannot be negative." value element_name)
200200+ ~code:"bad-sizes-value"
201201+ ~element:element_name ~attribute:"sizes" ();
202202+ valid := false
203203+ | InvalidUnit ->
204204+ Message_collector.add_error collector
205205+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csizes\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad source size value." value element_name)
206206+ ~code:"bad-sizes-value"
207207+ ~element:element_name ~attribute:"sizes" ();
208208+ valid := false
209209+ end
210210+ end
211211+ ) entries;
212212+213213+ !valid
214214+ end
215215+ end
216216+ end
217217+218218+(** Validate srcset descriptor *)
219219+let validate_srcset_descriptor desc element_name srcset_value collector =
220220+ let desc_lower = String.lowercase_ascii (String.trim desc) in
221221+ if String.length desc_lower = 0 then true
222222+ else begin
223223+ let last_char = desc_lower.[String.length desc_lower - 1] in
224224+ let num_part = String.sub desc_lower 0 (String.length desc_lower - 1) in
225225+226226+ match last_char with
227227+ | 'w' ->
228228+ (* Width descriptor - must be positive integer *)
229229+ (try
230230+ let n = int_of_string num_part in
231231+ if n <= 0 then begin
232232+ Message_collector.add_error collector
233233+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Width must be positive." srcset_value element_name)
234234+ ~code:"bad-srcset-value"
235235+ ~element:element_name ~attribute:"srcset" ();
236236+ false
237237+ end else begin
238238+ (* Check for uppercase W - compare original desc with lowercase version *)
239239+ let original_last = desc.[String.length desc - 1] in
240240+ if original_last = 'W' then begin
241241+ Message_collector.add_error collector
242242+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Width descriptor must use lowercase \xe2\x80\x9cw\xe2\x80\x9d." srcset_value element_name)
243243+ ~code:"bad-srcset-value"
244244+ ~element:element_name ~attribute:"srcset" ();
245245+ false
246246+ end else true
247247+ end
248248+ with _ ->
249249+ (* Check for scientific notation or decimal *)
250250+ if String.contains num_part 'e' || String.contains num_part 'E' then begin
251251+ Message_collector.add_error collector
252252+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Scientific notation not allowed in width descriptor." srcset_value element_name)
253253+ ~code:"bad-srcset-value"
254254+ ~element:element_name ~attribute:"srcset" ();
255255+ false
256256+ end else begin
257257+ Message_collector.add_error collector
258258+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Invalid width descriptor." srcset_value element_name)
259259+ ~code:"bad-srcset-value"
260260+ ~element:element_name ~attribute:"srcset" ();
261261+ false
262262+ end)
263263+ | 'x' ->
264264+ (* Pixel density descriptor - must be positive number, no leading + *)
265265+ let trimmed_desc = String.trim desc in
266266+ if String.length trimmed_desc > 0 && trimmed_desc.[0] = '+' then begin
267267+ Message_collector.add_error collector
268268+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Leading plus sign not allowed." srcset_value element_name)
269269+ ~code:"bad-srcset-value"
270270+ ~element:element_name ~attribute:"srcset" ();
271271+ false
272272+ end else begin
273273+ (try
274274+ let n = float_of_string num_part in
275275+ if Float.is_nan n then begin
276276+ Message_collector.add_error collector
277277+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: NaN not allowed." srcset_value element_name)
278278+ ~code:"bad-srcset-value"
279279+ ~element:element_name ~attribute:"srcset" ();
280280+ false
281281+ end else if n <= 0.0 then begin
282282+ Message_collector.add_error collector
283283+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Density must be positive." srcset_value element_name)
284284+ ~code:"bad-srcset-value"
285285+ ~element:element_name ~attribute:"srcset" ();
286286+ false
287287+ end else if n = neg_infinity || n = infinity then begin
288288+ Message_collector.add_error collector
289289+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Infinity not allowed." srcset_value element_name)
290290+ ~code:"bad-srcset-value"
291291+ ~element:element_name ~attribute:"srcset" ();
292292+ false
293293+ end else true
294294+ with _ ->
295295+ Message_collector.add_error collector
296296+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Invalid density descriptor." srcset_value element_name)
297297+ ~code:"bad-srcset-value"
298298+ ~element:element_name ~attribute:"srcset" ();
299299+ false)
300300+ end
301301+ | 'h' ->
302302+ (* Height descriptor - not allowed *)
303303+ Message_collector.add_error collector
304304+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor: Height descriptor \xe2\x80\x9ch\xe2\x80\x9d is not allowed." srcset_value element_name)
305305+ ~code:"bad-srcset-value"
306306+ ~element:element_name ~attribute:"srcset" ();
307307+ false
308308+ | _ ->
309309+ (* Unknown descriptor *)
310310+ Message_collector.add_error collector
311311+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset descriptor." srcset_value element_name)
312312+ ~code:"bad-srcset-value"
313313+ ~element:element_name ~attribute:"srcset" ();
314314+ false
315315+ end
316316+317317+(** Normalize descriptor for duplicate detection (e.g., 1x = 1.0x) *)
318318+let normalize_descriptor desc =
319319+ let desc_lower = String.lowercase_ascii (String.trim desc) in
320320+ if String.length desc_lower = 0 then desc_lower
321321+ else
322322+ let last_char = desc_lower.[String.length desc_lower - 1] in
323323+ let num_part = String.sub desc_lower 0 (String.length desc_lower - 1) in
324324+ match last_char with
325325+ | 'x' ->
326326+ (* Normalize density to a float string for comparison *)
327327+ (try
328328+ let f = float_of_string num_part in
329329+ Printf.sprintf "%gx" f (* %g removes trailing zeros *)
330330+ with _ -> desc_lower)
331331+ | 'w' ->
332332+ (* Width should be integer, just return as-is *)
333333+ desc_lower
334334+ | _ -> desc_lower
335335+336336+(** Parse and validate srcset attribute value *)
337337+let validate_srcset value element_name has_sizes collector =
338338+ let entries = String.split_on_char ',' value in
339339+ let has_w_descriptor = ref false in
340340+ let has_x_descriptor = ref false in
341341+ let seen_descriptors = Hashtbl.create 8 in (* Track seen descriptor values *)
342342+343343+ (* Check for empty srcset *)
344344+ if String.trim value = "" then begin
345345+ Message_collector.add_error collector
346346+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Must not be empty." value element_name)
347347+ ~code:"bad-srcset-value"
348348+ ~element:element_name ~attribute:"srcset" ()
349349+ end;
350350+351351+ (* Check for leading comma *)
352352+ if String.length value > 0 && value.[0] = ',' then begin
353353+ Message_collector.add_error collector
354354+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset: Leading comma." value element_name)
355355+ ~code:"bad-srcset-value"
356356+ ~element:element_name ~attribute:"srcset" ()
357357+ end;
358358+359359+ (* Check for trailing comma *)
360360+ let trimmed_value = String.trim value in
361361+ if String.length trimmed_value > 0 && trimmed_value.[String.length trimmed_value - 1] = ',' then begin
362362+ Message_collector.add_error collector
363363+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset: Trailing comma." value element_name)
364364+ ~code:"bad-srcset-value"
365365+ ~element:element_name ~attribute:"srcset" ()
366366+ end;
367367+368368+ List.iter (fun entry ->
369369+ let entry = String.trim entry in
370370+ if entry <> "" then begin
371371+ (* Split entry into URL and optional descriptor *)
372372+ let parts = String.split_on_char ' ' entry |> List.filter (fun s -> s <> "") in
373373+ match parts with
374374+ | [] -> ()
375375+ | [_url] ->
376376+ (* URL only = implicit 1x descriptor - only flag if explicit 1x also seen *)
377377+ if Hashtbl.mem seen_descriptors "explicit-1x" then begin
378378+ Message_collector.add_error collector
379379+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Duplicate descriptor." value element_name)
380380+ ~code:"bad-srcset-value"
381381+ ~element:element_name ~attribute:"srcset" ()
382382+ end else
383383+ Hashtbl.add seen_descriptors "implicit-1x" true
384384+ | _url :: desc :: rest ->
385385+ (* Check for extra junk - multiple descriptors are not allowed *)
386386+ if rest <> [] then begin
387387+ Message_collector.add_error collector
388388+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Bad srcset: Multiple descriptors in candidate." value element_name)
389389+ ~code:"bad-srcset-value"
390390+ ~element:element_name ~attribute:"srcset" ()
391391+ end;
392392+393393+ let desc_lower = String.lowercase_ascii (String.trim desc) in
394394+ if String.length desc_lower > 0 then begin
395395+ let last_char = desc_lower.[String.length desc_lower - 1] in
396396+ if last_char = 'w' then has_w_descriptor := true
397397+ else if last_char = 'x' then has_x_descriptor := true;
398398+399399+ (* Check for duplicate descriptors - use normalized form *)
400400+ let normalized = normalize_descriptor desc in
401401+ let is_1x = (normalized = "1x") in
402402+ if Hashtbl.mem seen_descriptors normalized then begin
403403+ Message_collector.add_error collector
404404+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Duplicate descriptor." value element_name)
405405+ ~code:"bad-srcset-value"
406406+ ~element:element_name ~attribute:"srcset" ()
407407+ end else if is_1x && Hashtbl.mem seen_descriptors "implicit-1x" then begin
408408+ (* Explicit 1x conflicts with implicit 1x *)
409409+ Message_collector.add_error collector
410410+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Duplicate descriptor." value element_name)
411411+ ~code:"bad-srcset-value"
412412+ ~element:element_name ~attribute:"srcset" ()
413413+ end else begin
414414+ Hashtbl.add seen_descriptors normalized true;
415415+ if is_1x then Hashtbl.add seen_descriptors "explicit-1x" true
416416+ end
417417+ end;
418418+419419+ ignore (validate_srcset_descriptor desc element_name value collector)
420420+ end
421421+ ) entries;
422422+423423+ (* Check: if w descriptor used and no sizes, that's an error for img and source *)
424424+ if !has_w_descriptor && not has_sizes then
425425+ Message_collector.add_error collector
426426+ ~message:(Printf.sprintf "When the \xe2\x80\x9csrcset\xe2\x80\x9d attribute on the \xe2\x80\x9c%s\xe2\x80\x9d element uses width descriptors, the \xe2\x80\x9csizes\xe2\x80\x9d attribute must also be present." element_name)
427427+ ~code:"srcset-w-without-sizes"
428428+ ~element:element_name ~attribute:"srcset" ();
429429+430430+ (* Check for mixing w and x descriptors *)
431431+ if !has_w_descriptor && !has_x_descriptor then
432432+ Message_collector.add_error collector
433433+ ~message:(Printf.sprintf "Bad value \xe2\x80\x9c%s\xe2\x80\x9d for attribute \xe2\x80\x9csrcset\xe2\x80\x9d on element \xe2\x80\x9c%s\xe2\x80\x9d: Mixing width and density descriptors is not allowed." value element_name)
434434+ ~code:"bad-srcset-value"
435435+ ~element:element_name ~attribute:"srcset" ()
436436+437437+let start_element _state ~name ~namespace ~attrs collector =
438438+ if namespace <> None then ()
439439+ else begin
440440+ let name_lower = String.lowercase_ascii name in
441441+442442+ (* Check sizes and srcset on img and source *)
443443+ if name_lower = "img" || name_lower = "source" then begin
444444+ let sizes_value = get_attr "sizes" attrs in
445445+ let srcset_value = get_attr "srcset" attrs in
446446+ let has_sizes = sizes_value <> None in
447447+448448+ (* Validate sizes if present *)
449449+ (match sizes_value with
450450+ | Some v -> ignore (validate_sizes v name_lower collector)
451451+ | None -> ());
452452+453453+ (* Validate srcset if present *)
454454+ (match srcset_value with
455455+ | Some v -> validate_srcset v name_lower has_sizes collector
456456+ | None -> ())
457457+ end
458458+ end
459459+460460+let end_element _state ~name:_ ~namespace:_ _collector = ()
461461+let characters _state _text _collector = ()
462462+let end_document _state _collector = ()
463463+464464+let checker =
465465+ (module struct
466466+ type nonrec state = state
467467+ let create = create
468468+ let reset = reset
469469+ let start_element = start_element
470470+ let end_element = end_element
471471+ let characters = characters
472472+ let end_document = end_document
473473+ end : Checker.S)