f00b0780b8
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>
293 lines
7.6 KiB
Rust
293 lines
7.6 KiB
Rust
//! 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}"
|
|
);
|
|
}
|
|
}
|