From 5a102013bb1368aa17614a375941432c852030f3 Mon Sep 17 00:00:00 2001 From: gffranco Date: Wed, 24 Jun 2026 00:26:59 +0000 Subject: [PATCH] fix(html): keyword-in-heading badge + brackets in wikilink descriptions --- crates/nuwiki-core/src/ast/block.rs | 4 + crates/nuwiki-core/src/ast/inline.rs | 17 ++ crates/nuwiki-core/src/render/html.rs | 174 ++++++++++++++---- .../nuwiki-core/src/syntax/vimwiki/lexer.rs | 39 +++- .../nuwiki-core/src/syntax/vimwiki/parser.rs | 6 +- crates/nuwiki-core/tests/html_renderer.rs | 87 +++++++-- crates/nuwiki-core/tests/lexer.rs | 10 +- crates/nuwiki-core/tests/tags.rs | 4 +- crates/nuwiki-lsp/tests/html_export.rs | 18 +- 9 files changed, 293 insertions(+), 66 deletions(-) diff --git a/crates/nuwiki-core/src/ast/block.rs b/crates/nuwiki-core/src/ast/block.rs index 52ccf8d..fb59e98 100644 --- a/crates/nuwiki-core/src/ast/block.rs +++ b/crates/nuwiki-core/src/ast/block.rs @@ -53,6 +53,10 @@ pub struct PreformattedNode { pub span: Span, pub content: String, pub language: Option, + /// Verbatim text after the opening `{{{`, trailing whitespace trimmed + /// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as + /// raw attributes on the `
` tag, so we preserve it for byte-parity.
+    pub attrs: String,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs
index 2e082f2..6b37e52 100644
--- a/crates/nuwiki-core/src/ast/inline.rs
+++ b/crates/nuwiki-core/src/ast/inline.rs
@@ -17,6 +17,22 @@ pub enum Keyword {
     Stopped,
 }
 
+impl Keyword {
+    /// The literal source text of the keyword (e.g. `"TODO"`). Used both for
+    /// the rendered span and for anchor/id derivation via [`inline_text`].
+    pub fn label(self) -> &'static str {
+        match self {
+            Keyword::Todo => "TODO",
+            Keyword::Done => "DONE",
+            Keyword::Started => "STARTED",
+            Keyword::Fixme => "FIXME",
+            Keyword::Fixed => "FIXED",
+            Keyword::Xxx => "XXX",
+            Keyword::Stopped => "STOPPED",
+        }
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum InlineNode {
     Text(TextNode),
@@ -89,6 +105,7 @@ fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
             InlineNode::Superscript(s) => push_inline_text(&s.children, out),
             InlineNode::Subscript(s) => push_inline_text(&s.children, out),
             InlineNode::Color(c) => push_inline_text(&c.children, out),
+            InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
             InlineNode::WikiLink(w) => match &w.description {
                 Some(d) => push_inline_text(d, out),
                 None => {
diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs
index 3d2558b..c21d511 100644
--- a/crates/nuwiki-core/src/render/html.rs
+++ b/crates/nuwiki-core/src/render/html.rs
@@ -278,10 +278,26 @@ impl HtmlRenderer {
         // quotes) go through `render_block` and are left unnumbered, matching
         // upstream which only numbers document-level headers.
         let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
+        // Stack of (level, anchor) for the headings enclosing the current one,
+        // so each heading can carry vimwiki's hierarchical id (the parent
+        // anchors joined by `-`, e.g. `Title-Section`).
+        let mut ancestors: Vec<(u8, String)> = Vec::new();
         for block in &doc.children {
             if let BlockNode::Heading(n) = block {
-                let number = numberer.prefix(n.level.clamp(1, 6));
-                self.render_heading(n, &number, w)?;
+                let level = n.level.clamp(1, 6);
+                let number = numberer.prefix(level);
+                let anchor = crate::ast::inline_text(&n.children);
+                while ancestors.last().is_some_and(|(l, _)| *l >= level) {
+                    ancestors.pop();
+                }
+                let mut hier = String::new();
+                for (_, a) in &ancestors {
+                    hier.push_str(a);
+                    hier.push('-');
+                }
+                hier.push_str(&anchor);
+                ancestors.push((level, anchor));
+                self.render_heading(n, &number, &hier, w)?;
             } else {
                 self.render_block(block, w)?;
             }
@@ -291,7 +307,7 @@ impl HtmlRenderer {
 
     fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
         match block {
-            BlockNode::Heading(n) => self.render_heading(n, "", w),
+            BlockNode::Heading(n) => self.render_heading(n, "", "", w),
             BlockNode::Paragraph(n) => self.render_paragraph(n, w),
             BlockNode::HorizontalRule(n) => self.render_hr(n, w),
             BlockNode::Blockquote(n) => self.render_blockquote(n, w),
@@ -307,13 +323,14 @@ impl HtmlRenderer {
     }
 
     fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
-        // `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
+        // `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump
+        // to the rendered tag.
         w.write_all(b"
")?; for (i, name) in n.tags.iter().enumerate() { if i > 0 { w.write_all(b" ")?; } - w.write_all(b"")?; write_escaped(name, w)?; @@ -324,34 +341,70 @@ impl HtmlRenderer { /// Render a heading. `number` is the optional section-number prefix /// (already including its trailing symbol and space, e.g. `"1.2. "`); - /// pass `""` for no numbering. - fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> { + /// pass `""` for no numbering. `hier_id` is the hierarchical anchor id + /// (parent anchors joined by `-`); pass `""` to default it to the flat id. + fn render_heading( + &self, + n: &HeadingNode, + number: &str, + hier_id: &str, + w: &mut dyn Write, + ) -> io::Result<()> { let level = n.level.clamp(1, 6); let anchor = crate::ast::inline_text(&n.children); + // Inside a heading, render TODO/DONE/… as plain text rather than a + // keyword badge — a section title that *is* a keyword (`== TODO ==`) + // should look like a header, not an inline tag. (The keyword text is + // still part of `anchor` above, so the id stays correct.) + let body = flatten_keywords(&n.children); + // The TOC section heading (vimwiki `toc_header`) is wrapped in a - // `
`, matching upstream's HTML so vimwiki's stylesheet - // (`.toc { … }`) applies. The class goes on the wrapping div, not the - // heading element. + // `
`, matching upstream so vimwiki's `.toc { … }` + // stylesheet rules apply. Kept in its simple form. let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header); if is_toc { w.write_all(b"
")?; + write!(w, "")?; + if !number.is_empty() { + write_escaped(number, w)?; + } + self.render_inlines(&body, w)?; + write!(w, "
")?; + return writeln!(w); } - let class = if n.centered { - " class=\"centered\"" - } else { - "" - }; - write!(w, "")?; + if !number.is_empty() { + write_escaped(number, w)?; + } + self.render_inlines(&body, w)?; + write!(w, "")?; + return writeln!(w); + } + + // Regular heading: vimwiki's structure — a wrapping `
` carrying the + // hierarchical id, `class="header"` on the heading, and an in-heading + // self-anchor linking to that id. The heading keeps the flat id so + // existing intra-page TOC / `#anchor` links still resolve. + let hier = if hier_id.is_empty() { &anchor } else { hier_id }; + w.write_all(b"")?; - } + self.render_inlines(&body, w)?; + write!(w, "
")?; writeln!(w) } @@ -379,16 +432,16 @@ impl HtmlRenderer { } fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> { - match &n.language { - Some(lang) => { - w.write_all(b"
")?;
-            }
-            None => w.write_all(b"
")?,
+        // vimwiki drops the verbatim fence text into the `
` tag as raw
+        // attributes (`{{{python` → `
`), with no inner ``.
+        // We mirror that so highlighters wired for vimwiki output still apply.
+        if n.attrs.is_empty() {
+            w.write_all(b"
")?;
+        } else {
+            write!(w, "
", n.attrs)?;
         }
         write_escaped(&n.content, w)?;
-        w.write_all(b"
\n") + w.write_all(b"
\n") } fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> { @@ -705,16 +758,16 @@ impl HtmlRenderer { // One class per keyword so stylesheets can colour them independently // (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the // vimwiki `todo` class so stock vimwiki CSS still styles it. - let (label, class) = match n.keyword { - Keyword::Todo => ("TODO", "todo"), - Keyword::Done => ("DONE", "done"), - Keyword::Started => ("STARTED", "started"), - Keyword::Fixme => ("FIXME", "fixme"), - Keyword::Fixed => ("FIXED", "fixed"), - Keyword::Xxx => ("XXX", "xxx"), - Keyword::Stopped => ("STOPPED", "stopped"), + let class = match n.keyword { + Keyword::Todo => "todo", + Keyword::Done => "done", + Keyword::Started => "started", + Keyword::Fixme => "fixme", + Keyword::Fixed => "fixed", + Keyword::Xxx => "xxx", + Keyword::Stopped => "stopped", }; - write!(w, "{label}") + write!(w, "{}", n.keyword.label()) } fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { @@ -891,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec> { /// wiki pages become `path.html`; interwiki links land in sibling /// directories; diary entries land under `diary/`; file/local schemes /// use their literal path; anchor-only links collapse to a fragment. +/// Return a copy of `nodes` with every `Keyword` replaced by its literal text. +/// Used for heading content so a keyword in a title renders as plain text (with +/// the heading's own styling) rather than as an inline keyword badge. +fn flatten_keywords(nodes: &[InlineNode]) -> Vec { + nodes + .iter() + .map(|n| match n { + InlineNode::Keyword(k) => InlineNode::Text(TextNode { + span: k.span, + content: k.keyword.label().to_string(), + }), + InlineNode::Bold(b) => InlineNode::Bold(BoldNode { + span: b.span, + children: flatten_keywords(&b.children), + }), + InlineNode::Italic(i) => InlineNode::Italic(ItalicNode { + span: i.span, + children: flatten_keywords(&i.children), + }), + InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode { + span: b.span, + children: flatten_keywords(&b.children), + }), + InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode { + span: s.span, + children: flatten_keywords(&s.children), + }), + InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode { + span: s.span, + children: flatten_keywords(&s.children), + }), + InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode { + span: s.span, + children: flatten_keywords(&s.children), + }), + InlineNode::Color(c) => InlineNode::Color(ColorNode { + span: c.span, + color: c.color.clone(), + children: flatten_keywords(&c.children), + }), + other => other.clone(), + }) + .collect() +} + pub fn default_link_resolver(target: &LinkTarget) -> String { if matches!(target.kind, LinkKind::AnchorOnly) { return match &target.anchor { diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs index c8b120d..12ba237 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs @@ -81,6 +81,9 @@ pub enum VimwikiTokenKind { PreformattedOpen { language: Option, attrs: HashMap, + /// Verbatim text after `{{{` (trailing whitespace trimmed), preserved + /// so the HTML renderer can reproduce vimwiki's raw `
` attrs.
+        raw: String,
     },
     PreformattedClose,
     PreformattedLine(String),
@@ -421,7 +424,14 @@ impl<'src> LexState<'src> {
         // pairs separated by spaces. Keep parsing forgiving.
         let after = trimmed[3..].trim();
         let (language, attrs) = parse_fence_attrs(after);
-        let kind = VimwikiTokenKind::PreformattedOpen { language, attrs };
+        // vimwiki keeps everything after `{{{` (minus trailing whitespace) and
+        // drops it verbatim into the `
` tag, so capture it unparsed.
+        let raw = trimmed[3..].trim_end().to_string();
+        let kind = VimwikiTokenKind::PreformattedOpen {
+            language,
+            attrs,
+            raw,
+        };
         let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
         self.push(kind, span);
         self.mode = BlockMode::Preformatted;
@@ -865,8 +875,10 @@ impl<'src> LexState<'src> {
                 flush(self, &mut buf, &mut buf_start, abs_col);
                 self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
                 i += 2;
-                // Lex until `]]` (absolute index into `slice`).
-                let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
+                // Lex until the closing `]]`. A single `]` inside the body —
+                // e.g. a `[TICKET-1]` in the description — is literal text, so
+                // we balance inner `[ ]` rather than stopping at the first `]]`.
+                let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
                 let inner_end = close_abs_in_slice.unwrap_or(slice.len());
                 self.lex_link_body(slice, i, line_col_start, inner_end, true);
                 i = inner_end;
@@ -1052,6 +1064,27 @@ impl<'src> LexState<'src> {
 /// Parse the bit after `{{{` on a fence line. Format is forgiving:
 /// `lang` (single word) followed by optional `key=value` pairs separated
 /// by spaces.
+/// Byte offset of the closing `]]` for a wikilink body that begins at the start
+/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
+/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
+/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
+/// body text. Returns `None` if no closing `]]` is found.
+fn find_wikilink_close(s: &str) -> Option {
+    let b = s.as_bytes();
+    let mut depth: u32 = 0;
+    let mut i = 0;
+    while i < b.len() {
+        match b[i] {
+            b'[' => depth += 1,
+            b']' if depth > 0 => depth -= 1, // closes an inner '['
+            b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
+            _ => {}
+        }
+        i += 1;
+    }
+    None
+}
+
 fn parse_fence_attrs(s: &str) -> (Option, HashMap) {
     let mut attrs = HashMap::new();
     let mut language: Option = None;
diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs
index e0e0c0b..89f2ea1 100644
--- a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs
+++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
         let open = self.advance().unwrap();
         let span_start = open.span.start;
         let mut span_end = open.span.end;
-        let language = match &open.kind {
-            K::PreformattedOpen { language, .. } => language.clone(),
+        let (language, attrs) = match &open.kind {
+            K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
             _ => unreachable!(),
         };
         let mut content = String::new();
@@ -390,6 +390,7 @@ impl<'a> ParseState<'a> {
                         span: Span::new(span_start, span_end),
                         content,
                         language,
+                        attrs,
                     });
                 }
                 K::PreformattedLine(s) => {
@@ -413,6 +414,7 @@ impl<'a> ParseState<'a> {
             span: Span::new(span_start, span_end),
             content,
             language,
+            attrs,
         })
     }
 
diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs
index 1ead749..7ab0598 100644
--- a/crates/nuwiki-core/tests/html_renderer.rs
+++ b/crates/nuwiki-core/tests/html_renderer.rs
@@ -63,7 +63,11 @@ fn heading_levels() {
     for level in 1..=6 {
         let bars = "=".repeat(level);
         let out = render(&format!("{bars} Title {bars}\n"));
-        assert!(out.starts_with(&format!("Title")));
+        // vimwiki structure: 
+ //
. A lone heading has hier == flat. + assert!(out.starts_with(&format!( + "" + )), "got: {out}"); } } @@ -73,6 +77,43 @@ fn centered_heading_gets_centered_class() { assert!(out.contains("

T

")); } +#[test] +fn heading_keyword_renders_as_plain_text_not_badge() { + // `== TODO ==` is a section title, not an inline keyword: it must render as + // a normal header (with a correct, non-empty id), not a `` + // badge. Regression for the exported header losing its header styling + id. + let out = render("== TODO ==\n"); + assert!( + out.contains( + "

\ + TODO

" + ), + "got: {out}" + ); + assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}"); + + // A keyword mid-title keeps its text in the id (no dropped word / double + // space) and still renders plainly. + let out2 = render("= My TODO list =\n"); + assert!(out2.contains("id=\"My TODO list\""), "got: {out2}"); + assert!(!out2.contains("class=\"todo\""), "got: {out2}"); +} + +#[test] +fn wikilink_description_keeps_inner_brackets() { + // A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close + // the link early. Regression for descriptions with brackets being mangled. + let out = render("[[page|Task [B-1]]]\n"); + assert!( + out.contains("Task [B-1]"), + "got: {out}" + ); + // Two bracketed links on one line both resolve. + let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n"); + assert!(out2.contains("X [1]"), "got: {out2}"); + assert!(out2.contains("Y [2]"), "got: {out2}"); +} + #[test] fn paragraph_wraps_inline_content() { let out = render("Hello world\n"); @@ -94,11 +135,26 @@ fn blockquote_lines_become_paragraphs() { } #[test] -fn preformatted_block_emits_pre_code_with_language_class() { +fn preformatted_block_emits_pre_with_raw_fence_attrs() { + // vimwiki drops the verbatim fence text into the
 tag (`{{{rust` →
+    // `
`), with no inner  wrapper.
     let out = render("{{{rust\nfn main() {}\n}}}\n");
-    assert!(out.contains("
"));
+    assert!(out.contains("
"), "got: {out}");
     assert!(out.contains("fn main() {}"));
-    assert!(out.contains("
")); + assert!(out.contains("
")); + assert!(!out.contains("plain"), "got: {out}"); +} + +#[test] +fn preformatted_block_keeps_full_attr_string() { + let out = render("{{{class=\"brush: python\"\nx\n}}}\n"); + assert!(out.contains("
"), "got: {out}");
 }
 
 #[test]
@@ -244,7 +300,10 @@ fn heading_id_matches_anchor_link_href() {
     // for "exported HTML anchor links don't work".
     let out = render("= My Section =\n\n[[#My Section]]\n");
     assert!(
-        out.contains("

My Section

"), + out.contains( + "" + ), "heading id: {out}" ); assert!( @@ -276,7 +335,13 @@ fn non_toc_heading_not_wrapped() { .with_toc_header("Contents") .render_to_string(&doc) .unwrap(); - assert!(out.contains("

Intro

"), "got: {out}"); + assert!( + out.contains( + "

\ + Intro

" + ), + "got: {out}" + ); assert!(!out.contains("class=\"toc\""), "got: {out}"); } @@ -342,7 +407,7 @@ fn template_substitutes_content_and_title() { ); let out = renderer.render_to_string(&doc).unwrap(); assert!(out.contains("My Page")); - assert!(out.contains("

Hello

")); + assert!(out.contains("")); } #[test] @@ -351,7 +416,7 @@ fn template_with_missing_title_substitutes_empty_string() { let renderer = HtmlRenderer::new().with_template("{{title}}{{content}}"); let out = renderer.render_to_string(&doc).unwrap(); assert!(out.contains("")); - assert!(out.contains("

Heading

")); + assert!(out.contains("")); } #[test] @@ -367,7 +432,7 @@ fn template_substitutes_vimwiki_percent_placeholders() { let out = renderer.render_to_string(&doc).unwrap(); assert!(out.contains("My Page"), "title: {out}"); assert!( - out.contains("

Hello

"), + out.contains(""), "content: {out}" ); assert!(out.contains("href=\"../style.css\""), "root_path: {out}"); @@ -461,7 +526,7 @@ fn x() {} let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); // A few sanity checks; the full output is exercised piece-by-piece above. for needle in [ - "

Heading

", + "", "bold", "italic", "code", @@ -469,7 +534,7 @@ fn x() {} "
", "
", "", - "
",
+        "
",
     ] {
         assert!(
             out.contains(needle),
diff --git a/crates/nuwiki-core/tests/lexer.rs b/crates/nuwiki-core/tests/lexer.rs
index bd0587c..53a3fcb 100644
--- a/crates/nuwiki-core/tests/lexer.rs
+++ b/crates/nuwiki-core/tests/lexer.rs
@@ -179,7 +179,13 @@ fn preformatted_block_parses_language() {
 #[test]
 fn preformatted_block_parses_attrs() {
     let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
-    let PreformattedOpen { language, attrs } = &lexed[0] else {
+    let PreformattedOpen {
+        language,
+        attrs,
+        raw,
+        ..
+    } = &lexed[0]
+    else {
         panic!("expected PreformattedOpen");
     };
     assert_eq!(language.as_deref(), Some("rust"));
@@ -187,6 +193,8 @@ fn preformatted_block_parses_attrs() {
     expected.insert("class".into(), "hl".into());
     expected.insert("key".into(), "val".into());
     assert_eq!(attrs, &expected);
+    // The verbatim fence text is preserved for the HTML renderer.
+    assert_eq!(raw, "rust class=\"hl\" key=val");
 }
 
 // ===== Math block =====
diff --git a/crates/nuwiki-core/tests/tags.rs b/crates/nuwiki-core/tests/tags.rs
index c92c727..f006bb9 100644
--- a/crates/nuwiki-core/tests/tags.rs
+++ b/crates/nuwiki-core/tests/tags.rs
@@ -190,8 +190,8 @@ fn file_tags_are_deduped_in_metadata() {
 fn renderer_emits_div_with_tag_spans() {
     let html = render(":todo:done:\n");
     assert!(html.contains("
")); - assert!(html.contains("todo")); - assert!(html.contains("done")); + assert!(html.contains("todo")); + assert!(html.contains("done")); } #[test] diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index 5369282..69446d1 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -385,19 +385,19 @@ fn render_page_html_numbers_headers_when_enabled() { ) .unwrap(); assert!( - html.contains("

1. Intro

"), + html.contains(""), "got: {html}" ); assert!( - html.contains("

1.1. Background

"), + html.contains(""), "got: {html}" ); assert!( - html.contains("

1.2. Methods

"), + html.contains(""), "got: {html}" ); assert!( - html.contains("

2. Results

"), + html.contains(""), "got: {html}" ); } @@ -419,14 +419,14 @@ fn render_page_html_numbering_skips_headers_above_start_level() { ) .unwrap(); assert!( - html.contains("

Title

"), + html.contains(""), "h1 unnumbered, got: {html}" ); assert!( - html.contains("

1 Section

"), + html.contains(""), "got: {html}" ); - assert!(html.contains("

1.1 Sub

"), "got: {html}"); + assert!(html.contains(""), "got: {html}"); } #[test] @@ -443,8 +443,8 @@ fn render_page_html_no_header_numbering_by_default() { HashMap::new(), ) .unwrap(); - assert!(html.contains("

Intro

"), "got: {html}"); - assert!(html.contains("

Sub

"), "got: {html}"); + assert!(html.contains(""), "got: {html}"); + assert!(html.contains(""), "got: {html}"); } #[test]