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
+40
View File
@@ -66,6 +66,46 @@ impl InlineNode {
}
}
/// Concatenate the plain-text content of a run of inlines, descending into
/// formatting containers. Links/external links use their description, falling
/// back to the target path / URL. This is the canonical heading/anchor text —
/// the HTML renderer uses it for heading `id`s and the LSP for anchor matching,
/// so the two never drift.
pub fn inline_text(inlines: &[InlineNode]) -> String {
let mut out = String::new();
push_inline_text(inlines, &mut out);
out.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),
},
_ => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextNode {
pub span: Span,
+3 -2
View File
@@ -16,8 +16,9 @@ pub use block::{
TagScope,
};
pub use inline::{
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, KeywordNode,
MathInlineNode, SoftBreakNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode,
inline_text, BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword,
KeywordNode, MathInlineNode, SoftBreakNode, StrikethroughNode, SubscriptNode, SuperscriptNode,
TextNode,
};
pub use link::{
ExternalLinkNode, LinkKind, LinkTarget, RawUrlNode, TransclusionNode, WikiLinkNode,
+7 -1
View File
@@ -319,7 +319,13 @@ impl HtmlRenderer {
} else {
""
};
write!(w, "<h{level}{class}>")?;
// `id` is the heading's plain text (vimwiki's anchor scheme), so
// `[[Page#Heading]]` and TOC `#Heading` links land here. Matches the
// anchor text the LSP validates and the resolver emits.
let anchor = crate::ast::inline_text(&n.children);
write!(w, "<h{level}{class} id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}