diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs
index 2e082f2..6b37e52 100644
--- a/crates/nuwiki-core/src/ast/inline.rs
+++ b/crates/nuwiki-core/src/ast/inline.rs
@@ -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 => {
diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs
index cbb628c..c21d511 100644
--- a/crates/nuwiki-core/src/render/html.rs
+++ b/crates/nuwiki-core/src/render/html.rs
@@ -352,6 +352,11 @@ impl HtmlRenderer {
) -> 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
// `
`, matching upstream so vimwiki's `.toc { … }`
@@ -365,7 +370,7 @@ impl HtmlRenderer {
if !number.is_empty() {
write_escaped(number, w)?;
}
- self.render_inlines(&n.children, w)?;
+ self.render_inlines(&body, w)?;
write!(w, "
")?;
return writeln!(w);
}
@@ -378,7 +383,7 @@ impl HtmlRenderer {
if !number.is_empty() {
write_escaped(number, w)?;
}
- self.render_inlines(&n.children, w)?;
+ self.render_inlines(&body, w)?;
write!(w, "")?;
return writeln!(w);
}
@@ -398,7 +403,7 @@ impl HtmlRenderer {
if !number.is_empty() {
write_escaped(number, w)?;
}
- self.render_inlines(&n.children, w)?;
+ self.render_inlines(&body, w)?;
write!(w, "")?;
writeln!(w)
}
@@ -753,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, "{label}")
+ write!(w, "{}", n.keyword.label())
}
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
@@ -939,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec> {
/// 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 {
+ 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 {
diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs
index e0a6aa5..12ba237 100644
--- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs
+++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs
@@ -875,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;
@@ -1062,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 {
+ 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, HashMap) {
let mut attrs = HashMap::new();
let mut language: Option = None;
diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs
index 915742e..7ab0598 100644
--- a/crates/nuwiki-core/tests/html_renderer.rs
+++ b/crates/nuwiki-core/tests/html_renderer.rs
@@ -77,6 +77,43 @@ fn centered_heading_gets_centered_class() {
assert!(out.contains("T
"));
}
+#[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 ``
+ // badge. Regression for the exported header losing its header styling + id.
+ let out = render("== TODO ==\n");
+ assert!(
+ out.contains(
+ ""
+ ),
+ "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("Task [B-1]"),
+ "got: {out}"
+ );
+ // Two bracketed links on one line both resolve.
+ let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
+ assert!(out2.contains("X [1]"), "got: {out2}");
+ assert!(out2.contains("Y [2]"), "got: {out2}");
+}
+
#[test]
fn paragraph_wraps_inline_content() {
let out = render("Hello world\n");