From f81ce12c86bf2a1b47b912fc46b54f2b696a3f05 Mon Sep 17 00:00:00 2001
From: wuyangfan <1102042793@qq.com>
Date: Sun, 17 May 2026 10:05:18 +0800
Subject: [PATCH] fix(html): skip HTML balance warnings when Handlebars is
present
Handlebars helpers such as `{{#include}}` are expanded after the HTML
tree is built. Validating tag balance on the pre-expansion source caused
false positives for patterns like `...{{#include ...}} `
inside admonitions.
Fixes #2941.
Co-authored-by: Cursor
---
crates/mdbook-html/src/html/tree.rs | 45 ++++++++++++++++++++---------
tests/testsuite/rendering.rs | 20 +++++++++++++
2 files changed, 52 insertions(+), 13 deletions(-)
diff --git a/crates/mdbook-html/src/html/tree.rs b/crates/mdbook-html/src/html/tree.rs
index 5cb97ce378..b20acf2061 100644
--- a/crates/mdbook-html/src/html/tree.rs
+++ b/crates/mdbook-html/src/html/tree.rs
@@ -199,6 +199,12 @@ pub(crate) struct MarkdownTreeBuilder<'opts, 'event, EventIter> {
/// tag. After the document has been parsed, all the definitions are moved
/// to the end of the document.
footnote_defs: HashMap, NodeId>,
+ /// Nesting depth of HTML fragments that contain Handlebars syntax.
+ ///
+ /// Handlebars helpers such as `{{#include}}` are expanded after the HTML
+ /// tree is built; validating tag balance on the pre-expansion source
+ /// produces false positives (see issue #2941).
+ suppress_html_balance_warnings: usize,
}
impl<'opts, 'event, EventIter> MarkdownTreeBuilder<'opts, 'event, EventIter>
@@ -222,6 +228,7 @@ where
table_cell_index: 0,
footnote_numbers: HashMap::new(),
footnote_defs: HashMap::new(),
+ suppress_html_balance_warnings: 0,
};
builder.process_events();
builder.add_header_links();
@@ -597,12 +604,14 @@ where
if !el.was_raw {
break;
}
- warn!(
- "unclosed HTML tag `<{}>` found in `{}` while exiting {tag:?}\n\
- HTML tags must be closed before exiting a markdown element.",
- el.name.local,
- self.options.path.display(),
- );
+ if self.suppress_html_balance_warnings == 0 {
+ warn!(
+ "unclosed HTML tag `<{}>` found in `{}` while exiting {tag:?}\n\
+ HTML tags must be closed before exiting a markdown element.",
+ el.name.local,
+ self.options.path.display(),
+ );
+ }
self.pop();
}
self.pop();
@@ -625,6 +634,10 @@ where
/// Given some HTML, parse it into [`Node`] elements and append them to
/// the current node.
fn append_html(&mut self, html: &str) {
+ let has_handlebars = html.contains("{{");
+ if has_handlebars {
+ self.suppress_html_balance_warnings += 1;
+ }
let tokens = parse_html(&html);
let mut is_raw = false;
for token in tokens {
@@ -647,7 +660,7 @@ where
}
Token::NullCharacterToken => {}
Token::EOFToken => {}
- Token::ParseError(error) => {
+ Token::ParseError(error) if self.suppress_html_balance_warnings == 0 => {
warn!(
"html parse error in `{}`: {error}\n\
Html text was:\n\
@@ -655,8 +668,12 @@ where
self.options.path.display()
);
}
+ Token::ParseError(_) => {}
}
}
+ if has_handlebars {
+ self.suppress_html_balance_warnings -= 1;
+ }
}
/// Adds an open HTML tag.
@@ -688,7 +705,7 @@ where
*is_raw = false;
if self.is_html_tag_matching(&tag.name) {
self.pop();
- } else {
+ } else if self.suppress_html_balance_warnings == 0 {
// The proper thing to do here is to recover. However, the HTML
// parsing algorithm for that is quite complex. See
// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
@@ -776,11 +793,13 @@ where
Node::Fragment => {}
Node::Element(el) => {
if el.was_raw {
- warn!(
- "unclosed HTML tag `<{}>` found in `{}`",
- el.name.local,
- self.options.path.display()
- );
+ if self.suppress_html_balance_warnings == 0 {
+ warn!(
+ "unclosed HTML tag `<{}>` found in `{}`",
+ el.name.local,
+ self.options.path.display()
+ );
+ }
} else {
panic!(
"internal error: expected empty tag stack.\n
diff --git a/tests/testsuite/rendering.rs b/tests/testsuite/rendering.rs
index c128829835..ee4a52d87e 100644
--- a/tests/testsuite/rendering.rs
+++ b/tests/testsuite/rendering.rs
@@ -283,6 +283,26 @@ fn unclosed_html_tags() {
);
}
+// HTML with Handlebars helpers inside raw tags should not emit balance warnings
+// before the template is expanded (see issue #2941).
+#[test]
+fn html_with_handlebars_include_in_admonition() {
+ BookTest::init(|_| {})
+ .change_file(
+ "src/chapter_1.md",
+ "> [!TIP]\n> Click to open
{{#include assets/partial.html}} ",
+ )
+ .change_file("src/assets/partial.html", "included
")
+ .run("build", |cmd| {
+ cmd.expect_stderr(str![[r#"
+ INFO Book building has started
+ INFO Running the html backend
+ INFO HTML book written to `[ROOT]/book`
+
+"#]]);
+ });
+}
+
// Test for HTML tags out of sync.
#[test]
fn unbalanced_html_tags() {