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
+5 -2
View File
@@ -2044,11 +2044,14 @@ pub mod ops {
// skip the TOC's own heading
continue;
}
let anchor = crate::index::slugify(&title);
// Anchor is the raw heading text (vimwiki scheme) so the generated
// `[[#anchor]]` link matches the heading `id` the HTML renderer
// emits on export. In-editor navigation slugifies both sides, so it
// resolves regardless.
out.push(TocItem {
level: h.level,
anchor: title.clone(),
title,
anchor,
});
}
out
+4 -33
View File
@@ -270,40 +270,11 @@ pub fn classify_outgoing(
}
/// Flatten heading inlines down to a plain string. Used by `commands::ops`
/// to match heading text against the configured TOC/Links section name.
/// to match heading text against the configured TOC/Links section name, and
/// (via `nuwiki_core::ast::inline_text`) the same text the HTML renderer uses
/// for heading `id`s — so anchors validated here match anchors emitted there.
pub fn heading_text(inlines: &[InlineNode]) -> String {
let mut s = String::new();
push_inline_text(inlines, &mut s);
s.trim().to_string()
}
fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
for n in inlines {
match n {
InlineNode::Text(t) => out.push_str(&t.content),
InlineNode::Bold(b) => push_inline_text(&b.children, out),
InlineNode::Italic(i) => push_inline_text(&i.children, out),
InlineNode::BoldItalic(bi) => push_inline_text(&bi.children, out),
InlineNode::Strikethrough(s) => push_inline_text(&s.children, out),
InlineNode::Code(c) => out.push_str(&c.content),
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::WikiLink(w) => match &w.description {
Some(d) => push_inline_text(d, out),
None => {
if let Some(p) = &w.target.path {
out.push_str(p);
}
}
},
InlineNode::ExternalLink(e) => match &e.description {
Some(d) => push_inline_text(d, out),
None => out.push_str(&e.url),
},
_ => {}
}
}
nuwiki_core::ast::inline_text(inlines)
}
/// Resolve a `file:` / `local:` link target to an absolute path.
+6 -42
View File
@@ -18,7 +18,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind};
use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::render::{HtmlRenderer, Renderer};
@@ -147,11 +147,10 @@ pub fn build_toc_html(doc: &DocumentNode) -> String {
out.push_str("</ul>");
depth -= 1;
}
let anchor = crate::index::slugify(&h.title);
out.push_str(&format!(
"<li><a href=\"#{anchor}\">{title}</a></li>",
title = escape_html(&h.title)
));
// Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must
// match the `id` the renderer puts on the heading element.
let anchor = escape_html(&h.title);
out.push_str(&format!("<li><a href=\"#{anchor}\">{anchor}</a></li>"));
}
while depth > 0 {
out.push_str("</ul>");
@@ -300,7 +299,7 @@ fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
if let BlockNode::Heading(h) = b {
out.push(HeadingProj {
level: h.level,
title: inline_to_text(h),
title: nuwiki_core::ast::inline_text(&h.children),
});
}
}
@@ -312,41 +311,6 @@ struct HeadingProj {
title: String,
}
fn inline_to_text(h: &HeadingNode) -> String {
let mut s = String::new();
for n in &h.children {
push_inline(n, &mut s);
}
s.trim().to_string()
}
fn push_inline(n: &InlineNode, out: &mut String) {
match n {
InlineNode::Text(t) => out.push_str(&t.content),
InlineNode::Bold(b) => b.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Italic(i) => i.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::BoldItalic(bi) => bi.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Strikethrough(s) => s.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Code(c) => out.push_str(&c.content),
InlineNode::Superscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Subscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::Color(c) => c.children.iter().for_each(|c| push_inline(c, out)),
InlineNode::WikiLink(w) => match &w.description {
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
None => {
if let Some(p) = &w.target.path {
out.push_str(p);
}
}
},
InlineNode::ExternalLink(e) => match &e.description {
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
None => out.push_str(&e.url),
},
_ => {}
}
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {