Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions minify-html-common/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,12 @@ pub fn create_common_test_data() -> HashMap<&'static [u8], &'static [u8]> {
b"<svg><path d=\"c d\"/></svg>",
);
t.insert(b"<svg><path d=' \n \n ' /></svg>", b"<svg><path/></svg>");
// Attribute names should be case insensitive.
t.insert(b"<svg><path D=' \n \n ' /></svg>", b"<svg><path/></svg>");
// SVG foreign content attribute names are case-sensitive (unlike HTML), so
// an uppercase `D` is a different attribute from `d` and is left untouched.
t.insert(
b"<svg><path D=' \n \n ' /></svg>",
b"<svg><path D=\" \n \n \"/></svg>",
);

// boolean attr value removal
t.insert(b"<div hidden=\"true\"></div>", b"<div hidden></div>");
Expand Down Expand Up @@ -406,11 +410,11 @@ pub fn create_common_test_data() -> HashMap<&'static [u8], &'static [u8]> {
// self closing svg
t.insert(
b"<a><svg viewBox=\"0 0 700 100\" /></a><footer></footer>",
b"<a><svg viewbox=\"0 0 700 100\"/></a><footer></footer>",
b"<a><svg viewBox=\"0 0 700 100\"/></a><footer></footer>",
);
t.insert(
b"<a><svg viewBox=\"0 0 700 100\"></svg></a><footer></footer>",
b"<a><svg viewbox=\"0 0 700 100\"></svg></a><footer></footer>",
b"<a><svg viewBox=\"0 0 700 100\"></svg></a><footer></footer>",
);

t
Expand Down
6 changes: 5 additions & 1 deletion minify-html-onepass/src/unit/attr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ pub fn process_attr(
let name = proc
.m(WhileInLookup(WHATWG_ATTR_NAME_CHAR), Keep)
.require("attribute name")?;
proc.make_lowercase(name);
// SVG foreign content attribute names are case-sensitive; only lowercase
// HTML attribute names.
if ns == Namespace::Html {
proc.make_lowercase(name);
}
let attr_cfg = ATTRS.get(ns, &proc[element], &proc[name]);
let is_boolean = attr_cfg.filter(|attr| attr.boolean).is_some();
let after_name = WriteCheckpoint::new(proc);
Expand Down
12 changes: 11 additions & 1 deletion minify-html-onepass/src/unit/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,17 @@ pub fn process_content(
let tag_name = proc
.m(WhileInLookup(TAG_NAME_CHAR), Discard)
.require("tag name")?;
proc.make_lowercase(tag_name);
// SVG is foreign content: element and attribute names are case-sensitive.
// An `svg` element enters the SVG namespace (case-insensitive match);
// inside it keep the author's casing, otherwise lowercase like HTML.
let tag_ns = if proc[tag_name].eq_ignore_ascii_case(b"svg") {
Namespace::Svg
} else {
ns
};
if tag_ns == Namespace::Html {
proc.make_lowercase(tag_name);
}

if can_omit_as_before(proc.get_or_empty(parent), &proc[tag_name]) {
// TODO Is this necessary? Can a previous closing tag even exist?
Expand Down
22 changes: 15 additions & 7 deletions minify-html-onepass/src/unit/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ pub fn process_tag(
// Write previously skipped name and use written code as range (otherwise source code will eventually be overwritten).
let tag_name = proc.write_range(source_tag_name);

// SVG is foreign content: element and attribute names are case-sensitive.
// An `svg` element enters the SVG namespace (case-insensitive match so a
// preserved `SVG` still switches), otherwise the tag inherits the parent's
// namespace. This tag's own attributes and closing tag follow this ns.
let tag_ns = if proc[tag_name].eq_ignore_ascii_case(b"svg") {
Namespace::Svg
} else {
ns
};

let mut tag_type = match &proc[tag_name] {
// Unless non-JS MIME `type` is provided, `script` tags contain JS.
b"script" => TagType::ScriptJs,
Expand Down Expand Up @@ -144,7 +154,7 @@ pub fn process_tag(
_ => {}
};

let ProcessedAttr { name, typ, value } = process_attr(proc, ns, tag_name)?;
let ProcessedAttr { name, typ, value } = process_attr(proc, tag_ns, tag_name)?;
match (tag_type, &proc[name]) {
// NOTE: We don't support multiple `type` attributes, so can't go from ScriptData => ScriptJs.
(TagType::ScriptJs, b"type") => {
Expand Down Expand Up @@ -207,11 +217,7 @@ pub fn process_tag(
return Ok(MaybeClosingTag(None));
};

let child_ns = if proc[tag_name].eq(b"svg") {
Namespace::Svg
} else {
ns
};
let child_ns = tag_ns;

let mut closing_tag_omitted = false;
match tag_type {
Expand All @@ -235,7 +241,9 @@ pub fn process_tag(
let closing_tag = proc
.m(WhileInLookup(TAG_NAME_CHAR), Discard)
.require("closing tag name")?;
proc.make_lowercase(closing_tag);
if tag_ns == Namespace::Html {
proc.make_lowercase(closing_tag);
}

// We need to check closing tag matches as otherwise when we later write closing tag, it might be longer than source closing tag and cause source to be overwritten.
if proc[closing_tag] != proc[tag_name] {
Expand Down
6 changes: 3 additions & 3 deletions minify-html/src/parse/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,14 @@ pub fn parse_content(
} else if name.is_empty() {
// Malformed code, drop until and including next `>`.
typ = MalformedLeftChevronSlash;
} else if grandparent == name.as_slice() && can_omit_as_last_node(grandparent, parent) {
} else if grandparent.eq_ignore_ascii_case(&name) && can_omit_as_last_node(grandparent, parent) {
// The upcoming closing tag implicitly closes the current element e.g. `<tr><td>(current position)</tr>`.
// This DOESN'T handle when grandparent doesn't exist (represented by an empty slice). However, in that case it's irrelevant, as it would mean we would be at EOF, and our parser simply auto-closes everything anyway. (Normally we'd have to determine if `<p>Hello` is an error or allowed.)
typ = OmittedClosingTag;
} else if VOID_TAGS.contains(name.as_slice()) {
// Closing tag for void element, drop.
typ = IgnoredTag;
} else if parent.is_empty() || parent != name.as_slice() {
} else if parent.is_empty() || !parent.eq_ignore_ascii_case(&name) {
// Closing tag mismatch, drop.
typ = IgnoredTag;
};
Expand All @@ -245,7 +245,7 @@ pub fn parse_content(
closing_tag_omitted = true;
break;
}
IgnoredTag => drop(parse_tag(code)),
IgnoredTag => drop(parse_tag(code, ns)),
e @ (OpaqueBraceBrace | OpaqueBraceHash | OpaqueBracePercent | OpaqueChevronPercent) => {
let closing_matcher = match e {
OpaqueBraceBrace => &CLOSING_BRACE_BRACE,
Expand Down
38 changes: 30 additions & 8 deletions minify-html/src/parse/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ use std::fmt::Debug;
use std::fmt::Formatter;
use std::str::from_utf8;

fn parse_tag_name(code: &mut Code) -> Vec<u8> {
fn parse_tag_name_raw(code: &mut Code) -> Vec<u8> {
debug_assert!(code.as_slice().starts_with(b"<"));
code.shift(1);
code.shift_if_next(b'/');
let mut name = code.copy_and_shift_while_in_lookup(TAG_NAME_CHAR);
code.copy_and_shift_while_in_lookup(TAG_NAME_CHAR)
}

fn parse_tag_name(code: &mut Code) -> Vec<u8> {
let mut name = parse_tag_name_raw(code);
name.make_ascii_lowercase();
name
}
Expand Down Expand Up @@ -67,8 +71,22 @@ impl Debug for ParsedTag {

// While not valid, attributes in closing tags still need to be parsed (and then discarded) as attributes e.g. `</div x=">">`, which is why this function is used for both opening and closing tags.
// TODO Use generics to create version that doesn't create an AHashMap.
pub fn parse_tag(code: &mut Code) -> ParsedTag {
let elem_name = parse_tag_name(code);
pub fn parse_tag(code: &mut Code, ns: Namespace) -> ParsedTag {
let raw_elem_name = parse_tag_name_raw(code);
// SVG is foreign content: per the HTML spec its element and attribute names
// are case-sensitive (e.g. `viewBox`, `linearGradient`). Enter the SVG
// namespace for this tag (case-insensitive match so `<SVG>` still switches)
// and preserve the author's casing inside it; HTML keeps lowercasing.
let ns = if raw_elem_name.eq_ignore_ascii_case(b"svg") {
Namespace::Svg
} else {
ns
};
let elem_name = if ns == Namespace::Svg {
raw_elem_name
} else {
raw_elem_name.to_ascii_lowercase()
};
let mut attributes = AHashMap::default();
let self_closing;
loop {
Expand All @@ -88,7 +106,9 @@ pub fn parse_tag(code: &mut Code) -> ParsedTag {
code.slice_and_shift_while_not_in_lookup(WHITESPACE_OR_SLASH_OR_EQUALS_OR_RIGHT_CHEVRON),
);
debug_assert!(!attr_name.is_empty());
attr_name.make_ascii_lowercase();
if ns == Namespace::Html {
attr_name.make_ascii_lowercase();
}
// See comment for WHITESPACE_OR_SLASH in codepoints.ts for details of complex attr parsing.
code.shift_while_in_lookup(WHITESPACE);
let has_value = code.shift_if_next(b'=');
Expand Down Expand Up @@ -136,10 +156,12 @@ pub fn parse_element(code: &mut Code, ns: Namespace, parent: &[u8]) -> NodeData
name: elem_name,
attributes,
self_closing,
} = parse_tag(code);
} = parse_tag(code, ns);

// Embedded svg tags are immediately in the svg namespace and must be parsed as such.
let ns = if elem_name == b"svg" {
// Case-insensitive so `<SVG>` switches namespaces even though parse_tag may
// have preserved the author's casing.
let ns = if elem_name.eq_ignore_ascii_case(b"svg") {
Namespace::Svg
} else {
ns
Expand Down Expand Up @@ -187,7 +209,7 @@ pub fn parse_element(code: &mut Code, ns: Namespace, parent: &[u8]) -> NodeData
};

if !closing_tag_omitted {
let closing_tag = parse_tag(code);
let closing_tag = parse_tag(code, ns);
debug_assert_eq!(closing_tag.name, elem_name);
};

Expand Down
31 changes: 30 additions & 1 deletion minify-html/src/parse/tests/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ fn test_parse_tag() {
=
"password" "a" = " b " :cd /e /=fg = /\h /i/ /j/k/l m=n=o q==\r/s/ / t] = /u / w=//>"###,
);
let tag = parse_tag(&mut code);
let tag = parse_tag(&mut code, Namespace::Html);
assert_eq!(tag, ParsedTag {
attributes: {
let mut map = AHashMap::<Vec<u8>, AttrVal>::default();
Expand All @@ -49,6 +49,35 @@ fn test_parse_tag() {
});
}

#[test]
fn test_parse_svg_preserves_case() {
// SVG is foreign content: element and attribute names are case-sensitive.
let mut code = Code::new(
br###"<svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid"><linearGradient></linearGradient></svg>"###,
);
let elem = parse_element(&mut code, Namespace::Html, EMPTY_SLICE);
let NodeData::Element {
attributes,
children,
name,
namespace,
..
} = elem
else {
panic!("expected element");
};
assert_eq!(name, b"svg".to_vec());
assert_eq!(namespace, Namespace::Svg);
assert!(attributes.contains_key(b"viewBox".as_ref()));
assert!(attributes.contains_key(b"preserveAspectRatio".as_ref()));
assert!(!attributes.contains_key(b"viewbox".as_ref()));
let NodeData::Element { name, namespace, .. } = &children[0] else {
panic!("expected child element");
};
assert_eq!(name.as_slice(), b"linearGradient");
assert_eq!(*namespace, Namespace::Svg);
}

#[test]
fn test_parse_element() {
let mut code = Code::new(br#"<a b=\"c\"></a>"#);
Expand Down
20 changes: 18 additions & 2 deletions minify-html/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ fn eval_without_keep_html_head(src: &'static [u8], expected: &'static [u8]) -> (
}

#[test]
fn test_common() {
for (a, b) in create_common_test_data() {
fn test_common() { for (a, b) in create_common_test_data() {
eval(a, b);
}
for (a, b) in create_common_noncompliant_test_data() {
Expand Down Expand Up @@ -232,3 +231,20 @@ fn test_style_attr_minification() {
// `style` attributes are removed if fully minified away.
eval_with_css_min(br#"<div style=" /* */ "></div>"#, br#"<div></div>"#);
}

#[test]
fn test_svg_foreign_content_case_preserved() {
// SVG is foreign content: attribute and element names are case-sensitive.
// minify-html must not lowercase viewBox / preserveAspectRatio / camelCase
// element names inside <svg>.
eval(
b"<svg viewBox=\"0 0 24 24\" preserveAspectRatio=\"xMidYMid meet\"><linearGradient></linearGradient></svg>",
br#"<svg preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><linearGradient></linearGradient></svg>"#,
);
}

#[test]
fn test_html_attributes_still_lowercased() {
// Regular HTML attribute names stay case-insensitive and lowercased.
eval(b"<DIV DATA-FOO=\"1\" CLASS=x></DIV>", b"<div class=x data-foo=1></div>");
}