2026-06-24 00:01:59 +00:00
|
|
|
//! End-to-end tests for the HTML renderer.
|
|
|
|
|
//!
|
|
|
|
|
//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`.
|
|
|
|
|
|
|
|
|
|
use nuwiki_core::ast::{
|
|
|
|
|
BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode,
|
|
|
|
|
TextNode,
|
|
|
|
|
};
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn span_cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
|
|
|
|
|
let s = Span::default();
|
|
|
|
|
TableCellNode {
|
|
|
|
|
span: s,
|
|
|
|
|
children: vec![InlineNode::Text(TextNode {
|
|
|
|
|
span: s,
|
|
|
|
|
content: text.into(),
|
|
|
|
|
})],
|
|
|
|
|
col_span,
|
|
|
|
|
row_span,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn span_table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
|
|
|
|
|
let span = Span::default();
|
|
|
|
|
let rows: Vec<TableRowNode> = rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, cells)| TableRowNode {
|
|
|
|
|
span,
|
|
|
|
|
cells,
|
|
|
|
|
is_header: has_header && i == 0,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
DocumentNode {
|
|
|
|
|
span,
|
|
|
|
|
metadata: Default::default(),
|
|
|
|
|
children: vec![BlockNode::Table(TableNode {
|
|
|
|
|
span,
|
|
|
|
|
rows,
|
|
|
|
|
has_header,
|
|
|
|
|
alignments: Vec::new(),
|
|
|
|
|
})],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 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"));
|
2026-06-24 00:26:59 +00:00
|
|
|
// vimwiki structure: <div id="hier"><h{l} id="flat" class="header">
|
|
|
|
|
// <a href="#hier">…</a></h{l}></div>. A lone heading has hier == flat.
|
|
|
|
|
assert!(out.starts_with(&format!(
|
|
|
|
|
"<div id=\"Title\"><h{level} id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h{level}></div>"
|
|
|
|
|
)), "got: {out}");
|
2026-06-24 00:01:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn centered_heading_gets_centered_class() {
|
|
|
|
|
let out = render(" = T =\n");
|
|
|
|
|
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 09:15:09 +00:00
|
|
|
#[test]
|
|
|
|
|
fn spaceless_heading_renders_as_heading() {
|
|
|
|
|
// `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a
|
|
|
|
|
// literal `<p>==Heading==</p>`.
|
|
|
|
|
let out = render("==Plain==\n");
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains(
|
|
|
|
|
"<div id=\"Plain\"><h2 id=\"Plain\" class=\"header\">\
|
|
|
|
|
<a href=\"#Plain\">Plain</a></h2></div>"
|
|
|
|
|
),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
// The spaceless + keyword combo from the report: a header, not a badge.
|
|
|
|
|
let out2 = render("==TODO==\n");
|
|
|
|
|
assert!(
|
|
|
|
|
out2.contains("<h2 id=\"TODO\" class=\"header\">"),
|
|
|
|
|
"got: {out2}"
|
|
|
|
|
);
|
|
|
|
|
assert!(!out2.contains("<p>"), "not a paragraph: {out2}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:26:59 +00:00
|
|
|
#[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 `<span class="todo">`
|
|
|
|
|
// badge. Regression for the exported header losing its header styling + id.
|
|
|
|
|
let out = render("== TODO ==\n");
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains(
|
|
|
|
|
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
|
|
|
|
|
<a href=\"#TODO\">TODO</a></h2></div>"
|
|
|
|
|
),
|
|
|
|
|
"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("<a href=\"page.html\">Task [B-1]</a>"),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
// Two bracketed links on one line both resolve.
|
|
|
|
|
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
|
|
|
|
|
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
|
|
|
|
|
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "got: {out2}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:01:59 +00:00
|
|
|
#[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]
|
2026-06-24 00:26:59 +00:00
|
|
|
fn preformatted_block_emits_pre_with_raw_fence_attrs() {
|
|
|
|
|
// vimwiki drops the verbatim fence text into the <pre> tag (`{{{rust` →
|
|
|
|
|
// `<pre rust>`), with no inner <code> wrapper.
|
2026-06-24 00:01:59 +00:00
|
|
|
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
2026-06-24 00:26:59 +00:00
|
|
|
assert!(out.contains("<pre rust>"), "got: {out}");
|
2026-06-24 00:01:59 +00:00
|
|
|
assert!(out.contains("fn main() {}"));
|
2026-06-24 00:26:59 +00:00
|
|
|
assert!(out.contains("</pre>"));
|
|
|
|
|
assert!(!out.contains("<code"), "no inner code element: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn preformatted_block_without_attrs_is_bare_pre() {
|
|
|
|
|
let out = render("{{{\nplain\n}}}\n");
|
|
|
|
|
assert!(out.contains("<pre>plain"), "got: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn preformatted_block_keeps_full_attr_string() {
|
|
|
|
|
let out = render("{{{class=\"brush: python\"\nx\n}}}\n");
|
|
|
|
|
assert!(out.contains("<pre class=\"brush: python\">"), "got: {out}");
|
2026-06-24 00:01:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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>"));
|
|
|
|
|
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
|
|
|
|
|
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
|
|
|
|
|
assert!(out.contains("<li>not done</li>"), "{out}");
|
|
|
|
|
assert!(!out.contains("<input"), "no input element: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn checkbox_states_use_vimwiki_done_classes() {
|
|
|
|
|
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
|
|
|
|
|
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
|
|
|
|
|
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
|
|
|
|
|
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
|
|
|
|
|
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
|
|
|
|
|
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
|
|
|
|
|
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
|
|
|
|
|
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
|
|
|
|
|
assert!(!out.contains("<input"), "no input elements: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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>"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 12:30:15 +00:00
|
|
|
#[test]
|
|
|
|
|
fn table_cell_with_wikilink_pipe_exports_one_cell() {
|
|
|
|
|
// Regression: the `|` in `[[url|title]]` must stay inside the link, so the
|
|
|
|
|
// row keeps two columns and the link renders as a single anchor. Before the
|
|
|
|
|
// fix the cell split into `[[https://example.com` + `My Site]]`, adding a
|
|
|
|
|
// phantom <td> and breaking the link.
|
|
|
|
|
let out = render("| Name | Link |\n|---|---|\n| foo | [[https://example.com|My Site]] |\n");
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("<td> foo </td><td> <a href=\"https://example.com\">My Site</a> </td>"),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:01:59 +00:00
|
|
|
#[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() {
|
|
|
|
|
// One class per keyword so a stylesheet can colour groups differently.
|
|
|
|
|
// TODO keeps the vimwiki `todo` class.
|
|
|
|
|
let cases = [
|
|
|
|
|
("TODO x\n", "<span class=\"todo\">TODO</span>"),
|
|
|
|
|
("DONE x\n", "<span class=\"done\">DONE</span>"),
|
|
|
|
|
("STARTED x\n", "<span class=\"started\">STARTED</span>"),
|
|
|
|
|
("FIXME x\n", "<span class=\"fixme\">FIXME</span>"),
|
|
|
|
|
("FIXED x\n", "<span class=\"fixed\">FIXED</span>"),
|
|
|
|
|
("XXX x\n", "<span class=\"xxx\">XXX</span>"),
|
|
|
|
|
("STOPPED x\n", "<span class=\"stopped\">STOPPED</span>"),
|
|
|
|
|
];
|
|
|
|
|
for (src, expected) in cases {
|
|
|
|
|
let out = render(src);
|
|
|
|
|
assert!(out.contains(expected), "src {src:?} -> {out}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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 heading_id_matches_anchor_link_href() {
|
|
|
|
|
// The heading carries an `id` equal to its plain text, so a `#anchor`
|
|
|
|
|
// link (TOC or cross-reference) actually lands on it. Regression guard
|
|
|
|
|
// for "exported HTML anchor links don't work".
|
|
|
|
|
let out = render("= My Section =\n\n[[#My Section]]\n");
|
|
|
|
|
assert!(
|
2026-06-24 00:26:59 +00:00
|
|
|
out.contains(
|
|
|
|
|
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
|
|
|
|
|
<a href=\"#My Section\">My Section</a></h1></div>"
|
|
|
|
|
),
|
2026-06-24 00:01:59 +00:00
|
|
|
"heading id: {out}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("<a href=\"#My Section\">"),
|
|
|
|
|
"anchor href: {out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn toc_header_heading_wrapped_in_div_toc() {
|
|
|
|
|
// The TOC section heading gets a `<div class="toc">` wrapper (vimwiki
|
|
|
|
|
// scheme) so `.toc` stylesheet rules apply. The class is on the div, not
|
|
|
|
|
// the heading.
|
|
|
|
|
let doc = VimwikiSyntax::new().parse("== Contents ==\n");
|
|
|
|
|
let out = HtmlRenderer::new()
|
|
|
|
|
.with_toc_header("Contents")
|
|
|
|
|
.render_to_string(&doc)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn non_toc_heading_not_wrapped() {
|
|
|
|
|
let doc = VimwikiSyntax::new().parse("== Intro ==\n");
|
|
|
|
|
let out = HtmlRenderer::new()
|
|
|
|
|
.with_toc_header("Contents")
|
|
|
|
|
.render_to_string(&doc)
|
|
|
|
|
.unwrap();
|
2026-06-24 00:26:59 +00:00
|
|
|
assert!(
|
|
|
|
|
out.contains(
|
|
|
|
|
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
|
|
|
|
|
<a href=\"#Intro\">Intro</a></h2></div>"
|
|
|
|
|
),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
2026-06-24 00:01:59 +00:00
|
|
|
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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>"));
|
2026-06-24 00:26:59 +00:00
|
|
|
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
|
2026-06-24 00:01:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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>"));
|
2026-06-24 00:26:59 +00:00
|
|
|
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
|
2026-06-24 00:01:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn template_substitutes_vimwiki_percent_placeholders() {
|
|
|
|
|
// Stock vimwiki templates use `%title%` / `%content%` / `%root_path%`
|
|
|
|
|
// rather than nuwiki's `{{…}}`. Both must resolve.
|
|
|
|
|
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
|
|
|
|
|
let renderer = HtmlRenderer::new()
|
|
|
|
|
.with_template(
|
|
|
|
|
"<title>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
|
|
|
|
|
)
|
|
|
|
|
.with_var("root_path", "../");
|
|
|
|
|
let out = renderer.render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
|
|
|
|
assert!(
|
2026-06-24 00:26:59 +00:00
|
|
|
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
|
2026-06-24 00:01:59 +00:00
|
|
|
"content: {out}"
|
|
|
|
|
);
|
|
|
|
|
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
|
|
|
|
assert!(!out.contains('%'), "no placeholder left: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Colour spans =====
|
|
|
|
|
|
|
|
|
|
// `ColorNode` is an extension point the vimwiki parser never emits, so build
|
|
|
|
|
// a document around one by hand and render it directly.
|
|
|
|
|
fn doc_with_color(color: &str) -> DocumentNode {
|
|
|
|
|
use nuwiki_core::ast::{ColorNode, ParagraphNode};
|
|
|
|
|
let color_node = InlineNode::Color(ColorNode {
|
|
|
|
|
span: Span::default(),
|
|
|
|
|
color: color.to_string(),
|
|
|
|
|
children: vec![InlineNode::Text(TextNode {
|
|
|
|
|
span: Span::default(),
|
|
|
|
|
content: "hi".to_string(),
|
|
|
|
|
})],
|
|
|
|
|
});
|
|
|
|
|
DocumentNode {
|
|
|
|
|
children: vec![BlockNode::Paragraph(ParagraphNode {
|
|
|
|
|
span: Span::default(),
|
|
|
|
|
children: vec![color_node],
|
|
|
|
|
})],
|
|
|
|
|
..DocumentNode::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn color_dic_name_uses_inline_style_via_default_template() {
|
|
|
|
|
let doc = doc_with_color("red");
|
|
|
|
|
let renderer =
|
|
|
|
|
HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into());
|
|
|
|
|
let out = renderer.render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("<span style=\"color:crimson\">hi</span>"),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn color_tag_template_override_is_honored() {
|
|
|
|
|
let doc = doc_with_color("red");
|
|
|
|
|
let renderer = HtmlRenderer::new()
|
|
|
|
|
.with_colors([("red".to_string(), "crimson".to_string())].into())
|
|
|
|
|
.with_color_template("<em data-style=\"__STYLE__\">__CONTENT__</em>");
|
|
|
|
|
let out = renderer.render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("<em data-style=\"color:crimson\">hi</em>"),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_color_name_falls_back_to_class() {
|
|
|
|
|
let doc = doc_with_color("plaid");
|
|
|
|
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("<span class=\"color-plaid\">hi</span>"),
|
|
|
|
|
"got: {out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 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 [
|
2026-06-24 00:26:59 +00:00
|
|
|
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
|
2026-06-24 00:01:59 +00:00
|
|
|
"<strong>bold</strong>",
|
|
|
|
|
"<em>italic</em>",
|
|
|
|
|
"<code>code</code>",
|
|
|
|
|
"<ul>",
|
|
|
|
|
"<blockquote>",
|
|
|
|
|
"<hr>",
|
|
|
|
|
"<table>",
|
2026-06-24 00:26:59 +00:00
|
|
|
"<pre rust>",
|
2026-06-24 00:01:59 +00:00
|
|
|
] {
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains(needle),
|
|
|
|
|
"expected {needle:?} in output:\n{out}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Table colspan / rowspan =====
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn colspan_marker_folds_into_lead_cell() {
|
|
|
|
|
// | a | b | c |
|
|
|
|
|
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
|
|
|
|
|
let doc = span_table(
|
|
|
|
|
vec![
|
|
|
|
|
vec![
|
|
|
|
|
span_cell("a", false, false),
|
|
|
|
|
span_cell("b", false, false),
|
|
|
|
|
span_cell("c", false, false),
|
|
|
|
|
],
|
|
|
|
|
vec![
|
|
|
|
|
span_cell("d", false, false),
|
|
|
|
|
span_cell(">", true, false),
|
|
|
|
|
span_cell("e", false, false),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
|
|
|
|
|
assert!(!out.contains(r#"class="col-span""#));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rowspan_marker_folds_into_lead_cell_above() {
|
|
|
|
|
let doc = span_table(
|
|
|
|
|
vec![
|
|
|
|
|
vec![span_cell("a", false, false), span_cell("b", false, false)],
|
|
|
|
|
vec![span_cell("c", false, false), span_cell("\\/", false, true)],
|
|
|
|
|
],
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn no_spans_emits_no_table_attrs() {
|
|
|
|
|
let doc = span_table(
|
|
|
|
|
vec![
|
|
|
|
|
vec![span_cell("a", false, false), span_cell("b", false, false)],
|
|
|
|
|
vec![span_cell("c", false, false), span_cell("d", false, false)],
|
|
|
|
|
],
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
|
|
|
|
assert!(!out.contains("colspan="));
|
|
|
|
|
assert!(!out.contains("rowspan="));
|
|
|
|
|
}
|