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("<") {
89 modified = fix_escaped_html_entities(&modified);
90 }
91
92 if modified.contains("<code><") {
93 modified = escape_markup_inside_code_spans(&modified);
94 }
95
96 modified
97}
98
99fn escape_markup_inside_code_spans(html: &str) -> String {
117 let mut out = String::with_capacity(html.len());
118 let mut rest = html;
119
120 while let Some(start) = rest.find("<code>") {
121 let after_open = start + "<code>".len();
122 let Some(close_rel) = rest[after_open..].find("</code>") else {
123 break;
124 };
125 let close = after_open + close_rel;
126
127 let preceding = rest[..start].trim_end();
138 let opens_a_pre_block = preceding.ends_with('>')
139 && preceding.rfind("<pre").is_some_and(|i| {
140 preceding[i..]
141 .find('>')
142 .is_some_and(|j| i + j + 1 == preceding.len())
143 });
144 if opens_a_pre_block {
145 let end = close + "</code>".len();
146 out.push_str(&rest[..end]);
147 rest = &rest[end..];
148 continue;
149 }
150
151 out.push_str(&rest[..after_open]);
152 for ch in rest[after_open..close].chars() {
155 match ch {
156 '<' => out.push_str("<"),
157 '>' => out.push_str(">"),
158 other => out.push(other),
159 }
160 }
161 out.push_str("</code>");
162 rest = &rest[close + "</code>".len()..];
163 }
164
165 out.push_str(rest);
166 out
167}
168
169fn add_table_header_scope(html: &str) -> String {
179 rewrite_html(
180 html,
181 vec![
182 element!("thead th:not([scope])", |el| {
183 el.set_attribute("scope", "col")?;
184 Ok(())
185 }),
186 element!("tbody th:not([scope])", |el| {
187 el.set_attribute("scope", "row")?;
188 Ok(())
189 }),
190 element!("table > tr th:not([scope])", |el| {
192 el.set_attribute("scope", "col")?;
193 Ok(())
194 }),
195 ],
196 )
197 .unwrap_or_else(|_| html.to_string())
198}
199
200fn wrap_tables_for_reflow(html: &str) -> String {
217 use lol_html::html_content::ContentType;
218
219 let already = "ssg-table-scroll";
220 if html.contains(already) {
221 return html.to_string();
222 }
223
224 rewrite_html(
225 html,
226 vec![element!("table", move |el| {
227 el.before(
228 &format!(
229 "<div class=\"table-wrap {already}\" role=\"region\" \
230 aria-label=\"Table, scrollable horizontally\" tabindex=\"0\">"
231 ),
232 ContentType::Html,
233 );
234 el.after("</div>", ContentType::Html);
235 Ok(())
236 })],
237 )
238 .unwrap_or_else(|_| html.to_string())
239}
240
241fn replace_table_align_attrs(html: &str) -> String {
259 let handler = |el: &mut lol_html::html_content::Element<'_, '_>| {
260 let Some(align) = el.get_attribute("align") else {
261 return Ok(());
262 };
263 el.remove_attribute("align");
264
265 let class = match align.trim().to_ascii_lowercase().as_str() {
266 "left" => "text-left",
267 "center" | "centre" => "text-center",
268 "right" => "text-right",
269 _ => return Ok(()),
273 };
274
275 let existing = el.get_attribute("class").unwrap_or_default();
276 if existing.split_whitespace().any(|c| c == class) {
277 return Ok(());
278 }
279 let merged = if existing.is_empty() {
280 class.to_string()
281 } else {
282 format!("{existing} {class}")
283 };
284 el.set_attribute("class", &merged)?;
285 Ok(())
286 };
287
288 rewrite_html(
289 html,
290 vec![
291 element!("th[align]", handler),
292 element!("td[align]", handler),
293 ],
294 )
295 .unwrap_or_else(|_| html.to_string())
296}
297
298fn needs_schema_context_fix(html: &str) -> bool {
300 html.contains("\"http://schema.org/\"")
301 || html.contains("\"http://schema.org\"")
302}
303
304fn needs_class_syntax_fix(html: &str) -> bool {
306 html.contains(".class="") || html.contains(".class=\"")
307}
308
309fn has_empty_preload(html: &str) -> bool {
315 let has_preload = html.contains("rel=preload")
320 || html.contains("rel=\"preload\"")
321 || html.contains("rel='preload'");
322 let has_empty_href = html.contains("href=\"\"")
323 || html.contains("href=''")
324 || html.contains(" href ")
325 || html.contains(" href>")
326 || html.contains(" href/>");
327 has_preload && has_empty_href
328}
329
330pub(super) fn remove_empty_preload_links(html: &str) -> String {
333 let mut out = String::with_capacity(html.len());
334 let mut cursor = 0;
335 while cursor < html.len() {
336 let Some(rel_offset) =
338 html[cursor..].to_ascii_lowercase().find("<link")
339 else {
340 out.push_str(&html[cursor..]);
341 break;
342 };
343 let tag_start = cursor + rel_offset;
344 out.push_str(&html[cursor..tag_start]);
345
346 let bytes = html.as_bytes();
348 let mut j = tag_start;
349 let mut quote: Option<u8> = None;
350 while j < bytes.len() {
351 let b = bytes[j];
352 match quote {
353 Some(q) if b == q => quote = None,
354 Some(_) => {}
355 None => match b {
356 b'"' | b'\'' => quote = Some(b),
357 b'>' => break,
358 _ => {}
359 },
360 }
361 j += 1;
362 }
363 let tag_end = (j + 1).min(html.len());
364 let tag = &html[tag_start..tag_end];
365 let lower = tag.to_ascii_lowercase();
366 let is_preload = lower.contains("rel=\"preload\"")
367 || lower.contains("rel='preload'")
368 || lower.contains("rel=preload");
369 let has_real_href = href_is_present_and_non_empty(&lower);
370 if !is_preload || has_real_href {
372 out.push_str(tag);
373 }
374 cursor = tag_end;
375 }
376 out
377}
378
379fn href_is_present_and_non_empty(lower_tag: &str) -> bool {
382 if lower_tag.contains("href=\"\"") || lower_tag.contains("href=''") {
383 return false;
384 }
385 let Some(idx) = lower_tag.find("href") else {
386 return false;
387 };
388 let after = lower_tag[idx + 4..].trim_start();
390 let Some(rest) = after.strip_prefix('=') else {
391 return false;
392 };
393 let rest = rest.trim_start();
394 match rest.chars().next() {
397 None | Some('>') => false,
398 Some('"') => rest.len() > 1 && !rest.starts_with("\"\""),
399 Some('\'') => rest.len() > 1 && !rest.starts_with("''"),
400 Some(_) => true,
401 }
402}
403
404fn needs_mobile_web_app_capable_meta(html: &str) -> bool {
410 let has_legacy = html.contains("apple-mobile-web-app-capable");
411 let has_modern = find_modern_mobile_web_app_capable(html).is_some();
412 has_legacy && !has_modern
413}
414
415fn find_modern_mobile_web_app_capable(html: &str) -> Option<usize> {
418 let needles = [
422 "name=\"mobile-web-app-capable\"",
423 "name='mobile-web-app-capable'",
424 "name=mobile-web-app-capable",
425 ];
426 for n in &needles {
427 if let Some(pos) = html.find(n) {
428 return Some(pos);
429 }
430 }
431 None
432}
433
434pub(super) fn inject_mobile_web_app_capable_meta(html: &str) -> String {
445 let modern = "<meta name=\"mobile-web-app-capable\" content=\"yes\">";
446 let candidates = [
448 "name=\"apple-mobile-web-app-capable\"",
449 "name='apple-mobile-web-app-capable'",
450 "name=apple-mobile-web-app-capable",
451 ];
452 let name_pos = candidates.iter().find_map(|n| html.find(n));
453 if let Some(name_pos) = name_pos {
454 let after = &html[name_pos..];
456 if let Some(rel_close) = after.find('>') {
457 let insert_at = name_pos + rel_close + 1;
458 return format!(
459 "{}{modern}{}",
460 &html[..insert_at],
461 &html[insert_at..]
462 );
463 }
464 }
465 inject_modern_meta_into_head(html, modern)
469}
470
471fn inject_modern_meta_into_head(html: &str, meta: &str) -> String {
476 let lower = html.to_ascii_lowercase();
479 if lower.contains("</head>") {
480 let injected = inject_before_head_close(html, meta);
481 if injected != html {
482 return injected;
483 }
484 }
485 if let Some(pos) = lower.find("<head>") {
486 let insert_at = pos + "<head>".len();
487 return format!("{}{meta}{}", &html[..insert_at], &html[insert_at..]);
488 }
489 format!("{meta}{html}")
491}
492
493pub(super) fn fix_jsonld_dates(html: &str) -> String {
495 let mut result = html.to_string();
496
497 for field in &["datePublished", "dateModified"] {
499 let pattern = format!("\"{field}\":\"");
500 let mut search_from = 0;
501 while let Some(start) = result[search_from..].find(&pattern) {
502 let abs_start = search_from + start + pattern.len();
503 if let Some(end) = result[abs_start..].find('"') {
504 let date_str = &result[abs_start..abs_start + end];
505 if date_str.len() > 5
508 && date_str.as_bytes()[3] == b','
509 && date_str.as_bytes()[0].is_ascii_alphabetic()
510 {
511 let iso = rfc2822_to_iso8601(date_str);
512 if iso != date_str {
513 result = format!(
514 "{}{}{}",
515 &result[..abs_start],
516 iso,
517 &result[abs_start + end..]
518 );
519 }
520 }
521 search_from = abs_start + 1;
522 } else {
523 break;
524 }
525 }
526 }
527
528 result
529}
530
531pub(super) fn fix_broken_img_tags(html: &str) -> String {
534 let mut result = html.to_string();
535 while let Some(p_pos) = result.find("<p src=") {
538 let before = &result[..p_pos];
540 if let Some(img_start) = before.rfind("<img") {
541 let after_p = &result[p_pos..]; if let Some(quote_start) = after_p.find("src=\"") {
544 let val_start = quote_start + 5; let remaining = &after_p[val_start..];
546 if let Some(quote_end) = remaining.find('"') {
547 let src_value = remaining[..quote_end].to_string();
548 let close_offset = remaining[quote_end..]
550 .find('>')
551 .map_or(result.len(), |i| {
552 p_pos + val_start + quote_end + i + 1
553 });
554
555 let img_attrs = result[img_start + 4..p_pos].trim();
557 let img_attrs_clean =
558 img_attrs.trim_end_matches(|c: char| {
559 c.is_whitespace() || c == '<'
560 });
561
562 let new_img = format!(
563 "<img {img_attrs_clean} src=\"{src_value}\" />"
564 );
565 result = format!(
566 "{}{}{}",
567 &result[..img_start],
568 new_img,
569 &result[close_offset..]
570 );
571 continue;
572 }
573 }
574 }
575 break;
577 }
578 result
579}
580
581pub(super) fn fix_literal_class_syntax(html: &str) -> String {
584 let mut result = html.to_string();
585
586 result = fix_class_syntax_variant(&result, ".class="", """);
588 result = fix_class_syntax_variant(&result, ".class=\"", "\"");
590
591 result
592}
593
594fn fix_class_syntax_variant(
596 html: &str,
597 open_pattern: &str,
598 close_pattern: &str,
599) -> String {
600 let mut result = html.to_string();
601 while let Some(start) = result.find(open_pattern) {
602 let after = &result[start + open_pattern.len()..];
603 if let Some(end) = after.find(close_pattern) {
604 let class_value = after[..end].to_string();
605 let remove_end =
606 start + open_pattern.len() + end + close_pattern.len();
607 result = format!("{}{}", &result[..start], &result[remove_end..]);
608 inject_class_attr(&mut result, start, &class_value);
609 } else {
610 break;
611 }
612 }
613 result
614}
615
616fn inject_class_attr(html: &mut String, pos: usize, class_value: &str) {
618 if let Some(tag_end) = html[..pos].rfind('>') {
619 if let Some(tag_start) = html[..tag_end].rfind('<') {
620 let tag = &html[tag_start..tag_end];
621 if !tag.contains("class=") {
622 let insert_pos = tag_end;
623 *html = format!(
624 "{} class=\"{}\"{}",
625 &html[..insert_pos],
626 class_value,
627 &html[insert_pos..]
628 );
629 }
630 }
631 }
632}
633
634fn fix_escaped_html_entities(html: &str) -> String {
636 let mut modified = html.to_string();
637
638 let tag_prefixes = [
639 "<section",
640 "</section>",
641 "<article",
642 "</article>",
643 "<header",
644 "</header>",
645 "<footer",
646 "</footer>",
647 "<nav",
648 "</nav>",
649 "<aside",
650 "</aside>",
651 "<main",
652 "</main>",
653 "<div",
654 "</div>",
655 "<form",
656 "</form>",
657 "<input",
658 "</input>",
659 "<label",
660 "</label>",
661 "<button",
662 "</button>",
663 "<select",
664 "</select>",
665 "<option",
666 "</option>",
667 "<textarea",
668 "</textarea>",
669 "<table",
670 "</table>",
671 "<thead",
672 "</thead>",
673 "<tbody",
674 "</tbody>",
675 "<tr",
676 "</tr>",
677 "<th",
678 "</th>",
679 "<td",
680 "</td>",
681 "<p",
682 "</p>",
683 "<span",
684 "</span>",
685 "<a ",
686 "</a>",
687 "<img",
688 "<picture",
689 "</picture>",
690 "<source",
691 "<h1",
692 "</h1>",
693 "<h2",
694 "</h2>",
695 "<h3",
696 "</h3>",
697 "<h4",
698 "</h4>",
699 "<h5",
700 "</h5>",
701 "<h6",
702 "</h6>",
703 "<ul",
704 "</ul>",
705 "<ol",
706 "</ol>",
707 "<li",
708 "</li>",
709 "<strong",
710 "</strong>",
711 "<em",
712 "</em>",
713 "<blockquote",
714 "</blockquote>",
715 "<hr",
716 "<br",
717 ];
718
719 for prefix in tag_prefixes {
720 if prefix.ends_with(">") {
721 let clean_closing =
722 prefix.replace("</", "</").replace(">", ">");
723 modified = modified.replace(prefix, &clean_closing);
724 } else {
725 while let Some(start) = modified.find(prefix) {
726 if let Some(end_rel) = modified[start..].find(">") {
727 let end = start + end_rel + 4;
728 let tag_chunk = &modified[start..end];
729 let decoded_tag = tag_chunk
730 .replace("<", "<")
731 .replace(">", ">")
732 .replace(""", "\"")
733 .replace("'", "'");
734 modified = format!(
735 "{}{}{}",
736 &modified[..start],
737 decoded_tag,
738 &modified[end..]
739 );
740 } else {
741 break;
742 }
743 }
744 }
745 }
746
747 modified
748}
749
750#[cfg(test)]
751mod tests {
752 #[test]
760 fn a_pre_block_keeps_authored_markup() {
761 let html = "<pre class=\"editor-code\"><code>\
762<span class=\"code-kw\">pub fn</span> main()</code></pre>";
763 let out = escape_markup_inside_code_spans(html);
764 assert_eq!(out, html, "a <pre><code> block must pass through intact");
765 assert!(!out.contains("<span"), "spans must not be escaped");
766 }
767
768 #[test]
770 fn an_inline_code_span_is_still_escaped() {
771 let html = "<p>Every <code><img></code> needs alt text.</p>";
772 let out = escape_markup_inside_code_spans(html);
773 assert!(
774 out.contains("<code><img></code>"),
775 "inline spans must still be escaped, got: {out}"
776 );
777 }
778
779 #[test]
781 fn an_inline_span_after_an_earlier_pre_is_still_escaped() {
782 let html = "<pre><code>x</code></pre><p>Use <code><br></code>.</p>";
783 let out = escape_markup_inside_code_spans(html);
784 assert!(
785 out.contains("<code><br></code>"),
786 "a later inline span must still be escaped, got: {out}"
787 );
788 assert!(
789 out.contains("<pre><code>x</code></pre>"),
790 "the earlier block must be untouched, got: {out}"
791 );
792 }
793
794 #[test]
795 fn escapes_a_tag_left_raw_inside_a_code_span() {
796 let html = "<li>Every <code><img></code> has a meaningful <code>alt</code></li>";
800 let out = apply_html_fixes(html);
801 assert!(out.contains("<code><img></code>"), "got: {out}");
802 assert!(!out.contains("<code><img></code>"), "got: {out}");
803 assert!(out.contains("<code>alt</code>"), "got: {out}");
805 }
806
807 #[test]
808 fn escaping_code_spans_leaves_surrounding_markup_alone() {
809 let html = "<p>Before</p><code><b>x</b></code><p>After <em>y</em></p>";
810 let out = apply_html_fixes(html);
811 assert!(
812 out.contains("<code><b>x</b></code>"),
813 "got: {out}"
814 );
815 assert!(out.contains("<p>Before</p>"), "got: {out}");
816 assert!(out.contains("<em>y</em>"), "got: {out}");
817 }
818
819 #[test]
820 fn already_escaped_code_spans_are_not_double_escaped() {
821 let html = "<code><img></code>";
824 let once = apply_html_fixes(html);
825 let twice = apply_html_fixes(&once);
826 assert_eq!(once, twice, "pass is not idempotent");
827 assert!(!twice.contains("&lt;"), "double-escaped: {twice}");
828 }
829
830 #[test]
831 fn unterminated_code_span_does_not_truncate_the_document() {
832 let html = "<p>keep</p><code><img>";
835 let out = apply_html_fixes(html);
836 assert!(out.contains("<p>keep</p>"), "content lost: {out}");
837 }
838
839 use super::*;
840 use crate::plugin::PluginContext;
841 use std::path::Path;
842 use tempfile::tempdir;
843
844 #[test]
849 fn apply_html_fixes_strips_table_align_attrs() {
850 let out = apply_html_fixes(
851 r#"<table><tr><td align="right">7</td></tr></table>"#,
852 );
853 assert!(
854 !out.contains("align="),
855 "not wired into the pipeline: {out}"
856 );
857 assert!(out.contains("text-right"), "{out}");
858 }
859
860 #[test]
864 fn table_headers_gain_a_scope() {
865 let out = add_table_header_scope(concat!(
866 "<table><thead><tr><th>Plan</th></tr></thead>",
867 "<tbody><tr><th>Starter</th><td>Free</td></tr></tbody></table>",
868 ));
869 assert!(out.contains(r#"<th scope="col">Plan"#), "{out}");
870 assert!(out.contains(r#"<th scope="row">Starter"#), "{out}");
871 }
872
873 #[test]
875 fn table_header_scope_does_not_overwrite_an_author_value() {
876 let out = add_table_header_scope(
877 r#"<table><thead><tr><th scope="rowgroup">X</th></tr></thead></table>"#,
878 );
879 assert!(out.contains(r#"scope="rowgroup""#), "{out}");
880 assert_eq!(out.matches("scope=").count(), 1, "{out}");
881 }
882
883 #[test]
886 fn tables_are_wrapped_in_a_focusable_scroll_region() {
887 let out = wrap_tables_for_reflow("<table><tr><td>x</td></tr></table>");
888 assert!(out.contains("table-wrap"), "{out}");
889 assert!(out.contains(r#"role="region""#), "{out}");
890 assert!(out.contains(r#"tabindex="0""#), "{out}");
891 assert!(out.contains("aria-label"), "{out}");
892 }
893
894 #[test]
896 fn table_wrapping_is_idempotent() {
897 let once = wrap_tables_for_reflow("<table><tr><td>x</td></tr></table>");
898 assert_eq!(wrap_tables_for_reflow(&once), once);
899 }
900
901 #[test]
905 fn table_align_attrs_become_text_classes() {
906 let html = concat!(
907 "<table><thead><tr>",
908 r#"<th align="left">Layer</th>"#,
909 r#"<th align="center">Maturity</th>"#,
910 r#"<th align="right">Metric</th>"#,
911 "</tr></thead></table>",
912 );
913 let out = replace_table_align_attrs(html);
914
915 assert!(
916 !out.contains("align="),
917 "obsolete attribute survived: {out}"
918 );
919 assert!(out.contains("text-left"), "{out}");
920 assert!(out.contains("text-center"), "{out}");
921 assert!(out.contains("text-right"), "{out}");
922 }
923
924 #[test]
927 fn table_align_does_not_duplicate_an_existing_class() {
928 let html = r#"<td align="right" class="text-right num">7</td>"#;
929 let out = replace_table_align_attrs(html);
930
931 assert!(!out.contains("align="), "{out}");
932 assert_eq!(out.matches("text-right").count(), 1, "duplicated: {out}");
933 assert!(out.contains("num"), "existing classes dropped: {out}");
934 }
935
936 #[test]
939 fn table_align_unrecognised_value_drops_attribute_without_a_class() {
940 let out = replace_table_align_attrs(r#"<td align="justify">x</td>"#);
941 assert!(!out.contains("align="), "{out}");
942 assert!(!out.contains("text-"), "invented a class: {out}");
943 }
944
945 #[test]
948 fn table_align_leaves_literal_text_in_pre_alone() {
949 let html = r#"<pre><code><td align="left"></code></pre>"#;
950 assert_eq!(replace_table_align_attrs(html), html);
951 }
952
953 #[test]
956 fn table_align_ignores_non_cell_elements() {
957 let html = r#"<div align="center">x</div>"#;
958 assert_eq!(replace_table_align_attrs(html), html);
959 }
960
961 fn test_ctx(site_dir: &Path) -> PluginContext {
962 crate::test_support::init_logger();
963 PluginContext::new(
964 Path::new("content"),
965 Path::new("build"),
966 site_dir,
967 Path::new("templates"),
968 )
969 }
970
971 #[test]
972 fn test_html_fix_upgrades_jsonld_context() -> Result<()> {
973 let tmp = tempdir().unwrap();
974 let ctx = test_ctx(tmp.path());
975
976 let html = r#"<html><head>
977<script type="application/ld+json">
978{"@context":"http://schema.org/","@type":"WebPage"}
979</script>
980</head><body></body></html>"#;
981
982 let result = HtmlFixPlugin
983 .transform_html(html, Path::new("index.html"), &ctx)
984 .unwrap();
985 assert!(result.contains("\"https://schema.org\""));
986 assert!(!result.contains("\"http://schema.org/\""));
987 Ok(())
988 }
989
990 #[test]
991 fn test_html_fix_converts_jsonld_dates() -> Result<()> {
992 let tmp = tempdir().unwrap();
993 let ctx = test_ctx(tmp.path());
994
995 let html = r#"<html><head>
996<script type="application/ld+json">
997{"@context":"https://schema.org","@type":"Article","datePublished":"Thu, 11 Apr 2026 06:06:06 +0000","dateModified":"Mon, 01 Sep 2025 06:06:06 +0000"}
998</script>
999</head><body></body></html>"#;
1000
1001 let result = HtmlFixPlugin
1002 .transform_html(html, Path::new("article.html"), &ctx)
1003 .unwrap();
1004 assert!(
1005 result.contains("\"datePublished\":\"2026-04-11"),
1006 "Expected ISO date, got: {result}"
1007 );
1008 assert!(
1009 result.contains("\"dateModified\":\"2025-09-01"),
1010 "Expected ISO date, got: {result}"
1011 );
1012 assert!(!result.contains("Thu, 11 Apr"));
1013 Ok(())
1014 }
1015
1016 #[test]
1017 fn test_fix_broken_img_tags() {
1018 let input =
1019 r#"<img alt="test" class="w-25" title="test" <p src="image.jpg">"#;
1020 let result = fix_broken_img_tags(input);
1021 assert!(result.contains("src=\"image.jpg\""));
1022 assert!(!result.contains("<p src="));
1023 }
1024
1025 #[test]
1026 fn test_fix_literal_class_syntax() {
1027 let input = r#"<img alt="test" src="img.jpg">.class="w-25 float-start""#;
1028 let result = fix_literal_class_syntax(input);
1029 assert!(!result.contains(".class=""));
1030 }
1031
1032 #[test]
1037 fn test_fix_jsonld_dates_iso_passthrough() {
1038 let input =
1039 r#"{"datePublished":"2026-04-11","dateModified":"2025-09-01"}"#;
1040 let result = fix_jsonld_dates(input);
1041 assert_eq!(result, input, "ISO dates should pass through unchanged");
1042 }
1043
1044 #[test]
1045 fn test_fix_jsonld_dates_converts_rfc2822() {
1046 let input = r#"{"datePublished":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1047 let result = fix_jsonld_dates(input);
1048 assert!(
1049 result.contains("\"datePublished\":\"2026-04-11T06:06:06+00:00\""),
1050 "Should convert RFC 2822 to ISO 8601, got: {result}"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_fix_jsonld_dates_both_fields() {
1056 let input = r#"{"datePublished":"Mon, 01 Sep 2025 12:00:00 +0000","dateModified":"Tue, 02 Sep 2025 14:30:00 +0000"}"#;
1057 let result = fix_jsonld_dates(input);
1058 assert!(result.contains("2025-09-01T12:00:00+00:00"));
1059 assert!(result.contains("2025-09-02T14:30:00+00:00"));
1060 }
1061
1062 #[test]
1067 fn test_fix_broken_img_tags_multiple() {
1068 let input =
1069 r#"<img alt="a" <p src="one.jpg"><img alt="b" <p src="two.jpg">"#;
1070 let result = fix_broken_img_tags(input);
1071 assert!(result.contains("src=\"one.jpg\""), "first img: {result}");
1072 assert!(result.contains("src=\"two.jpg\""), "second img: {result}");
1073 assert!(
1074 !result.contains("<p src="),
1075 "no broken tags remain: {result}"
1076 );
1077 }
1078
1079 #[test]
1080 fn test_fix_broken_img_tags_none() {
1081 let input = r#"<img alt="ok" src="good.jpg" />"#;
1082 let result = fix_broken_img_tags(input);
1083 assert_eq!(
1084 result, input,
1085 "No broken tags should leave input unchanged"
1086 );
1087 }
1088
1089 #[test]
1094 fn test_fix_literal_class_syntax_html_encoded() {
1095 let input =
1096 r#"<img src="img.jpg">.class="w-25 float-start" rest"#;
1097 let result = fix_literal_class_syntax(input);
1098 assert!(
1099 !result.contains(".class=""),
1100 "should remove .class=""
1101 );
1102 assert!(
1103 result.contains("class=\"w-25 float-start\""),
1104 "should inject class attr, got: {result}"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_fix_literal_class_syntax_literal_quotes() {
1110 let input = r#"<img src="img.jpg">.class="my-class" rest"#;
1111 let result = fix_literal_class_syntax(input);
1112 assert!(
1113 !result.contains(".class=\""),
1114 "should remove .class=\", got: {result}"
1115 );
1116 assert!(
1117 result.contains("class=\"my-class\""),
1118 "should inject class attr, got: {result}"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_fix_literal_class_syntax_no_class() {
1124 let input = r#"<img src="img.jpg"> some text"#;
1125 let result = fix_literal_class_syntax(input);
1126 assert_eq!(result, input, "No .class= should leave input unchanged");
1127 }
1128
1129 #[test]
1134 fn test_inject_mobile_web_app_capable_meta_added() {
1135 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"></head>"#;
1136 let result = inject_mobile_web_app_capable_meta(input);
1137 assert!(
1138 result.contains(
1139 r#"<meta name="mobile-web-app-capable" content="yes">"#
1140 ),
1141 "modern meta should be injected, got: {result}"
1142 );
1143 assert!(
1144 result.contains(
1145 r#"<meta name="apple-mobile-web-app-capable" content="yes">"#
1146 ),
1147 "legacy meta must remain for backwards compatibility"
1148 );
1149 }
1150
1151 #[test]
1156 fn test_remove_empty_preload_drops_bare_href() {
1157 let input = r#"<head><link as=image fetchpriority=high href rel=preload type=image/webp><title>x</title></head>"#;
1158 let result = remove_empty_preload_links(input);
1159 assert!(
1160 !result.contains("rel=preload"),
1161 "empty preload should be removed, got: {result}"
1162 );
1163 assert!(result.contains("<title>x</title>"), "rest preserved");
1164 }
1165
1166 #[test]
1167 fn test_remove_empty_preload_drops_quoted_empty_href() {
1168 let input = r#"<link rel="preload" href="" as="image">"#;
1169 let result = remove_empty_preload_links(input);
1170 assert_eq!(result, "");
1171 }
1172
1173 #[test]
1174 fn test_remove_empty_preload_keeps_valid_preload() {
1175 let input = r#"<link rel="preload" href="/banner.webp" as="image">"#;
1176 let result = remove_empty_preload_links(input);
1177 assert_eq!(result, input);
1178 }
1179
1180 #[test]
1181 fn test_remove_empty_preload_preserves_utf8() {
1182 let input = r#"<title>日本語</title><link rel=preload href as=image><p>テスト</p>"#;
1183 let result = remove_empty_preload_links(input);
1184 assert!(result.contains("日本語"));
1185 assert!(result.contains("テスト"));
1186 assert!(!result.contains("rel=preload"));
1187 }
1188
1189 #[test]
1190 fn test_apply_html_fixes_idempotent_on_modern_meta() {
1191 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name="mobile-web-app-capable" content="yes"></head>"#;
1192 let result = apply_html_fixes(input);
1193 let count = result.matches("name=\"mobile-web-app-capable\"").count();
1195 assert_eq!(count, 1, "no duplicate injection, got: {result}");
1196 }
1197
1198 #[test]
1199 fn test_apply_html_fixes_idempotent_on_modern_meta_single_quotes() {
1200 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name='mobile-web-app-capable' content="yes"></head>"#;
1201 let result = apply_html_fixes(input);
1202 assert!(
1203 !result.contains("name=\"mobile-web-app-capable\""),
1204 "Should not inject modern meta when single quoted one exists"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_apply_html_fixes_idempotent_on_modern_meta_unquoted() {
1210 let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name=mobile-web-app-capable content="yes"></head>"#;
1211 let result = apply_html_fixes(input);
1212 assert!(
1213 !result.contains("name=\"mobile-web-app-capable\""),
1214 "Should not inject modern meta when unquoted one exists"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_html_fix_plugin_metadata() {
1220 assert_eq!(HtmlFixPlugin.name(), "html-fix");
1221 assert!(HtmlFixPlugin.has_transform());
1222 let tmp = tempdir().unwrap();
1223 let ctx = test_ctx(tmp.path());
1224 assert!(HtmlFixPlugin.after_compile(&ctx).is_ok());
1225 }
1226
1227 #[test]
1228 fn test_needs_schema_context_fix() {
1229 assert!(needs_schema_context_fix("\"http://schema.org/\""));
1230 assert!(needs_schema_context_fix("\"http://schema.org\""));
1231 assert!(!needs_schema_context_fix("\"https://schema.org\""));
1232 }
1233
1234 #[test]
1235 fn test_needs_class_syntax_fix() {
1236 assert!(needs_class_syntax_fix(".class="foo""));
1237 assert!(needs_class_syntax_fix(".class=\"foo\""));
1238 assert!(!needs_class_syntax_fix("class=\"foo\""));
1239 }
1240
1241 #[test]
1242 fn test_has_empty_preload() {
1243 assert!(has_empty_preload("<link rel=\"preload\" href=\"\">"));
1244 assert!(has_empty_preload("<link rel='preload' href=''>"));
1245 assert!(has_empty_preload("<link rel=preload href>"));
1246 assert!(!has_empty_preload("<link rel=\"preload\" href=\"/foo\">"));
1247 assert!(!has_empty_preload("<link rel=\"stylesheet\" href=\"\">"));
1248 }
1249
1250 #[test]
1251 fn test_remove_empty_preload_unclosed_tag() {
1252 let input = "<link rel=\"preload\" href=\"\"";
1253 let result = remove_empty_preload_links(input);
1254 assert_eq!(result, "");
1255 }
1256
1257 #[test]
1258 fn test_remove_empty_preload_unclosed_quotes() {
1259 let input = "<link rel=\"preload href=\"\" >";
1260 let result = remove_empty_preload_links(input);
1261 assert_eq!(result, input);
1262 }
1263
1264 #[test]
1265 fn test_href_is_present_and_non_empty_edge_cases() {
1266 assert!(!href_is_present_and_non_empty(""));
1267 assert!(!href_is_present_and_non_empty("src=foo"));
1268 assert!(!href_is_present_and_non_empty("href"));
1269 assert!(!href_is_present_and_non_empty("href "));
1270 assert!(!href_is_present_and_non_empty("href = >"));
1271 assert!(!href_is_present_and_non_empty("href = \"\""));
1272 assert!(!href_is_present_and_non_empty("href = ''"));
1273 assert!(!href_is_present_and_non_empty("href = "));
1274 assert!(!href_is_present_and_non_empty("href="));
1275 assert!(!href_is_present_and_non_empty("href=>"));
1276 assert!(!href_is_present_and_non_empty("href= "));
1277 assert!(!href_is_present_and_non_empty("href=\""));
1278 assert!(!href_is_present_and_non_empty("href='"));
1279 assert!(href_is_present_and_non_empty("href = \"/a\""));
1280 assert!(href_is_present_and_non_empty("href = '/a'"));
1281 assert!(href_is_present_and_non_empty("href=foo"));
1282 }
1283
1284 #[test]
1285 fn test_needs_mobile_web_app_capable_meta() {
1286 assert!(needs_mobile_web_app_capable_meta(
1287 "apple-mobile-web-app-capable"
1288 ));
1289 assert!(!needs_mobile_web_app_capable_meta(
1290 "apple-mobile-web-app-capable and name=\"mobile-web-app-capable\""
1291 ));
1292 assert!(!needs_mobile_web_app_capable_meta("no legacy meta"));
1293 }
1294
1295 #[test]
1296 fn test_inject_mobile_web_app_capable_meta_edge_cases() {
1297 let no_head = inject_mobile_web_app_capable_meta("plain text");
1301 assert!(
1302 no_head.contains("name=\"mobile-web-app-capable\""),
1303 "fallback should inject modern meta: {no_head}"
1304 );
1305
1306 let unclosed = inject_mobile_web_app_capable_meta(
1310 "<meta name=\"apple-mobile-web-app-capable\"",
1311 );
1312 assert!(
1313 unclosed.contains("name=\"mobile-web-app-capable\""),
1314 "fallback should inject modern meta: {unclosed}"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_inject_modern_meta_fallback_when_apple_meta_is_escaped() {
1320 let html = "<html><head><title>x</title></head><body>\
1326 <meta name="apple-mobile-web-app-capable" \
1327 content="yes"></body></html>";
1328 let result = apply_html_fixes(html);
1329 assert!(
1330 result.contains("name=\"mobile-web-app-capable\""),
1331 "modern companion must be injected even when legacy is escaped"
1332 );
1333 let modern_pos =
1335 result.find("name=\"mobile-web-app-capable\"").unwrap();
1336 let head_close_pos = result.find("</head>").unwrap();
1337 assert!(
1338 modern_pos < head_close_pos,
1339 "modern meta should live inside <head>:\n{result}"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_fix_jsonld_dates_invalid_rfc2822() {
1345 let input = r#"{"datePublished":"Mon"}"#;
1347 assert_eq!(fix_jsonld_dates(input), input);
1348
1349 let input2 = r#"{"datePublished":"2026, 11 Apr 2026"}"#;
1351 assert_eq!(fix_jsonld_dates(input2), input2);
1352
1353 let input3 = r#"{"dateCreated":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1355 assert_eq!(fix_jsonld_dates(input3), input3);
1356
1357 let input4 = r#"{"datePublished":"Thu, 11 Apr 2026"#;
1359 assert_eq!(fix_jsonld_dates(input4), input4);
1360 }
1361
1362 #[test]
1363 fn test_fix_broken_img_tags_edge_cases() {
1364 let input = r#"<img <p src=image.jpg>"#;
1366 assert_eq!(fix_broken_img_tags(input), input);
1367
1368 let input2 = r#"<p src="image.jpg">"#;
1370 assert_eq!(fix_broken_img_tags(input2), input2);
1371 }
1372
1373 #[test]
1374 fn test_fix_literal_class_syntax_edge_cases() {
1375 let input = r#"<img src="img.jpg">.class="my-class"#;
1377 assert_eq!(fix_literal_class_syntax(input), input);
1378 }
1379
1380 #[test]
1381 fn test_inject_class_attr_edge_cases() {
1382 let mut html = "some text without tags".to_string();
1384 inject_class_attr(&mut html, 10, "foo");
1385 assert_eq!(html, "some text without tags");
1386
1387 let mut html2 = "<img class=\"existing\"> some text".to_string();
1389 let len = html2.len();
1390 inject_class_attr(&mut html2, len, "foo");
1391 assert_eq!(html2, "<img class=\"existing\"> some text");
1392
1393 let mut html3 = "> stray text".to_string();
1397 let len3 = html3.len();
1398 inject_class_attr(&mut html3, len3, "foo");
1399 assert_eq!(html3, "> stray text");
1400 }
1401
1402 #[test]
1407 fn test_apply_html_fixes_routes_broken_img_repair() {
1408 let html = r#"<img alt="x" <p src="/pic.png"> tail"#;
1409 let out = apply_html_fixes(html);
1410 assert!(
1411 out.contains(r#"<img alt="x" src="/pic.png" />"#),
1412 "broken img must be repaired via the apply pipeline: {out}"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_apply_html_fixes_routes_class_syntax_repair() {
1418 let html = r#"<div>.class="hero"</div>"#;
1419 let out = apply_html_fixes(html);
1420 assert!(
1421 !out.contains(".class="),
1422 "literal class syntax must be removed via the apply pipeline: {out}"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_apply_html_fixes_routes_empty_preload_removal() {
1428 let html = r#"<head><link rel="preload" href="" as="style"><link rel="stylesheet" href="/a.css"></head>"#;
1429 let out = apply_html_fixes(html);
1430 assert!(
1431 !out.contains("rel=\"preload\""),
1432 "empty-href preload must be dropped via the apply pipeline: {out}"
1433 );
1434 assert!(out.contains("/a.css"), "real links survive: {out}");
1435 }
1436
1437 #[test]
1442 fn test_fix_jsonld_dates_keeps_unparseable_rfc_shaped_date() {
1443 let html = r#"{"datePublished":"Mon, not a real date"}"#;
1444 let out = fix_jsonld_dates(html);
1445 assert_eq!(out, html, "unparseable date passes through verbatim");
1446 }
1447
1448 #[test]
1453 fn test_fix_broken_img_tags_unterminated_src_bails_out() {
1454 let html = r#"<img alt="x" <p src="never-closes"#;
1455 let out = fix_broken_img_tags(html);
1456 assert_eq!(out, html, "unterminated src must not loop or rewrite");
1457 }
1458
1459 #[test]
1464 fn test_fix_literal_class_syntax_keeps_existing_class_attr() {
1465 let html = r#"<div class="old">.class="new"</div>"#;
1466 let out = fix_literal_class_syntax(html);
1467 assert!(out.contains(r#"class="old""#), "existing class kept: {out}");
1468 assert!(
1469 !out.contains(r#"class="new""#),
1470 "no second class attribute injected: {out}"
1471 );
1472 }
1473
1474 #[test]
1479 fn test_inject_meta_falls_back_when_head_close_is_escaped_only() {
1480 let html = "no real head here </head>";
1484 let out = inject_mobile_web_app_capable_meta(html);
1485 assert!(
1486 out.starts_with("<meta name=\"mobile-web-app-capable\""),
1487 "prepend fallback used: {out}"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_inject_meta_after_open_head_when_no_close_tag() {
1493 let html = "<head><meta charset=\"utf-8\">";
1494 let out = inject_mobile_web_app_capable_meta(html);
1495 assert!(
1496 out.starts_with(
1497 "<head><meta name=\"mobile-web-app-capable\" content=\"yes\">"
1498 ),
1499 "meta injected right after <head>: {out}"
1500 );
1501 }
1502}