fix(html): match vimwiki export output for headings, code fences, tags, anchors (#6)
CI / cargo fmt --check (push) Failing after 42s
CI / cargo clippy (push) Successful in 1m6s
CI / cargo test (push) Successful in 1m14s
CI / editor keymaps (push) Successful in 1m31s

Exported HTML diverged from upstream vimwiki's :VimwikiAll2HTML in ways
that break custom templates' CSS/JS and deep-links. Bring the renderer to
parity on the structural differences reported in #6:

1. Headings — restore vimwiki's structure: a wrapping
   `<div id="{hierarchical}">` (parent anchors joined by `-`),
   `class="header"`, and an in-heading `<a href="#{hierarchical}">`
   self-link. The flat `id` stays on the heading element so intra-page
   TOC / `#anchor` links keep resolving. Ancestor path is tracked while
   walking top-level headings.

2. Code fences — emit vimwiki's `<pre {raw-attrs}>` (the verbatim text
   after `{{{`, e.g. `<pre python>`) instead of
   `<pre><code class="language-X">`, so highlighters wired for vimwiki
   output apply. The fence text is now preserved verbatim through the
   lexer/AST (PreformattedNode.attrs).

3. Inline tag ids — drop the `tag-` prefix (`id="wiki"`, not
   `id="tag-wiki"`). Also fixes a real inconsistency: the LSP validated
   `[[Page#wiki]]` as resolvable while the HTML emitted `id="tag-wiki"`,
   so the exported link was broken.

4. Same-page anchors — `[[#Section]]` resolves to `index.html#Section`
   (current page filename + fragment), matching vimwiki, rather than a
   bare `#Section`.

Also ship vimwiki's stock style.css verbatim as the default stylesheet
(was a ~7-line minimal one) so exports look identical out of the box and
the `.header`/`.tag`/`.toc`/`done*` classes are styled.

Centered headings and the `<div class="toc">` Contents wrapper keep their
existing form. Tests updated across core + lsp to the new output; added
coverage for hierarchical ids, raw fence attrs, bare tag ids, and the
same-page anchor filename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:04:14 +00:00
parent 59494e9a86
commit 8fdfa9e256
9 changed files with 411 additions and 70 deletions
+4
View File
@@ -53,6 +53,10 @@ pub struct PreformattedNode {
pub span: Span,
pub content: String,
pub language: Option<String>,
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
/// raw attributes on the `<pre>` tag, so we preserve it for byte-parity.
pub attrs: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+76 -28
View File
@@ -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"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() {
if i > 0 {
w.write_all(b" ")?;
}
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
w.write_all(b"<span class=\"tag\" id=\"")?;
write_escaped(name, w)?;
w.write_all(b"\">")?;
write_escaped(name, w)?;
@@ -324,34 +341,65 @@ 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);
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
// `<div class="toc">`, matching upstream's HTML so vimwiki's stylesheet
// (`.toc { … }`) applies. The class goes on the wrapping div, not the
// heading element.
// `<div class="toc">`, 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"<div class=\"toc\">")?;
write!(w, "<h{level} id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&n.children, w)?;
write!(w, "</h{level}></div>")?;
return writeln!(w);
}
let class = if n.centered {
" class=\"centered\""
} else {
""
};
write!(w, "<h{level}{class} id=\"")?;
// Centered headings keep their `class="centered"` form.
if n.centered {
write!(w, "<h{level} class=\"centered\" id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&n.children, w)?;
write!(w, "</h{level}>")?;
return writeln!(w);
}
// Regular heading: vimwiki's structure — a wrapping `<div>` 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"<div id=\"")?;
write_escaped(hier, w)?;
write!(w, "\"><h{level} id=\"")?;
write_escaped(&anchor, w)?;
w.write_all(b"\" class=\"header\"><a href=\"#")?;
write_escaped(hier, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&n.children, w)?;
write!(w, "</h{level}>")?;
if is_toc {
w.write_all(b"</div>")?;
}
write!(w, "</a></h{level}></div>")?;
writeln!(w)
}
@@ -379,16 +427,16 @@ impl HtmlRenderer {
}
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
match &n.language {
Some(lang) => {
w.write_all(b"<pre><code class=\"language-")?;
write_escaped(lang, w)?;
w.write_all(b"\">")?;
}
None => w.write_all(b"<pre><code>")?,
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
// We mirror that so highlighters wired for vimwiki output still apply.
if n.attrs.is_empty() {
w.write_all(b"<pre>")?;
} else {
write!(w, "<pre {}>", n.attrs)?;
}
write_escaped(&n.content, w)?;
w.write_all(b"</code></pre>\n")
w.write_all(b"</pre>\n")
}
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
+11 -1
View File
@@ -81,6 +81,9 @@ pub enum VimwikiTokenKind {
PreformattedOpen {
language: Option<String>,
attrs: HashMap<String, String>,
/// Verbatim text after `{{{` (trailing whitespace trimmed), preserved
/// so the HTML renderer can reproduce vimwiki's raw `<pre …>` 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 `<pre>` 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;
@@ -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,
})
}
+39 -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}");
}
}
@@ -94,11 +98,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 +263,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 +298,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 +370,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 +379,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 +395,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 +489,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 +497,7 @@ fn x() {}
"<blockquote>",
"<hr>",
"<table>",
"<pre><code class=\"language-rust\">",
"<pre rust>",
] {
assert!(
out.contains(needle),
+5 -1
View File
@@ -179,7 +179,9 @@ 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 +189,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 =====
+3 -2
View File
@@ -190,8 +190,9 @@ fn file_tags_are_deduped_in_metadata() {
fn renderer_emits_div_with_tag_spans() {
let html = render(":todo:done:\n");
assert!(html.contains("<div class=\"tags\">"));
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
}
#[test]