fix(html): keyword-in-heading badge + brackets in wikilink descriptions
CI / cargo fmt --check (push) Successful in 37s
CI / cargo clippy (push) Successful in 39s
CI / cargo test (push) Successful in 49s

This commit is contained in:
gffranco
2026-06-24 00:26:59 +00:00
parent cdd85a4cc1
commit 5a102013bb
9 changed files with 293 additions and 66 deletions
+76 -11
View File
@@ -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!("<h{level} id=\"Title\">Title</h{level}>")));
// vimwiki structure: <div id="hier"><h{l} id="flat" class="header">
// <a href="#hier">…</a></h{l}></div>. A lone heading has hier == flat.
assert!(out.starts_with(&format!(
"<div id=\"Title\"><h{level} id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h{level}></div>"
)), "got: {out}");
}
}
@@ -73,6 +77,43 @@ fn centered_heading_gets_centered_class() {
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
}
#[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 `<span class="todo">`
// badge. Regression for the exported header losing its header styling + id.
let out = render("== TODO ==\n");
assert!(
out.contains(
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
<a href=\"#TODO\">TODO</a></h2></div>"
),
"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("<a href=\"page.html\">Task [B-1]</a>"),
"got: {out}"
);
// Two bracketed links on one line both resolve.
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "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 <pre> tag (`{{{rust` →
// `<pre rust>`), with no inner <code> wrapper.
let out = render("{{{rust\nfn main() {}\n}}}\n");
assert!(out.contains("<pre><code class=\"language-rust\">"));
assert!(out.contains("<pre rust>"), "got: {out}");
assert!(out.contains("fn main() {}"));
assert!(out.contains("</code></pre>"));
assert!(out.contains("</pre>"));
assert!(!out.contains("<code"), "no inner code element: {out}");
}
#[test]
fn preformatted_block_without_attrs_is_bare_pre() {
let out = render("{{{\nplain\n}}}\n");
assert!(out.contains("<pre>plain"), "got: {out}");
}
#[test]
fn preformatted_block_keeps_full_attr_string() {
let out = render("{{{class=\"brush: python\"\nx\n}}}\n");
assert!(out.contains("<pre class=\"brush: python\">"), "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("<h1 id=\"My Section\">My Section</h1>"),
out.contains(
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
<a href=\"#My Section\">My Section</a></h1></div>"
),
"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("<h2 id=\"Intro\">Intro</h2>"), "got: {out}");
assert!(
out.contains(
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
<a href=\"#Intro\">Intro</a></h2></div>"
),
"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("<title>My Page</title>"));
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
}
#[test]
@@ -351,7 +416,7 @@ fn template_with_missing_title_substitutes_empty_string() {
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title></title>"));
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
}
#[test]
@@ -367,7 +432,7 @@ fn template_substitutes_vimwiki_percent_placeholders() {
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!(
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
"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 [
"<h1 id=\"Heading\">Heading</h1>",
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
"<strong>bold</strong>",
"<em>italic</em>",
"<code>code</code>",
@@ -469,7 +534,7 @@ fn x() {}
"<blockquote>",
"<hr>",
"<table>",
"<pre><code class=\"language-rust\">",
"<pre rust>",
] {
assert!(
out.contains(needle),