diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 2e4f935..3d2558b 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -60,8 +60,9 @@ pub struct HtmlRenderer { /// section number (e.g. `.` → `1.2. Heading`). Empty by default. header_numbering_sym: String, /// vimwiki `toc_header`: the heading text that identifies the TOC - /// section. When a heading matches this value (case-insensitive), it - /// gets `class="toc"` in HTML export. + /// section. A heading matching this value (case-insensitive) is wrapped + /// in `
` on export, matching upstream so vimwiki's + /// stylesheet applies. toc_header: String, /// vimwiki `valid_html_tags`: inline HTML tag names passed through to /// export verbatim (instead of escaping `<`/`>`). Empty = escape all. @@ -327,25 +328,18 @@ impl HtmlRenderer { fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> { let level = n.level.clamp(1, 6); let anchor = crate::ast::inline_text(&n.children); - let is_toc = self - .toc_header - .as_bytes() - .iter() - .zip(anchor.as_bytes()) - .all(|(a, b)| a.eq_ignore_ascii_case(b)) - && !self.toc_header.is_empty() - && self.toc_header.len() == anchor.len(); - let mut classes = Vec::new(); + // The TOC section heading (vimwiki `toc_header`) is wrapped in a + // `
`, matching upstream's HTML so vimwiki's stylesheet + // (`.toc { … }`) applies. The class goes on the wrapping div, not the + // heading element. + let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header); if is_toc { - classes.push("toc"); + w.write_all(b"
")?; } - if n.centered { - classes.push("centered"); - } - let class = if classes.is_empty() { - String::new() + let class = if n.centered { + " class=\"centered\"" } else { - format!(" class=\"{}\"", classes.join(" ")) + "" }; write!(w, "") + write!(w, "")?; + if is_toc { + w.write_all(b"
")?; + } + writeln!(w) } fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs index d77de5f..1ead749 100644 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -253,6 +253,33 @@ fn heading_id_matches_anchor_link_href() { ); } +#[test] +fn toc_header_heading_wrapped_in_div_toc() { + // The TOC section heading gets a `
` wrapper (vimwiki + // scheme) so `.toc` stylesheet rules apply. The class is on the div, not + // the heading. + let doc = VimwikiSyntax::new().parse("== Contents ==\n"); + let out = HtmlRenderer::new() + .with_toc_header("Contents") + .render_to_string(&doc) + .unwrap(); + assert!( + out.contains("

Contents

"), + "got: {out}" + ); +} + +#[test] +fn non_toc_heading_not_wrapped() { + let doc = VimwikiSyntax::new().parse("== Intro ==\n"); + let out = HtmlRenderer::new() + .with_toc_header("Contents") + .render_to_string(&doc) + .unwrap(); + assert!(out.contains("

Intro

"), "got: {out}"); + assert!(!out.contains("class=\"toc\""), "got: {out}"); +} + #[test] fn interwiki_numbered_link() { let out = render("[[wiki1:Page]]\n"); diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index d4e3025..b88c92c 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -639,14 +639,11 @@ impl Config { let wikis = if let Some(list) = raw.wikis { list.into_iter().map(WikiConfig::from).collect() - } else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) { - let mut wc = WikiConfig::from_root(root); - if let Some(ext) = raw.file_extension { - wc.file_extension = ext; - } - if let Some(s) = raw.syntax { - wc.syntax = s; - } + } else if let Some(wc) = params + .initialization_options + .as_ref() + .and_then(single_wiki_from_value) + { vec![wc] } else if let Some(folder) = first_workspace_folder(params) { vec![WikiConfig::from_root(folder)] @@ -687,14 +684,7 @@ impl Config { }; if let Some(list) = raw.wikis { self.wikis = list.into_iter().map(WikiConfig::from).collect(); - } else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) { - let mut wc = WikiConfig::from_root(root); - if let Some(ext) = raw.file_extension { - wc.file_extension = ext; - } - if let Some(s) = raw.syntax { - wc.syntax = s; - } + } else if let Some(wc) = single_wiki_from_value(inner) { self.wikis = vec![wc]; } // Only touch diagnostic settings when the payload carries them, so a @@ -1042,6 +1032,32 @@ impl From for WikiConfig { } } +/// Build the single-wiki [`WikiConfig`] from the flat top-level options object +/// (single-wiki shorthand: a `wiki_root` plus any per-wiki keys set alongside +/// it). Returns `None` when there's no expandable `wiki_root` (so callers fall +/// through to the workspace-folder default) or a `wikis` list is present. +/// +/// Re-uses the full [`RawWiki`] schema so **every** per-wiki key set at the top +/// level — `toc_header`/`toc_header_level`, `links_header`, `html_path`, +/// `auto_export`, … — is honoured, not just `file_extension`/`syntax`. The +/// shorthand keys on `wiki_root`; `RawWiki` keys on `root`, so we inject it. +fn single_wiki_from_value(value: &serde_json::Value) -> Option { + let obj = value.as_object()?; + if obj.contains_key("wikis") { + return None; + } + let wiki_root = obj.get("wiki_root").and_then(|v| v.as_str())?; + // Bail (→ workspace-folder fallback) when the root can't be expanded. + expand_path(wiki_root)?; + let mut wiki_obj = obj.clone(); + wiki_obj.insert( + "root".to_string(), + serde_json::Value::String(wiki_root.to_string()), + ); + let raw: RawWiki = serde_json::from_value(serde_json::Value::Object(wiki_obj)).ok()?; + Some(WikiConfig::from(raw)) +} + fn expand_path(s: &str) -> Option { let trimmed = s.trim(); if trimmed.is_empty() { diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index 996dc27..96e0dbb 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -152,6 +152,28 @@ fn v1_0_single_wiki_desugars_to_one_entry() { assert_eq!(cfg.wikis[0].syntax, "vimwiki"); } +#[test] +fn single_wiki_shorthand_honours_top_level_per_wiki_keys() { + // Regression: a single-wiki user sets per-wiki keys at the top level + // (alongside wiki_root); they must reach the synthesized wiki, not just + // file_extension/syntax. Previously toc_header_level etc. were dropped. + let cfg = config_from_json(json!({ + "wiki_root": "/tmp/vimwiki", + "toc_header": "Table of Contents", + "toc_header_level": 2, + "links_header_level": 3, + "auto_export": true, + "html_path": "/tmp/out", + })); + assert_eq!(cfg.wikis.len(), 1); + let w = &cfg.wikis[0]; + assert_eq!(w.toc_header, "Table of Contents"); + assert_eq!(w.toc_header_level, 2); + assert_eq!(w.links_header_level, 3); + assert!(w.html.auto_export); + assert_eq!(w.html.html_path, PathBuf::from("/tmp/out")); +} + #[test] fn explicit_wikis_list_overrides_legacy_shape() { let cfg = config_from_json(json!({