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("<code><") {
89        modified = escape_markup_inside_code_spans(&modified);
90    }
91
92    modified
93}
94
95/// Escapes raw tags that the legacy compiler leaves inside `<code>` spans.
96///
97/// Markdown renders `` `<img>` `` as a code span whose text is the literal
98/// characters `<img>`; the correct HTML is `<code>&lt;img&gt;</code>`. The
99/// `staticdatagen` renderer escapes quotes but not angle brackets, so it emits
100/// `<code><img></code>` — a real, attribute-less element.
101///
102/// The blog example is the case that surfaced it: an accessibility checklist
103/// reading "Every `<img>` has a meaningful `alt`" shipped a page containing an
104/// `<img>` with no `alt`, which `tests/example_outputs.rs` correctly failed.
105/// Beyond the irony, any documentation quoting `<script>` in prose would have
106/// emitted a live script element.
107///
108/// `ssg_core::compile_markdown` gets this right (it uses pulldown-cmark's own
109/// HTML writer), so this repair exists only until WS1 retires the legacy
110/// compiler — at which point the guard above stops matching and the pass costs
111/// nothing.
112///
113/// The bodies of `<style>`, `<script>` and comments are not markup, so a
114/// `<code>` in them is text and is skipped along with everything up to the
115/// region's end. A span that opens before such a region is still repaired,
116/// even when its text is `<script>`: that is the case this repair exists for.
117fn escape_markup_inside_code_spans(html: &str) -> String {
118    let mut out = String::with_capacity(html.len());
119    let mut rest = html;
120    // ASCII lowercasing keeps every byte offset where it was, so `rest` and
121    // the same suffix of `lower` line up.
122    let lower = html.to_ascii_lowercase();
123
124    while let Some(start) = rest.find("<code>") {
125        let rest_lower = &lower[html.len() - rest.len()..];
126        if let Some((_, end)) =
127            first_raw_text_region(rest_lower).filter(|&(open, _)| open < start)
128        {
129            out.push_str(&rest[..end]);
130            rest = &rest[end..];
131            continue;
132        }
133        let after_open = start + "<code>".len();
134        let Some(close_rel) = rest[after_open..].find("</code>") else {
135            break;
136        };
137        let close = after_open + close_rel;
138
139        // A bare `<code>` opening a `<pre>` is a code *block*, not an inline
140        // span, and a theme may have authored real markup inside it -- Voxt
141        // ships a hand-highlighted Rust sample built from `<span class=
142        // "code-kw">` and friends. Escaping those printed the tags on the
143        // page as text. Markdown-derived blocks are unaffected either way:
144        // the highlighter emits `<code class="language-x">`, which this
145        // function never matches because it looks for the bare string.
146        //
147        // Inline spans -- the case this repair exists for -- are never
148        // preceded by `<pre>`, so they still get escaped.
149        let preceding = rest[..start].trim_end();
150        let opens_a_pre_block = preceding.ends_with('>')
151            && preceding.rfind("<pre").is_some_and(|i| {
152                preceding[i..]
153                    .find('>')
154                    .is_some_and(|j| i + j + 1 == preceding.len())
155            });
156        if opens_a_pre_block {
157            let end = close + "</code>".len();
158            out.push_str(&rest[..end]);
159            rest = &rest[end..];
160            continue;
161        }
162
163        out.push_str(&rest[..after_open]);
164        // Only the span's text is escaped; the surrounding markup, including
165        // any attributes on the <code> element itself, is left untouched.
166        for ch in rest[after_open..close].chars() {
167            match ch {
168                '<' => out.push_str("&lt;"),
169                '>' => out.push_str("&gt;"),
170                other => out.push(other),
171            }
172        }
173        out.push_str("</code>");
174        rest = &rest[close + "</code>".len()..];
175    }
176
177    out.push_str(rest);
178    out
179}
180
181/// Openers and terminators of the regions whose text is not markup.
182const RAW_TEXT_REGIONS: [(&str, &str); 3] = [
183    ("<style", "</style"),
184    ("<script", "</script"),
185    ("<!--", "-->"),
186];
187
188/// Returns the first raw-text region in `lower`, an ASCII-lowercased page,
189/// as the byte offset of its opener and the offset just past its
190/// terminator's `>`, or the end of the page when it is never closed.
191fn first_raw_text_region(lower: &str) -> Option<(usize, usize)> {
192    RAW_TEXT_REGIONS
193        .iter()
194        .filter_map(|&(open, close)| {
195            let start = lower.find(open)?;
196            let body = start + open.len();
197            let end = lower[body..].find(close).map_or(lower.len(), |p| {
198                let at = body + p;
199                lower[at..].find('>').map_or(lower.len(), |q| at + q + 1)
200            });
201            Some((start, end))
202        })
203        .min_by_key(|&(start, _)| start)
204}
205
206/// Gives every `<th>` a `scope`.
207///
208/// Markdown emits bare `<th>` elements. Without `scope`, a screen reader
209/// has to guess which cells a header governs, and on anything wider than
210/// two columns it guesses wrong — WCAG 1.3.1, technique H63.
211///
212/// A header inside `<thead>` labels its column; one inside `<tbody>` is a
213/// row header, which is the shape Markdown produces for a leading label
214/// column. An author-supplied `scope` is never overwritten.
215fn add_table_header_scope(html: &str) -> String {
216    rewrite_html(
217        html,
218        vec![
219            element!("thead th:not([scope])", |el| {
220                el.set_attribute("scope", "col")?;
221                Ok(())
222            }),
223            element!("tbody th:not([scope])", |el| {
224                el.set_attribute("scope", "row")?;
225                Ok(())
226            }),
227            // A `<th>` in neither section still governs its column.
228            element!("table > tr th:not([scope])", |el| {
229                el.set_attribute("scope", "col")?;
230                Ok(())
231            }),
232        ],
233    )
234    .unwrap_or_else(|_| html.to_string())
235}
236
237/// Wraps every `<table>` in a horizontally scrollable container.
238///
239/// A table is the one element that legitimately cannot reflow: its columns
240/// have a minimum width, and below that the table pushes the document
241/// wider than the viewport. That is a WCAG 1.4.10 (Reflow) failure, and it
242/// is what a Markdown table does on a phone — measured at 320px, a
243/// five-column table made the document 588px wide.
244///
245/// Scrolling *inside* a container is the accepted fix, so the table gets
246/// its own scroll context and the page stops scrolling sideways. Applied
247/// here rather than in a theme because Markdown-generated tables have no
248/// wrapper to style: only a post-process pass can reach them.
249///
250/// `role="region"` plus a label makes the scrollable area focusable and
251/// announced, which is what lets a keyboard user reach the overflowed
252/// columns at all.
253fn wrap_tables_for_reflow(html: &str) -> String {
254    use lol_html::html_content::ContentType;
255
256    let already = "ssg-table-scroll";
257    if html.contains(already) {
258        return html.to_string();
259    }
260
261    rewrite_html(
262        html,
263        vec![element!("table", move |el| {
264            el.before(
265                &format!(
266                    "<div class=\"table-wrap {already}\" role=\"region\" \
267                     aria-label=\"Table, scrollable horizontally\" tabindex=\"0\">"
268                ),
269                ContentType::Html,
270            );
271            el.after("</div>", ContentType::Html);
272            Ok(())
273        })],
274    )
275    .unwrap_or_else(|_| html.to_string())
276}
277
278/// Replaces the obsolete presentational `align` attribute on table cells
279/// with an equivalent `text-*` class (issue #618).
280///
281/// Markdown column-alignment syntax (`:---`, `---:`, `:---:`) is rendered
282/// downstream as `<th align="left">` / `<td align="right">`. `align` has
283/// been obsolete since HTML5 and pa11y flags it as
284/// `WCAG2AAA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr`.
285///
286/// The alignment itself is meaningful, so it is preserved rather than
287/// dropped: each cell gains `text-left` / `text-center` / `text-right`,
288/// which is the class the renderer already emits on `<td>` alongside the
289/// attribute. `<th>` previously received the attribute and no class, so
290/// this is also what makes header alignment stylable at all.
291///
292/// Uses a real parser rather than string surgery: an `align=` literal can
293/// legitimately appear inside a `<pre>` block or a comment, and only a
294/// parser knows the difference.
295fn replace_table_align_attrs(html: &str) -> String {
296    let handler = |el: &mut lol_html::html_content::Element<'_, '_>| {
297        let Some(align) = el.get_attribute("align") else {
298            return Ok(());
299        };
300        el.remove_attribute("align");
301
302        let class = match align.trim().to_ascii_lowercase().as_str() {
303            "left" => "text-left",
304            "center" | "centre" => "text-center",
305            "right" => "text-right",
306            // `justify`, `char`, or anything unrecognised: the attribute is
307            // still obsolete and must go, but inventing a class for it would
308            // be guessing at intent.
309            _ => return Ok(()),
310        };
311
312        let existing = el.get_attribute("class").unwrap_or_default();
313        if existing.split_whitespace().any(|c| c == class) {
314            return Ok(());
315        }
316        let merged = if existing.is_empty() {
317            class.to_string()
318        } else {
319            format!("{existing} {class}")
320        };
321        el.set_attribute("class", &merged)?;
322        Ok(())
323    };
324
325    rewrite_html(
326        html,
327        vec![
328            element!("th[align]", handler),
329            element!("td[align]", handler),
330        ],
331    )
332    .unwrap_or_else(|_| html.to_string())
333}
334
335/// Returns `true` if the HTML contains `http://schema.org` context that needs upgrading.
336fn needs_schema_context_fix(html: &str) -> bool {
337    html.contains("\"http://schema.org/\"")
338        || html.contains("\"http://schema.org\"")
339}
340
341/// Returns `true` if the HTML contains literal `.class=` syntax to fix.
342fn needs_class_syntax_fix(html: &str) -> bool {
343    html.contains(".class=&quot;") || html.contains(".class=\"")
344}
345
346/// Returns `true` if the HTML appears to contain a `<link rel="preload">`
347/// tag whose `href` is empty or absent. Chrome logs
348/// "<link rel=preload> has an invalid href value" for these. The check
349/// is intentionally cheap; `remove_empty_preload_links` does the precise
350/// per-tag work only if this returns `true`.
351fn has_empty_preload(html: &str) -> bool {
352    // The cheapest signal of "preload + no real href" is `href` followed
353    // immediately by space or `>` (bare attribute) anywhere in the same
354    // document, *and* a preload link somewhere too. False positives just
355    // trigger the precise rewriter, which is idempotent.
356    let has_preload = html.contains("rel=preload")
357        || html.contains("rel=\"preload\"")
358        || html.contains("rel='preload'");
359    let has_empty_href = html.contains("href=\"\"")
360        || html.contains("href=''")
361        || html.contains(" href ")
362        || html.contains(" href>")
363        || html.contains(" href/>");
364    has_preload && has_empty_href
365}
366
367/// Removes any `<link>` tag that declares `rel="preload"` and has an empty
368/// or missing `href`. Idempotent.
369pub(super) fn remove_empty_preload_links(html: &str) -> String {
370    let mut out = String::with_capacity(html.len());
371    let mut cursor = 0;
372    while cursor < html.len() {
373        // Find the next `<link` (case-insensitive) starting at cursor.
374        let Some(rel_offset) =
375            html[cursor..].to_ascii_lowercase().find("<link")
376        else {
377            out.push_str(&html[cursor..]);
378            break;
379        };
380        let tag_start = cursor + rel_offset;
381        out.push_str(&html[cursor..tag_start]);
382
383        // Walk forward to the closing `>`, respecting quoted attribute values.
384        let bytes = html.as_bytes();
385        let mut j = tag_start;
386        let mut quote: Option<u8> = None;
387        while j < bytes.len() {
388            let b = bytes[j];
389            match quote {
390                Some(q) if b == q => quote = None,
391                Some(_) => {}
392                None => match b {
393                    b'"' | b'\'' => quote = Some(b),
394                    b'>' => break,
395                    _ => {}
396                },
397            }
398            j += 1;
399        }
400        let tag_end = (j + 1).min(html.len());
401        let tag = &html[tag_start..tag_end];
402        let lower = tag.to_ascii_lowercase();
403        let is_preload = lower.contains("rel=\"preload\"")
404            || lower.contains("rel='preload'")
405            || lower.contains("rel=preload");
406        let has_real_href = href_is_present_and_non_empty(&lower);
407        // Drop only empty-href preload tags; keep everything else.
408        if !is_preload || has_real_href {
409            out.push_str(tag);
410        }
411        cursor = tag_end;
412    }
413    out
414}
415
416/// Returns `true` if a (lowercased) tag string has a `href` attribute that
417/// is present and non-empty. Tolerates double, single, and unquoted forms.
418fn href_is_present_and_non_empty(lower_tag: &str) -> bool {
419    if lower_tag.contains("href=\"\"") || lower_tag.contains("href=''") {
420        return false;
421    }
422    let Some(idx) = lower_tag.find("href") else {
423        return false;
424    };
425    // Must be followed by `=`, possibly with surrounding whitespace.
426    let after = lower_tag[idx + 4..].trim_start();
427    let Some(rest) = after.strip_prefix('=') else {
428        return false;
429    };
430    let rest = rest.trim_start();
431    // NB: `trim_start` above means the next char can never be
432    // whitespace, so no dedicated whitespace arm is needed.
433    match rest.chars().next() {
434        None | Some('>') => false,
435        Some('"') => rest.len() > 1 && !rest.starts_with("\"\""),
436        Some('\'') => rest.len() > 1 && !rest.starts_with("''"),
437        Some(_) => true,
438    }
439}
440
441/// Returns `true` if the HTML emits the legacy
442/// `apple-mobile-web-app-capable` meta but lacks the modern
443/// `mobile-web-app-capable` meta that Chrome now requires. Tolerates
444/// quoted, single-quoted, or unquoted attribute values (post-minify HTML
445/// often drops quotes around short values like `yes`).
446fn needs_mobile_web_app_capable_meta(html: &str) -> bool {
447    let has_legacy = html.contains("apple-mobile-web-app-capable");
448    let has_modern = find_modern_mobile_web_app_capable(html).is_some();
449    has_legacy && !has_modern
450}
451
452/// Returns the byte offset of a `name=...mobile-web-app-capable...` meta
453/// attribute that is **not** the apple variant, or `None` if none found.
454fn find_modern_mobile_web_app_capable(html: &str) -> Option<usize> {
455    // Search for the bare attribute name in any of the three quoting
456    // styles, then verify it isn't preceded by `apple-` (which would make
457    // it the legacy variant).
458    let needles = [
459        "name=\"mobile-web-app-capable\"",
460        "name='mobile-web-app-capable'",
461        "name=mobile-web-app-capable",
462    ];
463    for n in &needles {
464        if let Some(pos) = html.find(n) {
465            return Some(pos);
466        }
467    }
468    None
469}
470
471/// Injects `<meta name="mobile-web-app-capable" content="yes">` immediately
472/// after the legacy Apple variant so installed-PWA support works in Chrome
473/// without console deprecation warnings. Handles minified HTML where the
474/// `name=` attribute may be unquoted and may appear after `content=`.
475///
476/// When the legacy meta is HTML-escaped (e.g. `&lt;meta
477/// name=&quot;apple-mobile-web-app-capable&quot;…&gt;` leaked into body
478/// content via a misconfigured template), no usable anchor exists. In that
479/// case, fall back to injecting the modern meta into `<head>` so the
480/// modern-companion validator still passes.
481pub(super) fn inject_mobile_web_app_capable_meta(html: &str) -> String {
482    let modern = "<meta name=\"mobile-web-app-capable\" content=\"yes\">";
483    // Find the apple-variant attribute name. Tolerate quoted/unquoted forms.
484    let candidates = [
485        "name=\"apple-mobile-web-app-capable\"",
486        "name='apple-mobile-web-app-capable'",
487        "name=apple-mobile-web-app-capable",
488    ];
489    let name_pos = candidates.iter().find_map(|n| html.find(n));
490    if let Some(name_pos) = name_pos {
491        // Walk forward to the next `>` that closes this <meta> tag.
492        let after = &html[name_pos..];
493        if let Some(rel_close) = after.find('>') {
494            let insert_at = name_pos + rel_close + 1;
495            return format!(
496                "{}{modern}{}",
497                &html[..insert_at],
498                &html[insert_at..]
499            );
500        }
501    }
502    // Fallback: the legacy meta is present only in escaped form (no real
503    // anchor in the source). Inject the modern meta into <head> so Chrome
504    // gets the companion and the modern-companion validator passes.
505    inject_modern_meta_into_head(html, modern)
506}
507
508/// Injects a `<meta>` tag just before `</head>`, or just after `<head>` if
509/// the close tag isn't present. If neither anchor exists, prepends the meta
510/// so the document still contains the marker — but in practice every
511/// well-formed page has a `<head>` element.
512fn inject_modern_meta_into_head(html: &str, meta: &str) -> String {
513    // Prefer inserting right before </head> so it lives in the head block
514    // regardless of where the apple-meta string appeared.
515    let lower = html.to_ascii_lowercase();
516    if lower.contains("</head>") {
517        let injected = inject_before_head_close(html, meta);
518        if injected != html {
519            return injected;
520        }
521    }
522    if let Some(pos) = lower.find("<head>") {
523        let insert_at = pos + "<head>".len();
524        return format!("{}{meta}{}", &html[..insert_at], &html[insert_at..]);
525    }
526    // No <head> at all — prepend so the substring is at least present.
527    format!("{meta}{html}")
528}
529
530/// Fix JSON-LD date fields from RFC 2822 to ISO 8601.
531pub(super) fn fix_jsonld_dates(html: &str) -> String {
532    let mut result = html.to_string();
533
534    // Match "datePublished":"..." and "dateModified":"..." patterns
535    for field in &["datePublished", "dateModified"] {
536        let pattern = format!("\"{field}\":\"");
537        let mut search_from = 0;
538        while let Some(start) = result[search_from..].find(&pattern) {
539            let abs_start = search_from + start + pattern.len();
540            if let Some(end) = result[abs_start..].find('"') {
541                let date_str = &result[abs_start..abs_start + end];
542                // Only convert if it looks like RFC 2822 (starts with
543                // a day abbreviation like "Mon," "Tue,", etc.)
544                if date_str.len() > 5
545                    && date_str.as_bytes()[3] == b','
546                    && date_str.as_bytes()[0].is_ascii_alphabetic()
547                {
548                    let iso = rfc2822_to_iso8601(date_str);
549                    if iso != date_str {
550                        result = format!(
551                            "{}{}{}",
552                            &result[..abs_start],
553                            iso,
554                            &result[abs_start + end..]
555                        );
556                    }
557                }
558                search_from = abs_start + 1;
559            } else {
560                break;
561            }
562        }
563    }
564
565    result
566}
567
568/// Repair broken `<img ... <p src="...">` patterns by reconstructing
569/// valid `<img>` tags.
570pub(super) fn fix_broken_img_tags(html: &str) -> String {
571    let mut result = html.to_string();
572    // Pattern: <img ... <p src="URL">
573    // Replace with: <img ... src="URL" />
574    while let Some(p_pos) = result.find("<p src=") {
575        // Look backwards for the <img tag start
576        let before = &result[..p_pos];
577        if let Some(img_start) = before.rfind("<img") {
578            // Extract the src value from <p src="...">
579            let after_p = &result[p_pos..]; // includes "<p src="
580            if let Some(quote_start) = after_p.find("src=\"") {
581                let val_start = quote_start + 5; // skip src="
582                let remaining = &after_p[val_start..];
583                if let Some(quote_end) = remaining.find('"') {
584                    let src_value = remaining[..quote_end].to_string();
585                    // Find the closing > of this broken tag
586                    let close_offset = remaining[quote_end..]
587                        .find('>')
588                        .map_or(result.len(), |i| {
589                            p_pos + val_start + quote_end + i + 1
590                        });
591
592                    // Extract existing attributes from the img tag portion
593                    let img_attrs = result[img_start + 4..p_pos].trim();
594                    let img_attrs_clean =
595                        img_attrs.trim_end_matches(|c: char| {
596                            c.is_whitespace() || c == '<'
597                        });
598
599                    let new_img = format!(
600                        "<img {img_attrs_clean} src=\"{src_value}\" />"
601                    );
602                    result = format!(
603                        "{}{}{}",
604                        &result[..img_start],
605                        new_img,
606                        &result[close_offset..]
607                    );
608                    continue;
609                }
610            }
611        }
612        // If we can't parse, skip to avoid infinite loop
613        break;
614    }
615    result
616}
617
618/// Remove literal `.class=&quot;...&quot;` or `.class="..."` from HTML
619/// and apply them as actual class attributes.
620pub(super) fn fix_literal_class_syntax(html: &str) -> String {
621    let mut result = html.to_string();
622
623    // Handle .class=&quot;...&quot; (HTML-encoded quotes)
624    result = fix_class_syntax_variant(&result, ".class=&quot;", "&quot;");
625    // Handle .class="..." (literal quotes)
626    result = fix_class_syntax_variant(&result, ".class=\"", "\"");
627
628    result
629}
630
631/// Handles one variant of the `.class=` syntax fix.
632fn fix_class_syntax_variant(
633    html: &str,
634    open_pattern: &str,
635    close_pattern: &str,
636) -> String {
637    let mut result = html.to_string();
638    while let Some(start) = result.find(open_pattern) {
639        let after = &result[start + open_pattern.len()..];
640        if let Some(end) = after.find(close_pattern) {
641            let class_value = after[..end].to_string();
642            let remove_end =
643                start + open_pattern.len() + end + close_pattern.len();
644            result = format!("{}{}", &result[..start], &result[remove_end..]);
645            inject_class_attr(&mut result, start, &class_value);
646        } else {
647            break;
648        }
649    }
650    result
651}
652
653/// Injects a class attribute into the nearest preceding tag if it doesn't already have one.
654fn inject_class_attr(html: &mut String, pos: usize, class_value: &str) {
655    if let Some(tag_end) = html[..pos].rfind('>') {
656        if let Some(tag_start) = html[..tag_end].rfind('<') {
657            let tag = &html[tag_start..tag_end];
658            if !tag.contains("class=") {
659                let insert_pos = tag_end;
660                *html = format!(
661                    "{} class=\"{}\"{}",
662                    &html[..insert_pos],
663                    class_value,
664                    &html[insert_pos..]
665                );
666            }
667        }
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    /// Markup quoted in prose stays text.
674    ///
675    /// An article explaining an element writes it as text: the source has
676    /// `&lt;div&gt;`, and the reader must see `<div>`. A pass that
677    /// "repaired" escaped tags from a fixed list turned that text into a
678    /// live element (and, matching by prefix, `&lt;embed` via `&lt;em` and
679    /// `&lt;link` via `&lt;li`). It existed to undo staticdatagen escaping
680    /// whole page bodies, which staticdatagen 0.0.21 no longer does.
681    #[test]
682    fn quoted_markup_in_prose_is_left_as_text() {
683        for quoted in [
684            "&lt;div class=&quot;card&quot;&gt;",
685            "&lt;section&gt;",
686            "&lt;embed src=&quot;x.swf&quot;&gt;",
687            "&lt;link rel=&quot;stylesheet&quot; href=&quot;x.css&quot;&gt;",
688        ] {
689            let html = format!(
690                "<html><head></head><body><main><p>Write {quoted} to wrap it.</p></main></body></html>"
691            );
692            let out = apply_html_fixes(&html);
693            assert!(
694                out.contains(quoted),
695                "quoted markup {quoted:?} must stay escaped, got: {out}"
696            );
697        }
698    }
699
700    /// A themed code block keeps its markup.
701    ///
702    /// Regression: v0.0.58 escaped every bare `<code>`, including the ones
703    /// opening a `<pre>`. Voxt ships a hand-highlighted Rust sample, and the
704    /// release printed `&lt;span class="code-kw"&gt;` on the page as visible
705    /// text. Production (built with 0.0.56) rendered it correctly, so this
706    /// shipped as a regression in a release.
707    #[test]
708    fn a_pre_block_keeps_authored_markup() {
709        let html = "<pre class=\"editor-code\"><code>\
710<span class=\"code-kw\">pub fn</span> main()</code></pre>";
711        let out = escape_markup_inside_code_spans(html);
712        assert_eq!(out, html, "a <pre><code> block must pass through intact");
713        assert!(!out.contains("&lt;span"), "spans must not be escaped");
714    }
715
716    /// An inline code span is still repaired -- the case the pass exists for.
717    #[test]
718    fn an_inline_code_span_is_still_escaped() {
719        let html = "<p>Every <code><img></code> needs alt text.</p>";
720        let out = escape_markup_inside_code_spans(html);
721        assert!(
722            out.contains("<code>&lt;img&gt;</code>"),
723            "inline spans must still be escaped, got: {out}"
724        );
725    }
726
727    /// The discriminator is `<pre>` immediately before, not merely present.
728    #[test]
729    fn an_inline_span_after_an_earlier_pre_is_still_escaped() {
730        let html = "<pre><code>x</code></pre><p>Use <code><br></code>.</p>";
731        let out = escape_markup_inside_code_spans(html);
732        assert!(
733            out.contains("<code>&lt;br&gt;</code>"),
734            "a later inline span must still be escaped, got: {out}"
735        );
736        assert!(
737            out.contains("<pre><code>x</code></pre>"),
738            "the earlier block must be untouched, got: {out}"
739        );
740    }
741
742    #[test]
743    fn escapes_a_tag_left_raw_inside_a_code_span() {
744        // The blog example's accessibility checklist: a page about alt text
745        // was shipping an <img> with no alt, because the code span rendered
746        // as markup.
747        let html = "<li>Every <code><img></code> has a meaningful <code>alt</code></li>";
748        let out = apply_html_fixes(html);
749        assert!(out.contains("<code>&lt;img&gt;</code>"), "got: {out}");
750        assert!(!out.contains("<code><img></code>"), "got: {out}");
751        // The neighbouring, already-correct span is untouched.
752        assert!(out.contains("<code>alt</code>"), "got: {out}");
753    }
754
755    #[test]
756    fn escaping_code_spans_leaves_surrounding_markup_alone() {
757        let html = "<p>Before</p><code><b>x</b></code><p>After <em>y</em></p>";
758        let out = apply_html_fixes(html);
759        assert!(
760            out.contains("<code>&lt;b&gt;x&lt;/b&gt;</code>"),
761            "got: {out}"
762        );
763        assert!(out.contains("<p>Before</p>"), "got: {out}");
764        assert!(out.contains("<em>y</em>"), "got: {out}");
765    }
766
767    #[test]
768    fn already_escaped_code_spans_are_not_double_escaped() {
769        // Idempotence matters: the pass runs on every page, and a second
770        // application must not turn &lt; into &amp;lt;.
771        let html = "<code>&lt;img&gt;</code>";
772        let once = apply_html_fixes(html);
773        let twice = apply_html_fixes(&once);
774        assert_eq!(once, twice, "pass is not idempotent");
775        assert!(!twice.contains("&amp;lt;"), "double-escaped: {twice}");
776    }
777
778    /// `<style>` and `<script>` are raw text: a `<code>` there is not an
779    /// element. The sebastienrousseau theme's print stylesheet has a comment
780    /// reading "Inline <code> in print…", and everything from it to a
781    /// `</code>` twenty lines on was escaped, `pre>code` included, which
782    /// invalidated that selector's whole rule.
783    #[test]
784    fn raw_text_elements_are_left_alone() {
785        let style = "<style>/* Inline <code> */ pre>code{color:#000} \
786/* `<pre><code>…</code></pre>` */</style>";
787        let script = "<script>const s = '<code><b>x</b></code>';</script>";
788        let comment = "<!-- <code><b>y</b></code> -->";
789        let html = format!(
790            "<head>{style}{script}</head><body>{comment}\
791<p><code><img></code></p></body>"
792        );
793        let out = apply_html_fixes(&html);
794        assert!(out.contains(style), "style rewritten: {out}");
795        assert!(out.contains(script), "script rewritten: {out}");
796        assert!(out.contains(comment), "comment rewritten: {out}");
797        assert!(out.contains("<code>&lt;img&gt;</code>"), "got: {out}");
798    }
799
800    #[test]
801    fn unterminated_code_span_does_not_truncate_the_document() {
802        // A malformed document must come through unchanged rather than lose
803        // everything after the opening tag.
804        let html = "<p>keep</p><code><img>";
805        let out = apply_html_fixes(html);
806        assert!(out.contains("<p>keep</p>"), "content lost: {out}");
807    }
808
809    use super::*;
810    use crate::plugin::PluginContext;
811    use std::path::Path;
812    use tempfile::tempdir;
813
814    /// Covers the *wiring*, not just the function: the unit tests above
815    /// call `replace_table_align_attrs` directly, so removing its call
816    /// from `apply_html_fixes` left them all green. This one goes through
817    /// the pipeline entry point.
818    #[test]
819    fn apply_html_fixes_strips_table_align_attrs() {
820        let out = apply_html_fixes(
821            r#"<table><tr><td align="right">7</td></tr></table>"#,
822        );
823        assert!(
824            !out.contains("align="),
825            "not wired into the pipeline: {out}"
826        );
827        assert!(out.contains("text-right"), "{out}");
828    }
829
830    /// Markdown emits bare `<th>`. Without `scope` a screen reader guesses
831    /// which cells a header governs, and gets it wrong on anything wider
832    /// than two columns (WCAG 1.3.1, technique H63).
833    #[test]
834    fn table_headers_gain_a_scope() {
835        let out = add_table_header_scope(concat!(
836            "<table><thead><tr><th>Plan</th></tr></thead>",
837            "<tbody><tr><th>Starter</th><td>Free</td></tr></tbody></table>",
838        ));
839        assert!(out.contains(r#"<th scope="col">Plan"#), "{out}");
840        assert!(out.contains(r#"<th scope="row">Starter"#), "{out}");
841    }
842
843    /// An author who scoped a header deliberately keeps their value.
844    #[test]
845    fn table_header_scope_does_not_overwrite_an_author_value() {
846        let out = add_table_header_scope(
847            r#"<table><thead><tr><th scope="rowgroup">X</th></tr></thead></table>"#,
848        );
849        assert!(out.contains(r#"scope="rowgroup""#), "{out}");
850        assert_eq!(out.matches("scope=").count(), 1, "{out}");
851    }
852
853    /// The wrapper is what stops a table widening the page; it must be
854    /// reachable by keyboard, or the overflowed columns are unreachable.
855    #[test]
856    fn tables_are_wrapped_in_a_focusable_scroll_region() {
857        let out = wrap_tables_for_reflow("<table><tr><td>x</td></tr></table>");
858        assert!(out.contains("table-wrap"), "{out}");
859        assert!(out.contains(r#"role="region""#), "{out}");
860        assert!(out.contains(r#"tabindex="0""#), "{out}");
861        assert!(out.contains("aria-label"), "{out}");
862    }
863
864    /// Wrapping twice would nest scroll containers on a rebuild.
865    #[test]
866    fn table_wrapping_is_idempotent() {
867        let once = wrap_tables_for_reflow("<table><tr><td>x</td></tr></table>");
868        assert_eq!(wrap_tables_for_reflow(&once), once);
869    }
870
871    /// Regression for #618: Markdown column-alignment syntax rendered
872    /// obsolete `align` attributes, which pa11y flags as
873    /// `WCAG2AAA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr`.
874    #[test]
875    fn table_align_attrs_become_text_classes() {
876        let html = concat!(
877            "<table><thead><tr>",
878            r#"<th align="left">Layer</th>"#,
879            r#"<th align="center">Maturity</th>"#,
880            r#"<th align="right">Metric</th>"#,
881            "</tr></thead></table>",
882        );
883        let out = replace_table_align_attrs(html);
884
885        assert!(
886            !out.contains("align="),
887            "obsolete attribute survived: {out}"
888        );
889        assert!(out.contains("text-left"), "{out}");
890        assert!(out.contains("text-center"), "{out}");
891        assert!(out.contains("text-right"), "{out}");
892    }
893
894    /// The renderer already emits `class="text-*"` on `<td>` beside the
895    /// attribute; merging must not duplicate it.
896    #[test]
897    fn table_align_does_not_duplicate_an_existing_class() {
898        let html = r#"<td align="right" class="text-right num">7</td>"#;
899        let out = replace_table_align_attrs(html);
900
901        assert!(!out.contains("align="), "{out}");
902        assert_eq!(out.matches("text-right").count(), 1, "duplicated: {out}");
903        assert!(out.contains("num"), "existing classes dropped: {out}");
904    }
905
906    /// An `align` value with no sensible class equivalent still loses the
907    /// obsolete attribute — inventing a class would be guessing at intent.
908    #[test]
909    fn table_align_unrecognised_value_drops_attribute_without_a_class() {
910        let out = replace_table_align_attrs(r#"<td align="justify">x</td>"#);
911        assert!(!out.contains("align="), "{out}");
912        assert!(!out.contains("text-"), "invented a class: {out}");
913    }
914
915    /// `align=` inside a `<pre>` block is content, not markup, and a
916    /// string-replacement implementation would corrupt it.
917    #[test]
918    fn table_align_leaves_literal_text_in_pre_alone() {
919        let html = r#"<pre><code>&lt;td align="left"&gt;</code></pre>"#;
920        assert_eq!(replace_table_align_attrs(html), html);
921    }
922
923    /// Non-table elements keep their `align` — the fix is scoped to the
924    /// cells the Markdown renderer emits, not a blanket attribute sweep.
925    #[test]
926    fn table_align_ignores_non_cell_elements() {
927        let html = r#"<div align="center">x</div>"#;
928        assert_eq!(replace_table_align_attrs(html), html);
929    }
930
931    fn test_ctx(site_dir: &Path) -> PluginContext {
932        crate::test_support::init_logger();
933        PluginContext::new(
934            Path::new("content"),
935            Path::new("build"),
936            site_dir,
937            Path::new("templates"),
938        )
939    }
940
941    #[test]
942    fn test_html_fix_upgrades_jsonld_context() -> Result<()> {
943        let tmp = tempdir().unwrap();
944        let ctx = test_ctx(tmp.path());
945
946        let html = r#"<html><head>
947<script type="application/ld+json">
948{"@context":"http://schema.org/","@type":"WebPage"}
949</script>
950</head><body></body></html>"#;
951
952        let result = HtmlFixPlugin
953            .transform_html(html, Path::new("index.html"), &ctx)
954            .unwrap();
955        assert!(result.contains("\"https://schema.org\""));
956        assert!(!result.contains("\"http://schema.org/\""));
957        Ok(())
958    }
959
960    #[test]
961    fn test_html_fix_converts_jsonld_dates() -> Result<()> {
962        let tmp = tempdir().unwrap();
963        let ctx = test_ctx(tmp.path());
964
965        let html = r#"<html><head>
966<script type="application/ld+json">
967{"@context":"https://schema.org","@type":"Article","datePublished":"Thu, 11 Apr 2026 06:06:06 +0000","dateModified":"Mon, 01 Sep 2025 06:06:06 +0000"}
968</script>
969</head><body></body></html>"#;
970
971        let result = HtmlFixPlugin
972            .transform_html(html, Path::new("article.html"), &ctx)
973            .unwrap();
974        assert!(
975            result.contains("\"datePublished\":\"2026-04-11"),
976            "Expected ISO date, got: {result}"
977        );
978        assert!(
979            result.contains("\"dateModified\":\"2025-09-01"),
980            "Expected ISO date, got: {result}"
981        );
982        assert!(!result.contains("Thu, 11 Apr"));
983        Ok(())
984    }
985
986    #[test]
987    fn test_fix_broken_img_tags() {
988        let input =
989            r#"<img alt="test" class="w-25" title="test" <p src="image.jpg">"#;
990        let result = fix_broken_img_tags(input);
991        assert!(result.contains("src=\"image.jpg\""));
992        assert!(!result.contains("<p src="));
993    }
994
995    #[test]
996    fn test_fix_literal_class_syntax() {
997        let input = r#"<img alt="test" src="img.jpg">.class=&quot;w-25 float-start&quot;"#;
998        let result = fix_literal_class_syntax(input);
999        assert!(!result.contains(".class=&quot;"));
1000    }
1001
1002    // -----------------------------------------------------------------
1003    // fix_jsonld_dates
1004    // -----------------------------------------------------------------
1005
1006    #[test]
1007    fn test_fix_jsonld_dates_iso_passthrough() {
1008        let input =
1009            r#"{"datePublished":"2026-04-11","dateModified":"2025-09-01"}"#;
1010        let result = fix_jsonld_dates(input);
1011        assert_eq!(result, input, "ISO dates should pass through unchanged");
1012    }
1013
1014    #[test]
1015    fn test_fix_jsonld_dates_converts_rfc2822() {
1016        let input = r#"{"datePublished":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1017        let result = fix_jsonld_dates(input);
1018        assert!(
1019            result.contains("\"datePublished\":\"2026-04-11T06:06:06+00:00\""),
1020            "Should convert RFC 2822 to ISO 8601, got: {result}"
1021        );
1022    }
1023
1024    #[test]
1025    fn test_fix_jsonld_dates_both_fields() {
1026        let input = r#"{"datePublished":"Mon, 01 Sep 2025 12:00:00 +0000","dateModified":"Tue, 02 Sep 2025 14:30:00 +0000"}"#;
1027        let result = fix_jsonld_dates(input);
1028        assert!(result.contains("2025-09-01T12:00:00+00:00"));
1029        assert!(result.contains("2025-09-02T14:30:00+00:00"));
1030    }
1031
1032    // -----------------------------------------------------------------
1033    // fix_broken_img_tags
1034    // -----------------------------------------------------------------
1035
1036    #[test]
1037    fn test_fix_broken_img_tags_multiple() {
1038        let input =
1039            r#"<img alt="a" <p src="one.jpg"><img alt="b" <p src="two.jpg">"#;
1040        let result = fix_broken_img_tags(input);
1041        assert!(result.contains("src=\"one.jpg\""), "first img: {result}");
1042        assert!(result.contains("src=\"two.jpg\""), "second img: {result}");
1043        assert!(
1044            !result.contains("<p src="),
1045            "no broken tags remain: {result}"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_fix_broken_img_tags_none() {
1051        let input = r#"<img alt="ok" src="good.jpg" />"#;
1052        let result = fix_broken_img_tags(input);
1053        assert_eq!(
1054            result, input,
1055            "No broken tags should leave input unchanged"
1056        );
1057    }
1058
1059    // -----------------------------------------------------------------
1060    // fix_literal_class_syntax
1061    // -----------------------------------------------------------------
1062
1063    #[test]
1064    fn test_fix_literal_class_syntax_html_encoded() {
1065        let input =
1066            r#"<img src="img.jpg">.class=&quot;w-25 float-start&quot; rest"#;
1067        let result = fix_literal_class_syntax(input);
1068        assert!(
1069            !result.contains(".class=&quot;"),
1070            "should remove .class=&quot;"
1071        );
1072        assert!(
1073            result.contains("class=\"w-25 float-start\""),
1074            "should inject class attr, got: {result}"
1075        );
1076    }
1077
1078    #[test]
1079    fn test_fix_literal_class_syntax_literal_quotes() {
1080        let input = r#"<img src="img.jpg">.class="my-class" rest"#;
1081        let result = fix_literal_class_syntax(input);
1082        assert!(
1083            !result.contains(".class=\""),
1084            "should remove .class=\", got: {result}"
1085        );
1086        assert!(
1087            result.contains("class=\"my-class\""),
1088            "should inject class attr, got: {result}"
1089        );
1090    }
1091
1092    #[test]
1093    fn test_fix_literal_class_syntax_no_class() {
1094        let input = r#"<img src="img.jpg"> some text"#;
1095        let result = fix_literal_class_syntax(input);
1096        assert_eq!(result, input, "No .class= should leave input unchanged");
1097    }
1098
1099    // -----------------------------------------------------------------
1100    // inject_mobile_web_app_capable_meta
1101    // -----------------------------------------------------------------
1102
1103    #[test]
1104    fn test_inject_mobile_web_app_capable_meta_added() {
1105        let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"></head>"#;
1106        let result = inject_mobile_web_app_capable_meta(input);
1107        assert!(
1108            result.contains(
1109                r#"<meta name="mobile-web-app-capable" content="yes">"#
1110            ),
1111            "modern meta should be injected, got: {result}"
1112        );
1113        assert!(
1114            result.contains(
1115                r#"<meta name="apple-mobile-web-app-capable" content="yes">"#
1116            ),
1117            "legacy meta must remain for backwards compatibility"
1118        );
1119    }
1120
1121    // -----------------------------------------------------------------
1122    // remove_empty_preload_links
1123    // -----------------------------------------------------------------
1124
1125    #[test]
1126    fn test_remove_empty_preload_drops_bare_href() {
1127        let input = r#"<head><link as=image fetchpriority=high href rel=preload type=image/webp><title>x</title></head>"#;
1128        let result = remove_empty_preload_links(input);
1129        assert!(
1130            !result.contains("rel=preload"),
1131            "empty preload should be removed, got: {result}"
1132        );
1133        assert!(result.contains("<title>x</title>"), "rest preserved");
1134    }
1135
1136    #[test]
1137    fn test_remove_empty_preload_drops_quoted_empty_href() {
1138        let input = r#"<link rel="preload" href="" as="image">"#;
1139        let result = remove_empty_preload_links(input);
1140        assert_eq!(result, "");
1141    }
1142
1143    #[test]
1144    fn test_remove_empty_preload_keeps_valid_preload() {
1145        let input = r#"<link rel="preload" href="/banner.webp" as="image">"#;
1146        let result = remove_empty_preload_links(input);
1147        assert_eq!(result, input);
1148    }
1149
1150    #[test]
1151    fn test_remove_empty_preload_preserves_utf8() {
1152        let input = r#"<title>日本語</title><link rel=preload href as=image><p>テスト</p>"#;
1153        let result = remove_empty_preload_links(input);
1154        assert!(result.contains("日本語"));
1155        assert!(result.contains("テスト"));
1156        assert!(!result.contains("rel=preload"));
1157    }
1158
1159    #[test]
1160    fn test_apply_html_fixes_idempotent_on_modern_meta() {
1161        let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name="mobile-web-app-capable" content="yes"></head>"#;
1162        let result = apply_html_fixes(input);
1163        // Should not double-inject when modern meta already exists.
1164        let count = result.matches("name=\"mobile-web-app-capable\"").count();
1165        assert_eq!(count, 1, "no duplicate injection, got: {result}");
1166    }
1167
1168    #[test]
1169    fn test_apply_html_fixes_idempotent_on_modern_meta_single_quotes() {
1170        let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name='mobile-web-app-capable' content="yes"></head>"#;
1171        let result = apply_html_fixes(input);
1172        assert!(
1173            !result.contains("name=\"mobile-web-app-capable\""),
1174            "Should not inject modern meta when single quoted one exists"
1175        );
1176    }
1177
1178    #[test]
1179    fn test_apply_html_fixes_idempotent_on_modern_meta_unquoted() {
1180        let input = r#"<head><meta name="apple-mobile-web-app-capable" content="yes"><meta name=mobile-web-app-capable content="yes"></head>"#;
1181        let result = apply_html_fixes(input);
1182        assert!(
1183            !result.contains("name=\"mobile-web-app-capable\""),
1184            "Should not inject modern meta when unquoted one exists"
1185        );
1186    }
1187
1188    #[test]
1189    fn test_html_fix_plugin_metadata() {
1190        assert_eq!(HtmlFixPlugin.name(), "html-fix");
1191        assert!(HtmlFixPlugin.has_transform());
1192        let tmp = tempdir().unwrap();
1193        let ctx = test_ctx(tmp.path());
1194        assert!(HtmlFixPlugin.after_compile(&ctx).is_ok());
1195    }
1196
1197    #[test]
1198    fn test_needs_schema_context_fix() {
1199        assert!(needs_schema_context_fix("\"http://schema.org/\""));
1200        assert!(needs_schema_context_fix("\"http://schema.org\""));
1201        assert!(!needs_schema_context_fix("\"https://schema.org\""));
1202    }
1203
1204    #[test]
1205    fn test_needs_class_syntax_fix() {
1206        assert!(needs_class_syntax_fix(".class=&quot;foo&quot;"));
1207        assert!(needs_class_syntax_fix(".class=\"foo\""));
1208        assert!(!needs_class_syntax_fix("class=\"foo\""));
1209    }
1210
1211    #[test]
1212    fn test_has_empty_preload() {
1213        assert!(has_empty_preload("<link rel=\"preload\" href=\"\">"));
1214        assert!(has_empty_preload("<link rel='preload' href=''>"));
1215        assert!(has_empty_preload("<link rel=preload href>"));
1216        assert!(!has_empty_preload("<link rel=\"preload\" href=\"/foo\">"));
1217        assert!(!has_empty_preload("<link rel=\"stylesheet\" href=\"\">"));
1218    }
1219
1220    #[test]
1221    fn test_remove_empty_preload_unclosed_tag() {
1222        let input = "<link rel=\"preload\" href=\"\"";
1223        let result = remove_empty_preload_links(input);
1224        assert_eq!(result, "");
1225    }
1226
1227    #[test]
1228    fn test_remove_empty_preload_unclosed_quotes() {
1229        let input = "<link rel=\"preload href=\"\" >";
1230        let result = remove_empty_preload_links(input);
1231        assert_eq!(result, input);
1232    }
1233
1234    #[test]
1235    fn test_href_is_present_and_non_empty_edge_cases() {
1236        assert!(!href_is_present_and_non_empty(""));
1237        assert!(!href_is_present_and_non_empty("src=foo"));
1238        assert!(!href_is_present_and_non_empty("href"));
1239        assert!(!href_is_present_and_non_empty("href  "));
1240        assert!(!href_is_present_and_non_empty("href = >"));
1241        assert!(!href_is_present_and_non_empty("href = \"\""));
1242        assert!(!href_is_present_and_non_empty("href = ''"));
1243        assert!(!href_is_present_and_non_empty("href =  "));
1244        assert!(!href_is_present_and_non_empty("href="));
1245        assert!(!href_is_present_and_non_empty("href=>"));
1246        assert!(!href_is_present_and_non_empty("href=  "));
1247        assert!(!href_is_present_and_non_empty("href=\""));
1248        assert!(!href_is_present_and_non_empty("href='"));
1249        assert!(href_is_present_and_non_empty("href = \"/a\""));
1250        assert!(href_is_present_and_non_empty("href = '/a'"));
1251        assert!(href_is_present_and_non_empty("href=foo"));
1252    }
1253
1254    #[test]
1255    fn test_needs_mobile_web_app_capable_meta() {
1256        assert!(needs_mobile_web_app_capable_meta(
1257            "apple-mobile-web-app-capable"
1258        ));
1259        assert!(!needs_mobile_web_app_capable_meta(
1260            "apple-mobile-web-app-capable and name=\"mobile-web-app-capable\""
1261        ));
1262        assert!(!needs_mobile_web_app_capable_meta("no legacy meta"));
1263    }
1264
1265    #[test]
1266    fn test_inject_mobile_web_app_capable_meta_edge_cases() {
1267        // Missing apple meta — no <head> either, must inject to keep
1268        // substring present (caller only invokes this when the legacy
1269        // substring is present somewhere in the document).
1270        let no_head = inject_mobile_web_app_capable_meta("plain text");
1271        assert!(
1272            no_head.contains("name=\"mobile-web-app-capable\""),
1273            "fallback should inject modern meta: {no_head}"
1274        );
1275
1276        // Unclosed apple meta tag — no closing `>` so the primary anchor
1277        // path cannot insert; falls through to head-injection. Since
1278        // there's also no <head>, the meta is prepended.
1279        let unclosed = inject_mobile_web_app_capable_meta(
1280            "<meta name=\"apple-mobile-web-app-capable\"",
1281        );
1282        assert!(
1283            unclosed.contains("name=\"mobile-web-app-capable\""),
1284            "fallback should inject modern meta: {unclosed}"
1285        );
1286    }
1287
1288    #[test]
1289    fn test_inject_modern_meta_fallback_when_apple_meta_is_escaped() {
1290        // Regression for PR #511 / feat/v0.0.41: staticdatagen-rendered
1291        // pages can leak the legacy apple meta as fully HTML-escaped body
1292        // text (`&lt;meta name=&quot;apple-…&quot;…&gt;`). The injector
1293        // cannot find a `name="apple-…"` anchor, so it must fall back to
1294        // injecting the modern companion into <head> instead.
1295        let html = "<html><head><title>x</title></head><body>\
1296                    &lt;meta name=&quot;apple-mobile-web-app-capable&quot; \
1297                    content=&quot;yes&quot;&gt;</body></html>";
1298        let result = apply_html_fixes(html);
1299        assert!(
1300            result.contains("name=\"mobile-web-app-capable\""),
1301            "modern companion must be injected even when legacy is escaped"
1302        );
1303        // And it should land inside <head>, not after the escaped body text.
1304        let modern_pos =
1305            result.find("name=\"mobile-web-app-capable\"").unwrap();
1306        let head_close_pos = result.find("</head>").unwrap();
1307        assert!(
1308            modern_pos < head_close_pos,
1309            "modern meta should live inside <head>:\n{result}"
1310        );
1311    }
1312
1313    #[test]
1314    fn test_fix_jsonld_dates_invalid_rfc2822() {
1315        // String too short
1316        let input = r#"{"datePublished":"Mon"}"#;
1317        assert_eq!(fix_jsonld_dates(input), input);
1318
1319        // Doesn't start with day abbreviation / comma
1320        let input2 = r#"{"datePublished":"2026, 11 Apr 2026"}"#;
1321        assert_eq!(fix_jsonld_dates(input2), input2);
1322
1323        // Non-matching field
1324        let input3 = r#"{"dateCreated":"Thu, 11 Apr 2026 06:06:06 +0000"}"#;
1325        assert_eq!(fix_jsonld_dates(input3), input3);
1326
1327        // Missing quote
1328        let input4 = r#"{"datePublished":"Thu, 11 Apr 2026"#;
1329        assert_eq!(fix_jsonld_dates(input4), input4);
1330    }
1331
1332    #[test]
1333    fn test_fix_broken_img_tags_edge_cases() {
1334        // Missing quote for src
1335        let input = r#"<img <p src=image.jpg>"#;
1336        assert_eq!(fix_broken_img_tags(input), input);
1337
1338        // No img tag before p
1339        let input2 = r#"<p src="image.jpg">"#;
1340        assert_eq!(fix_broken_img_tags(input2), input2);
1341    }
1342
1343    #[test]
1344    fn test_fix_literal_class_syntax_edge_cases() {
1345        // Unclosed class syntax
1346        let input = r#"<img src="img.jpg">.class="my-class"#;
1347        assert_eq!(fix_literal_class_syntax(input), input);
1348    }
1349
1350    #[test]
1351    fn test_inject_class_attr_edge_cases() {
1352        // No preceding tag
1353        let mut html = "some text without tags".to_string();
1354        inject_class_attr(&mut html, 10, "foo");
1355        assert_eq!(html, "some text without tags");
1356
1357        // Preceding tag already has class
1358        let mut html2 = "<img class=\"existing\"> some text".to_string();
1359        let len = html2.len();
1360        inject_class_attr(&mut html2, len, "foo");
1361        assert_eq!(html2, "<img class=\"existing\"> some text");
1362
1363        // A `>` exists before `pos` but has no matching `<` before it
1364        // (malformed/truncated markup) — the inner `rfind('<')` must
1365        // return `None` and the function must leave the string alone.
1366        let mut html3 = "> stray text".to_string();
1367        let len3 = html3.len();
1368        inject_class_attr(&mut html3, len3, "foo");
1369        assert_eq!(html3, "> stray text");
1370    }
1371
1372    // -----------------------------------------------------------------
1373    // apply_html_fixes: routing gates for each fixer
1374    // -----------------------------------------------------------------
1375
1376    #[test]
1377    fn test_apply_html_fixes_routes_broken_img_repair() {
1378        let html = r#"<img alt="x" <p src="/pic.png"> tail"#;
1379        let out = apply_html_fixes(html);
1380        assert!(
1381            out.contains(r#"<img alt="x" src="/pic.png" />"#),
1382            "broken img must be repaired via the apply pipeline: {out}"
1383        );
1384    }
1385
1386    #[test]
1387    fn test_apply_html_fixes_routes_class_syntax_repair() {
1388        let html = r#"<div>.class="hero"</div>"#;
1389        let out = apply_html_fixes(html);
1390        assert!(
1391            !out.contains(".class="),
1392            "literal class syntax must be removed via the apply pipeline: {out}"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_apply_html_fixes_routes_empty_preload_removal() {
1398        let html = r#"<head><link rel="preload" href="" as="style"><link rel="stylesheet" href="/a.css"></head>"#;
1399        let out = apply_html_fixes(html);
1400        assert!(
1401            !out.contains("rel=\"preload\""),
1402            "empty-href preload must be dropped via the apply pipeline: {out}"
1403        );
1404        assert!(out.contains("/a.css"), "real links survive: {out}");
1405    }
1406
1407    // -----------------------------------------------------------------
1408    // fix_jsonld_dates: RFC-2822-shaped but unparseable value
1409    // -----------------------------------------------------------------
1410
1411    #[test]
1412    fn test_fix_jsonld_dates_keeps_unparseable_rfc_shaped_date() {
1413        let html = r#"{"datePublished":"Mon, not a real date"}"#;
1414        let out = fix_jsonld_dates(html);
1415        assert_eq!(out, html, "unparseable date passes through verbatim");
1416    }
1417
1418    // -----------------------------------------------------------------
1419    // fix_broken_img_tags: unterminated src attribute bails out
1420    // -----------------------------------------------------------------
1421
1422    #[test]
1423    fn test_fix_broken_img_tags_unterminated_src_bails_out() {
1424        let html = r#"<img alt="x" <p src="never-closes"#;
1425        let out = fix_broken_img_tags(html);
1426        assert_eq!(out, html, "unterminated src must not loop or rewrite");
1427    }
1428
1429    // -----------------------------------------------------------------
1430    // inject_class_attr: preceding tag already has a class
1431    // -----------------------------------------------------------------
1432
1433    #[test]
1434    fn test_fix_literal_class_syntax_keeps_existing_class_attr() {
1435        let html = r#"<div class="old">.class="new"</div>"#;
1436        let out = fix_literal_class_syntax(html);
1437        assert!(out.contains(r#"class="old""#), "existing class kept: {out}");
1438        assert!(
1439            !out.contains(r#"class="new""#),
1440            "no second class attribute injected: {out}"
1441        );
1442    }
1443
1444    // -----------------------------------------------------------------
1445    // inject_modern_meta_into_head fallbacks
1446    // -----------------------------------------------------------------
1447
1448    #[test]
1449    fn test_inject_meta_falls_back_when_head_close_is_escaped_only() {
1450        // `</head>` appears only as text with no real head element, so
1451        // the lol_html pass injects nothing and we fall through to the
1452        // prepend fallback.
1453        let html = "no real head here </head>";
1454        let out = inject_mobile_web_app_capable_meta(html);
1455        assert!(
1456            out.starts_with("<meta name=\"mobile-web-app-capable\""),
1457            "prepend fallback used: {out}"
1458        );
1459    }
1460
1461    #[test]
1462    fn test_inject_meta_after_open_head_when_no_close_tag() {
1463        let html = "<head><meta charset=\"utf-8\">";
1464        let out = inject_mobile_web_app_capable_meta(html);
1465        assert!(
1466            out.starts_with(
1467                "<head><meta name=\"mobile-web-app-capable\" content=\"yes\">"
1468            ),
1469            "meta injected right after <head>: {out}"
1470        );
1471    }
1472}