fix(html): keyword-in-heading badge + brackets in wikilink descriptions
Two export bugs reported against v0.4.0: 1. A heading whose text is a keyword (`== TODO ==`) rendered as an inline `<span class="todo">` badge with an *empty* id, instead of a styled header. Root causes: `inline_text` dropped Keyword nodes (so the heading id/anchor came out empty, or `My TODO list` → `My list`), and the keyword span was emitted inside the heading. Fix: `inline_text` now includes the keyword's literal text (via new `Keyword::label()`), and heading content renders keywords as plain text (flatten_keywords) so a keyword title looks like a header. Keyword badges still render in body text as before. 2. A `]` inside a wikilink description (e.g. `[[page|Task [B-1]]]`) closed the link at the first `]]`, truncating the description and leaking a stray `]`. The close-scan is now bracket-aware (find_wikilink_close): inner `[ ]` pairs are balanced so the link closes at the correct `]]`. Added regression tests for both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,22 @@ pub enum Keyword {
|
|||||||
Stopped,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum InlineNode {
|
pub enum InlineNode {
|
||||||
Text(TextNode),
|
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::Superscript(s) => push_inline_text(&s.children, out),
|
||||||
InlineNode::Subscript(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::Color(c) => push_inline_text(&c.children, out),
|
||||||
|
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
|
||||||
InlineNode::WikiLink(w) => match &w.description {
|
InlineNode::WikiLink(w) => match &w.description {
|
||||||
Some(d) => push_inline_text(d, out),
|
Some(d) => push_inline_text(d, out),
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
@@ -352,6 +352,11 @@ impl HtmlRenderer {
|
|||||||
) -> io::Result<()> {
|
) -> 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);
|
||||||
|
// 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
|
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
||||||
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
|
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
|
||||||
@@ -365,7 +370,7 @@ impl HtmlRenderer {
|
|||||||
if !number.is_empty() {
|
if !number.is_empty() {
|
||||||
write_escaped(number, w)?;
|
write_escaped(number, w)?;
|
||||||
}
|
}
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&body, w)?;
|
||||||
write!(w, "</h{level}></div>")?;
|
write!(w, "</h{level}></div>")?;
|
||||||
return writeln!(w);
|
return writeln!(w);
|
||||||
}
|
}
|
||||||
@@ -378,7 +383,7 @@ impl HtmlRenderer {
|
|||||||
if !number.is_empty() {
|
if !number.is_empty() {
|
||||||
write_escaped(number, w)?;
|
write_escaped(number, w)?;
|
||||||
}
|
}
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&body, w)?;
|
||||||
write!(w, "</h{level}>")?;
|
write!(w, "</h{level}>")?;
|
||||||
return writeln!(w);
|
return writeln!(w);
|
||||||
}
|
}
|
||||||
@@ -398,7 +403,7 @@ impl HtmlRenderer {
|
|||||||
if !number.is_empty() {
|
if !number.is_empty() {
|
||||||
write_escaped(number, w)?;
|
write_escaped(number, w)?;
|
||||||
}
|
}
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&body, w)?;
|
||||||
write!(w, "</a></h{level}></div>")?;
|
write!(w, "</a></h{level}></div>")?;
|
||||||
writeln!(w)
|
writeln!(w)
|
||||||
}
|
}
|
||||||
@@ -753,16 +758,16 @@ impl HtmlRenderer {
|
|||||||
// One class per keyword so stylesheets can colour them independently
|
// One class per keyword so stylesheets can colour them independently
|
||||||
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
|
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
|
||||||
// vimwiki `todo` class so stock vimwiki CSS still styles it.
|
// vimwiki `todo` class so stock vimwiki CSS still styles it.
|
||||||
let (label, class) = match n.keyword {
|
let class = match n.keyword {
|
||||||
Keyword::Todo => ("TODO", "todo"),
|
Keyword::Todo => "todo",
|
||||||
Keyword::Done => ("DONE", "done"),
|
Keyword::Done => "done",
|
||||||
Keyword::Started => ("STARTED", "started"),
|
Keyword::Started => "started",
|
||||||
Keyword::Fixme => ("FIXME", "fixme"),
|
Keyword::Fixme => "fixme",
|
||||||
Keyword::Fixed => ("FIXED", "fixed"),
|
Keyword::Fixed => "fixed",
|
||||||
Keyword::Xxx => ("XXX", "xxx"),
|
Keyword::Xxx => "xxx",
|
||||||
Keyword::Stopped => ("STOPPED", "stopped"),
|
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<()> {
|
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
@@ -939,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
|
|||||||
/// wiki pages become `path.html`; interwiki links land in sibling
|
/// wiki pages become `path.html`; interwiki links land in sibling
|
||||||
/// directories; diary entries land under `diary/`; file/local schemes
|
/// directories; diary entries land under `diary/`; file/local schemes
|
||||||
/// use their literal path; anchor-only links collapse to a fragment.
|
/// 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 {
|
pub fn default_link_resolver(target: &LinkTarget) -> String {
|
||||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
if matches!(target.kind, LinkKind::AnchorOnly) {
|
||||||
return match &target.anchor {
|
return match &target.anchor {
|
||||||
|
|||||||
@@ -875,8 +875,10 @@ impl<'src> LexState<'src> {
|
|||||||
flush(self, &mut buf, &mut buf_start, abs_col);
|
flush(self, &mut buf, &mut buf_start, abs_col);
|
||||||
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
|
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
|
||||||
i += 2;
|
i += 2;
|
||||||
// Lex until `]]` (absolute index into `slice`).
|
// Lex until the closing `]]`. A single `]` inside the body —
|
||||||
let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
|
// 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());
|
let inner_end = close_abs_in_slice.unwrap_or(slice.len());
|
||||||
self.lex_link_body(slice, i, line_col_start, inner_end, true);
|
self.lex_link_body(slice, i, line_col_start, inner_end, true);
|
||||||
i = inner_end;
|
i = inner_end;
|
||||||
@@ -1062,6 +1064,27 @@ impl<'src> LexState<'src> {
|
|||||||
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
|
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
|
||||||
/// `lang` (single word) followed by optional `key=value` pairs separated
|
/// `lang` (single word) followed by optional `key=value` pairs separated
|
||||||
/// by spaces.
|
/// 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>) {
|
fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
|
||||||
let mut attrs = HashMap::new();
|
let mut attrs = HashMap::new();
|
||||||
let mut language: Option<String> = None;
|
let mut language: Option<String> = None;
|
||||||
|
|||||||
@@ -77,6 +77,43 @@ fn centered_heading_gets_centered_class() {
|
|||||||
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_keyword_renders_as_plain_text_not_badge() {
|
||||||
|
// `== TODO ==` is a section title, not an inline keyword: it must render as
|
||||||
|
// a normal header (with a correct, non-empty id), not a `<span class="todo">`
|
||||||
|
// badge. Regression for the exported header losing its header styling + id.
|
||||||
|
let out = render("== TODO ==\n");
|
||||||
|
assert!(
|
||||||
|
out.contains(
|
||||||
|
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
|
||||||
|
<a href=\"#TODO\">TODO</a></h2></div>"
|
||||||
|
),
|
||||||
|
"got: {out}"
|
||||||
|
);
|
||||||
|
assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}");
|
||||||
|
|
||||||
|
// A keyword mid-title keeps its text in the id (no dropped word / double
|
||||||
|
// space) and still renders plainly.
|
||||||
|
let out2 = render("= My TODO list =\n");
|
||||||
|
assert!(out2.contains("id=\"My TODO list\""), "got: {out2}");
|
||||||
|
assert!(!out2.contains("class=\"todo\""), "got: {out2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wikilink_description_keeps_inner_brackets() {
|
||||||
|
// A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close
|
||||||
|
// the link early. Regression for descriptions with brackets being mangled.
|
||||||
|
let out = render("[[page|Task [B-1]]]\n");
|
||||||
|
assert!(
|
||||||
|
out.contains("<a href=\"page.html\">Task [B-1]</a>"),
|
||||||
|
"got: {out}"
|
||||||
|
);
|
||||||
|
// Two bracketed links on one line both resolve.
|
||||||
|
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
|
||||||
|
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
|
||||||
|
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "got: {out2}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paragraph_wraps_inline_content() {
|
fn paragraph_wraps_inline_content() {
|
||||||
let out = render("Hello world\n");
|
let out = render("Hello world\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user