wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! HTML tag recognition against MediaWiki's Sanitizer allowlist.

use crate::engine::ast::{Inline, TextRange};
use crate::warn::{Warning, WarningCollector};

use super::scan::{Step, Token};

/// Tags MediaWiki's Sanitizer keeps; everything else is escaped to literal text.
/// Sorted for binary search.
const ALLOWED: &[&str] = &[
    "abbr",
    "b",
    "bdi",
    "bdo",
    "big",
    "blockquote",
    "br",
    "caption",
    "center",
    "cite",
    "code",
    "data",
    "dd",
    "del",
    "dfn",
    "div",
    "dl",
    "dt",
    "em",
    "font",
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    "hr",
    "i",
    "ins",
    "kbd",
    "li",
    "mark",
    "ol",
    "p",
    "pre",
    "q",
    "rb",
    "rp",
    "rt",
    "rtc",
    "ruby",
    "s",
    "samp",
    "small",
    "span",
    "strike",
    "strong",
    "sub",
    "sup",
    "table",
    "td",
    "th",
    "time",
    "tr",
    "tt",
    "u",
    "ul",
    "var",
    "wbr",
];

/// Parses `<name …>`, `</name>` or `<name …/>` at `pos` (which sits on `<`).
///
/// Allowed tags become [`Inline::HtmlTag`]; disallowed ones warn (once per name — the
/// collector dedups) and the whole `<…>` stays literal text while its inner content is
/// scanned normally. A `<` that opens no tag on this line is a literal character.
pub(super) fn scan_tag(
    flat: &str,
    pos: usize,
    end: usize,
    warnings: &mut WarningCollector,
) -> Step {
    let rest = &flat[pos..end];
    let closing = rest[1..].starts_with('/');
    let name_at = 1 + usize::from(closing);
    let name_len = rest[name_at..]
        .bytes()
        .take_while(u8::is_ascii_alphanumeric)
        .count();
    if name_len == 0 || !rest.as_bytes()[name_at].is_ascii_alphabetic() {
        return Step::Miss;
    }
    let name_end = name_at + name_len;
    // A tag opens only when the name is followed by whitespace, `/` or `>`; anything
    // else (`<b]]`) is not markup and the `<` stays literal.
    match rest.as_bytes().get(name_end) {
        Some(b) if b.is_ascii_whitespace() || *b == b'/' || *b == b'>' => {}
        _ => return Step::Miss,
    }

    // find the closing `>`, skipping over quoted attribute values
    let mut in_quote: Option<char> = None;
    let mut gt = None;
    for (i, c) in rest[name_end..].char_indices() {
        match in_quote {
            Some(q) if c == q => in_quote = None,
            Some(_) => {}
            None if c == '"' || c == '\'' => in_quote = Some(c),
            None if c == '>' => {
                gt = Some(name_end + i);
                break;
            }
            None => {}
        }
    }
    let Some(gt) = gt else {
        return Step::Miss;
    };

    let trimmed_inner = rest[name_end..gt].trim_end();
    let (self_closing, attr_body) = match trimmed_inner.strip_suffix('/') {
        Some(without) => (true, without),
        None => (false, trimmed_inner),
    };
    let attr_trim = attr_body.trim();
    let attributes = if attr_trim.is_empty() {
        None
    } else {
        let lead = attr_body.len() - attr_body.trim_start().len();
        let start = pos + name_end + lead;
        Some(TextRange::new(
            start as u32,
            (start + attr_trim.len()) as u32,
        ))
    };

    let name = rest[name_at..name_end].to_ascii_lowercase();
    let next = pos + gt + 1;
    if ALLOWED.binary_search(&name.as_str()).is_ok() {
        Step::Emit(
            Token::Node(Inline::HtmlTag {
                name,
                closing,
                self_closing,
                attributes,
            }),
            next,
        )
    } else {
        warnings.emit(Warning::DisallowedHtmlTag { name });
        Step::Text(next)
    }
}

#[cfg(test)]
mod tests {
    use super::super::testutil::{check, dump, parse};
    use super::ALLOWED;
    use crate::warn::Warning;

    #[test]
    fn allowlist_is_sorted_for_binary_search() {
        assert!(ALLOWED.is_sorted());
        assert_eq!(ALLOWED.len(), 59);
    }

    #[test]
    fn open_and_close_tags() {
        check("<b>x</b>", "Html(b) Text(x) Html(b/c)");
    }

    #[test]
    fn self_closing_tag() {
        check("<br/>", "Html(br/s)");
        check("<br />", "Html(br/s)");
    }

    #[test]
    fn attributes_keep_their_raw_range() {
        check("<span class=\"a\">", "Html(span class=\"a\")");
        check("<span class=\"a\" />", "Html(span/s class=\"a\")");
    }

    #[test]
    fn quoted_attribute_may_contain_gt() {
        check("<span title=\"a>b\">", "Html(span title=\"a>b\")");
        check("<span title='a>b'>", "Html(span title='a>b')");
    }

    #[test]
    fn unterminated_quote_leaves_the_bracket_literal() {
        check("<span class=\"a>", "Text(<span class=\"a>)");
    }

    #[test]
    fn tag_names_are_lowercased() {
        check("<SPAN>", "Html(span)");
        check("</DIV>", "Html(div/c)");
    }

    #[test]
    fn disallowed_tag_warns_and_stays_literal() {
        let src = "<script>alert(1)</script>";
        let (nodes, warnings) = parse(src);
        assert_eq!(dump(src, &nodes), "Text(<script>alert(1)</script>)");
        // both the opening and closing tag map to the same warning; the collector dedups
        assert_eq!(
            warnings,
            vec![Warning::DisallowedHtmlTag {
                name: "script".to_owned()
            }]
        );
    }

    #[test]
    fn unknown_tag_warns_too() {
        let src = "<iframe src=\"x\">";
        let (nodes, warnings) = parse(src);
        assert_eq!(dump(src, &nodes), "Text(<iframe src=\"x\">)");
        assert_eq!(
            warnings,
            vec![Warning::DisallowedHtmlTag {
                name: "iframe".to_owned()
            }]
        );
    }

    #[test]
    fn non_tag_brackets_are_literal() {
        check("< notag", "Text(< notag)");
        check("<3>", "Text(<3>)");
        check("a<", "Text(a<)");
        check("</", "Text(</)");
    }

    #[test]
    fn unclosed_tag_is_literal() {
        check("<b", "Text(<b)");
        check("<b class=x", "Text(<b class=x)");
    }

    #[test]
    fn name_must_be_followed_by_whitespace_slash_or_gt() {
        check("<b]]>", "Text(<b]]>)");
        check("<b=x>", "Text(<b=x>)");
        check("a<b, c>", "Text(a<b, c>)");
    }
}