From 79b502fa5cf5f2d2c0811c8a66d59ed498f451eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 2 Jun 2026 00:33:01 +0000 Subject: [PATCH] feat(html): html_header_numbering section numbering for HTML export Mirror vimwiki's html_header_numbering / html_header_numbering_sym: top-level headings at or below the start level get a dotted section number (1, 1.1, 1.2, ...) with a configurable trailing symbol. Off by default (level 0). Nested headings in lists/quotes stay unnumbered, matching upstream's document-level-only scan. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/nuwiki-core/src/render/html.rs | 87 ++++++++++++++++++++- crates/nuwiki-lsp/src/config.rs | 18 +++++ crates/nuwiki-lsp/src/export.rs | 6 ++ crates/nuwiki-lsp/tests/html_export.rs | 69 ++++++++++++++++ crates/nuwiki-lsp/tests/index_and_config.rs | 7 ++ 5 files changed, 184 insertions(+), 3 deletions(-) diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 695eeec..69c9103 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -50,6 +50,14 @@ pub struct HtmlRenderer { /// outermost list. `-1` (the default) emits no inline style and /// defers to the stylesheet; `>= 0` forces `margin-left:em`. list_margin: i32, + /// vimwiki `html_header_numbering`: the heading level at which automatic + /// section numbering begins (`0` = off, the default). When `>= 1`, every + /// heading at that level or deeper is prefixed with a dotted section + /// number (`1`, `1.1`, `1.2`, …); shallower headings stay unnumbered. + header_numbering: u8, + /// vimwiki `html_header_numbering_sym`: a symbol appended after the + /// section number (e.g. `.` → `1.2. Heading`). Empty by default. + header_numbering_sym: String, } impl Default for HtmlRenderer { @@ -66,6 +74,8 @@ impl HtmlRenderer { vars: HashMap::new(), colors: HashMap::new(), list_margin: -1, + header_numbering: 0, + header_numbering_sym: String::new(), } } @@ -114,6 +124,61 @@ impl HtmlRenderer { self.colors = colors; self } + + /// Enable vimwiki-style HTML section numbering. `level` is the heading + /// level at which numbering starts (`0` disables it); `sym` is appended + /// after the number. Mirrors `html_header_numbering` / + /// `html_header_numbering_sym`. + pub fn with_header_numbering(mut self, level: u8, sym: impl Into) -> Self { + self.header_numbering = level; + self.header_numbering_sym = sym.into(); + self + } +} + +/// Running state for vimwiki-style HTML section numbering +/// (`html_header_numbering`). Headings are visited in document order; each +/// at or below the start level bumps its depth's counter, resets deeper +/// counters, and yields a dotted prefix (`1`, `1.1`, `2`, …). +struct HeadingNumberer { + /// Heading level numbering starts at (`0` disables it). + start: u8, + /// Symbol appended after the dotted number. + sym: String, + /// Per-depth counters, indexed by `level - start` (levels clamp to 1–6, + /// so the depth is always `0..=5`). + counters: [u32; 6], +} + +impl HeadingNumberer { + fn new(start: u8, sym: &str) -> Self { + Self { + start, + sym: sym.to_string(), + counters: [0; 6], + } + } + + /// Advance to a heading at `level` (1–6) and return its numeric prefix, + /// including the trailing symbol and a separating space (e.g. `"1.2. "`). + /// Returns an empty string when numbering is off or the heading is + /// shallower than the start level. + fn prefix(&mut self, level: u8) -> String { + if self.start == 0 || level < self.start { + return String::new(); + } + let depth = (level - self.start) as usize; + self.counters[depth] += 1; + for c in self.counters.iter_mut().skip(depth + 1) { + *c = 0; + } + let num = self.counters[..=depth] + .iter() + .map(|c| c.to_string()) + .collect::>() + .join("."); + format!("{num}{} ", self.sym) + } } impl Renderer for HtmlRenderer { @@ -146,15 +211,25 @@ impl Renderer for HtmlRenderer { impl HtmlRenderer { fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { + // Section numbering runs over top-level headings in document order, + // mirroring vimwiki's line-by-line scan. Nested headings (in lists, + // quotes) go through `render_block` and are left unnumbered, matching + // upstream which only numbers document-level headers. + let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym); for block in &doc.children { - self.render_block(block, w)?; + if let BlockNode::Heading(n) = block { + let number = numberer.prefix(n.level.clamp(1, 6)); + self.render_heading(n, &number, w)?; + } else { + self.render_block(block, w)?; + } } Ok(()) } fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> { match block { - BlockNode::Heading(n) => self.render_heading(n, w), + BlockNode::Heading(n) => self.render_heading(n, "", w), BlockNode::Paragraph(n) => self.render_paragraph(n, w), BlockNode::HorizontalRule(n) => self.render_hr(n, w), BlockNode::Blockquote(n) => self.render_blockquote(n, w), @@ -185,7 +260,10 @@ impl HtmlRenderer { w.write_all(b"\n") } - fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> { + /// Render a heading. `number` is the optional section-number prefix + /// (already including its trailing symbol and space, e.g. `"1.2. "`); + /// pass `""` for no numbering. + fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> { let level = n.level.clamp(1, 6); let class = if n.centered { " class=\"centered\"" @@ -193,6 +271,9 @@ impl HtmlRenderer { "" }; write!(w, "")?; + if !number.is_empty() { + write_escaped(number, w)?; + } self.render_inlines(&n.children, w)?; writeln!(w, "") } diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 94a0ecb..562b183 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -137,6 +137,12 @@ pub struct HtmlConfig { pub custom_wiki2html_args: String, /// URL prefix prepended to RSS feed / diary item links (`base_url`). pub base_url: String, + /// vimwiki `html_header_numbering`: heading level at which automatic + /// section numbering starts in HTML export (`0` = off, the default). + pub html_header_numbering: u8, + /// vimwiki `html_header_numbering_sym`: symbol appended after the + /// section number (e.g. `.` → `1.2. Heading`). Empty by default. + pub html_header_numbering_sym: String, } impl Default for HtmlConfig { @@ -155,6 +161,8 @@ impl Default for HtmlConfig { custom_wiki2html: String::new(), custom_wiki2html_args: String::new(), base_url: String::new(), + html_header_numbering: 0, + html_header_numbering_sym: String::new(), } } } @@ -650,6 +658,10 @@ struct RawWiki { custom_wiki2html_args: Option, #[serde(default)] base_url: Option, + #[serde(default)] + html_header_numbering: Option, + #[serde(default)] + html_header_numbering_sym: Option, // per-wiki keys mirroring vimwiki globals. All optional so // existing single-wiki configs migrate without touching anything. #[serde(default)] @@ -745,6 +757,12 @@ impl From for WikiConfig { if let Some(s) = r.base_url { html.base_url = s; } + if let Some(n) = r.html_header_numbering { + html.html_header_numbering = n; + } + if let Some(s) = r.html_header_numbering_sym { + html.html_header_numbering_sym = s; + } let d = wiki_defaults(); WikiConfig { name: r.name.unwrap_or(derived_name), diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index 6b5bfa5..ed283dd 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -252,6 +252,12 @@ pub fn render_page_html( if !cfg.color_dic.is_empty() { r = r.with_colors(cfg.color_dic.clone()); } + if cfg.html_header_numbering > 0 { + r = r.with_header_numbering( + cfg.html_header_numbering, + cfg.html_header_numbering_sym.clone(), + ); + } r.render_to_string(doc) } diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index 47fbcd7..441d302 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -41,6 +41,8 @@ fn html_config_defaults_match_vimwiki() { assert!(!c.auto_export); assert!(!c.html_filename_parameterization); assert!(c.exclude_files.is_empty()); + assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off + assert!(c.html_header_numbering_sym.is_empty()); } #[test] @@ -270,6 +272,73 @@ fn render_page_html_uses_metadata_date_when_set() { assert!(html.contains("DATE=2025-01-02"), "got: {html}"); } +#[test] +fn render_page_html_numbers_headers_when_enabled() { + let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n"); + let mut c = cfg("/tmp/x"); + c.html.html_header_numbering = 1; // number from h1 + c.html.html_header_numbering_sym = ".".into(); + let html = export::render_page_html( + &ast, + None, + "Home", + DiaryDate::from_ymd(2026, 5, 11).unwrap(), + &c.html, + None, + -1, + 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}"); +} + +#[test] +fn render_page_html_numbering_skips_headers_above_start_level() { + let ast = parse("= Title =\n== Section ==\n=== Sub ===\n"); + let mut c = cfg("/tmp/x"); + c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered + // empty sym → number followed by a bare space + let html = export::render_page_html( + &ast, + None, + "Home", + DiaryDate::from_ymd(2026, 5, 11).unwrap(), + &c.html, + None, + -1, + HashMap::new(), + ) + .unwrap(); + assert!( + html.contains("

Title

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

1 Section

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

1.1 Sub

"), "got: {html}"); +} + +#[test] +fn render_page_html_no_header_numbering_by_default() { + let ast = parse("= Intro =\n== Sub ==\n"); + let c = cfg("/tmp/x"); + let html = export::render_page_html( + &ast, + None, + "Home", + DiaryDate::from_ymd(2026, 5, 11).unwrap(), + &c.html, + None, + -1, + HashMap::new(), + ) + .unwrap(); + assert!(html.contains("

Intro

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

Sub

"), "got: {html}"); +} + #[test] fn render_page_html_root_path_for_nested_pages() { let ast = parse("= D =\n"); diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index a3a5279..1e4d1be 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -288,6 +288,9 @@ fn defaults_carry_vimwiki_per_wiki_keys() { assert_eq!(cfg.html.custom_wiki2html, ""); assert_eq!(cfg.html.custom_wiki2html_args, ""); assert_eq!(cfg.html.base_url, ""); + // HTML section numbering is off by default (vimwiki's `0`). + assert_eq!(cfg.html.html_header_numbering, 0); + assert_eq!(cfg.html.html_header_numbering_sym, ""); } #[test] @@ -319,6 +322,8 @@ fn raw_wiki_parses_every_new_key() { "custom_wiki2html": "~/bin/my_wiki2html.sh", "custom_wiki2html_args": "--flag", "base_url": "https://example.com/wiki/", + "html_header_numbering": 2, + "html_header_numbering_sym": ".", }], })); let w = &cfg.wikis[0]; @@ -350,6 +355,8 @@ fn raw_wiki_parses_every_new_key() { assert_eq!(w.html.custom_wiki2html, "~/bin/my_wiki2html.sh"); assert_eq!(w.html.custom_wiki2html_args, "--flag"); assert_eq!(w.html.base_url, "https://example.com/wiki/"); + assert_eq!(w.html.html_header_numbering, 2); + assert_eq!(w.html.html_header_numbering_sym, "."); // 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.