1use super::helpers::rfc2822_to_iso8601;
7use crate::error::SsgError;
8use crate::plugin::{Plugin, PluginContext};
9use crate::util::head_dom::inject_before_head_close;
10use crate::util::html_rewriter::rewrite_html;
11use anyhow::Result;
12use lol_html::element;
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy)]
21pub struct HtmlFixPlugin;
22
23impl Plugin for HtmlFixPlugin {
24 fn name(&self) -> &'static str {
25 "html-fix"
26 }
27
28 fn has_transform(&self) -> bool {
29 true
30 }
31
32 fn transform_html(
33 &self,
34 html: &str,
35 _path: &Path,
36 _ctx: &PluginContext,
37 ) -> Result<String, SsgError> {
38 Ok(apply_html_fixes(html))
39 }
40
41 fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
42 Ok(())
43 }
44}
45
46fn apply_html_fixes(html: &str) -> String {
48 let mut modified = html.to_string();
49
50 if needs_schema_context_fix(&modified) {
51 modified = modified
52 .replace("\"http://schema.org/\"", "\"https://schema.org\"")
53 .replace("\"http://schema.org\"", "\"https://schema.org\"");
54 }
55
56 if modified.contains("application/ld+json") {
57 modified = fix_jsonld_dates(&modified);
58 }
59
60 if modified.contains("<p src=") {
61 modified = fix_broken_img_tags(&modified);
62 }
63
64 if needs_class_syntax_fix(&modified) {
65 modified = fix_literal_class_syntax(&modified);
66 }
67
68 if needs_mobile_web_app_capable_meta(&modified) {
69 modified = inject_mobile_web_app_capable_meta(&modified);
70 }
71
72 if has_empty_preload(&modified) {
73 modified = remove_empty_preload_links(&modified);
74 }
75
76 if modified.contains("align=") {
77 modified = replace_table_align_attrs(&modified);
78 }
79
80 if modified.contains("<th") {
81 modified = add_table_header_scope(&modified);
82 }
83
84 if modified.contains("<table") {
85 modified = wrap_tables_for_reflow(&modified);
86 }
87
88 if modified.contains("<code><") {
89 modified = escape_markup_inside_code_spans(&modified);
90 }
91
92 modified
93}
94
95fn escape_markup_inside_code_spans(html: &str) -> String {
118 let mut out = String::with_capacity(html.len());
119 let mut rest = html;
120 let lower = html.to_ascii_lowercase();
123
124 while let Some(start) = rest.find("<code>") {
125 let rest_lower = &lower[html.len() - rest.len()..];
126 if let Some((_, end)) =
127 first_raw_text_region(rest_lower).filter(|&(open, _)| open < start)
128 {
129 out.push_str(&rest[..end]);
130 rest = &rest[end..];
131 continue;
132 }
133 let after_open = start + "<code>".len();
134 let Some(close_rel) = rest[after_open..].find("</code>") else {
135 break;
136 };
137 let close = after_open + close_rel;
138
139 let preceding = rest[..start].trim_end();
150 let opens_a_pre_block = preceding.ends_with('>')
151 && preceding.rfind("<pre").is_some_and(|i| {
152 preceding[i..]
153 .find('>')
154 .is_some_and(|j| i + j + 1 == preceding.len())
155 });
156 if opens_a_pre_block {
157 let end = close + "</code>".len();
158 out.push_str(&rest[..end]);
159 rest = &rest[end..];
160 continue;
161 }
162
163 out.push_str(&rest[..after_open]);
164 for ch in rest[after_open..close].chars() {
167 match ch {
168 '<' => out.push_str("<"),
169 '>' => out.push_str(">"),
170 other => out.push(other),
171 }
172 }
173 out.push_str("</code>");
174 rest = &rest[close + "</code>".len()..];
175 }
176
177 out.push_str(rest);
178 out
179}
180
181const RAW_TEXT_REGIONS: [(&str, &str); 3] = [
183 ("<style", "</style"),
184 ("<script", "</script"),
185 ("<!--", "-->"),
186];
187
188fn first_raw_text_region(lower: &str) -> Option<(usize, usize)> {
192 RAW_TEXT_REGIONS
193 .iter()
194 .filter_map(|&(open, close)| {
195 let start = lower.find(open)?;
196 let body = start + open.len();
197 let end = lower[body..].find(close).map_or(lower.len(), |p| {
198 let at = body + p;
199 lower[at..].find('>').map_or(lower.len(), |q| at + q + 1)
200 });
201 Some((start, end))
202 })
203 .min_by_key(|&(start, _)| start)
204}
205
206fn add_table_header_scope(html: &str) -> String {
216 rewrite_html(
217 html,
218 vec![
219 element!("thead th:not([scope])", |el| {
220 el.set_attribute("scope", "col")?;
221 Ok(())
222 }),
223 element!("tbody th:not([scope])", |el| {
224 el.set_attribute("scope", "row")?;
225 Ok(())
226 }),
227 element!("table > tr th:not([scope])", |el| {
229 el.set_attribute("scope", "col")?;
230 Ok(())
231 }),
232 ],
233 )
234 .unwrap_or_else(|_| html.to_string())
235}
236
237fn wrap_tables_for_reflow(html: &str) -> String {
254 use lol_html::html_content::ContentType;
255
256 let already = "ssg-table-scroll";
257 if html.contains(already) {
258 return html.to_string();
259 }
260
261 rewrite_html(
262 html,
263 vec![element!("table", move |el| {
264 el.before(
265 &format!(
266 "<div class=\"table-wrap {already}\" role=\"region\" \
267 aria-label=\"Table, scrollable horizontally\" tabindex=\"0\">"
268 ),
269 ContentType::Html,
270 );
271 el.after("</div>", ContentType::Html);
272 Ok(())
273 })],
274 )
275 .unwrap_or_else(|_| html.to_string())
276}
277
278fn replace_table_align_attrs(html: &str) -> String {
296 let handler = |el: &mut lol_html::html_content::Element<'_, '_>| {
297 let Some(align) = el.get_attribute("align") else {
298 return Ok(());
299 };
300 el.remove_attribute("align");
301
302 let class = match align.trim().to_ascii_lowercase().as_str() {
303 "left" => "text-left",
304 "center" | "centre" => "text-center",
305 "right" => "text-right",
306 _ => return Ok(()),
310 };
311
312 let existing = el.get_attribute("class").unwrap_or_default();
313 if existing.split_whitespace().any(|c| c == class) {
314 return Ok(());
315 }
316 let merged = if existing.is_empty() {
317 class.to_string()
318 } else {
319 format!("{existing} {class}")
320 };
321 el.set_attribute("class", &merged)?;
322 Ok(())
323 };
324
325 rewrite_html(
326 html,
327 vec![
328 element!("th[align]", handler),
329 element!("td[align]", handler),
330 ],
331 )
332 .unwrap_or_else(|_| html.to_string())
333}
334
335fn needs_schema_context_fix(html: &str) -> bool {
337 html.contains("\"http://schema.org/\"")
338 || html.contains("\"http://schema.org\"")
339}
340
341fn needs_class_syntax_fix(html: &str) -> bool {
343 html.contains(".class="") || html.contains(".class=\"")
344}
345
346fn has_empty_preload(html: &str) -> bool {
352 let has_preload = html.contains("rel=preload")
357 || html.contains("rel=\"preload\"")
358 || html.contains("rel='preload'");
359 let has_empty_href = html.contains("href=\"\"")
360 || html.contains("href=''")
361 || html.contains(" href ")
362 || html.contains(" href>")
363 || html.contains(" href/>");
364 has_preload && has_empty_href
365}
366
367pub(super) fn remove_empty_preload_links(html: &str) -> String {
370 let mut out = String::with_capacity(html.len());
371 let mut cursor = 0;
372 while cursor < html.len() {
373 let Some(rel_offset) =
375 html[cursor..].to_ascii_lowercase().find("<link")
376 else {
377 out.push_str(&html[cursor..]);
378 break;
379 };
380 let tag_start = cursor + rel_offset;
381 out.push_str(&html[cursor..tag_start]);
382
383 let bytes = html.as_bytes();
385 let mut j = tag_start;
386 let mut quote: Option<u8> = None;
387 while j < bytes.len() {
388 let b = bytes[j];
389 match quote {
390 Some(q) if b == q => quote = None,
391 Some(_) => {}
392 None => match b {
393 b'"' | b'\'' => quote = Some(b),
394 b'>' => break,
395 _ => {}
396 },
397 }
398 j += 1;
399 }
400 let tag_end = (j + 1).min(html.len());
401 let tag = &html[tag_start..tag_end];
402 let lower = tag.to_ascii_lowercase();
403 let is_preload = lower.contains("rel=\"preload\"")
404 || lower.contains("rel='preload'")
405 || lower.contains("rel=preload");
406 let has_real_href = href_is_present_and_non_empty(&lower);
407 if !is_preload || has_real_href {
409 out.push_str(tag);
410 }
411 cursor = tag_end;
412 }
413 out
414}
415
416fn href_is_present_and_non_empty(lower_tag: &str) -> bool {
419 if lower_tag.contains("href=\"\"") || lower_tag.contains("href=''") {
420 return false;
421 }
422 let Some(idx) = lower_tag.find("href") else {
423 return false;
424 };
425 let after = lower_tag[idx + 4..].trim_start();
427 let Some(rest) = after.strip_prefix('=') else {
428 return false;
429 };
430 let rest = rest.trim_start();
431 match rest.chars().next() {
434 None | Some('>') => false,
435 Some('"') => rest.len() > 1 && !rest.starts_with("\"\""),
436 Some('\'') => rest.len() > 1 && !rest.starts_with("''"),
437 Some(_) => true,
438 }
439}
440
441fn needs_mobile_web_app_capable_meta(html: &str) -> bool {
447 let has_legacy = html.contains("apple-mobile-web-app-capable");
448 let has_modern = find_modern_mobile_web_app_capable(html).is_some();
449 has_legacy && !has_modern
450}
451
452fn find_modern_mobile_web_app_capable(html: &str) -> Option<usize> {
455 let needles = [
459 "name=\"mobile-web-app-capable\"",
460 "name='mobile-web-app-capable'",
461 "name=mobile-web-app-capable",
462 ];
463 for n in &needles {
464 if let Some(pos) = html.find(n) {
465 return Some(pos);
466 }
467 }
468 None
469}
470
471pub(super) fn inject_mobile_web_app_capable_meta(html: &str) -> String {
482 let modern = "<meta name=\"mobile-web-app-capable\" content=\"yes\">";
483 let candidates = [
485 "name=\"apple-mobile-web-app-capable\"",
486 "name='apple-mobile-web-app-capable'",
487 "name=apple-mobile-web-app-capable",
488 ];
489 let name_pos = candidates.iter().find_map(|n| html.find(n));
490 if let Some(name_pos) = name_pos {
491 let after = &html[name_pos..];
493 if let Some(rel_close) = after.find('>') {
494 let insert_at = name_pos + rel_close + 1;
495 return format!(
496 "{}{modern}{}",
497 &html[..insert_at],
498 &html[insert_at..]
499 );
500 }
501 }
502 inject_modern_meta_into_head(html, modern)
506}
507
508fn inject_modern_meta_into_head(html: &str, meta: &str) -> String {
513 let lower = html.to_ascii_lowercase();
516 if lower.contains("</head>") {
517 let injected = inject_before_head_close(html, meta);
518 if injected != html {
519 return injected;
520 }
521 }
522 if let Some(pos) = lower.find("<head>") {
523 let insert_at = pos + "<head>".len();
524 return format!("{}{meta}{}", &html[..insert_at], &html[insert_at..]);
525 }
526 format!("{meta}{html}")
528}
529
530pub(super) fn fix_jsonld_dates(html: &str) -> String {
532 let mut result = html.to_string();
533
534 for field in &["datePublished", "dateModified"] {
536 let pattern = format!("\"{field}\":\"");
537 let mut search_from = 0;
538 while let Some(start) = result[search_from..].find(&pattern) {
539 let abs_start = search_from + start + pattern.len();
540 if let Some(end) = result[abs_start..].find('"') {
541 let date_str = &result[abs_start..abs_start + end];
542 if date_str.len() > 5
545 && date_str.as_bytes()[3] == b','
546 && date_str.as_bytes()[0].is_ascii_alphabetic()
547 {
548 let iso = rfc2822_to_iso8601(date_str);
549 if iso != date_str {
550 result = format!(
551 "{}{}{}",
552 &result[..abs_start],
553 iso,
554 &result[abs_start + end..]
555 );
556 }
557 }
558 search_from = abs_start + 1;
559 } else {
560 break;
561 }
562 }
563 }
564
565 result
566}
567
568pub(super) fn fix_broken_img_tags(html: &str) -> String {
571 let mut result = html.to_string();
572 while let Some(p_pos) = result.find("<p src=") {
575 let before = &result[..p_pos];
577 if let Some(img_start) = before.rfind("<img") {
578 let after_p = &result[p_pos..]; if let Some(quote_start) = after_p.find("src=\"") {
581 let val_start = quote_start + 5; let remaining = &after_p[val_start..];
583 if let Some(quote_end) = remaining.find('"') {
584 let src_value = remaining[..quote_end].to_string();
585 let close_offset = remaining[quote_end..]
587 .find('>')
588 .map_or(result.len(), |i| {
589 p_pos + val_start + quote_end + i + 1
590 });
591
592 let img_attrs = result[img_start + 4..p_pos].trim();
594 let img_attrs_clean =
595 img_attrs.trim_end_matches(|c: char| {
596 c.is_whitespace() || c == '<'
597 });
598
599 let new_img = format!(
600 "<img {img_attrs_clean} src=\"{src_value}\" />"
601 );
602 result = format!(
603 "{}{}{}",
604 &result[..img_start],
605 new_img,
606 &result[close_offset..]
607 );
608 continue;
609 }
610 }
611 }
612 break;
614 }
615 result
616}
617
618pub(super) fn fix_literal_class_syntax(html: &str) -> String {
621 let mut result = html.to_string();
622
623 result = fix_class_syntax_variant(&result, ".class="", """);
625 result = fix_class_syntax_variant(&result, ".class=\"", "\"");
627
628 result
629}
630
631fn fix_class_syntax_variant(
633 html: &str,
634 open_pattern: &str,
635 close_pattern: &str,
636) -> String {
637 let mut result = html.to_string();
638 while let Some(start) = result.find(open_pattern) {
639 let after = &result[start + open_pattern.len()..];
640 if let Some(end) = after.find(close_pattern) {
641 let class_value = after[..end].to_string();
642 let remove_end =
643 start + open_pattern.len() + end + close_pattern.len();
644 result = format!("{}{}", &result[..start], &result[remove_end..]);
645 inject_class_attr(&mut result, start, &class_value);
646 } else {
647 break;
648 }
649 }
650 result
651}
652
653fn inject_class_attr(html: &mut String, pos: usize, class_value: &str) {
655 if let Some(tag_end) = html[..pos].rfind('>') {
656 if let Some(tag_start) = html[..tag_end].rfind('<') {
657 let tag = &html[tag_start..tag_end];
658 if !tag.contains("class=") {
659 let insert_pos = tag_end;
660 *html = format!(
661 "{} class=\"{}\"{}",
662 &html[..insert_pos],
663 class_value,
664 &html[insert_pos..]
665 );
666 }
667 }
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 #[test]
682 fn quoted_markup_in_prose_is_left_as_text() {
683 for quoted in [
684 "<div class="card">",
685 "<section>",
686 "<embed src="x.swf">",
687 "<link rel="stylesheet" href="x.css">",
688 ] {
689 let html = format!(
690 "<html><head></head><body><main><p>Write {quoted} to wrap it.</p></main></body></html>"
691 );
692 let out = apply_html_fixes(&html);
693 assert!(
694 out.contains(quoted),
695 "quoted markup {quoted:?} must stay escaped, got: {out}"
696 );
697 }
698 }
699
700 #[test]
708 fn a_pre_block_keeps_authored_markup() {
709 let html = "<pre class=\"editor-code\"><code>\
710<span class=\"code-kw\">pub fn</span> main()</code></pre>";
711 let out = escape_markup_inside_code_spans(html);
712 assert_eq!(out, html, "a <pre><code> block must pass through intact");
713 assert!(!out.contains("<span"), "spans must not be escaped");
714 }
715
716 #[test]
718 fn an_inline_code_span_is_still_escaped() {
719 let html = "<p>Every <code><img></code> needs alt text.</p>";
720 let out = escape_markup_inside_code_spans(html);
721 assert!(
722 out.contains("<code><img></code>"),
723 "inline spans must still be escaped, got: {out}"
724 );
725 }
726
727 #[test]
729 fn an_inline_span_after_an_earlier_pre_is_still_escaped() {
730 let html = "<pre><code>x</code></pre><p>Use <code><br></code>.</p>";
731 let out = escape_markup_inside_code_spans(html);
732 assert!(
733 out.contains("<code><br></code>"),
734 "a later inline span must still be escaped, got: {out}"
735 );
736 assert!(
737 out.contains("<pre><code>x</code></pre>"),
738 "the earlier block must be untouched, got: {out}"
739 );
740 }
741
742 #[test]
743 fn escapes_a_tag_left_raw_inside_a_code_span() {
744 let html = "<li>Every <code><img></code> has a meaningful <code>alt</code></li>";
748 let out = apply_html_fixes(html);
749 assert!(out.contains("<code><img></code>"), "got: {out}");
750 assert!(!out.contains("<code><img></code>"), "got: {out}");
751 assert!(out.contains("<code>alt</code>"), "got: {out}");
753 }
754
755 #[test]
756 fn escaping_code_spans_leaves_surrounding_markup_alone() {
757 let html = "<p>Before</p><code><b>x</b></code><p>After <em>y</em></p>";
758 let out = apply_html_fixes(html);
759 assert!(
760 out.contains("<code><b>x</b></code>"),
761 "got: {out}"
762 );
763 assert!(out.contains("<p>Before</p>"), "got: {out}");
764 assert!(out.contains("<em>y</em>"), "got: {out}");
765 }
766
767 #[test]
768 fn already_escaped_code_spans_are_not_double_escaped() {
769 let html = "<code><img></code>";
772 let once = apply_html_fixes(html);
773 let twice = apply_html_fixes(&once);
774 assert_eq!(once, twice, "pass is not idempotent");
775 assert!(!twice.contains("&lt;"), "double-escaped: {twice}");
776 }
777
778 #[test]
784 fn raw_text_elements_are_left_alone() {
785 let style = "<style>/* Inline <code> */ pre>code{color:#000} \
786/* `<pre><code>…</code></pre>` */</style>";
787 let script = "<script>const s = '<code><b>x</b></code>';</script>";
788 let comment = "<!-- <code><b>y</b></code> -->";
789 let html = format!(
790 "<head>{style}{script}</head><body>{comment}\
791<p><code><img></code></p></body>"
792 );
793 let out = apply_html_fixes(&html);
794 assert!(out.contains(style), "style rewritten: {out}");
795 assert!(out.contains(script), "script rewritten: {out}");
796 assert!(out.contains(comment), "comment rewritten: {out}");
797 assert!(out.contains("<code><img></code>"), "got: {out}");
798 }
799
800 #[test]
801 fn unterminated_code_span_does_not_truncate_the_document() {
802 let html = "<p>keep</p><code><img>";
805 let out = apply_html_fixes(html);
806 assert!(out.contains("<p>keep</p>"), "content lost: {out}");
807 }
808
809 use super::*;
810 use crate::plugin::PluginContext;
811 use std::path::Path;
812 use tempfile::tempdir;
813
814 #[test]
819 fn apply_html_fixes_strips_table_align_attrs() {
820 let out = apply_html_fixes(
821 r#"<table><tr><td align="right">7</td></tr></table>"#,
822 );
823 assert!(
824 !out.contains("align="),
825 "not wired into the pipeline: {out}"
826 );
827 assert!(out.contains("text-right"), "{out}");
828 }
829
830 #[test]
834 fn table_headers_gain_a_scope() {
835 let out = add_table_header_scope(concat!(
836 "<table><thead><tr><th>Plan</th></tr></thead>",
837 "<tbody><tr><th>Starter</th><td>Free</td></tr></tbody></table>",
838 ));
839 assert!(out.contains(r#"<th scope="col">Plan"#), "{out}");
840 assert!(out.contains(r#"<th scope="row">Starter"#), "{out}");
841 }
842
843 #[test]
845 fn table_header_scope_does_not_overwrite_an_author_value() {
846 let out = add_table_header_scope(
847 r#"<table><thead><tr><th scope="rowgroup">X</th></tr></thead></table>"#,
848 );
849 assert!(out.contains(r#"scope="rowgroup""#), "{out}");
850 assert_eq!(out.matches("scope=").count(), 1, "{out}");
851 }
852
853 #[test]
856 fn tables_are_wrapped_in_a_focusable_scroll_region() {
857 let out = wrap_tables_for_reflow("<table><tr><td>x</td></tr></table>");
858 assert!(out.contains("table-wrap"), "{out}");
859 assert!(out.contains(r#"role="region""#), "{out}");
860 assert!(out.contains(r#"tabindex="0""#), "{out}");
861 assert!(out.contains("aria-label"), "{out}");
862 }
863
864 #[test]
866 fn table_wrapping_is_idempotent() {
867 let once = wrap_tables_for_reflow("<table><tr><td>x</td></tr></table>");
868 assert_eq!(wrap_tables_for_reflow(&once), once);
869 }
870
871 #[test]
875 fn table_align_attrs_become_text_classes() {
876 let html = concat!(
877 "<table><thead><tr>",
878 r#"<th align="left">Layer</th>"#,
879 r#"<th align="center">Maturity</th>"#,
880 r#"<th align="right">Metric</th>"#,
881 "</tr></thead></table>",
882 );
883 let out = replace_table_align_attrs(html);
884
885 assert!(
886 !out.contains("align="),
887 "obsolete attribute survived: {out}"
888 );
889 assert!(out.contains("text-left"), "{out}");
890 assert!(out.contains("text-center"), "{out}");
891 assert!(out.contains("text-right"), "{out}");
892 }
893
894 #[test]
897 fn table_align_does_not_duplicate_an_existing_class() {
898 let html = r#"<td align="right" class="text-right num">7</td>"#;
899 let out = replace_table_align_attrs(html);
900
901 assert!(!out.contains("align="), "{out}");
902 assert_eq!(out.matches("text-right").count(), 1, "duplicated: {out}");
903 assert!(out.contains("num"), "existing classes dropped: {out}");
904 }
905
906 #[test]
909 fn table_align_unrecognised_value_drops_attribute_without_a_class() {
910 let out = replace_table_align_attrs(r#"<td align="justify">x</td>"#);
911 assert!(!out.contains("align="), "{out}");
912 assert!(!out.contains("text-"), "invented a class: {out}");
913 }
914
915 #[test]
918 fn table_align_leaves_literal_text_in_pre_alone() {
919 let html = r#"<pre><code><td align="left"></code></pre>"#;
920 assert_eq!(replace_table_align_attrs(html), html);
921 }
922
923 #[test]
926 fn table_align_ignores_non_cell_elements() {
927 let html = r#"<div align="center">x</div>"#;
928 assert_eq!(replace_table_align_attrs(html), html);
929 }
930
931 fn test_ctx(site_dir: &Path) -> PluginContext {
932 crate::test_support::init_logger();
933 PluginContext::new(
934 Path::new("content"),
935 Path::new("build"),
936 site_dir,
937 Path::new("templates"),
938 )
939 }
940
941 #[test]
942 fn test_html_fix_upgrades_jsonld_context() -> Result<()> {
943 let tmp = tempdir().unwrap();
944 let ctx = test_ctx(tmp.path());
945
946 let html = r#"<html><head>
947<script type="application/ld+json">
948{"@context":"http://schema.org/","@type":"WebPage"}
949</script>
950</head><body></body></html>"#;
951
952 let result = HtmlFixPlugin
953 .transform_html(html, Path::new("index.html"), &ctx)
954 .unwrap();
955 assert!(result.contains("\"https://schema.org\""));
956 assert!(!result.contains("\"http://schema.org/\""));
957 Ok(())
958 }
959
960 #[test]
961 fn test_html_fix_converts_jsonld_dates() -> Result<()> {
962 let tmp = tempdir().unwrap();
963 let ctx = test_ctx(tmp.path());
964
965 let html = r#"<html><head>
966<script type="application/ld+json">
967{"@context":"https://schema.org","@type":"Article","datePublished":"Thu, 11 Apr 2026 06:06:06 +0000","dateModified":"Mon, 01 Sep 2025 06:06:06 +0000"}
968</script>
969</head><body></body></html>"#;
970
971 let result = HtmlFixPlugin
972 .transform_html(html, Path::new("article.html"), &ctx)
973 .unwrap();
974 assert!(
975 result.contains("\"datePublished\":\"2026-04-11"),
976 "Expected ISO date, got: {result}"
977 );
978 assert!(
979 result.contains("\"dateModified\":\"2025-09-01"),
980 "Expected ISO date, got: {result}"
981 );
982 assert!(!result.contains("Thu, 11 Apr"));
983 Ok(())
984 }
985
986 #[test]
987 fn test_fix_broken_img_tags() {
988 let input =
989 r#"<img alt="test" class="w-25" title="test" <p src="image.jpg">"#;
990 let result = fix_broken_img_tags(input);
991 assert!(result.contains("src=\"image.jpg\""));
992 assert!(!result.contains("<p src="));
993 }
994
995 #[test]
996 fn test_fix_literal_class_syntax() {
997 let input = r#"<img alt="test" src="img.jpg">.class="w-25 float-start""#;
998 let result = fix_literal_class_syntax(input);
999 assert!(!result.contains(".class=""));
1000 }
1001
1002 #[test]
1007 fn test_fix_jsonld_dates_iso_passthrough() {
1008 let input =
1009 r#"{"datePublished":"2026-04-11","dateModified":"2025-09-01"}"#;
1010 let result = fix_jsonld_dates(input);
1011 assert_eq!(result, input, "ISO dates should pass through unchanged");
1012 }
1013
1014 #[test]
1015 fn test_fix_jsonld_dates_converts_rfc2822() {
1016 let input = r#"{"datePublished":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1017 let result = fix_jsonld_dates(input);
1018 assert!(
1019 result.contains("\"datePublished\":\"2026-04-11T06:06:06+00:00\""),
1020 "Should convert RFC 2822 to ISO 8601, got: {result}"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_fix_jsonld_dates_both_fields() {
1026 let input = r#"{"datePublished":"Mon, 01 Sep 2025 12:00:00 +0000","dateModified":"Tue, 02 Sep 2025 14:30:00 +0000"}"#;
1027 let result = fix_jsonld_dates(input);
1028 assert!(result.contains("2025-09-01T12:00:00+00:00"));
1029 assert!(result.contains("2025-09-02T14:30:00+00:00"));
1030 }
1031
1032 #[test]
1037 fn test_fix_broken_img_tags_multiple() {
1038 let input =
1039 r#"<img alt="a" <p src="one.jpg"><img alt="b" <p src="two.jpg">"#;
1040 let result = fix_broken_img_tags(input);
1041 assert!(result.contains("src=\"one.jpg\""), "first img: {result}");
1042 assert!(result.contains("src=\"two.jpg\""), "second img: {result}");
1043 assert!(
1044 !result.contains("<p src="),
1045 "no broken tags remain: {result}"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_fix_broken_img_tags_none() {
1051 let input = r#"<img alt="ok" src="good.jpg" />"#;
1052 let result = fix_broken_img_tags(input);
1053 assert_eq!(
1054 result, input,
1055 "No broken tags should leave input unchanged"
1056 );
1057 }
1058
1059 #[test]
1064 fn test_fix_literal_class_syntax_html_encoded() {
1065 let input =
1066 r#"<img src="img.jpg">.class="w-25 float-start" rest"#;
1067 let result = fix_literal_class_syntax(input);
1068 assert!(
1069 !result.contains(".class=""),
1070 "should remove .class=""
1071 );
1072 assert!(
1073 result.contains("class=\"w-25 float-start\""),
1074 "should inject class attr, got: {result}"
1075 );
1076 }
1077
1078 #[test]
1079 fn test_fix_literal_class_syntax_literal_quotes() {
1080 let input = r#"<img src="img.jpg">.class="my-class" rest"#;
1081 let result = fix_literal_class_syntax(input);
1082 assert!(
1083 !result.contains(".class=\""),
1084 "should remove .class=\", got: {result}"
1085 );
1086 assert!(
1087 result.contains("class=\"my-class\""),
1088 "should inject class attr, got: {result}"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_fix_literal_class_syntax_no_class() {
1094 let input = r#"<img src="img.jpg"> some text"#;
1095 let result = fix_literal_class_syntax(input);
1096 assert_eq!(result, input, "No .class= should leave input unchanged");
1097 }
1098
1099 #[test]
1104 fn test_inject_mobile_web_app_capable_meta_added() {
1105 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"></head>"#;
1106 let result = inject_mobile_web_app_capable_meta(input);
1107 assert!(
1108 result.contains(
1109 r#"<meta name="mobile-web-app-capable" content="yes">"#
1110 ),
1111 "modern meta should be injected, got: {result}"
1112 );
1113 assert!(
1114 result.contains(
1115 r#"<meta name="apple-mobile-web-app-capable" content="yes">"#
1116 ),
1117 "legacy meta must remain for backwards compatibility"
1118 );
1119 }
1120
1121 #[test]
1126 fn test_remove_empty_preload_drops_bare_href() {
1127 let input = r#"<head><link as=image fetchpriority=high href rel=preload type=image/webp><title>x</title></head>"#;
1128 let result = remove_empty_preload_links(input);
1129 assert!(
1130 !result.contains("rel=preload"),
1131 "empty preload should be removed, got: {result}"
1132 );
1133 assert!(result.contains("<title>x</title>"), "rest preserved");
1134 }
1135
1136 #[test]
1137 fn test_remove_empty_preload_drops_quoted_empty_href() {
1138 let input = r#"<link rel="preload" href="" as="image">"#;
1139 let result = remove_empty_preload_links(input);
1140 assert_eq!(result, "");
1141 }
1142
1143 #[test]
1144 fn test_remove_empty_preload_keeps_valid_preload() {
1145 let input = r#"<link rel="preload" href="/banner.webp" as="image">"#;
1146 let result = remove_empty_preload_links(input);
1147 assert_eq!(result, input);
1148 }
1149
1150 #[test]
1151 fn test_remove_empty_preload_preserves_utf8() {
1152 let input = r#"<title>日本語</title><link rel=preload href as=image><p>テスト</p>"#;
1153 let result = remove_empty_preload_links(input);
1154 assert!(result.contains("日本語"));
1155 assert!(result.contains("テスト"));
1156 assert!(!result.contains("rel=preload"));
1157 }
1158
1159 #[test]
1160 fn test_apply_html_fixes_idempotent_on_modern_meta() {
1161 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name="mobile-web-app-capable" content="yes"></head>"#;
1162 let result = apply_html_fixes(input);
1163 let count = result.matches("name=\"mobile-web-app-capable\"").count();
1165 assert_eq!(count, 1, "no duplicate injection, got: {result}");
1166 }
1167
1168 #[test]
1169 fn test_apply_html_fixes_idempotent_on_modern_meta_single_quotes() {
1170 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name='mobile-web-app-capable' content="yes"></head>"#;
1171 let result = apply_html_fixes(input);
1172 assert!(
1173 !result.contains("name=\"mobile-web-app-capable\""),
1174 "Should not inject modern meta when single quoted one exists"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_apply_html_fixes_idempotent_on_modern_meta_unquoted() {
1180 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name=mobile-web-app-capable content="yes"></head>"#;
1181 let result = apply_html_fixes(input);
1182 assert!(
1183 !result.contains("name=\"mobile-web-app-capable\""),
1184 "Should not inject modern meta when unquoted one exists"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_html_fix_plugin_metadata() {
1190 assert_eq!(HtmlFixPlugin.name(), "html-fix");
1191 assert!(HtmlFixPlugin.has_transform());
1192 let tmp = tempdir().unwrap();
1193 let ctx = test_ctx(tmp.path());
1194 assert!(HtmlFixPlugin.after_compile(&ctx).is_ok());
1195 }
1196
1197 #[test]
1198 fn test_needs_schema_context_fix() {
1199 assert!(needs_schema_context_fix("\"http://schema.org/\""));
1200 assert!(needs_schema_context_fix("\"http://schema.org\""));
1201 assert!(!needs_schema_context_fix("\"https://schema.org\""));
1202 }
1203
1204 #[test]
1205 fn test_needs_class_syntax_fix() {
1206 assert!(needs_class_syntax_fix(".class="foo""));
1207 assert!(needs_class_syntax_fix(".class=\"foo\""));
1208 assert!(!needs_class_syntax_fix("class=\"foo\""));
1209 }
1210
1211 #[test]
1212 fn test_has_empty_preload() {
1213 assert!(has_empty_preload("<link rel=\"preload\" href=\"\">"));
1214 assert!(has_empty_preload("<link rel='preload' href=''>"));
1215 assert!(has_empty_preload("<link rel=preload href>"));
1216 assert!(!has_empty_preload("<link rel=\"preload\" href=\"/foo\">"));
1217 assert!(!has_empty_preload("<link rel=\"stylesheet\" href=\"\">"));
1218 }
1219
1220 #[test]
1221 fn test_remove_empty_preload_unclosed_tag() {
1222 let input = "<link rel=\"preload\" href=\"\"";
1223 let result = remove_empty_preload_links(input);
1224 assert_eq!(result, "");
1225 }
1226
1227 #[test]
1228 fn test_remove_empty_preload_unclosed_quotes() {
1229 let input = "<link rel=\"preload href=\"\" >";
1230 let result = remove_empty_preload_links(input);
1231 assert_eq!(result, input);
1232 }
1233
1234 #[test]
1235 fn test_href_is_present_and_non_empty_edge_cases() {
1236 assert!(!href_is_present_and_non_empty(""));
1237 assert!(!href_is_present_and_non_empty("src=foo"));
1238 assert!(!href_is_present_and_non_empty("href"));
1239 assert!(!href_is_present_and_non_empty("href "));
1240 assert!(!href_is_present_and_non_empty("href = >"));
1241 assert!(!href_is_present_and_non_empty("href = \"\""));
1242 assert!(!href_is_present_and_non_empty("href = ''"));
1243 assert!(!href_is_present_and_non_empty("href = "));
1244 assert!(!href_is_present_and_non_empty("href="));
1245 assert!(!href_is_present_and_non_empty("href=>"));
1246 assert!(!href_is_present_and_non_empty("href= "));
1247 assert!(!href_is_present_and_non_empty("href=\""));
1248 assert!(!href_is_present_and_non_empty("href='"));
1249 assert!(href_is_present_and_non_empty("href = \"/a\""));
1250 assert!(href_is_present_and_non_empty("href = '/a'"));
1251 assert!(href_is_present_and_non_empty("href=foo"));
1252 }
1253
1254 #[test]
1255 fn test_needs_mobile_web_app_capable_meta() {
1256 assert!(needs_mobile_web_app_capable_meta(
1257 "apple-mobile-web-app-capable"
1258 ));
1259 assert!(!needs_mobile_web_app_capable_meta(
1260 "apple-mobile-web-app-capable and name=\"mobile-web-app-capable\""
1261 ));
1262 assert!(!needs_mobile_web_app_capable_meta("no legacy meta"));
1263 }
1264
1265 #[test]
1266 fn test_inject_mobile_web_app_capable_meta_edge_cases() {
1267 let no_head = inject_mobile_web_app_capable_meta("plain text");
1271 assert!(
1272 no_head.contains("name=\"mobile-web-app-capable\""),
1273 "fallback should inject modern meta: {no_head}"
1274 );
1275
1276 let unclosed = inject_mobile_web_app_capable_meta(
1280 "<meta name=\"apple-mobile-web-app-capable\"",
1281 );
1282 assert!(
1283 unclosed.contains("name=\"mobile-web-app-capable\""),
1284 "fallback should inject modern meta: {unclosed}"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_inject_modern_meta_fallback_when_apple_meta_is_escaped() {
1290 let html = "<html><head><title>x</title></head><body>\
1296 <meta name="apple-mobile-web-app-capable" \
1297 content="yes"></body></html>";
1298 let result = apply_html_fixes(html);
1299 assert!(
1300 result.contains("name=\"mobile-web-app-capable\""),
1301 "modern companion must be injected even when legacy is escaped"
1302 );
1303 let modern_pos =
1305 result.find("name=\"mobile-web-app-capable\"").unwrap();
1306 let head_close_pos = result.find("</head>").unwrap();
1307 assert!(
1308 modern_pos < head_close_pos,
1309 "modern meta should live inside <head>:\n{result}"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_fix_jsonld_dates_invalid_rfc2822() {
1315 let input = r#"{"datePublished":"Mon"}"#;
1317 assert_eq!(fix_jsonld_dates(input), input);
1318
1319 let input2 = r#"{"datePublished":"2026, 11 Apr 2026"}"#;
1321 assert_eq!(fix_jsonld_dates(input2), input2);
1322
1323 let input3 = r#"{"dateCreated":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1325 assert_eq!(fix_jsonld_dates(input3), input3);
1326
1327 let input4 = r#"{"datePublished":"Thu, 11 Apr 2026"#;
1329 assert_eq!(fix_jsonld_dates(input4), input4);
1330 }
1331
1332 #[test]
1333 fn test_fix_broken_img_tags_edge_cases() {
1334 let input = r#"<img <p src=image.jpg>"#;
1336 assert_eq!(fix_broken_img_tags(input), input);
1337
1338 let input2 = r#"<p src="image.jpg">"#;
1340 assert_eq!(fix_broken_img_tags(input2), input2);
1341 }
1342
1343 #[test]
1344 fn test_fix_literal_class_syntax_edge_cases() {
1345 let input = r#"<img src="img.jpg">.class="my-class"#;
1347 assert_eq!(fix_literal_class_syntax(input), input);
1348 }
1349
1350 #[test]
1351 fn test_inject_class_attr_edge_cases() {
1352 let mut html = "some text without tags".to_string();
1354 inject_class_attr(&mut html, 10, "foo");
1355 assert_eq!(html, "some text without tags");
1356
1357 let mut html2 = "<img class=\"existing\"> some text".to_string();
1359 let len = html2.len();
1360 inject_class_attr(&mut html2, len, "foo");
1361 assert_eq!(html2, "<img class=\"existing\"> some text");
1362
1363 let mut html3 = "> stray text".to_string();
1367 let len3 = html3.len();
1368 inject_class_attr(&mut html3, len3, "foo");
1369 assert_eq!(html3, "> stray text");
1370 }
1371
1372 #[test]
1377 fn test_apply_html_fixes_routes_broken_img_repair() {
1378 let html = r#"<img alt="x" <p src="/pic.png"> tail"#;
1379 let out = apply_html_fixes(html);
1380 assert!(
1381 out.contains(r#"<img alt="x" src="/pic.png" />"#),
1382 "broken img must be repaired via the apply pipeline: {out}"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_apply_html_fixes_routes_class_syntax_repair() {
1388 let html = r#"<div>.class="hero"</div>"#;
1389 let out = apply_html_fixes(html);
1390 assert!(
1391 !out.contains(".class="),
1392 "literal class syntax must be removed via the apply pipeline: {out}"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_apply_html_fixes_routes_empty_preload_removal() {
1398 let html = r#"<head><link rel="preload" href="" as="style"><link rel="stylesheet" href="/a.css"></head>"#;
1399 let out = apply_html_fixes(html);
1400 assert!(
1401 !out.contains("rel=\"preload\""),
1402 "empty-href preload must be dropped via the apply pipeline: {out}"
1403 );
1404 assert!(out.contains("/a.css"), "real links survive: {out}");
1405 }
1406
1407 #[test]
1412 fn test_fix_jsonld_dates_keeps_unparseable_rfc_shaped_date() {
1413 let html = r#"{"datePublished":"Mon, not a real date"}"#;
1414 let out = fix_jsonld_dates(html);
1415 assert_eq!(out, html, "unparseable date passes through verbatim");
1416 }
1417
1418 #[test]
1423 fn test_fix_broken_img_tags_unterminated_src_bails_out() {
1424 let html = r#"<img alt="x" <p src="never-closes"#;
1425 let out = fix_broken_img_tags(html);
1426 assert_eq!(out, html, "unterminated src must not loop or rewrite");
1427 }
1428
1429 #[test]
1434 fn test_fix_literal_class_syntax_keeps_existing_class_attr() {
1435 let html = r#"<div class="old">.class="new"</div>"#;
1436 let out = fix_literal_class_syntax(html);
1437 assert!(out.contains(r#"class="old""#), "existing class kept: {out}");
1438 assert!(
1439 !out.contains(r#"class="new""#),
1440 "no second class attribute injected: {out}"
1441 );
1442 }
1443
1444 #[test]
1449 fn test_inject_meta_falls_back_when_head_close_is_escaped_only() {
1450 let html = "no real head here </head>";
1454 let out = inject_mobile_web_app_capable_meta(html);
1455 assert!(
1456 out.starts_with("<meta name=\"mobile-web-app-capable\""),
1457 "prepend fallback used: {out}"
1458 );
1459 }
1460
1461 #[test]
1462 fn test_inject_meta_after_open_head_when_no_close_tag() {
1463 let html = "<head><meta charset=\"utf-8\">";
1464 let out = inject_mobile_web_app_capable_meta(html);
1465 assert!(
1466 out.starts_with(
1467 "<head><meta name=\"mobile-web-app-capable\" content=\"yes\">"
1468 ),
1469 "meta injected right after <head>: {out}"
1470 );
1471 }
1472}