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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -384,10 +384,22 @@ fn render_page_html_numbers_headers_when_enabled() {
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<h1>1. Intro</h1>"), "got: {html}");
|
||||
assert!(html.contains("<h2>1.1. Background</h2>"), "got: {html}");
|
||||
assert!(html.contains("<h2>1.2. Methods</h2>"), "got: {html}");
|
||||
assert!(html.contains("<h1>2. Results</h1>"), "got: {html}");
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Results\">2. Results</h1>"),
|
||||
"got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -407,11 +419,14 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<h1>Title</h1>"),
|
||||
html.contains("<h1 id=\"Title\">Title</h1>"),
|
||||
"h1 unnumbered, got: {html}"
|
||||
);
|
||||
assert!(html.contains("<h2>1 Section</h2>"), "got: {html}");
|
||||
assert!(html.contains("<h3>1.1 Sub</h3>"), "got: {html}");
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Section\">1 Section</h2>"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -428,8 +443,8 @@ fn render_page_html_no_header_numbering_by_default() {
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<h1>Intro</h1>"), "got: {html}");
|
||||
assert!(html.contains("<h2>Sub</h2>"), "got: {html}");
|
||||
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
|
||||
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -492,7 +492,9 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
|
||||
// Insertion at position 0,0 → zero-width range.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert!(te.new_text.starts_with("= Contents =\n"));
|
||||
assert!(te.new_text.contains("[[#one|One]]"));
|
||||
// Anchor is the raw heading text (vimwiki scheme), matching the heading id
|
||||
// the HTML renderer emits on export.
|
||||
assert!(te.new_text.contains("[[#One|One]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -504,7 +506,7 @@ fn toc_edit_replaces_existing_toc() {
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
let te = &edits[0];
|
||||
assert!(te.new_text.contains("[[#real|Real]]"));
|
||||
assert!(te.new_text.contains("[[#Real|Real]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
// The replacement range must start at line 0.
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
@@ -536,7 +538,7 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() {
|
||||
let edit =
|
||||
ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[#real|Real]]"));
|
||||
assert!(te.new_text.contains("[[#Real|Real]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user