fix(html): emit heading id anchors so exported #links resolve
CI / cargo fmt --check (push) Successful in 26s
CI / cargo clippy (push) Successful in 27s
CI / cargo test (push) Successful in 31s
CI / editor keymaps (push) Successful in 1m22s

Exported HTML anchor links (TOC entries, [[Page#Heading]], [[#Heading]])
went nowhere because heading elements carried no `id`. Worse, the two TOC
builders slugified anchors (`#my-heading`) while the link resolver emits
the raw heading text (`#My Heading`, matching upstream vimwiki), so even
once ids existed the two halves wouldn't have agreed.

Fix — adopt vimwiki's scheme (raw heading text as the anchor) everywhere:
- render_heading emits `<hN id="<plain heading text>">`.
- build_toc_html (HTML export TOC) uses the raw, HTML-escaped title for
  both the href and the link text (was slugify).
- collect_toc_items (:VimwikiTOC buffer output) uses the raw title for the
  generated `[[#anchor]]` (was slugify). In-editor navigation slugifies
  both sides, so it still resolves.

Consistency: add canonical `nuwiki_core::ast::inline_text()` and route the
heading id, the HTML-TOC title, the buffer-TOC title, and
`diagnostics::heading_text` through it — three duplicated extractors
collapsed into one, so the anchor and its validation can't drift.

Regression test: heading_id_matches_anchor_link_href (core) asserts the
heading id and a `#anchor` link's href are identical. Updated the heading
assertions in the renderer/export tests for the new `id=` attribute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:09:33 +00:00
parent ddb9d0b916
commit 68f10971f7
9 changed files with 119 additions and 98 deletions
+25 -6
View File
@@ -63,14 +63,14 @@ 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}>Title</h{level}>")));
assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
}
}
#[test]
fn centered_heading_gets_centered_class() {
let out = render(" = T =\n");
assert!(out.contains("<h1 class=\"centered\">T</h1>"));
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
}
#[test]
@@ -237,6 +237,22 @@ fn anchor_only_link() {
assert!(out.contains("<a href=\"#Section\">"));
}
#[test]
fn heading_id_matches_anchor_link_href() {
// The heading carries an `id` equal to its plain text, so a `#anchor`
// link (TOC or cross-reference) actually lands on it. Regression guard
// 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>"),
"heading id: {out}"
);
assert!(
out.contains("<a href=\"#My Section\">"),
"anchor href: {out}"
);
}
#[test]
fn interwiki_numbered_link() {
let out = render("[[wiki1:Page]]\n");
@@ -299,7 +315,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>Hello</h1>"));
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
}
#[test]
@@ -308,7 +324,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>Heading</h1>"));
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
}
#[test]
@@ -323,7 +339,10 @@ fn template_substitutes_vimwiki_percent_placeholders() {
.with_var("root_path", "../");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!(out.contains("<body><h1>Hello</h1>"), "content: {out}");
assert!(
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
"content: {out}"
);
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
assert!(!out.contains('%'), "no placeholder left: {out}");
}
@@ -415,7 +434,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>Heading</h1>",
"<h1 id=\"Heading\">Heading</h1>",
"<strong>bold</strong>",
"<em>italic</em>",
"<code>code</code>",