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( + "

\ + TODO

" + ), + "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");