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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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>",
|
||||
|
||||
Reference in New Issue
Block a user