phase 5: renderer trait + HtmlRenderer
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<dyn Renderer>` 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<N>/` / `../wn-<Name>/`), 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-<lang>` 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, `<del>`, code (escaped),
`<sup>`/`<sub>`, math-inline wrapped in `\(...\)`, keyword spans
with per-keyword class, color spans, wikilink/external/raw URL
anchors, transclusion as `<img>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a
|
A Vim/Neovim plugin providing full vimwiki syntax support, implemented as a
|
||||||
Rust-based language server (LSP).
|
Rust-based language server (LSP).
|
||||||
|
|
||||||
> **Status:** Phase 4 (vimwiki parser) complete. End-to-end source → AST works
|
> **Status:** Phase 5 (HTML renderer) complete. End-to-end source → AST → HTML
|
||||||
> via `VimwikiSyntax`. Renderer and editor glue still pending.
|
> works via `VimwikiSyntax` + `HtmlRenderer`. LSP and editor glue still pending.
|
||||||
|
|
||||||
See [`SPEC.md`](./SPEC.md) for the full project specification.
|
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 |
|
| 2 | Syntax Plugin Interface | ✅ done |
|
||||||
| 3 | Vimwiki Lexer | ✅ done |
|
| 3 | Vimwiki Lexer | ✅ done |
|
||||||
| 4 | Vimwiki Parser | ✅ done |
|
| 4 | Vimwiki Parser | ✅ done |
|
||||||
| 5 | Renderer | ⏳ next |
|
| 5 | Renderer | ✅ done |
|
||||||
| 6 | LSP Foundation | ⏳ |
|
| 6 | LSP Foundation | ⏳ next |
|
||||||
| 7 | Semantic Tokens | ⏳ |
|
| 7 | Semantic Tokens | ⏳ |
|
||||||
| 8 | Navigation | ⏳ |
|
| 8 | Navigation | ⏳ |
|
||||||
| 9 | Editor Glue | ⏳ |
|
| 9 | Editor Glue | ⏳ |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# nuwiki — Project Specification
|
# nuwiki — Project Specification
|
||||||
|
|
||||||
> Last updated: 2026-05-10
|
> 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
//! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules.
|
//! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules.
|
||||||
|
|
||||||
pub mod ast;
|
pub mod ast;
|
||||||
|
pub mod render;
|
||||||
pub mod syntax;
|
pub mod syntax;
|
||||||
|
|||||||
@@ -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<dyn Fn(&LinkTarget) -> String + Send + Sync>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HtmlRenderer {
|
||||||
|
link_resolver: LinkResolver,
|
||||||
|
template: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<F>(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<String>) -> 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, "<h{level}{class}>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
writeln!(w, "</h{level}>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<p>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</p>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<hr>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<blockquote>\n")?;
|
||||||
|
for child in &n.children {
|
||||||
|
self.render_block(child, w)?;
|
||||||
|
}
|
||||||
|
w.write_all(b"</blockquote>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
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>")?,
|
||||||
|
}
|
||||||
|
write_escaped(&n.content, w)?;
|
||||||
|
w.write_all(b"</code></pre>\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,
|
||||||
|
"<div class=\"math-block\" data-env=\"{}\">\\begin{{{env}}}",
|
||||||
|
escape(env)
|
||||||
|
)?,
|
||||||
|
None => w.write_all(b"<div class=\"math-block\">\\[\n")?,
|
||||||
|
}
|
||||||
|
write_escaped(&n.content, w)?;
|
||||||
|
match &n.environment {
|
||||||
|
Some(env) => write!(w, "\n\\end{{{env}}}</div>\n", env = escape(env))?,
|
||||||
|
None => w.write_all(b"\n\\]</div>\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, "</{tag}>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"<li class=\"{class}\"><input type=\"checkbox\" disabled{checked}> "
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => w.write_all(b"<li>")?,
|
||||||
|
}
|
||||||
|
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"</li>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<dl>\n")?;
|
||||||
|
for item in &n.items {
|
||||||
|
self.render_definition_item(item, w)?;
|
||||||
|
}
|
||||||
|
w.write_all(b"</dl>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
if let Some(term) = &n.term {
|
||||||
|
w.write_all(b"<dt>")?;
|
||||||
|
self.render_inlines(term, w)?;
|
||||||
|
w.write_all(b"</dt>\n")?;
|
||||||
|
}
|
||||||
|
for def in &n.definitions {
|
||||||
|
w.write_all(b"<dd>")?;
|
||||||
|
self.render_inlines(def, w)?;
|
||||||
|
w.write_all(b"</dd>\n")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<table>\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"<thead>\n")?;
|
||||||
|
in_thead = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if in_thead {
|
||||||
|
w.write_all(b"</thead>\n")?;
|
||||||
|
in_thead = false;
|
||||||
|
}
|
||||||
|
if !in_tbody {
|
||||||
|
w.write_all(b"<tbody>\n")?;
|
||||||
|
in_tbody = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.render_table_row(row, w)?;
|
||||||
|
}
|
||||||
|
if in_thead {
|
||||||
|
w.write_all(b"</thead>\n")?;
|
||||||
|
}
|
||||||
|
if in_tbody {
|
||||||
|
w.write_all(b"</tbody>\n")?;
|
||||||
|
}
|
||||||
|
w.write_all(b"</table>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table_row(&self, n: &TableRowNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<tr>")?;
|
||||||
|
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"</tr>\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, "</{tag}>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "<!--{safe}-->")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
write!(w, "<span class=\"parse-error\" title=\"")?;
|
||||||
|
write_escaped(&n.message, w)?;
|
||||||
|
w.write_all(b"\">")?;
|
||||||
|
write_escaped(&n.raw, w)?;
|
||||||
|
w.write_all(b"</span>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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"<strong>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</strong>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<em>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</em>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<strong><em>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</em></strong>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<del>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</del>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<code>")?;
|
||||||
|
write_escaped(&n.content, w)?;
|
||||||
|
w.write_all(b"</code>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<sup>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</sup>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<sub>")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</sub>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<span class=\"math-inline\">\\(")?;
|
||||||
|
write_escaped(&n.content, w)?;
|
||||||
|
w.write_all(b"\\)</span>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "<span class=\"{class}\">{label}</span>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
write!(w, "<span class=\"color-")?;
|
||||||
|
write_escaped(&n.color, w)?;
|
||||||
|
w.write_all(b"\">")?;
|
||||||
|
self.render_inlines(&n.children, w)?;
|
||||||
|
w.write_all(b"</span>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
let href = (self.link_resolver)(&n.target);
|
||||||
|
w.write_all(b"<a href=\"")?;
|
||||||
|
write_escaped(&href, w)?;
|
||||||
|
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"</a>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<a href=\"")?;
|
||||||
|
write_escaped(&n.url, w)?;
|
||||||
|
w.write_all(b"\">")?;
|
||||||
|
match &n.description {
|
||||||
|
Some(desc) => self.render_inlines(desc, w)?,
|
||||||
|
None => write_escaped(&n.url, w)?,
|
||||||
|
}
|
||||||
|
w.write_all(b"</a>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
|
w.write_all(b"<img src=\"")?;
|
||||||
|
write_escaped(&n.url, w)?;
|
||||||
|
w.write_all(b"\" alt=\"")?;
|
||||||
|
write_escaped(n.alt.as_deref().unwrap_or(""), w)?;
|
||||||
|
w.write_all(b"\"")?;
|
||||||
|
// Emit any extra attrs verbatim; both key and value are escaped.
|
||||||
|
let mut keys: Vec<&String> = 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"<a href=\"")?;
|
||||||
|
write_escaped(&n.url, w)?;
|
||||||
|
w.write_all(b"\">")?;
|
||||||
|
write_escaped(&n.url, w)?;
|
||||||
|
w.write_all(b"</a>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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
|
||||||
|
}
|
||||||
@@ -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<dyn Renderer>` 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<String> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
self.render(doc, &mut buf)?;
|
||||||
|
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!("<h{level}>Title</h{level}>")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn centered_heading_gets_centered_class() {
|
||||||
|
let out = render(" = T =\n");
|
||||||
|
assert!(out.contains("<h1 class=\"centered\">T</h1>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paragraph_wraps_inline_content() {
|
||||||
|
let out = render("Hello world\n");
|
||||||
|
assert_eq!(out.trim(), "<p>Hello world</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horizontal_rule() {
|
||||||
|
let out = render("----\n");
|
||||||
|
assert!(out.contains("<hr>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blockquote_lines_become_paragraphs() {
|
||||||
|
let out = render("> first\n> second\n");
|
||||||
|
assert!(out.contains("<blockquote>"));
|
||||||
|
assert!(out.contains("<p>first</p>"));
|
||||||
|
assert!(out.contains("<p>second</p>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preformatted_block_emits_pre_code_with_language_class() {
|
||||||
|
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
||||||
|
assert!(out.contains("<pre><code class=\"language-rust\">"));
|
||||||
|
assert!(out.contains("fn main() {}"));
|
||||||
|
assert!(out.contains("</code></pre>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn math_block_emits_div() {
|
||||||
|
let out = render("{{$\nx=1\n}}$\n");
|
||||||
|
assert!(out.contains("<div class=\"math-block\">"));
|
||||||
|
assert!(out.contains("x=1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unordered_list_with_checkbox() {
|
||||||
|
let out = render("- [X] done\n- not done\n");
|
||||||
|
assert!(out.contains("<ul>"));
|
||||||
|
assert!(out.contains("<input type=\"checkbox\" disabled checked>"));
|
||||||
|
assert!(out.contains("done</li>"));
|
||||||
|
assert!(out.contains("not done</li>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordered_list() {
|
||||||
|
let out = render("1. first\n");
|
||||||
|
assert!(out.contains("<ol>"));
|
||||||
|
assert!(out.contains("<li>first</li>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_list_renders_recursively() {
|
||||||
|
let out = render("- top\n - nested\n");
|
||||||
|
assert!(out.contains("<ul>"));
|
||||||
|
let inner_idx = out
|
||||||
|
.find("<ul>\n<li>top")
|
||||||
|
.map(|i| i + "<ul>\n<li>top".len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert!(out[inner_idx..].contains("<ul>"));
|
||||||
|
assert!(out.contains("nested"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn definition_list() {
|
||||||
|
let out = render("Term:: Defn\n");
|
||||||
|
assert!(out.contains("<dl>"));
|
||||||
|
assert!(out.contains("<dt>Term</dt>"));
|
||||||
|
assert!(out.contains("<dd>Defn</dd>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_with_header_separator() {
|
||||||
|
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||||
|
assert!(out.contains("<table>"));
|
||||||
|
assert!(out.contains("<thead>"));
|
||||||
|
assert!(out.contains("<th> a </th>"));
|
||||||
|
assert!(out.contains("<tbody>"));
|
||||||
|
assert!(out.contains("<td> 1 </td>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_comment_becomes_html_comment() {
|
||||||
|
let out = render("%% hidden\n");
|
||||||
|
assert!(out.contains("<!--"));
|
||||||
|
assert!(out.contains("hidden"));
|
||||||
|
assert!(out.contains("-->"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Inline =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_italic_strike_super_sub() {
|
||||||
|
let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n");
|
||||||
|
assert!(out.contains("<strong>b</strong>"));
|
||||||
|
assert!(out.contains("<em>i</em>"));
|
||||||
|
assert!(out.contains("<del>s</del>"));
|
||||||
|
assert!(out.contains("<sup>sup</sup>"));
|
||||||
|
assert!(out.contains("<sub>sub</sub>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_code_is_escaped() {
|
||||||
|
let out = render("Use `Vec<u32>` here\n");
|
||||||
|
assert!(out.contains("<code>Vec<u32></code>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyword_spans() {
|
||||||
|
let out = render("TODO write tests\n");
|
||||||
|
assert!(out.contains("<span class=\"keyword keyword-todo\">TODO</span>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_url_link() {
|
||||||
|
let out = render("See https://example.com here\n");
|
||||||
|
assert!(out.contains("<a href=\"https://example.com\">https://example.com</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wikilink_with_default_resolver() {
|
||||||
|
let out = render("[[Page]]\n");
|
||||||
|
assert!(out.contains("<a href=\"Page.html\">Page</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wikilink_with_anchor_default_resolver() {
|
||||||
|
let out = render("[[Page#Section]]\n");
|
||||||
|
assert!(out.contains("<a href=\"Page.html#Section\">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchor_only_link() {
|
||||||
|
let out = render("[[#Section]]\n");
|
||||||
|
assert!(out.contains("<a href=\"#Section\">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interwiki_numbered_link() {
|
||||||
|
let out = render("[[wiki1:Page]]\n");
|
||||||
|
assert!(out.contains("<a href=\"../wiki1/Page.html\">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interwiki_named_link() {
|
||||||
|
let out = render("[[wn.Notes:Page]]\n");
|
||||||
|
assert!(out.contains("<a href=\"../wn-Notes/Page.html\">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_link() {
|
||||||
|
let out = render("[[diary:2026-05-10]]\n");
|
||||||
|
assert!(out.contains("<a href=\"diary/2026-05-10.html\">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_link_routes_to_external_link_node() {
|
||||||
|
let out = render("[[https://example.com|docs]]\n");
|
||||||
|
assert!(out.contains("<a href=\"https://example.com\">docs</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transclusion_becomes_img() {
|
||||||
|
let out = render("{{img.png|cat photo|class=hi}}\n");
|
||||||
|
assert!(out.contains("<img src=\"img.png\""));
|
||||||
|
assert!(out.contains("alt=\"cat photo\""));
|
||||||
|
assert!(out.contains("class=\"hi\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HTML escaping =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_special_chars_in_text_are_escaped() {
|
||||||
|
let out = render("a < b > c & d \"e\"\n");
|
||||||
|
assert!(out.contains("a < b > c & d "e""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Custom link resolver =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_link_resolver_overrides_href() {
|
||||||
|
let doc = VimwikiSyntax::new().parse("[[Page]]\n");
|
||||||
|
let renderer = HtmlRenderer::new()
|
||||||
|
.with_link_resolver(|t: &LinkTarget| format!("/wiki/{}", t.path.as_deref().unwrap_or("")));
|
||||||
|
let out = renderer.render_to_string(&doc).unwrap();
|
||||||
|
assert!(out.contains("<a href=\"/wiki/Page\">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Template =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_substitutes_content_and_title() {
|
||||||
|
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
|
||||||
|
let renderer = HtmlRenderer::new().with_template(
|
||||||
|
"<!doctype html><html><head><title>{{title}}</title></head>\
|
||||||
|
<body>{{content}}</body></html>",
|
||||||
|
);
|
||||||
|
let out = renderer.render_to_string(&doc).unwrap();
|
||||||
|
assert!(out.contains("<title>My Page</title>"));
|
||||||
|
assert!(out.contains("<body><h1>Hello</h1>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_with_missing_title_substitutes_empty_string() {
|
||||||
|
let doc = VimwikiSyntax::new().parse("= Heading =\n");
|
||||||
|
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
||||||
|
let out = renderer.render_to_string(&doc).unwrap();
|
||||||
|
assert!(out.contains("<title></title>"));
|
||||||
|
assert!(out.contains("<h1>Heading</h1>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== End-to-end smoke =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_document_round_trip() {
|
||||||
|
let src = "\
|
||||||
|
%title Smoke
|
||||||
|
= Heading =
|
||||||
|
|
||||||
|
Paragraph with *bold* and _italic_ and `code`.
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
> quoted
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
| a | b |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 2 |
|
||||||
|
|
||||||
|
{{{rust
|
||||||
|
fn x() {}
|
||||||
|
}}}
|
||||||
|
";
|
||||||
|
let doc = VimwikiSyntax::new().parse(src);
|
||||||
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||||
|
// A few sanity checks; the full output is exercised piece-by-piece above.
|
||||||
|
for needle in [
|
||||||
|
"<h1>Heading</h1>",
|
||||||
|
"<strong>bold</strong>",
|
||||||
|
"<em>italic</em>",
|
||||||
|
"<code>code</code>",
|
||||||
|
"<ul>",
|
||||||
|
"<blockquote>",
|
||||||
|
"<hr>",
|
||||||
|
"<table>",
|
||||||
|
"<pre><code class=\"language-rust\">",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
out.contains(needle),
|
||||||
|
"expected {needle:?} in output:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user