diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs index 6417914..65dd635 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs @@ -177,6 +177,60 @@ mod tests { assert_eq!(output, r#""#); } + #[test] + fn preserves_utf8_attribute_values() { + // Two-, three-, and four-byte characters are retained in quoted and + // unquoted values, including the mismatched quotes from issue #1013. + let cases = [ + ( + r#"
x
"#, + r#"
x
"#, + ), + ("
x
", "
x
"), + ("
x
", "
x
"), + ("
x
", "
x
"), + ("
x
", "
x
"), + ("
x
", "
x
"), + (r#"
x
"#, "
x
"), + ("
x
", "
x
"), + ]; + + for convert_charrefs in [true, false] { + let mut options = options(); + options.convert_charrefs = convert_charrefs; + + for (input, expected) in cases { + let output = minify(input, &options, false, false); + + assert_eq!(output, expected, "input: {input}"); + } + } + } + + #[test] + fn preserves_unquoted_attribute_source_spelling() { + // Source spelling is retained when character-reference conversion is + // disabled or an attribute is protected with the configured prefix. + let mut options = options(); + options.convert_charrefs = false; + let input = + "
x
"; + + let output = minify(input, &options, false, false); + + assert_eq!(output, input); + + options.convert_charrefs = true; + let input = "
x
"; + + let output = minify(input, &options, false, false); + + assert_eq!( + output, + "
x
" + ); + } + #[test] fn supports_custom_preservation_markers() { let mut options = options(); @@ -293,6 +347,28 @@ mod tests { assert!(output.ends_with("")); } + #[test] + fn inline_only_retains_utf8_attribute_values() { + // Surrounding HTML is preserved while inline languages are minified, + // even when an unquoted attribute starts with a multi-byte character. + let input = concat!( + r#"
x
"#, + "", + "", + ); + + let output = minify_inline(input.into(), true, true); + + assert_eq!( + output, + concat!( + r#"
x
"#, + "", + "", + ) + ); + } + #[test] fn inline_minification_never_expands_a_body() { let input = ""; diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs index 647b043..528b355 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs @@ -32,8 +32,8 @@ use std::ops::Range; use crate::compat::mkdocs::plugin::minify::{script, style}; use super::syntax::{ - is_css, is_javascript, is_module, source_name, Attribute, InlineKind, - StartTag, Value, + is_css, is_javascript, is_module, source_name, source_value, Attribute, + InlineKind, StartTag, Value, }; // ---------------------------------------------------------------------------- @@ -120,7 +120,7 @@ impl<'a> InlineEditor<'a> { { attribute.value = Some(Value { decoded: String::from_utf8_lossy(value).into_owned(), - raw: self.input[span.start..span.end].into(), + raw: source_value(self.input, span), }); } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs index 36d3463..1832305 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs @@ -34,8 +34,8 @@ use crate::config::plugins::HtmlMinOptions; use super::syntax::{ closes_on_start, collapse_whitespace, escape_text, is_boolean_attribute, is_css, is_html_whitespace, is_javascript, is_module, is_void, - serialize_attribute_value, source_name, Attribute, Element, Inline, - InlineKind, StartTag, Value, + serialize_attribute_value, source_name, source_value, Attribute, Element, + Inline, InlineKind, StartTag, Value, }; // ---------------------------------------------------------------------------- @@ -151,7 +151,7 @@ impl<'a> Serializer<'a> { }; attribute.value = Some(Value { decoded: String::from_utf8_lossy(value).into_owned(), - raw: self.input[span.start..span.end].into(), + raw: source_value(self.input, span), }); } diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs index 7a04eed..50c30d8 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs @@ -131,6 +131,18 @@ pub fn source_name( input.get(start..end).unwrap_or_default().into() } +/// An attribute value is extracted with its source spelling. +pub fn source_value(input: &str, span: Span) -> String { + // The opening quote or first unquoted byte is excluded from html5gum's + // attribute-value spans. The first byte must be restored for unquoted + // values. + let mut start = span.start - 1; + if matches!(input.as_bytes()[start], b'\'' | b'"') { + start += 1; + } + input[start..span.end].into() +} + /// Returns whether a character is HTML whitespace. pub fn is_html_whitespace(character: char) -> bool { matches!(character, '\t' | '\n' | '\u{c}' | '\r' | ' ') diff --git a/python/tests/integration/test_minify.py b/python/tests/integration/test_minify.py index f1b0e45..5972ccf 100644 --- a/python/tests/integration/test_minify.py +++ b/python/tests/integration/test_minify.py @@ -117,6 +117,31 @@ def test_inline_minification_does_not_require_html_minification( assert "screen and (min-width:45em)" in output +@pytest.mark.parametrize( + ("minify_html", "expected"), + [ + (True, """
x
"""), + (False, '
x
'), + ], +) +def test_minification_preserves_utf8_attribute_values( + tmp_path: Path, + minify_html: bool, + expected: str, +) -> None: + """Preserve UTF-8 attribute values during HTML and inline minification.""" + config = _project(tmp_path, minify_html=minify_html) + + # The mismatched quotes from issue #1013 are included in the rendered page. + with (tmp_path / "docs" / "index.md").open("a", encoding="utf-8") as stream: + stream.write('\n
x
\n') + + zensical.build(str(config), {"clean": False, "strict": False}) + output = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + + assert expected in output + + def _asset_project(root: Path, *, minify: bool, cache_safe: bool) -> Path: """Create a project covering exact, glob, and configured assets.""" docs = root / "docs"