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.