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('\nx
\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"