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
+58 -9
View File
@@ -385,19 +385,27 @@ fn render_page_html_numbers_headers_when_enabled() {
)
.unwrap();
assert!(
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
html.contains(
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"
),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
html.contains(
"<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
html.contains(
"<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains("<h1 id=\"Results\">2. Results</h1>"),
html.contains(
"<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"
),
"got: {html}"
);
}
@@ -419,14 +427,45 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
)
.unwrap();
assert!(
html.contains("<h1 id=\"Title\">Title</h1>"),
html.contains(
"<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"
),
"h1 unnumbered, got: {html}"
);
assert!(
html.contains("<h2 id=\"Section\">1 Section</h2>"),
html.contains(
"<div id=\"Title-Section\"><h2 id=\"Section\" class=\"header\"><a href=\"#Title-Section\">1 Section</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Title-Section-Sub\"><h3 id=\"Sub\" class=\"header\"><a href=\"#Title-Section-Sub\">1.1 Sub</a></h3></div>"
),
"got: {html}"
);
}
#[test]
fn render_page_html_same_page_anchor_uses_current_filename() {
// vimwiki parity (#4): `[[#Section]]` resolves to `<page>.html#Section`,
// not a bare `#Section`, where <page> is the file being rendered.
let ast = parse("= Contents =\n\n[[#Contents]]\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
None,
"index",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<a href=\"index.html#Contents\">"),
"got: {html}"
);
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
}
#[test]
@@ -443,8 +482,18 @@ fn render_page_html_no_header_numbering_by_default() {
HashMap::new(),
)
.unwrap();
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
assert!(
html.contains(
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">Intro</a></h1></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"
),
"got: {html}"
);
}
#[test]