Skip to main content

ssg/plugins/postprocess/
html_fix.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! HTML fix plugin.
5
6use 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/// Repairs HTML output:
16/// - Fix 7: Upgrades JSON-LD `@context` from `http://schema.org/` to
17///   `https://schema.org`.
18/// - Fix 9: Repairs broken `.class=` image syntax where `<p` is
19///   injected into `<img>` tags.
20#[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
46/// Applies all HTML fixes to a single page and returns the modified content.
47fn 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("&lt;") {
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
99/// Escapes raw tags that the legacy compiler leaves inside `<code>` spans.
100///
101/// Markdown renders `` `<img>` `` as a code span whose text is the literal
102/// characters `<img>`; the correct HTML is `<code>&lt;img&gt;</code>`. The
103/// `staticdatagen` renderer escapes quotes but not angle brackets, so it emits
104/// `<code><img></code>` — a real, attribute-less element.
105///
106/// The blog example is the case that surfaced it: an accessibility checklist
107/// reading "Every `<img>` has a meaningful `alt`" shipped a page containing an
108/// `<img>` with no `alt`, which `tests/example_outputs.rs` correctly failed.
109/// Beyond the irony, any documentation quoting `<script>` in prose would have
110/// emitted a live script element.
111///
112/// `ssg_core::compile_markdown` gets this right (it uses pulldown-cmark's own
113/// HTML writer), so this repair exists only until WS1 retires the legacy
114/// compiler — at which point the guard above stops matching and the pass costs
115/// nothing.
116fn 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        // A bare `<code>` opening a `<pre>` is a code *block*, not an inline
128        // span, and a theme may have authored real markup inside it -- Voxt
129        // ships a hand-highlighted Rust sample built from `<span class=
130        // "code-kw">` and friends. Escaping those printed the tags on the
131        // page as text. Markdown-derived blocks are unaffected either way:
132        // the highlighter emits `<code class="language-x">`, which this
133        // function never matches because it looks for the bare string.
134        //
135        // Inline spans -- the case this repair exists for -- are never
136        // preceded by `<pre>`, so they still get escaped.
137        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        // Only the span's text is escaped; the surrounding markup, including
153        // any attributes on the <code> element itself, is left untouched.
154        for ch in rest[after_open..close].chars() {
155            match ch {
156                '<' => out.push_str("&lt;"),
157                '>' => out.push_str("&gt;"),
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
169/// Gives every `<th>` a `scope`.
170///
171/// Markdown emits bare `<th>` elements. Without `scope`, a screen reader
172/// has to guess which cells a header governs, and on anything wider than
173/// two columns it guesses wrong — WCAG 1.3.1, technique H63.
174///
175/// A header inside `<thead>` labels its column; one inside `<tbody>` is a
176/// row header, which is the shape Markdown produces for a leading label
177/// column. An author-supplied `scope` is never overwritten.
178fn 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            // A `<th>` in neither section still governs its column.
191            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
200/// Wraps every `<table>` in a horizontally scrollable container.
201///
202/// A table is the one element that legitimately cannot reflow: its columns
203/// have a minimum width, and below that the table pushes the document
204/// wider than the viewport. That is a WCAG 1.4.10 (Reflow) failure, and it
205/// is what a Markdown table does on a phone — measured at 320px, a
206/// five-column table made the document 588px wide.
207///
208/// Scrolling *inside* a container is the accepted fix, so the table gets
209/// its own scroll context and the page stops scrolling sideways. Applied
210/// here rather than in a theme because Markdown-generated tables have no
211/// wrapper to style: only a post-process pass can reach them.
212///
213/// `role="region"` plus a label makes the scrollable area focusable and
214/// announced, which is what lets a keyboard user reach the overflowed
215/// columns at all.
216fn 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
241/// Replaces the obsolete presentational `align` attribute on table cells
242/// with an equivalent `text-*` class (issue #618).
243///
244/// Markdown column-alignment syntax (`:---`, `---:`, `:---:`) is rendered
245/// downstream as `<th align="left">` / `<td align="right">`. `align` has
246/// been obsolete since HTML5 and pa11y flags it as
247/// `WCAG2AAA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr`.
248///
249/// The alignment itself is meaningful, so it is preserved rather than
250/// dropped: each cell gains `text-left` / `text-center` / `text-right`,
251/// which is the class the renderer already emits on `<td>` alongside the
252/// attribute. `<th>` previously received the attribute and no class, so
253/// this is also what makes header alignment stylable at all.
254///
255/// Uses a real parser rather than string surgery: an `align=` literal can
256/// legitimately appear inside a `<pre>` block or a comment, and only a
257/// parser knows the difference.
258fn 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            // `justify`, `char`, or anything unrecognised: the attribute is
270            // still obsolete and must go, but inventing a class for it would
271            // be guessing at intent.
272            _ => 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
298/// Returns `true` if the HTML contains `http://schema.org` context that needs upgrading.
299fn needs_schema_context_fix(html: &str) -> bool {
300    html.contains("\"http://schema.org/\"")
301        || html.contains("\"http://schema.org\"")
302}
303
304/// Returns `true` if the HTML contains literal `.class=` syntax to fix.
305fn needs_class_syntax_fix(html: &str) -> bool {
306    html.contains(".class=&quot;") || html.contains(".class=\"")
307}
308
309/// Returns `true` if the HTML appears to contain a `<link rel="preload">`
310/// tag whose `href` is empty or absent. Chrome logs
311/// "<link rel=preload> has an invalid href value" for these. The check
312/// is intentionally cheap; `remove_empty_preload_links` does the precise
313/// per-tag work only if this returns `true`.
314fn has_empty_preload(html: &str) -> bool {
315    // The cheapest signal of "preload + no real href" is `href` followed
316    // immediately by space or `>` (bare attribute) anywhere in the same
317    // document, *and* a preload link somewhere too. False positives just
318    // trigger the precise rewriter, which is idempotent.
319    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
330/// Removes any `<link>` tag that declares `rel="preload"` and has an empty
331/// or missing `href`. Idempotent.
332pub(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        // Find the next `<link` (case-insensitive) starting at cursor.
337        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        // Walk forward to the closing `>`, respecting quoted attribute values.
347        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        // Drop only empty-href preload tags; keep everything else.
371        if !is_preload || has_real_href {
372            out.push_str(tag);
373        }
374        cursor = tag_end;
375    }
376    out
377}
378
379/// Returns `true` if a (lowercased) tag string has a `href` attribute that
380/// is present and non-empty. Tolerates double, single, and unquoted forms.
381fn 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    // Must be followed by `=`, possibly with surrounding whitespace.
389    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    // NB: `trim_start` above means the next char can never be
395    // whitespace, so no dedicated whitespace arm is needed.
396    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
404/// Returns `true` if the HTML emits the legacy
405/// `apple-mobile-web-app-capable` meta but lacks the modern
406/// `mobile-web-app-capable` meta that Chrome now requires. Tolerates
407/// quoted, single-quoted, or unquoted attribute values (post-minify HTML
408/// often drops quotes around short values like `yes`).
409fn 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
415/// Returns the byte offset of a `name=...mobile-web-app-capable...` meta
416/// attribute that is **not** the apple variant, or `None` if none found.
417fn find_modern_mobile_web_app_capable(html: &str) -> Option<usize> {
418    // Search for the bare attribute name in any of the three quoting
419    // styles, then verify it isn't preceded by `apple-` (which would make
420    // it the legacy variant).
421    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
434/// Injects `<meta name="mobile-web-app-capable" content="yes">` immediately
435/// after the legacy Apple variant so installed-PWA support works in Chrome
436/// without console deprecation warnings. Handles minified HTML where the
437/// `name=` attribute may be unquoted and may appear after `content=`.
438///
439/// When the legacy meta is HTML-escaped (e.g. `&lt;meta
440/// name=&quot;apple-mobile-web-app-capable&quot;…&gt;` leaked into body
441/// content via a misconfigured template), no usable anchor exists. In that
442/// case, fall back to injecting the modern meta into `<head>` so the
443/// modern-companion validator still passes.
444pub(super) fn inject_mobile_web_app_capable_meta(html: &str) -> String {
445    let modern = "<meta name=\"mobile-web-app-capable\" content=\"yes\">";
446    // Find the apple-variant attribute name. Tolerate quoted/unquoted forms.
447    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        // Walk forward to the next `>` that closes this <meta> tag.
455        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    // Fallback: the legacy meta is present only in escaped form (no real
466    // anchor in the source). Inject the modern meta into <head> so Chrome
467    // gets the companion and the modern-companion validator passes.
468    inject_modern_meta_into_head(html, modern)
469}
470
471/// Injects a `<meta>` tag just before `</head>`, or just after `<head>` if
472/// the close tag isn't present. If neither anchor exists, prepends the meta
473/// so the document still contains the marker — but in practice every
474/// well-formed page has a `<head>` element.
475fn inject_modern_meta_into_head(html: &str, meta: &str) -> String {
476    // Prefer inserting right before </head> so it lives in the head block
477    // regardless of where the apple-meta string appeared.
478    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    // No <head> at all — prepend so the substring is at least present.
490    format!("{meta}{html}")
491}
492
493/// Fix JSON-LD date fields from RFC 2822 to ISO 8601.
494pub(super) fn fix_jsonld_dates(html: &str) -> String {
495    let mut result = html.to_string();
496
497    // Match "datePublished":"..." and "dateModified":"..." patterns
498    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                // Only convert if it looks like RFC 2822 (starts with
506                // a day abbreviation like "Mon," "Tue,", etc.)
507                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
531/// Repair broken `<img ... <p src="...">` patterns by reconstructing
532/// valid `<img>` tags.
533pub(super) fn fix_broken_img_tags(html: &str) -> String {
534    let mut result = html.to_string();
535    // Pattern: <img ... <p src="URL">
536    // Replace with: <img ... src="URL" />
537    while let Some(p_pos) = result.find("<p src=") {
538        // Look backwards for the <img tag start
539        let before = &result[..p_pos];
540        if let Some(img_start) = before.rfind("<img") {
541            // Extract the src value from <p src="...">
542            let after_p = &result[p_pos..]; // includes "<p src="
543            if let Some(quote_start) = after_p.find("src=\"") {
544                let val_start = quote_start + 5; // skip src="
545                let remaining = &after_p[val_start..];
546                if let Some(quote_end) = remaining.find('"') {
547                    let src_value = remaining[..quote_end].to_string();
548                    // Find the closing > of this broken tag
549                    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                    // Extract existing attributes from the img tag portion
556                    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        // If we can't parse, skip to avoid infinite loop
576        break;
577    }
578    result
579}
580
581/// Remove literal `.class=&quot;...&quot;` or `.class="..."` from HTML
582/// and apply them as actual class attributes.
583pub(super) fn fix_literal_class_syntax(html: &str) -> String {
584    let mut result = html.to_string();
585
586    // Handle .class=&quot;...&quot; (HTML-encoded quotes)
587    result = fix_class_syntax_variant(&result, ".class=&quot;", "&quot;");
588    // Handle .class="..." (literal quotes)
589    result = fix_class_syntax_variant(&result, ".class=\"", "\"");
590
591    result
592}
593
594/// Handles one variant of the `.class=` syntax fix.
595fn 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
616/// Injects a class attribute into the nearest preceding tag if it doesn't already have one.
617fn 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
634/// Decodes HTML entities that were escaped inside markdown template bodies.
635fn fix_escaped_html_entities(html: &str) -> String {
636    let mut modified = html.to_string();
637
638    let tag_prefixes = [
639        "&lt;section",
640        "&lt;/section&gt;",
641        "&lt;article",
642        "&lt;/article&gt;",
643        "&lt;header",
644        "&lt;/header&gt;",
645        "&lt;footer",
646        "&lt;/footer&gt;",
647        "&lt;nav",
648        "&lt;/nav&gt;",
649        "&lt;aside",
650        "&lt;/aside&gt;",
651        "&lt;main",
652        "&lt;/main&gt;",
653        "&lt;div",
654        "&lt;/div&gt;",
655        "&lt;form",
656        "&lt;/form&gt;",
657        "&lt;input",
658        "&lt;/input&gt;",
659        "&lt;label",
660        "&lt;/label&gt;",
661        "&lt;button",
662        "&lt;/button&gt;",
663        "&lt;select",
664        "&lt;/select&gt;",
665        "&lt;option",
666        "&lt;/option&gt;",
667        "&lt;textarea",
668        "&lt;/textarea&gt;",
669        "&lt;table",
670        "&lt;/table&gt;",
671        "&lt;thead",
672        "&lt;/thead&gt;",
673        "&lt;tbody",
674        "&lt;/tbody&gt;",
675        "&lt;tr",
676        "&lt;/tr&gt;",
677        "&lt;th",
678        "&lt;/th&gt;",
679        "&lt;td",
680        "&lt;/td&gt;",
681        "&lt;p",
682        "&lt;/p&gt;",
683        "&lt;span",
684        "&lt;/span&gt;",
685        "&lt;a ",
686        "&lt;/a&gt;",
687        "&lt;img",
688        "&lt;picture",
689        "&lt;/picture&gt;",
690        "&lt;source",
691        "&lt;h1",
692        "&lt;/h1&gt;",
693        "&lt;h2",
694        "&lt;/h2&gt;",
695        "&lt;h3",
696        "&lt;/h3&gt;",
697        "&lt;h4",
698        "&lt;/h4&gt;",
699        "&lt;h5",
700        "&lt;/h5&gt;",
701        "&lt;h6",
702        "&lt;/h6&gt;",
703        "&lt;ul",
704        "&lt;/ul&gt;",
705        "&lt;ol",
706        "&lt;/ol&gt;",
707        "&lt;li",
708        "&lt;/li&gt;",
709        "&lt;strong",
710        "&lt;/strong&gt;",
711        "&lt;em",
712        "&lt;/em&gt;",
713        "&lt;blockquote",
714        "&lt;/blockquote&gt;",
715        "&lt;hr",
716        "&lt;br",
717    ];
718
719    for prefix in tag_prefixes {
720        if prefix.ends_with("&gt;") {
721            let clean_closing =
722                prefix.replace("&lt;/", "</").replace("&gt;", ">");
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("&gt;") {
727                    let end = start + end_rel + 4;
728                    let tag_chunk = &modified[start..end];
729                    let decoded_tag = tag_chunk
730                        .replace("&lt;", "<")
731                        .replace("&gt;", ">")
732                        .replace("&quot;", "\"")
733                        .replace("&#x27;", "'");
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    /// A themed code block keeps its markup.
753    ///
754    /// Regression: v0.0.58 escaped every bare `<code>`, including the ones
755    /// opening a `<pre>`. Voxt ships a hand-highlighted Rust sample, and the
756    /// release printed `&lt;span class="code-kw"&gt;` on the page as visible
757    /// text. Production (built with 0.0.56) rendered it correctly, so this
758    /// shipped as a regression in a release.
759    #[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("&lt;span"), "spans must not be escaped");
766    }
767
768    /// An inline code span is still repaired -- the case the pass exists for.
769    #[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>&lt;img&gt;</code>"),
775            "inline spans must still be escaped, got: {out}"
776        );
777    }
778
779    /// The discriminator is `<pre>` immediately before, not merely present.
780    #[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>&lt;br&gt;</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        // The blog example's accessibility checklist: a page about alt text
797        // was shipping an <img> with no alt, because the code span rendered
798        // as markup.
799        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>&lt;img&gt;</code>"), "got: {out}");
802        assert!(!out.contains("<code><img></code>"), "got: {out}");
803        // The neighbouring, already-correct span is untouched.
804        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>&lt;b&gt;x&lt;/b&gt;</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        // Idempotence matters: the pass runs on every page, and a second
822        // application must not turn &lt; into &amp;lt;.
823        let html = "<code>&lt;img&gt;</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("&amp;lt;"), "double-escaped: {twice}");
828    }
829
830    #[test]
831    fn unterminated_code_span_does_not_truncate_the_document() {
832        // A malformed document must come through unchanged rather than lose
833        // everything after the opening tag.
834        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    /// Covers the *wiring*, not just the function: the unit tests above
845    /// call `replace_table_align_attrs` directly, so removing its call
846    /// from `apply_html_fixes` left them all green. This one goes through
847    /// the pipeline entry point.
848    #[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    /// Markdown emits bare `<th>`. Without `scope` a screen reader guesses
861    /// which cells a header governs, and gets it wrong on anything wider
862    /// than two columns (WCAG 1.3.1, technique H63).
863    #[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    /// An author who scoped a header deliberately keeps their value.
874    #[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    /// The wrapper is what stops a table widening the page; it must be
884    /// reachable by keyboard, or the overflowed columns are unreachable.
885    #[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    /// Wrapping twice would nest scroll containers on a rebuild.
895    #[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    /// Regression for #618: Markdown column-alignment syntax rendered
902    /// obsolete `align` attributes, which pa11y flags as
903    /// `WCAG2AAA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr`.
904    #[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    /// The renderer already emits `class="text-*"` on `<td>` beside the
925    /// attribute; merging must not duplicate it.
926    #[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    /// An `align` value with no sensible class equivalent still loses the
937    /// obsolete attribute — inventing a class would be guessing at intent.
938    #[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    /// `align=` inside a `<pre>` block is content, not markup, and a
946    /// string-replacement implementation would corrupt it.
947    #[test]
948    fn table_align_leaves_literal_text_in_pre_alone() {
949        let html = r#"<pre><code>&lt;td align="left"&gt;</code></pre>"#;
950        assert_eq!(replace_table_align_attrs(html), html);
951    }
952
953    /// Non-table elements keep their `align` — the fix is scoped to the
954    /// cells the Markdown renderer emits, not a blanket attribute sweep.
955    #[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=&quot;w-25 float-start&quot;"#;
1028        let result = fix_literal_class_syntax(input);
1029        assert!(!result.contains(".class=&quot;"));
1030    }
1031
1032    // -----------------------------------------------------------------
1033    // fix_jsonld_dates
1034    // -----------------------------------------------------------------
1035
1036    #[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    // -----------------------------------------------------------------
1063    // fix_broken_img_tags
1064    // -----------------------------------------------------------------
1065
1066    #[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    // -----------------------------------------------------------------
1090    // fix_literal_class_syntax
1091    // -----------------------------------------------------------------
1092
1093    #[test]
1094    fn test_fix_literal_class_syntax_html_encoded() {
1095        let input =
1096            r#"<img src="img.jpg">.class=&quot;w-25 float-start&quot; rest"#;
1097        let result = fix_literal_class_syntax(input);
1098        assert!(
1099            !result.contains(".class=&quot;"),
1100            "should remove .class=&quot;"
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    // -----------------------------------------------------------------
1130    // inject_mobile_web_app_capable_meta
1131    // -----------------------------------------------------------------
1132
1133    #[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    // -----------------------------------------------------------------
1152    // remove_empty_preload_links
1153    // -----------------------------------------------------------------
1154
1155    #[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        // Should not double-inject when modern meta already exists.
1194        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=&quot;foo&quot;"));
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        // Missing apple meta — no <head> either, must inject to keep
1298        // substring present (caller only invokes this when the legacy
1299        // substring is present somewhere in the document).
1300        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        // Unclosed apple meta tag — no closing `>` so the primary anchor
1307        // path cannot insert; falls through to head-injection. Since
1308        // there's also no <head>, the meta is prepended.
1309        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        // Regression for PR #511 / feat/v0.0.41: staticdatagen-rendered
1321        // pages can leak the legacy apple meta as fully HTML-escaped body
1322        // text (`&lt;meta name=&quot;apple-…&quot;…&gt;`). The injector
1323        // cannot find a `name="apple-…"` anchor, so it must fall back to
1324        // injecting the modern companion into <head> instead.
1325        let html = "<html><head><title>x</title></head><body>\
1326                    &lt;meta name=&quot;apple-mobile-web-app-capable&quot; \
1327                    content=&quot;yes&quot;&gt;</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        // And it should land inside <head>, not after the escaped body text.
1334        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        // String too short
1346        let input = r#"{"datePublished":"Mon"}"#;
1347        assert_eq!(fix_jsonld_dates(input), input);
1348
1349        // Doesn't start with day abbreviation / comma
1350        let input2 = r#"{"datePublished":"2026, 11 Apr 2026"}"#;
1351        assert_eq!(fix_jsonld_dates(input2), input2);
1352
1353        // Non-matching field
1354        let input3 = r#"{"dateCreated":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1355        assert_eq!(fix_jsonld_dates(input3), input3);
1356
1357        // Missing quote
1358        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        // Missing quote for src
1365        let input = r#"<img <p src=image.jpg>"#;
1366        assert_eq!(fix_broken_img_tags(input), input);
1367
1368        // No img tag before p
1369        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        // Unclosed class syntax
1376        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        // No preceding tag
1383        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        // Preceding tag already has class
1388        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        // A `>` exists before `pos` but has no matching `<` before it
1394        // (malformed/truncated markup) — the inner `rfind('<')` must
1395        // return `None` and the function must leave the string alone.
1396        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    // -----------------------------------------------------------------
1403    // apply_html_fixes: routing gates for each fixer
1404    // -----------------------------------------------------------------
1405
1406    #[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    // -----------------------------------------------------------------
1438    // fix_jsonld_dates: RFC-2822-shaped but unparseable value
1439    // -----------------------------------------------------------------
1440
1441    #[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    // -----------------------------------------------------------------
1449    // fix_broken_img_tags: unterminated src attribute bails out
1450    // -----------------------------------------------------------------
1451
1452    #[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    // -----------------------------------------------------------------
1460    // inject_class_attr: preceding tag already has a class
1461    // -----------------------------------------------------------------
1462
1463    #[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    // -----------------------------------------------------------------
1475    // inject_modern_meta_into_head fallbacks
1476    // -----------------------------------------------------------------
1477
1478    #[test]
1479    fn test_inject_meta_falls_back_when_head_close_is_escaped_only() {
1480        // `</head>` appears only as text with no real head element, so
1481        // the lol_html pass injects nothing and we fall through to the
1482        // prepend fallback.
1483        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}