diff --git a/README.md b/README.md index 13d5567..d33b099 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,9 @@ require('nuwiki').setup({ css_name = 'style.css', auto_export = false, -- export on save auto_toc = false, -- auto-refresh TOC on save + toc_header = 'Contents', -- :VimwikiTOC caption (+ toc_header_level = 1) + links_header = 'Generated Links', + tags_header = 'Generated Tags', exclude_files = {}, -- glob patterns -- Checkbox progression characters: " .oOX" by default. listsyms = ' .oOX', @@ -333,6 +336,9 @@ Each is a boolean. All default to `true` except `mouse`. | `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` | | `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths | | `auto_toc` | bool | `false` | `true` \| `false` | +| `toc_header` / `toc_header_level` | string / int | `'Contents'` / `1` | `:VimwikiTOC` caption + heading level | +| `links_header` / `links_header_level` | string / int | `'Generated Links'` / `1` | `:VimwikiGenerateLinks` caption + level | +| `tags_header` / `tags_header_level` | string / int | `'Generated Tags'` / `1` | `:VimwikiGenerateTagLinks` index caption + level | #### HTML export keys (per-wiki) diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 6eb5ace..823c568 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -324,7 +324,14 @@ fn toc_generate(backend: &Backend, args: Vec) -> Result return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); - Ok(ops::toc_edit(&doc.text, &doc.ast, &p.uri, utf8)) + let wiki = backend.wiki_for_uri(&p.uri); + let (header, level) = wiki + .as_ref() + .map(|w| (w.config.toc_header.clone(), w.config.toc_header_level)) + .unwrap_or_else(|| (ops::TOC_HEADING.to_string(), 1)); + Ok(ops::toc_edit( + &doc.text, &doc.ast, &p.uri, utf8, &header, level, + )) } fn links_generate(backend: &Backend, args: Vec) -> Result, String> { @@ -353,6 +360,8 @@ fn links_generate(backend: &Backend, args: Vec) -> Result String { + let eq = "=".repeat((level as usize).clamp(1, 6)); + format!("{eq} {name} {eq}\n") + } + /// Build a heading + nested list TOC for the current document. The TOC /// itself (any existing `= Contents =` heading on the page) is skipped /// so re-generation is idempotent. - pub fn build_toc_text(items: &[(u8, String, String)], heading_name: &str) -> String { - let mut out = String::new(); - out.push_str("= "); - out.push_str(heading_name); - out.push_str(" =\n"); + pub fn build_toc_text(items: &[(u8, String, String)], heading_name: &str, level: u8) -> String { + let mut out = caption_line(heading_name, level); if items.is_empty() { return out; } @@ -1808,11 +1823,13 @@ pub mod ops { /// Build a flat list of wikilinks to every page (sorted), optionally /// excluding `current_page`. - pub fn build_links_text(pages: &[String], heading_name: &str, exclude: Option<&str>) -> String { - let mut out = String::new(); - out.push_str("= "); - out.push_str(heading_name); - out.push_str(" =\n"); + pub fn build_links_text( + pages: &[String], + heading_name: &str, + level: u8, + exclude: Option<&str>, + ) -> String { + let mut out = caption_line(heading_name, level); for name in pages { if Some(name.as_str()) == exclude { continue; @@ -1938,8 +1955,10 @@ pub mod ops { ast: &DocumentNode, uri: &Url, utf8: bool, + heading: &str, + level: u8, ) -> Option { - let items = collect_toc_items(ast, TOC_HEADING); + let items = collect_toc_items(ast, heading); if items.is_empty() { return None; } @@ -1947,9 +1966,9 @@ pub mod ops { .into_iter() .map(|it| (it.level, it.title, it.anchor)) .collect(); - let new_text = build_toc_text(&triples, TOC_HEADING); + let new_text = build_toc_text(&triples, heading, level); let mut b = WorkspaceEditBuilder::new(); - match find_section_range(ast, TOC_HEADING) { + match find_section_range(ast, heading) { Some((start, end)) => { let span = Span::new(start, end); b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); @@ -1980,13 +1999,16 @@ pub mod ops { ast: &DocumentNode, uri: &Url, utf8: bool, + heading: &str, + level: u8, ) -> Option { - find_section_range(ast, TOC_HEADING)?; - toc_edit(text, ast, uri, utf8) + find_section_range(ast, heading)?; + toc_edit(text, ast, uri, utf8, heading, level) } /// Produce a `WorkspaceEdit` that replaces or inserts the /// auto-generated links section for the given document. + #[allow(clippy::too_many_arguments)] pub fn links_edit( text: &str, ast: &DocumentNode, @@ -1994,13 +2016,15 @@ pub mod ops { current_page: &str, all_pages: &[String], utf8: bool, + heading: &str, + level: u8, ) -> Option { if all_pages.is_empty() { return None; } - let new_text = build_links_text(all_pages, LINKS_HEADING, Some(current_page)); + let new_text = build_links_text(all_pages, heading, level, Some(current_page)); let mut b = WorkspaceEditBuilder::new(); - match find_section_range(ast, LINKS_HEADING) { + match find_section_range(ast, heading) { Some((start, end)) => { let span = Span::new(start, end); b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); @@ -3118,6 +3142,8 @@ pub mod ops { pub fn build_tag_links_text( pages_by_tag: &std::collections::BTreeMap>, tag: Option<&str>, + header: &str, + level: u8, ) -> Option { match tag { Some(t) => { @@ -3125,7 +3151,7 @@ pub mod ops { if pages.is_empty() { return None; } - let mut out = format!("= Tag: {t} =\n"); + let mut out = caption_line(&format!("Tag: {t}"), level); for p in pages { out.push_str(&format!("- [[{p}]]\n")); } @@ -3135,9 +3161,11 @@ pub mod ops { if pages_by_tag.is_empty() { return None; } - let mut out = String::from("= Tags =\n"); + let mut out = caption_line(header, level); + let sub = level.saturating_add(1); for (name, pages) in pages_by_tag { - out.push_str(&format!("\n== {name} ==\n")); + out.push('\n'); + out.push_str(&caption_line(name, sub)); for p in pages { out.push_str(&format!("- [[{p}]]\n")); } @@ -3148,11 +3176,12 @@ pub mod ops { } /// Heading name used to locate an existing tag-links section so - /// re-generation is idempotent. - fn tag_section_heading(tag: Option<&str>) -> String { + /// re-generation is idempotent. The no-arg index uses the configured + /// `tags_header`; a single-tag section uses `Tag: `. + fn tag_section_heading(tag: Option<&str>, header: &str) -> String { match tag { Some(t) => format!("Tag: {t}"), - None => "Tags".to_string(), + None => header.to_string(), } } @@ -3160,6 +3189,7 @@ pub mod ops { /// section (case-insensitive match on the h1 title) or inserts at the /// top of the document when no such section exists. Returns `None` /// when there are no matching tagged pages. + #[allow(clippy::too_many_arguments)] pub fn tag_links_edit( text: &str, ast: &DocumentNode, @@ -3167,9 +3197,11 @@ pub mod ops { tag: Option<&str>, pages_by_tag: &std::collections::BTreeMap>, utf8: bool, + header: &str, + level: u8, ) -> Option { - let body = build_tag_links_text(pages_by_tag, tag)?; - let heading = tag_section_heading(tag); + let body = build_tag_links_text(pages_by_tag, tag, header, level)?; + let heading = tag_section_heading(tag, header); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, &heading) { Some((start, end)) => { diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 2987a45..08312b2 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -75,6 +75,18 @@ pub struct WikiConfig { pub links_space_char: String, /// Rebuild the page's TOC on save when set. pub auto_toc: bool, + /// Heading text + level for the generated `:VimwikiTOC` section + /// (vimwiki's `toc_header` / `toc_header_level`). + pub toc_header: String, + pub toc_header_level: u8, + /// Heading text + level for `:VimwikiGenerateLinks` (`links_header` / + /// `links_header_level`). + pub links_header: String, + pub links_header_level: u8, + /// Heading text + level for the no-arg `:VimwikiGenerateTagLinks` index + /// (`tags_header` / `tags_header_level`). + pub tags_header: String, + pub tags_header_level: u8, /// HTML export options. pub html: HtmlConfig, } @@ -196,6 +208,12 @@ impl WikiConfig { list_margin: d.list_margin, links_space_char: d.links_space_char, auto_toc: d.auto_toc, + toc_header: d.toc_header, + toc_header_level: d.toc_header_level, + links_header: d.links_header, + links_header_level: d.links_header_level, + tags_header: d.tags_header, + tags_header_level: d.tags_header_level, html: HtmlConfig::default(), } } @@ -226,6 +244,12 @@ impl WikiConfig { list_margin: d.list_margin, links_space_char: d.links_space_char, auto_toc: d.auto_toc, + toc_header: d.toc_header, + toc_header_level: d.toc_header_level, + links_header: d.links_header, + links_header_level: d.links_header_level, + tags_header: d.tags_header, + tags_header_level: d.tags_header_level, html, } } @@ -322,6 +346,18 @@ fn default_listsym_rejected() -> String { "-".to_string() } +fn default_toc_header() -> String { + "Contents".to_string() +} + +fn default_links_header() -> String { + "Generated Links".to_string() +} + +fn default_tags_header() -> String { + "Generated Tags".to_string() +} + fn default_links_space_char() -> String { " ".to_string() } @@ -348,6 +384,12 @@ fn wiki_defaults() -> WikiDefaults { list_margin: -1, links_space_char: default_links_space_char(), auto_toc: false, + toc_header: default_toc_header(), + toc_header_level: 1, + links_header: default_links_header(), + links_header_level: 1, + tags_header: default_tags_header(), + tags_header_level: 1, } } @@ -367,6 +409,12 @@ struct WikiDefaults { list_margin: i32, links_space_char: String, auto_toc: bool, + toc_header: String, + toc_header_level: u8, + links_header: String, + links_header_level: u8, + tags_header: String, + tags_header_level: u8, } impl Config { @@ -593,6 +641,18 @@ struct RawWiki { links_space_char: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_toc: Option, + #[serde(default)] + toc_header: Option, + #[serde(default)] + toc_header_level: Option, + #[serde(default)] + links_header: Option, + #[serde(default)] + links_header_level: Option, + #[serde(default)] + tags_header: Option, + #[serde(default)] + tags_header_level: Option, } impl From for WikiConfig { @@ -654,6 +714,12 @@ impl From for WikiConfig { list_margin: r.list_margin.unwrap_or(d.list_margin), links_space_char: r.links_space_char.unwrap_or(d.links_space_char), auto_toc: r.auto_toc.unwrap_or(d.auto_toc), + toc_header: r.toc_header.unwrap_or(d.toc_header), + toc_header_level: r.toc_header_level.unwrap_or(d.toc_header_level), + links_header: r.links_header.unwrap_or(d.links_header), + links_header_level: r.links_header_level.unwrap_or(d.links_header_level), + tags_header: r.tags_header.unwrap_or(d.tags_header), + tags_header_level: r.tags_header_level.unwrap_or(d.tags_header_level), html, } } diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index ebd9f2e..599cc85 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -625,6 +625,8 @@ impl LanguageServer for Backend { &doc.ast, &uri, self.use_utf8.load(Ordering::Relaxed), + &wiki.config.toc_header, + wiki.config.toc_header_level, ) }) }; diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs index 620612f..46dd55c 100644 --- a/crates/nuwiki-lsp/tests/commands_coverage.rs +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -298,7 +298,7 @@ fn nuwiki_toc_generates_nested_contents() { let src = "= Top =\n== Sub ==\ntext\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Page.wiki").unwrap(); - let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced"); + let edit = ops::toc_edit(src, &doc, &uri, true, "Contents", 1).expect("toc edit produced"); assert!(edit.changes.is_some() || edit.document_changes.is_some()); let toc = ops::build_toc_text( @@ -307,6 +307,7 @@ fn nuwiki_toc_generates_nested_contents() { (2, "Sub".into(), "sub".into()), ], "Contents", + 1, ); assert!(toc.contains("= Contents =")); assert!(toc.contains("- [[#top|Top]]")); @@ -317,7 +318,7 @@ fn nuwiki_toc_generates_nested_contents() { #[test] fn nuwiki_generate_links_lists_all_pages_excluding_current() { let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()]; - let text = ops::build_links_text(&pages, "Links", Some("Home")); + let text = ops::build_links_text(&pages, "Links", 1, Some("Home")); assert!(text.contains("= Links =")); assert!(text.contains("- [[About]]")); assert!(text.contains("- [[Notes]]")); @@ -326,7 +327,7 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() { let src = "= Existing =\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); - assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some()); + assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true, "Generated Links", 1).is_some()); } // :NuwikiCheckLinks — surface broken links across the workspace. @@ -383,21 +384,31 @@ fn nuwiki_generate_tag_links_builds_section() { ); let by_tag = ops::tag_pages_snapshot(&idx); - let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap(); + let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1).unwrap(); assert!(single.starts_with("= Tag: alpha =")); assert!(single.contains("- [[Home]]")); assert!(single.contains("- [[Work]]")); // No-arg variant emits a section per tag. - let all = ops::build_tag_links_text(&by_tag, None).unwrap(); - assert!(all.starts_with("= Tags =")); + let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1).unwrap(); + assert!(all.starts_with("= Generated Tags =")); assert!(all.contains("== alpha ==")); assert!(all.contains("== beta ==")); let src = "= Home =\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); - assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some()); + assert!(ops::tag_links_edit( + src, + &doc, + &uri, + Some("alpha"), + &by_tag, + true, + "Generated Tags", + 1 + ) + .is_some()); } // :NuwikiRebuildTags — force a full workspace re-index. A rebuild must diff --git a/crates/nuwiki-lsp/tests/commands_tags.rs b/crates/nuwiki-lsp/tests/commands_tags.rs index 64c2bbe..d586570 100644 --- a/crates/nuwiki-lsp/tests/commands_tags.rs +++ b/crates/nuwiki-lsp/tests/commands_tags.rs @@ -203,7 +203,7 @@ fn tag_pages_snapshot_sorts_pages_alphabetically() { fn build_tag_links_for_single_tag() { let mut snap = BTreeMap::new(); snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]); - let out = ops::build_tag_links_text(&snap, Some("release")).unwrap(); + let out = ops::build_tag_links_text(&snap, Some("release"), "Generated Tags", 1).unwrap(); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Tag: release ="); assert_eq!(lines[1], "- [[Alpha]]"); @@ -213,7 +213,7 @@ fn build_tag_links_for_single_tag() { #[test] fn build_tag_links_for_missing_tag_returns_none() { let snap = BTreeMap::new(); - assert!(ops::build_tag_links_text(&snap, Some("ghost")).is_none()); + assert!(ops::build_tag_links_text(&snap, Some("ghost"), "Generated Tags", 1).is_none()); } #[test] @@ -221,8 +221,8 @@ fn build_tag_links_full_index_groups_by_tag() { let mut snap = BTreeMap::new(); snap.insert("a".to_string(), vec!["P1".into()]); snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]); - let out = ops::build_tag_links_text(&snap, None).unwrap(); - assert!(out.starts_with("= Tags =\n")); + let out = ops::build_tag_links_text(&snap, None, "Generated Tags", 1).unwrap(); + assert!(out.starts_with("= Generated Tags =\n")); assert!(out.contains("== a ==")); assert!(out.contains("== b ==")); assert!(out.contains("- [[P1]]")); @@ -233,7 +233,7 @@ fn build_tag_links_full_index_groups_by_tag() { #[test] fn build_tag_links_full_index_returns_none_when_no_tags() { let snap = BTreeMap::new(); - assert!(ops::build_tag_links_text(&snap, None).is_none()); + assert!(ops::build_tag_links_text(&snap, None, "Generated Tags", 1).is_none()); } // ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) ===== @@ -245,8 +245,17 @@ fn tag_links_edit_inserts_when_section_missing() { let uri = Url::parse("file:///tmp/p.wiki").unwrap(); let mut snap = BTreeMap::new(); snap.insert("release".to_string(), vec!["Alpha".into()]); - let edit = - ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("got an edit"); + let edit = ops::tag_links_edit( + src, + &ast, + &uri, + Some("release"), + &snap, + true, + "Generated Tags", + 1, + ) + .expect("got an edit"); let te = &edit.changes.unwrap()[&uri][0]; // Insertion at start. assert_eq!(te.range.start, te.range.end); @@ -261,7 +270,17 @@ fn tag_links_edit_replaces_existing_section() { let uri = Url::parse("file:///tmp/p.wiki").unwrap(); let mut snap = BTreeMap::new(); snap.insert("release".to_string(), vec!["Fresh".into()]); - let edit = ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("edit"); + let edit = ops::tag_links_edit( + src, + &ast, + &uri, + Some("release"), + &snap, + true, + "Generated Tags", + 1, + ) + .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert_eq!(te.range.start.line, 0); assert!(te.new_text.contains("[[Fresh]]")); @@ -270,12 +289,13 @@ fn tag_links_edit_replaces_existing_section() { #[test] fn tag_links_edit_full_index_replaces_existing_tags_section() { - let src = "= Tags =\n- [[old]]\n"; + let src = "= Generated Tags =\n- [[old]]\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/p.wiki").unwrap(); let mut snap = BTreeMap::new(); snap.insert("alpha".to_string(), vec!["P".into()]); - let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true).expect("edit"); + let edit = + ops::tag_links_edit(src, &ast, &uri, None, &snap, true, "Generated Tags", 1).expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("== alpha ==")); assert!(!te.new_text.contains("[[old]]")); @@ -287,7 +307,17 @@ fn tag_links_edit_returns_none_for_unknown_tag() { let ast = parse(src); let uri = Url::parse("file:///tmp/p.wiki").unwrap(); let snap = BTreeMap::new(); - assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none()); + assert!(ops::tag_links_edit( + src, + &ast, + &uri, + Some("ghost"), + &snap, + true, + "Generated Tags", + 1 + ) + .is_none()); } // ===== smoke: COMMANDS list advertises tag commands ===== diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index 87f7933..b69f0d5 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -275,6 +275,12 @@ fn defaults_carry_vimwiki_per_wiki_keys() { assert_eq!(cfg.list_margin, -1); assert_eq!(cfg.links_space_char, " "); assert!(!cfg.auto_toc); + // Generated-section captions match upstream defaults. + assert_eq!(cfg.toc_header, "Contents"); + assert_eq!(cfg.toc_header_level, 1); + assert_eq!(cfg.links_header, "Generated Links"); + assert_eq!(cfg.tags_header, "Generated Tags"); + assert_eq!(cfg.listsym_rejected, "-"); } #[test] @@ -295,6 +301,11 @@ fn raw_wiki_parses_every_new_key() { "list_margin": 2, "links_space_char": "_", "auto_toc": true, + "toc_header": "Table of Contents", + "toc_header_level": 2, + "links_header": "All Pages", + "tags_header": "Tag Index", + "tags_header_level": 3, }], })); let w = &cfg.wikis[0]; @@ -312,6 +323,12 @@ fn raw_wiki_parses_every_new_key() { assert_eq!(w.list_margin, 2); assert_eq!(w.links_space_char, "_"); assert!(w.auto_toc); + assert_eq!(w.toc_header, "Table of Contents"); + assert_eq!(w.toc_header_level, 2); + assert_eq!(w.links_header, "All Pages"); + assert_eq!(w.links_header_level, 1); // unspecified → default + assert_eq!(w.tags_header, "Tag Index"); + assert_eq!(w.tags_header_level, 3); // The parsed keys drive a date-mode, Sunday-start diary calendar: // a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days. diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index 0872aac..f413793 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -290,7 +290,7 @@ fn build_toc_text_nests_by_level() { (2u8, "Sub".into(), "sub".into()), (1u8, "Other".into(), "other".into()), ]; - let out = ops::build_toc_text(&items, "Contents"); + let out = ops::build_toc_text(&items, "Contents", 1); assert!(out.starts_with("= Contents =\n")); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Contents ="); @@ -301,16 +301,30 @@ fn build_toc_text_nests_by_level() { #[test] fn build_toc_text_with_no_headings_is_just_the_heading() { - let out = ops::build_toc_text(&[], "Contents"); + let out = ops::build_toc_text(&[], "Contents", 1); assert_eq!(out, "= Contents =\n"); } +#[test] +fn captions_honour_custom_header_and_level() { + // toc_header="Table of Contents", level 2 → `== Table of Contents ==`. + let out = ops::build_toc_text(&[], "Table of Contents", 2); + assert_eq!(out, "== Table of Contents ==\n"); + // links_header at level 3. + let pages = vec!["A".to_string(), "B".to_string()]; + let links = ops::build_links_text(&pages, "All Pages", 3, None); + assert!(links.starts_with("=== All Pages ===\n")); + // level clamps to 1..=6. + assert!(ops::build_toc_text(&[], "X", 9).starts_with("====== X ======")); + assert!(ops::build_toc_text(&[], "X", 0).starts_with("= X =")); +} + // ===== Links text generation ===== #[test] fn build_links_text_excludes_current_page() { let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", Some("Home")); + let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home")); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Generated Links ="); assert!(lines.contains(&"- [[A]]")); @@ -321,7 +335,7 @@ fn build_links_text_excludes_current_page() { #[test] fn build_links_text_no_excludes_includes_all() { let pages = vec!["A".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", None); + let out = ops::build_links_text(&pages, "Generated Links", 1, None); assert!(out.contains("[[A]]")); assert!(out.contains("[[B]]")); } @@ -359,7 +373,7 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() { let src = "= One =\n== Two ==\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true).expect("got an edit"); + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; assert_eq!(edits.len(), 1); @@ -375,7 +389,7 @@ fn toc_edit_replaces_existing_toc() { let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true).expect("got an edit"); + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; let te = &edits[0]; @@ -390,7 +404,7 @@ fn toc_edit_returns_none_for_empty_doc() { let src = ""; let ast = parse(src); let uri = Url::parse("file:///tmp/empty.wiki").unwrap(); - assert!(ops::toc_edit(src, &ast, &uri, true).is_none()); + assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1).is_none()); } #[test] @@ -401,14 +415,14 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() { let without = "= One =\n== Two ==\n"; let ast = parse(without); assert!( - ops::toc_rebuild_edit(without, &ast, &uri, true).is_none(), + ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1).is_none(), "auto_toc should not insert a TOC where none existed" ); // Existing TOC → rebuild refreshes it. let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let ast = parse(with); - let edit = ops::toc_rebuild_edit(with, &ast, &uri, true).expect("rebuild edit"); + let edit = ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1).expect("rebuild edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[#real|Real]]")); assert!(!te.new_text.contains("Stale")); @@ -422,7 +436,8 @@ fn links_edit_inserts_when_section_absent() { let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); let pages = vec!["A".into(), "B".into(), "Home".into()]; - let edit = ops::links_edit(src, &ast, &uri, "Home", &pages, true).expect("edit"); + let edit = + ops::links_edit(src, &ast, &uri, "Home", &pages, true, "Generated Links", 1).expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[A]]")); assert!(te.new_text.contains("[[B]]")); @@ -435,7 +450,8 @@ fn links_edit_replaces_when_section_present() { let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); let pages = vec!["Fresh".into()]; - let edit = ops::links_edit(src, &ast, &uri, "Home", &pages, true).expect("edit"); + let edit = + ops::links_edit(src, &ast, &uri, "Home", &pages, true, "Generated Links", 1).expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[Fresh]]")); assert!(!te.new_text.contains("Stale")); @@ -447,7 +463,7 @@ fn links_edit_returns_none_for_empty_page_list() { let src = "Hi\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true).is_none()); + assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true, "Generated Links", 1).is_none()); } // ===== find_orphans ===== diff --git a/development/vimwiki-gap.md b/development/vimwiki-gap.md index ca84f73..ba99a40 100644 --- a/development/vimwiki-gap.md +++ b/development/vimwiki-gap.md @@ -87,9 +87,17 @@ fix site. `wm` stays tomorrow — exact upstream parity (`lua/nuwiki/keymaps.lua`, `ftplugin/vimwiki.vim`; docs + README updated). Covered by `diary.leader_t_opens_today_in_tab` / `diary.leader_m_opens_tomorrow`. -- [ ] **Generated-section captions hardcoded** — `toc_header` / - `toc_header_level`, `links_header`, `tags_header` (+levels). - _Fix:_ `crates/nuwiki-lsp/src/config.rs` + server TOC/generate renderers. +- [x] **Generated-section captions hardcoded** — TOC/links/tags headings and + their levels were hardcoded (`= Contents =`, `= Generated Links =`, `= Tags =`, + all level 1). _Fix:_ added per-wiki `toc_header`/`toc_header_level`, + `links_header`/`links_header_level`, `tags_header`/`tags_header_level` + (defaults `Contents`/`Generated Links`/`Generated Tags`, level 1 — matching + upstream; this also corrects the tags index heading from `Tags` → + `Generated Tags`). A `caption_line(name, level)` helper emits the markers; the + text + level thread through `toc_edit`/`links_edit`/`tag_links_edit` (+ the + auto_toc save hook) and `find_section_range` matches the configured text so + regen stays idempotent. Tests: `captions_honour_custom_header_and_level` + (`link_health.rs`) + config round-trip in `index_and_config.rs`. - [ ] **On-save autoregen family** — `auto_diary_index`, `auto_generate_links`, `auto_generate_tags`, `auto_tags` (only `auto_toc` / `auto_export` exist). _Fix:_ `config.rs` `RawWiki`/`WikiConfig` + the `didSave` path. diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index eda2d37..2f13fff 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -196,6 +196,9 @@ match upstream vimwiki. `css_name` `'style.css'` `auto_export` `false` — export to HTML on save. `auto_toc` `false` — refresh TOC on save. +`toc_header` `'Contents'` (+ `toc_header_level` `1`) — :VimwikiTOC. +`links_header` `'Generated Links'` (+ `links_header_level` `1`). +`tags_header` `'Generated Tags'` (+ `tags_header_level` `1`). `exclude_files` List of glob patterns excluded from export. `listsyms` `' .oOX'` — checkbox progression characters. `listsym_rejected` `'-'` — glyph for a cancelled checkbox `[-]`. diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index 0e6a828..4b05f9d 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -26,6 +26,8 @@ M.defaults = { -- html_path, template_path, template_default, template_ext, -- template_date_format, css_name, auto_export, auto_toc, -- html_filename_parameterization, exclude_files, color_dic + -- toc_header, toc_header_level, links_header, links_header_level, + -- tags_header, tags_header_level, -- listsyms, listsym_rejected, listsyms_propagate, list_margin, -- links_space_char wikis = nil,