From 68f10971f7f9967cfea108d6bfcfec2c87b6d770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Wed, 3 Jun 2026 15:09:33 +0000 Subject: [PATCH] fix(html): emit heading id anchors so exported #links resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. - 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 --- crates/nuwiki-core/src/ast/inline.rs | 40 +++++++++++++++++++ crates/nuwiki-core/src/ast/mod.rs | 5 ++- crates/nuwiki-core/src/render/html.rs | 8 +++- crates/nuwiki-core/tests/html_renderer.rs | 31 ++++++++++++--- crates/nuwiki-lsp/src/commands.rs | 7 +++- crates/nuwiki-lsp/src/diagnostics.rs | 37 ++--------------- crates/nuwiki-lsp/src/export.rs | 48 +++-------------------- crates/nuwiki-lsp/tests/html_export.rs | 33 +++++++++++----- crates/nuwiki-lsp/tests/link_health.rs | 8 ++-- 9 files changed, 119 insertions(+), 98 deletions(-) diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs index 8d04ecb..2e082f2 100644 --- a/crates/nuwiki-core/src/ast/inline.rs +++ b/crates/nuwiki-core/src/ast/inline.rs @@ -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, diff --git a/crates/nuwiki-core/src/ast/mod.rs b/crates/nuwiki-core/src/ast/mod.rs index 7a135cd..69c070a 100644 --- a/crates/nuwiki-core/src/ast/mod.rs +++ b/crates/nuwiki-core/src/ast/mod.rs @@ -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, diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 5f56797..b8bea65 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -319,7 +319,13 @@ impl HtmlRenderer { } else { "" }; - write!(w, "")?; + // `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, "")?; if !number.is_empty() { write_escaped(number, w)?; } diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs index 71f6f35..d77de5f 100644 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -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!("Title"))); + assert!(out.starts_with(&format!("Title"))); } } #[test] fn centered_heading_gets_centered_class() { let out = render(" = T =\n"); - assert!(out.contains("

T

")); + assert!(out.contains("

T

")); } #[test] @@ -237,6 +237,22 @@ fn anchor_only_link() { assert!(out.contains("")); } +#[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("

My Section

"), + "heading id: {out}" + ); + assert!( + out.contains("
"), + "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("My Page")); - assert!(out.contains("

Hello

")); + assert!(out.contains("

Hello

")); } #[test] @@ -308,7 +324,7 @@ fn template_with_missing_title_substitutes_empty_string() { let renderer = HtmlRenderer::new().with_template("{{title}}{{content}}"); let out = renderer.render_to_string(&doc).unwrap(); assert!(out.contains("")); - assert!(out.contains("

Heading

")); + assert!(out.contains("

Heading

")); } #[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("My Page"), "title: {out}"); - assert!(out.contains("

Hello

"), "content: {out}"); + assert!( + out.contains("

Hello

"), + "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 [ - "

Heading

", + "

Heading

", "bold", "italic", "code", diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index a77bb46..76e574f 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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 diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs index 31769e1..8ab613a 100644 --- a/crates/nuwiki-lsp/src/diagnostics.rs +++ b/crates/nuwiki-lsp/src/diagnostics.rs @@ -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. diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index 46a39be..405a009 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -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(""); depth -= 1; } - let anchor = crate::index::slugify(&h.title); - out.push_str(&format!( - "
  • {title}
  • ", - 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!("
  • {anchor}
  • ")); } while depth > 0 { out.push_str(""); @@ -300,7 +299,7 @@ fn collect_headings(doc: &DocumentNode) -> Vec { 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() { diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index d1beea7..5369282 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -384,10 +384,22 @@ fn render_page_html_numbers_headers_when_enabled() { HashMap::new(), ) .unwrap(); - assert!(html.contains("

    1. Intro

    "), "got: {html}"); - assert!(html.contains("

    1.1. Background

    "), "got: {html}"); - assert!(html.contains("

    1.2. Methods

    "), "got: {html}"); - assert!(html.contains("

    2. Results

    "), "got: {html}"); + assert!( + html.contains("

    1. Intro

    "), + "got: {html}" + ); + assert!( + html.contains("

    1.1. Background

    "), + "got: {html}" + ); + assert!( + html.contains("

    1.2. Methods

    "), + "got: {html}" + ); + assert!( + html.contains("

    2. Results

    "), + "got: {html}" + ); } #[test] @@ -407,11 +419,14 @@ fn render_page_html_numbering_skips_headers_above_start_level() { ) .unwrap(); assert!( - html.contains("

    Title

    "), + html.contains("

    Title

    "), "h1 unnumbered, got: {html}" ); - assert!(html.contains("

    1 Section

    "), "got: {html}"); - assert!(html.contains("

    1.1 Sub

    "), "got: {html}"); + assert!( + html.contains("

    1 Section

    "), + "got: {html}" + ); + assert!(html.contains("

    1.1 Sub

    "), "got: {html}"); } #[test] @@ -428,8 +443,8 @@ fn render_page_html_no_header_numbering_by_default() { HashMap::new(), ) .unwrap(); - assert!(html.contains("

    Intro

    "), "got: {html}"); - assert!(html.contains("

    Sub

    "), "got: {html}"); + assert!(html.contains("

    Intro

    "), "got: {html}"); + assert!(html.contains("

    Sub

    "), "got: {html}"); } #[test] diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index c328810..ae4b9cf 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -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")); }