From f00b0780b833037257c59e14045491a6ea907e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Sun, 10 May 2026 19:12:01 +0000 Subject: [PATCH] phase 5: renderer trait + HtmlRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writer-based `Renderer` trait per SPEC §6.8: object-safe `fn render(&self, doc, w: &mut dyn Write) -> io::Result<()>` plus a `render_to_string` convenience. Object safety preserves the option of `Box` in the LSP layer. `HtmlRenderer` builder accepts a link-resolution callback (via `with_link_resolver`) and an optional enclosing template (via `with_template`). The default resolver mirrors §9: Wiki paths become `path.html`, interwiki links land in sibling directories (`../wiki/` / `../wn-/`), diary entries under `diary/`, file/local schemes use literal paths, anchor-only collapses to `#`. Renderer covers every AST node: - block: heading (centered class), paragraph, hr, blockquote, preformatted (`language-` class), math block (with `\begin{env}` when an environment is set), lists (ordered/unordered + every checkbox state, plus recursive sublists), definition list (dt/dd), table (thead/tbody driven by `is_header`, col/row span as CSS class hooks), comment (as HTML comment with `--` neutralised), error node (as `.parse-error` span) - inline: strong/em + bold-italic, ``, code (escaped), ``/``, math-inline wrapped in `\(...\)`, keyword spans with per-keyword class, color spans, wikilink/external/raw URL anchors, transclusion as `` with sorted attrs HTML escaping is byte-level (`<>&"'`). Template substitution replaces `{{content}}` first so a body containing the literal `{{title}}` isn't double-substituted. Tests (31 new) cover every block + inline construct, every LinkKind through the default resolver, HTML escaping, custom resolver override, template with/without title metadata, and an end-to-end smoke test on a multi-construct document. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 +- SPEC.md | 2 +- crates/nuwiki-core/src/lib.rs | 1 + crates/nuwiki-core/src/render/html.rs | 578 ++++++++++++++++++++++ crates/nuwiki-core/src/render/mod.rs | 26 + crates/nuwiki-core/tests/html_renderer.rs | 292 +++++++++++ 6 files changed, 902 insertions(+), 5 deletions(-) create mode 100644 crates/nuwiki-core/src/render/html.rs create mode 100644 crates/nuwiki-core/src/render/mod.rs create mode 100644 crates/nuwiki-core/tests/html_renderer.rs diff --git a/README.md b/README.md index 0a5da30..241c1f0 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a Rust-based language server (LSP). -> **Status:** Phase 4 (vimwiki parser) complete. End-to-end source → AST works -> via `VimwikiSyntax`. Renderer and editor glue still pending. +> **Status:** Phase 5 (HTML renderer) complete. End-to-end source → AST → HTML +> works via `VimwikiSyntax` + `HtmlRenderer`. LSP and editor glue still pending. See [`SPEC.md`](./SPEC.md) for the full project specification. @@ -17,8 +17,8 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 2 | Syntax Plugin Interface | ✅ done | | 3 | Vimwiki Lexer | ✅ done | | 4 | Vimwiki Parser | ✅ done | -| 5 | Renderer | ⏳ next | -| 6 | LSP Foundation | ⏳ | +| 5 | Renderer | ✅ done | +| 6 | LSP Foundation | ⏳ next | | 7 | Semantic Tokens | ⏳ | | 8 | Navigation | ⏳ | | 9 | Editor Glue | ⏳ | diff --git a/SPEC.md b/SPEC.md index 69f71a3..196f534 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,7 +1,7 @@ # nuwiki — Project Specification > Last updated: 2026-05-10 -> Status: Phase 4 (Vimwiki Parser) complete — moving to Phase 5 (Renderer) +> Status: Phase 5 (Renderer) complete — moving to Phase 6 (LSP Foundation) --- diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs index a2e9f5d..90ea884 100644 --- a/crates/nuwiki-core/src/lib.rs +++ b/crates/nuwiki-core/src/lib.rs @@ -4,4 +4,5 @@ //! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules. pub mod ast; +pub mod render; pub mod syntax; diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs new file mode 100644 index 0000000..547c559 --- /dev/null +++ b/crates/nuwiki-core/src/render/html.rs @@ -0,0 +1,578 @@ +//! HTML renderer. +//! +//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is +//! configured with a link resolver (callback that turns a `LinkTarget` into +//! a URL string) and, optionally, an enclosing template with `{{title}}` / +//! `{{content}}` placeholders (SPEC.md §6.8). +//! +//! Without a template the output is just the body markup, so callers are +//! free to embed it in their own page shell. With a template the rendered +//! body replaces `{{content}}` and the document's `metadata.title` +//! replaces `{{title}}`. + +use std::io::{self, Write}; +use std::sync::Arc; + +use crate::ast::{ + BlockNode, BlockquoteNode, BoldItalicNode, BoldNode, CheckboxState, CodeNode, ColorNode, + CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, + HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind, + LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode, + PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, + TableNode, TableRowNode, TextNode, TransclusionNode, WikiLinkNode, +}; + +use super::Renderer; + +/// Boxed link-resolution callback. Held in an `Arc` so the renderer is +/// cheap to clone and stays `Send + Sync`. +pub type LinkResolver = Arc String + Send + Sync>; + +#[derive(Clone)] +pub struct HtmlRenderer { + link_resolver: LinkResolver, + template: Option, +} + +impl Default for HtmlRenderer { + fn default() -> Self { + Self::new() + } +} + +impl HtmlRenderer { + pub fn new() -> Self { + Self { + link_resolver: Arc::new(default_link_resolver), + template: None, + } + } + + /// Override the link resolver. The callback is invoked for every + /// wikilink and is expected to return a URL string. + pub fn with_link_resolver(mut self, f: F) -> Self + where + F: Fn(&LinkTarget) -> String + Send + Sync + 'static, + { + self.link_resolver = Arc::new(f); + self + } + + /// Wrap the rendered body in a template. `{{title}}` and `{{content}}` + /// are substituted; everything else passes through verbatim. + pub fn with_template(mut self, template: impl Into) -> Self { + self.template = Some(template.into()); + self + } +} + +impl Renderer for HtmlRenderer { + fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { + if let Some(template) = &self.template { + let mut body = Vec::new(); + self.render_body(doc, &mut body)?; + let body_str = std::str::from_utf8(&body) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let title = doc.metadata.title.as_deref().unwrap_or(""); + // Replace `{{content}}` first so its substituted body, which may + // legitimately contain the literal `{{title}}`, isn't itself + // rewritten in the next pass. + let with_content = template.replace("{{content}}", body_str); + let final_doc = with_content.replace("{{title}}", title); + w.write_all(final_doc.as_bytes())?; + } else { + self.render_body(doc, w)?; + } + Ok(()) + } +} + +impl HtmlRenderer { + fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { + for block in &doc.children { + self.render_block(block, w)?; + } + Ok(()) + } + + fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> { + match block { + 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), + BlockNode::Preformatted(n) => self.render_preformatted(n, w), + BlockNode::MathBlock(n) => self.render_math_block(n, w), + BlockNode::List(n) => self.render_list(n, w), + BlockNode::DefinitionList(n) => self.render_definition_list(n, w), + BlockNode::Table(n) => self.render_table(n, w), + BlockNode::Comment(n) => self.render_comment(n, w), + BlockNode::Error(n) => self.render_error(n, w), + } + } + + fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> { + let level = n.level.clamp(1, 6); + let class = if n.centered { + " class=\"centered\"" + } else { + "" + }; + write!(w, "")?; + self.render_inlines(&n.children, w)?; + writeln!(w, "") + } + + fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"

