fix(toc): honour toc_header/level in single-wiki config + div.toc on export
Two TOC bugs: 1. :NuwikiTOC ignored toc_header / toc_header_level (always "= Contents =" at level 1). In the single-wiki shorthand the server only carried file_extension/syntax from the top-level options into the synthesized wiki; every other per-wiki key (toc_header, toc_header_level, links_header, html_path, auto_export, …) was dropped. Add single_wiki_from_value(), which re-parses the top-level object as a full RawWiki (injecting root from wiki_root), so all per-wiki keys set at the top level flow through. Wired into from_init_params + apply_change. 2. HTML export put class="toc" on the <hN> element, but upstream vimwiki (and its stylesheet) wraps the TOC heading in <div class="toc">…</div>. render_heading now emits the div wrapper, matching upstream so .toc CSS applies. With (1) fixed, detection also works for a custom toc_header. Tests: single_wiki_shorthand_honours_top_level_per_wiki_keys (config), toc_header_heading_wrapped_in_div_toc + non_toc_heading_not_wrapped (renderer). Full suite 572 passed; clippy clean; config-parity goldens match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -60,8 +60,9 @@ pub struct HtmlRenderer {
|
|||||||
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
||||||
header_numbering_sym: String,
|
header_numbering_sym: String,
|
||||||
/// vimwiki `toc_header`: the heading text that identifies the TOC
|
/// vimwiki `toc_header`: the heading text that identifies the TOC
|
||||||
/// section. When a heading matches this value (case-insensitive), it
|
/// section. A heading matching this value (case-insensitive) is wrapped
|
||||||
/// gets `class="toc"` in HTML export.
|
/// in `<div class="toc">…</div>` on export, matching upstream so vimwiki's
|
||||||
|
/// stylesheet applies.
|
||||||
toc_header: String,
|
toc_header: String,
|
||||||
/// vimwiki `valid_html_tags`: inline HTML tag names passed through to
|
/// vimwiki `valid_html_tags`: inline HTML tag names passed through to
|
||||||
/// export verbatim (instead of escaping `<`/`>`). Empty = escape all.
|
/// 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<()> {
|
fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
|
||||||
let level = n.level.clamp(1, 6);
|
let level = n.level.clamp(1, 6);
|
||||||
let anchor = crate::ast::inline_text(&n.children);
|
let anchor = crate::ast::inline_text(&n.children);
|
||||||
let is_toc = self
|
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
||||||
.toc_header
|
// `<div class="toc">`, matching upstream's HTML so vimwiki's stylesheet
|
||||||
.as_bytes()
|
// (`.toc { … }`) applies. The class goes on the wrapping div, not the
|
||||||
.iter()
|
// heading element.
|
||||||
.zip(anchor.as_bytes())
|
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
|
||||||
.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();
|
|
||||||
if is_toc {
|
if is_toc {
|
||||||
classes.push("toc");
|
w.write_all(b"<div class=\"toc\">")?;
|
||||||
}
|
}
|
||||||
if n.centered {
|
let class = if n.centered {
|
||||||
classes.push("centered");
|
" class=\"centered\""
|
||||||
}
|
|
||||||
let class = if classes.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
} else {
|
||||||
format!(" class=\"{}\"", classes.join(" "))
|
""
|
||||||
};
|
};
|
||||||
write!(w, "<h{level}{class} id=\"")?;
|
write!(w, "<h{level}{class} id=\"")?;
|
||||||
write_escaped(&anchor, w)?;
|
write_escaped(&anchor, w)?;
|
||||||
@@ -354,7 +348,11 @@ impl HtmlRenderer {
|
|||||||
write_escaped(number, w)?;
|
write_escaped(number, w)?;
|
||||||
}
|
}
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&n.children, w)?;
|
||||||
writeln!(w, "</h{level}>")
|
write!(w, "</h{level}>")?;
|
||||||
|
if is_toc {
|
||||||
|
w.write_all(b"</div>")?;
|
||||||
|
}
|
||||||
|
writeln!(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
|||||||
@@ -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 `<div class="toc">` 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("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
|
||||||
|
"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("<h2 id=\"Intro\">Intro</h2>"), "got: {out}");
|
||||||
|
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interwiki_numbered_link() {
|
fn interwiki_numbered_link() {
|
||||||
let out = render("[[wiki1:Page]]\n");
|
let out = render("[[wiki1:Page]]\n");
|
||||||
|
|||||||
@@ -639,14 +639,11 @@ impl Config {
|
|||||||
|
|
||||||
let wikis = if let Some(list) = raw.wikis {
|
let wikis = if let Some(list) = raw.wikis {
|
||||||
list.into_iter().map(WikiConfig::from).collect()
|
list.into_iter().map(WikiConfig::from).collect()
|
||||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
} else if let Some(wc) = params
|
||||||
let mut wc = WikiConfig::from_root(root);
|
.initialization_options
|
||||||
if let Some(ext) = raw.file_extension {
|
.as_ref()
|
||||||
wc.file_extension = ext;
|
.and_then(single_wiki_from_value)
|
||||||
}
|
{
|
||||||
if let Some(s) = raw.syntax {
|
|
||||||
wc.syntax = s;
|
|
||||||
}
|
|
||||||
vec![wc]
|
vec![wc]
|
||||||
} else if let Some(folder) = first_workspace_folder(params) {
|
} else if let Some(folder) = first_workspace_folder(params) {
|
||||||
vec![WikiConfig::from_root(folder)]
|
vec![WikiConfig::from_root(folder)]
|
||||||
@@ -687,14 +684,7 @@ impl Config {
|
|||||||
};
|
};
|
||||||
if let Some(list) = raw.wikis {
|
if let Some(list) = raw.wikis {
|
||||||
self.wikis = list.into_iter().map(WikiConfig::from).collect();
|
self.wikis = list.into_iter().map(WikiConfig::from).collect();
|
||||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
} else if let Some(wc) = single_wiki_from_value(inner) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
self.wikis = vec![wc];
|
self.wikis = vec![wc];
|
||||||
}
|
}
|
||||||
// Only touch diagnostic settings when the payload carries them, so a
|
// Only touch diagnostic settings when the payload carries them, so a
|
||||||
@@ -1042,6 +1032,32 @@ impl From<RawWiki> 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<WikiConfig> {
|
||||||
|
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<PathBuf> {
|
fn expand_path(s: &str) -> Option<PathBuf> {
|
||||||
let trimmed = s.trim();
|
let trimmed = s.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
|
|||||||
@@ -152,6 +152,28 @@ fn v1_0_single_wiki_desugars_to_one_entry() {
|
|||||||
assert_eq!(cfg.wikis[0].syntax, "vimwiki");
|
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]
|
#[test]
|
||||||
fn explicit_wikis_list_overrides_legacy_shape() {
|
fn explicit_wikis_list_overrides_legacy_shape() {
|
||||||
let cfg = config_from_json(json!({
|
let cfg = config_from_json(json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user