fix(html): keyword-in-heading badge + brackets in wikilink descriptions
CI / cargo fmt --check (push) Successful in 37s
CI / cargo clippy (push) Successful in 39s
CI / cargo test (push) Successful in 49s

This commit is contained in:
gffranco
2026-06-24 00:26:59 +00:00
parent cdd85a4cc1
commit 5a102013bb
9 changed files with 293 additions and 66 deletions
+4
View File
@@ -53,6 +53,10 @@ pub struct PreformattedNode {
pub span: Span,
pub content: String,
pub language: Option<String>,
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
/// raw attributes on the `<pre>` tag, so we preserve it for byte-parity.
pub attrs: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+17
View File
@@ -17,6 +17,22 @@ pub enum Keyword {
Stopped,
}
impl Keyword {
/// The literal source text of the keyword (e.g. `"TODO"`). Used both for
/// the rendered span and for anchor/id derivation via [`inline_text`].
pub fn label(self) -> &'static str {
match self {
Keyword::Todo => "TODO",
Keyword::Done => "DONE",
Keyword::Started => "STARTED",
Keyword::Fixme => "FIXME",
Keyword::Fixed => "FIXED",
Keyword::Xxx => "XXX",
Keyword::Stopped => "STOPPED",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InlineNode {
Text(TextNode),
@@ -89,6 +105,7 @@ fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
InlineNode::Color(c) => push_inline_text(&c.children, out),
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
InlineNode::WikiLink(w) => match &w.description {
Some(d) => push_inline_text(d, out),
None => {
+136 -38
View File
@@ -278,10 +278,26 @@ impl HtmlRenderer {
// 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);
// Stack of (level, anchor) for the headings enclosing the current one,
// so each heading can carry vimwiki's hierarchical id (the parent
// anchors joined by `-`, e.g. `Title-Section`).
let mut ancestors: Vec<(u8, String)> = Vec::new();
for block in &doc.children {
if let BlockNode::Heading(n) = block {
let number = numberer.prefix(n.level.clamp(1, 6));
self.render_heading(n, &number, w)?;
let level = n.level.clamp(1, 6);
let number = numberer.prefix(level);
let anchor = crate::ast::inline_text(&n.children);
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
ancestors.pop();
}
let mut hier = String::new();
for (_, a) in &ancestors {
hier.push_str(a);
hier.push('-');
}
hier.push_str(&anchor);
ancestors.push((level, anchor));
self.render_heading(n, &number, &hier, w)?;
} else {
self.render_block(block, w)?;
}
@@ -291,7 +307,7 @@ impl HtmlRenderer {
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),
@@ -307,13 +323,14 @@ impl HtmlRenderer {
}
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
// `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump
// to the rendered tag.
w.write_all(b"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() {
if i > 0 {
w.write_all(b" ")?;
}
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
w.write_all(b"<span class=\"tag\" id=\"")?;
write_escaped(name, w)?;
w.write_all(b"\">")?;
write_escaped(name, w)?;
@@ -324,34 +341,70 @@ impl HtmlRenderer {
/// 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<()> {
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id
/// (parent anchors joined by `-`); pass `""` to default it to the flat id.
fn render_heading(
&self,
n: &HeadingNode,
number: &str,
hier_id: &str,
w: &mut dyn Write,
) -> io::Result<()> {
let level = n.level.clamp(1, 6);
let anchor = crate::ast::inline_text(&n.children);
// Inside a heading, render TODO/DONE/… as plain text rather than a
// keyword badge — a section title that *is* a keyword (`== TODO ==`)
// should look like a header, not an inline tag. (The keyword text is
// still part of `anchor` above, so the id stays correct.)
let body = flatten_keywords(&n.children);
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
// `<div class="toc">`, matching upstream's HTML so vimwiki's stylesheet
// (`.toc { … }`) applies. The class goes on the wrapping div, not the
// heading element.
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
// stylesheet rules apply. Kept in its simple form.
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
if is_toc {
w.write_all(b"<div class=\"toc\">")?;
write!(w, "<h{level} id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&body, w)?;
write!(w, "</h{level}></div>")?;
return writeln!(w);
}
let class = if n.centered {
" class=\"centered\""
} else {
""
};
write!(w, "<h{level}{class} id=\"")?;
// Centered headings keep their `class="centered"` form.
if n.centered {
write!(w, "<h{level} class=\"centered\" id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&body, w)?;
write!(w, "</h{level}>")?;
return writeln!(w);
}
// Regular heading: vimwiki's structure — a wrapping `<div>` carrying the
// hierarchical id, `class="header"` on the heading, and an in-heading
// self-anchor linking to that id. The heading keeps the flat id so
// existing intra-page TOC / `#anchor` links still resolve.
let hier = if hier_id.is_empty() { &anchor } else { hier_id };
w.write_all(b"<div id=\"")?;
write_escaped(hier, w)?;
write!(w, "\"><h{level} id=\"")?;
write_escaped(&anchor, w)?;
w.write_all(b"\" class=\"header\"><a href=\"#")?;
write_escaped(hier, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&n.children, w)?;
write!(w, "</h{level}>")?;
if is_toc {
w.write_all(b"</div>")?;
}
self.render_inlines(&body, w)?;
write!(w, "</a></h{level}></div>")?;
writeln!(w)
}
@@ -379,16 +432,16 @@ impl HtmlRenderer {
}
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
match &n.language {
Some(lang) => {
w.write_all(b"<pre><code class=\"language-")?;
write_escaped(lang, w)?;
w.write_all(b"\">")?;
}
None => w.write_all(b"<pre><code>")?,
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
// We mirror that so highlighters wired for vimwiki output still apply.
if n.attrs.is_empty() {
w.write_all(b"<pre>")?;
} else {
write!(w, "<pre {}>", n.attrs)?;
}
write_escaped(&n.content, w)?;
w.write_all(b"</code></pre>\n")
w.write_all(b"</pre>\n")
}
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
@@ -705,16 +758,16 @@ impl HtmlRenderer {
// One class per keyword so stylesheets can colour them independently
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
// vimwiki `todo` class so stock vimwiki CSS still styles it.
let (label, class) = match n.keyword {
Keyword::Todo => ("TODO", "todo"),
Keyword::Done => ("DONE", "done"),
Keyword::Started => ("STARTED", "started"),
Keyword::Fixme => ("FIXME", "fixme"),
Keyword::Fixed => ("FIXED", "fixed"),
Keyword::Xxx => ("XXX", "xxx"),
Keyword::Stopped => ("STOPPED", "stopped"),
let class = match n.keyword {
Keyword::Todo => "todo",
Keyword::Done => "done",
Keyword::Started => "started",
Keyword::Fixme => "fixme",
Keyword::Fixed => "fixed",
Keyword::Xxx => "xxx",
Keyword::Stopped => "stopped",
};
write!(w, "<span class=\"{class}\">{label}</span>")
write!(w, "<span class=\"{class}\">{}</span>", n.keyword.label())
}
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
@@ -891,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
/// wiki pages become `path.html`; interwiki links land in sibling
/// directories; diary entries land under `diary/`; file/local schemes
/// use their literal path; anchor-only links collapse to a fragment.
/// Return a copy of `nodes` with every `Keyword` replaced by its literal text.
/// Used for heading content so a keyword in a title renders as plain text (with
/// the heading's own styling) rather than as an inline keyword badge.
fn flatten_keywords(nodes: &[InlineNode]) -> Vec<InlineNode> {
nodes
.iter()
.map(|n| match n {
InlineNode::Keyword(k) => InlineNode::Text(TextNode {
span: k.span,
content: k.keyword.label().to_string(),
}),
InlineNode::Bold(b) => InlineNode::Bold(BoldNode {
span: b.span,
children: flatten_keywords(&b.children),
}),
InlineNode::Italic(i) => InlineNode::Italic(ItalicNode {
span: i.span,
children: flatten_keywords(&i.children),
}),
InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode {
span: b.span,
children: flatten_keywords(&b.children),
}),
InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode {
span: s.span,
children: flatten_keywords(&s.children),
}),
InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode {
span: s.span,
children: flatten_keywords(&s.children),
}),
InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode {
span: s.span,
children: flatten_keywords(&s.children),
}),
InlineNode::Color(c) => InlineNode::Color(ColorNode {
span: c.span,
color: c.color.clone(),
children: flatten_keywords(&c.children),
}),
other => other.clone(),
})
.collect()
}
pub fn default_link_resolver(target: &LinkTarget) -> String {
if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor {
+36 -3
View File
@@ -81,6 +81,9 @@ pub enum VimwikiTokenKind {
PreformattedOpen {
language: Option<String>,
attrs: HashMap<String, String>,
/// Verbatim text after `{{{` (trailing whitespace trimmed), preserved
/// so the HTML renderer can reproduce vimwiki's raw `<pre …>` attrs.
raw: String,
},
PreformattedClose,
PreformattedLine(String),
@@ -421,7 +424,14 @@ impl<'src> LexState<'src> {
// pairs separated by spaces. Keep parsing forgiving.
let after = trimmed[3..].trim();
let (language, attrs) = parse_fence_attrs(after);
let kind = VimwikiTokenKind::PreformattedOpen { language, attrs };
// vimwiki keeps everything after `{{{` (minus trailing whitespace) and
// drops it verbatim into the `<pre>` tag, so capture it unparsed.
let raw = trimmed[3..].trim_end().to_string();
let kind = VimwikiTokenKind::PreformattedOpen {
language,
attrs,
raw,
};
let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
self.push(kind, span);
self.mode = BlockMode::Preformatted;
@@ -865,8 +875,10 @@ impl<'src> LexState<'src> {
flush(self, &mut buf, &mut buf_start, abs_col);
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
i += 2;
// Lex until `]]` (absolute index into `slice`).
let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
// Lex until the closing `]]`. A single `]` inside the body —
// e.g. a `[TICKET-1]` in the description — is literal text, so
// we balance inner `[ ]` rather than stopping at the first `]]`.
let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
let inner_end = close_abs_in_slice.unwrap_or(slice.len());
self.lex_link_body(slice, i, line_col_start, inner_end, true);
i = inner_end;
@@ -1052,6 +1064,27 @@ impl<'src> LexState<'src> {
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
/// `lang` (single word) followed by optional `key=value` pairs separated
/// by spaces.
/// Byte offset of the closing `]]` for a wikilink body that begins at the start
/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
/// body text. Returns `None` if no closing `]]` is found.
fn find_wikilink_close(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut depth: u32 = 0;
let mut i = 0;
while i < b.len() {
match b[i] {
b'[' => depth += 1,
b']' if depth > 0 => depth -= 1, // closes an inner '['
b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
_ => {}
}
i += 1;
}
None
}
fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
let mut attrs = HashMap::new();
let mut language: Option<String> = None;
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
let open = self.advance().unwrap();
let span_start = open.span.start;
let mut span_end = open.span.end;
let language = match &open.kind {
K::PreformattedOpen { language, .. } => language.clone(),
let (language, attrs) = match &open.kind {
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
_ => unreachable!(),
};
let mut content = String::new();
@@ -390,6 +390,7 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end),
content,
language,
attrs,
});
}
K::PreformattedLine(s) => {
@@ -413,6 +414,7 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end),
content,
language,
attrs,
})
}