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) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,14 @@ pub struct HtmlRenderer {
|
|||||||
/// outermost list. `-1` (the default) emits no inline style and
|
/// outermost list. `-1` (the default) emits no inline style and
|
||||||
/// defers to the stylesheet; `>= 0` forces `margin-left:<n>em`.
|
/// defers to the stylesheet; `>= 0` forces `margin-left:<n>em`.
|
||||||
list_margin: i32,
|
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 {
|
impl Default for HtmlRenderer {
|
||||||
@@ -66,6 +74,8 @@ impl HtmlRenderer {
|
|||||||
vars: HashMap::new(),
|
vars: HashMap::new(),
|
||||||
colors: HashMap::new(),
|
colors: HashMap::new(),
|
||||||
list_margin: -1,
|
list_margin: -1,
|
||||||
|
header_numbering: 0,
|
||||||
|
header_numbering_sym: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +124,61 @@ impl HtmlRenderer {
|
|||||||
self.colors = colors;
|
self.colors = colors;
|
||||||
self
|
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<String>) -> 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::<Vec<_>>()
|
||||||
|
.join(".");
|
||||||
|
format!("{num}{} ", self.sym)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Renderer for HtmlRenderer {
|
impl Renderer for HtmlRenderer {
|
||||||
@@ -146,15 +211,25 @@ impl Renderer for HtmlRenderer {
|
|||||||
|
|
||||||
impl HtmlRenderer {
|
impl HtmlRenderer {
|
||||||
fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
|
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 {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
match block {
|
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::Paragraph(n) => self.render_paragraph(n, w),
|
||||||
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
|
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
|
||||||
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
|
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
|
||||||
@@ -185,7 +260,10 @@ impl HtmlRenderer {
|
|||||||
w.write_all(b"</div>\n")
|
w.write_all(b"</div>\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 level = n.level.clamp(1, 6);
|
||||||
let class = if n.centered {
|
let class = if n.centered {
|
||||||
" class=\"centered\""
|
" class=\"centered\""
|
||||||
@@ -193,6 +271,9 @@ impl HtmlRenderer {
|
|||||||
""
|
""
|
||||||
};
|
};
|
||||||
write!(w, "<h{level}{class}>")?;
|
write!(w, "<h{level}{class}>")?;
|
||||||
|
if !number.is_empty() {
|
||||||
|
write_escaped(number, w)?;
|
||||||
|
}
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&n.children, w)?;
|
||||||
writeln!(w, "</h{level}>")
|
writeln!(w, "</h{level}>")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ pub struct HtmlConfig {
|
|||||||
pub custom_wiki2html_args: String,
|
pub custom_wiki2html_args: String,
|
||||||
/// URL prefix prepended to RSS feed / diary item links (`base_url`).
|
/// URL prefix prepended to RSS feed / diary item links (`base_url`).
|
||||||
pub base_url: String,
|
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 {
|
impl Default for HtmlConfig {
|
||||||
@@ -155,6 +161,8 @@ impl Default for HtmlConfig {
|
|||||||
custom_wiki2html: String::new(),
|
custom_wiki2html: String::new(),
|
||||||
custom_wiki2html_args: String::new(),
|
custom_wiki2html_args: String::new(),
|
||||||
base_url: 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<String>,
|
custom_wiki2html_args: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
base_url: Option<String>,
|
base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
html_header_numbering: Option<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
html_header_numbering_sym: Option<String>,
|
||||||
// per-wiki keys mirroring vimwiki globals. All optional so
|
// per-wiki keys mirroring vimwiki globals. All optional so
|
||||||
// existing single-wiki configs migrate without touching anything.
|
// existing single-wiki configs migrate without touching anything.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -745,6 +757,12 @@ impl From<RawWiki> for WikiConfig {
|
|||||||
if let Some(s) = r.base_url {
|
if let Some(s) = r.base_url {
|
||||||
html.base_url = s;
|
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();
|
let d = wiki_defaults();
|
||||||
WikiConfig {
|
WikiConfig {
|
||||||
name: r.name.unwrap_or(derived_name),
|
name: r.name.unwrap_or(derived_name),
|
||||||
|
|||||||
@@ -252,6 +252,12 @@ pub fn render_page_html(
|
|||||||
if !cfg.color_dic.is_empty() {
|
if !cfg.color_dic.is_empty() {
|
||||||
r = r.with_colors(cfg.color_dic.clone());
|
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)
|
r.render_to_string(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ fn html_config_defaults_match_vimwiki() {
|
|||||||
assert!(!c.auto_export);
|
assert!(!c.auto_export);
|
||||||
assert!(!c.html_filename_parameterization);
|
assert!(!c.html_filename_parameterization);
|
||||||
assert!(c.exclude_files.is_empty());
|
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]
|
#[test]
|
||||||
@@ -270,6 +272,73 @@ fn render_page_html_uses_metadata_date_when_set() {
|
|||||||
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
|
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("<h1>1. Intro</h1>"), "got: {html}");
|
||||||
|
assert!(html.contains("<h2>1.1. Background</h2>"), "got: {html}");
|
||||||
|
assert!(html.contains("<h2>1.2. Methods</h2>"), "got: {html}");
|
||||||
|
assert!(html.contains("<h1>2. Results</h1>"), "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("<h1>Title</h1>"),
|
||||||
|
"h1 unnumbered, got: {html}"
|
||||||
|
);
|
||||||
|
assert!(html.contains("<h2>1 Section</h2>"), "got: {html}");
|
||||||
|
assert!(html.contains("<h3>1.1 Sub</h3>"), "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("<h1>Intro</h1>"), "got: {html}");
|
||||||
|
assert!(html.contains("<h2>Sub</h2>"), "got: {html}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_page_html_root_path_for_nested_pages() {
|
fn render_page_html_root_path_for_nested_pages() {
|
||||||
let ast = parse("= D =\n");
|
let ast = parse("= D =\n");
|
||||||
|
|||||||
@@ -288,6 +288,9 @@ fn defaults_carry_vimwiki_per_wiki_keys() {
|
|||||||
assert_eq!(cfg.html.custom_wiki2html, "");
|
assert_eq!(cfg.html.custom_wiki2html, "");
|
||||||
assert_eq!(cfg.html.custom_wiki2html_args, "");
|
assert_eq!(cfg.html.custom_wiki2html_args, "");
|
||||||
assert_eq!(cfg.html.base_url, "");
|
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]
|
#[test]
|
||||||
@@ -319,6 +322,8 @@ fn raw_wiki_parses_every_new_key() {
|
|||||||
"custom_wiki2html": "~/bin/my_wiki2html.sh",
|
"custom_wiki2html": "~/bin/my_wiki2html.sh",
|
||||||
"custom_wiki2html_args": "--flag",
|
"custom_wiki2html_args": "--flag",
|
||||||
"base_url": "https://example.com/wiki/",
|
"base_url": "https://example.com/wiki/",
|
||||||
|
"html_header_numbering": 2,
|
||||||
|
"html_header_numbering_sym": ".",
|
||||||
}],
|
}],
|
||||||
}));
|
}));
|
||||||
let w = &cfg.wikis[0];
|
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, "~/bin/my_wiki2html.sh");
|
||||||
assert_eq!(w.html.custom_wiki2html_args, "--flag");
|
assert_eq!(w.html.custom_wiki2html_args, "--flag");
|
||||||
assert_eq!(w.html.base_url, "https://example.com/wiki/");
|
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:
|
// 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.
|
// a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days.
|
||||||
|
|||||||
Reference in New Issue
Block a user