fix(html): emit heading id anchors so exported #links resolve
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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user