")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"

\n") + } + + fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"
\n") + } + + fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"
\n")?; + for child in &n.children { + self.render_block(child, w)?; + } + w.write_all(b"
\n") + } + + fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> { + match &n.language { + Some(lang) => { + w.write_all(b"
")?;
+            }
+            None => w.write_all(b"
")?,
+        }
+        write_escaped(&n.content, w)?;
+        w.write_all(b"
\n") + } + + fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> { + // No actual TeX rendering — caller is expected to run MathJax / KaTeX + // over the wrapped node. The delimiters match vimwiki convention. + match &n.environment { + Some(env) => writeln!( + w, + "
\\begin{{{env}}}", + escape(env) + )?, + None => w.write_all(b"
\\[\n")?, + } + write_escaped(&n.content, w)?; + match &n.environment { + Some(env) => write!(w, "\n\\end{{{env}}}
\n", env = escape(env))?, + None => w.write_all(b"\n\\]
\n")?, + } + Ok(()) + } + + fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> { + let tag = if n.ordered { "ol" } else { "ul" }; + writeln!(w, "<{tag}>")?; + for item in &n.items { + self.render_list_item(item, w)?; + } + writeln!(w, "") + } + + fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> { + match n.checkbox { + Some(state) => { + let (checked, class) = match state { + CheckboxState::Empty => ("", "task-empty"), + CheckboxState::Quarter => ("", "task-quarter"), + CheckboxState::Half => ("", "task-half"), + CheckboxState::ThreeQuarters => ("", "task-three-quarters"), + CheckboxState::Done => (" checked", "task-done"), + CheckboxState::Rejected => ("", "task-rejected"), + }; + write!( + w, + "
  • " + )?; + } + None => w.write_all(b"
  • ")?, + } + self.render_inlines(&n.children, w)?; + if let Some(sublist) = &n.sublist { + w.write_all(b"\n")?; + self.render_list(sublist, w)?; + } + w.write_all(b"
  • \n") + } + + fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"
    \n")?; + for item in &n.items { + self.render_definition_item(item, w)?; + } + w.write_all(b"
    \n") + } + + fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> { + if let Some(term) = &n.term { + w.write_all(b"
    ")?; + self.render_inlines(term, w)?; + w.write_all(b"
    \n")?; + } + for def in &n.definitions { + w.write_all(b"
    ")?; + self.render_inlines(def, w)?; + w.write_all(b"
    \n")?; + } + Ok(()) + } + + fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"\n")?; + let mut in_thead = false; + let mut in_tbody = false; + for row in &n.rows { + if row.is_header { + if !in_thead { + w.write_all(b"\n")?; + in_thead = true; + } + } else { + if in_thead { + w.write_all(b"\n")?; + in_thead = false; + } + if !in_tbody { + w.write_all(b"\n")?; + in_tbody = true; + } + } + self.render_table_row(row, w)?; + } + if in_thead { + w.write_all(b"\n")?; + } + if in_tbody { + w.write_all(b"\n")?; + } + w.write_all(b"
    \n") + } + + fn render_table_row(&self, n: &TableRowNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + for cell in &n.cells { + // col_span / row_span here are *spec flags* meaning "this cell is + // a continuation marker", which HTML expresses via the *previous* + // cell's colspan/rowspan attrs. Computing those attrs requires + // forward state on the renderer side; for now we emit class + // markers so styling can show the span visually. + self.render_table_cell(cell, n.is_header, w)?; + } + w.write_all(b"\n") + } + + fn render_table_cell( + &self, + n: &TableCellNode, + is_header: bool, + w: &mut dyn Write, + ) -> io::Result<()> { + let tag = if is_header { "th" } else { "td" }; + let class = match (n.col_span, n.row_span) { + (true, _) => " class=\"col-span\"", + (_, true) => " class=\"row-span\"", + _ => "", + }; + write!(w, "<{tag}{class}>")?; + self.render_inlines(&n.children, w)?; + write!(w, "") + } + + fn render_comment(&self, n: &CommentNode, w: &mut dyn Write) -> io::Result<()> { + // Emit as an HTML comment so it survives round-trips through view-source + // but isn't rendered to the reader. `--` inside a comment is illegal + // in HTML, so collapse runs. + let safe = n.content.replace("--", "- -"); + writeln!(w, "") + } + + fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> { + write!(w, "")?; + write_escaped(&n.raw, w)?; + w.write_all(b"") + } + + // ===== Inline ===== + + fn render_inlines(&self, nodes: &[InlineNode], w: &mut dyn Write) -> io::Result<()> { + for n in nodes { + self.render_inline(n, w)?; + } + Ok(()) + } + + fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> { + match n { + InlineNode::Text(t) => self.render_text(t, w), + InlineNode::Bold(t) => self.render_bold(t, w), + InlineNode::Italic(t) => self.render_italic(t, w), + InlineNode::BoldItalic(t) => self.render_bold_italic(t, w), + InlineNode::Strikethrough(t) => self.render_strikethrough(t, w), + InlineNode::Code(t) => self.render_code(t, w), + InlineNode::Superscript(t) => self.render_superscript(t, w), + InlineNode::Subscript(t) => self.render_subscript(t, w), + InlineNode::MathInline(t) => self.render_math_inline(t, w), + InlineNode::Keyword(t) => self.render_keyword(t, w), + InlineNode::Color(t) => self.render_color(t, w), + InlineNode::WikiLink(t) => self.render_wikilink(t, w), + InlineNode::ExternalLink(t) => self.render_external_link(t, w), + InlineNode::Transclusion(t) => self.render_transclusion(t, w), + InlineNode::RawUrl(t) => self.render_raw_url(t, w), + } + } + + fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> { + write_escaped(&n.content, w) + } + + fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + write_escaped(&n.content, w)?; + w.write_all(b"") + } + + fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"\\(")?; + write_escaped(&n.content, w)?; + w.write_all(b"\\)") + } + + fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> { + let (label, class) = match n.keyword { + Keyword::Todo => ("TODO", "keyword keyword-todo"), + Keyword::Done => ("DONE", "keyword keyword-done"), + Keyword::Started => ("STARTED", "keyword keyword-started"), + Keyword::Fixme => ("FIXME", "keyword keyword-fixme"), + Keyword::Fixed => ("FIXED", "keyword keyword-fixed"), + Keyword::Xxx => ("XXX", "keyword keyword-xxx"), + }; + write!(w, "{label}") + } + + fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { + write!(w, "")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> { + let href = (self.link_resolver)(&n.target); + w.write_all(b"")?; + match &n.description { + Some(desc) => self.render_inlines(desc, w)?, + None => { + let label = n + .target + .path + .clone() + .or_else(|| n.target.anchor.clone()) + .unwrap_or_else(|| href.clone()); + write_escaped(&label, w)?; + } + } + w.write_all(b"") + } + + fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + match &n.description { + Some(desc) => self.render_inlines(desc, w)?, + None => write_escaped(&n.url, w)?, + } + w.write_all(b"") + } + + fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"\"")?; = n.attrs.keys().collect(); + keys.sort(); + for k in keys { + let v = &n.attrs[k]; + w.write_all(b" ")?; + write_escaped(k, w)?; + w.write_all(b"=\"")?; + write_escaped(v, w)?; + w.write_all(b"\"")?; + } + w.write_all(b">") + } + + fn render_raw_url(&self, n: &RawUrlNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + write_escaped(&n.url, w)?; + w.write_all(b"") + } +} + +// ===== Default link resolver ===== + +/// Default `LinkResolver`. Mirrors the conventions in SPEC.md §9: +/// 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. +pub fn default_link_resolver(target: &LinkTarget) -> String { + if matches!(target.kind, LinkKind::AnchorOnly) { + return match &target.anchor { + Some(a) => format!("#{a}"), + None => String::new(), + }; + } + + let path = target.path.as_deref().unwrap_or(""); + let mut out = String::new(); + + match target.kind { + LinkKind::Wiki => { + if target.is_absolute { + out.push('/'); + } + out.push_str(path); + if target.is_directory { + out.push('/'); + } else if !path.is_empty() { + out.push_str(".html"); + } + } + LinkKind::Interwiki => { + if let Some(idx) = target.wiki_index { + out.push_str(&format!("../wiki{idx}/")); + } else if let Some(name) = &target.wiki_name { + out.push_str(&format!("../wn-{name}/")); + } + out.push_str(path); + if !target.is_directory { + out.push_str(".html"); + } + } + LinkKind::Diary => { + out.push_str("diary/"); + out.push_str(path); + out.push_str(".html"); + } + LinkKind::File => { + out.push_str(path); + } + LinkKind::Local => { + out.push_str("file://"); + if target.is_absolute && !path.starts_with('/') { + out.push('/'); + } + out.push_str(path); + } + LinkKind::Raw => { + out.push_str(path); + } + LinkKind::AnchorOnly => unreachable!(), + } + + if let Some(anchor) = &target.anchor { + out.push('#'); + out.push_str(anchor); + } + out +} + +// ===== HTML escaping ===== + +fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> { + let bytes = s.as_bytes(); + let mut last = 0; + for (i, &b) in bytes.iter().enumerate() { + let replacement: &[u8] = match b { + b'<' => b"<", + b'>' => b">", + b'&' => b"&", + b'"' => b""", + b'\'' => b"'", + _ => continue, + }; + if i > last { + w.write_all(&bytes[last..i])?; + } + w.write_all(replacement)?; + last = i + 1; + } + if last < bytes.len() { + w.write_all(&bytes[last..])?; + } + Ok(()) +} + +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} diff --git a/crates/nuwiki-core/src/render/mod.rs b/crates/nuwiki-core/src/render/mod.rs new file mode 100644 index 0000000..a305703 --- /dev/null +++ b/crates/nuwiki-core/src/render/mod.rs @@ -0,0 +1,26 @@ +//! Document renderers. +//! +//! SPEC.md §6.8: a `Renderer` is writer-based, takes a `DocumentNode`, and +//! emits its representation into any `Write`. Errors propagate through +//! `io::Result` — there's no separate per-renderer error type so the trait +//! stays object-safe (`Box` is useful for the LSP later). + +pub mod html; + +pub use html::HtmlRenderer; + +use std::io::{self, Write}; + +use crate::ast::DocumentNode; + +pub trait Renderer { + fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()>; + + /// Convenience: render into a `String`. Renderers that emit non-UTF-8 + /// bytes should override or skip; the default assumes UTF-8 output. + fn render_to_string(&self, doc: &DocumentNode) -> io::Result { + let mut buf = Vec::new(); + self.render(doc, &mut buf)?; + String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } +} diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs new file mode 100644 index 0000000..b5d6f5e --- /dev/null +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -0,0 +1,292 @@ +//! End-to-end tests for the HTML renderer. +//! +//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`. + +use nuwiki_core::ast::LinkTarget; +use nuwiki_core::render::{HtmlRenderer, Renderer}; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; + +fn render(src: &str) -> String { + let doc = VimwikiSyntax::new().parse(src); + HtmlRenderer::new().render_to_string(&doc).unwrap() +} + +// ===== Block elements ===== + +#[test] +fn empty_document_renders_to_empty_string() { + assert_eq!(render(""), ""); +} + +#[test] +fn heading_levels() { + for level in 1..=6 { + let bars = "=".repeat(level); + let out = render(&format!("{bars} Title {bars}\n")); + assert!(out.starts_with(&format!("Title"))); + } +} + +#[test] +fn centered_heading_gets_centered_class() { + let out = render(" = T =\n"); + assert!(out.contains("

    T

    ")); +} + +#[test] +fn paragraph_wraps_inline_content() { + let out = render("Hello world\n"); + assert_eq!(out.trim(), "

    Hello world

    "); +} + +#[test] +fn horizontal_rule() { + let out = render("----\n"); + assert!(out.contains("
    ")); +} + +#[test] +fn blockquote_lines_become_paragraphs() { + let out = render("> first\n> second\n"); + assert!(out.contains("
    ")); + assert!(out.contains("

    first

    ")); + assert!(out.contains("

    second

    ")); +} + +#[test] +fn preformatted_block_emits_pre_code_with_language_class() { + let out = render("{{{rust\nfn main() {}\n}}}\n"); + assert!(out.contains("
    "));
    +    assert!(out.contains("fn main() {}"));
    +    assert!(out.contains("
    ")); +} + +#[test] +fn math_block_emits_div() { + let out = render("{{$\nx=1\n}}$\n"); + assert!(out.contains("
    ")); + assert!(out.contains("x=1")); +} + +#[test] +fn unordered_list_with_checkbox() { + let out = render("- [X] done\n- not done\n"); + assert!(out.contains("