sync: bring Rust crates in line with nuwiki main (v0.4.0+release bumps)
CI / cargo clippy (push) Successful in 40s
CI / cargo fmt --check (push) Successful in 53s
CI / cargo test (push) Successful in 55s

This commit is contained in:
gffranco
2026-06-24 00:35:51 +00:00
parent 5a102013bb
commit 93312d4a23
10 changed files with 6800 additions and 16 deletions
+196
View File
@@ -0,0 +1,196 @@
//! Block-level AST nodes and their supporting types.
use super::inline::InlineNode;
use super::span::Span;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockNode {
Heading(HeadingNode),
Paragraph(ParagraphNode),
HorizontalRule(HorizontalRuleNode),
Blockquote(BlockquoteNode),
Preformatted(PreformattedNode),
MathBlock(MathBlockNode),
List(ListNode),
DefinitionList(DefinitionListNode),
Table(TableNode),
Comment(CommentNode),
/// Vimwiki tag line — `:tag1:tag2:`. The placement
/// rule (file / heading / standalone) is captured in `TagScope` so
/// LSP handlers and renderers can treat the three differently.
Tag(TagNode),
Error(ErrorNode),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadingNode {
pub span: Span,
/// 1..=6 — invariant enforced by the parser, not the type.
pub level: u8,
pub children: Vec<InlineNode>,
pub centered: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParagraphNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HorizontalRuleNode {
pub span: Span,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockquoteNode {
pub span: Span,
pub children: Vec<BlockNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreformattedNode {
pub span: Span,
pub content: String,
pub language: Option<String>,
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
/// raw attributes on the `<pre>` tag, so we preserve it for byte-parity.
pub attrs: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MathBlockNode {
pub span: Span,
pub content: String,
pub environment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListNode {
pub span: Span,
pub ordered: bool,
pub symbol: ListSymbol,
pub items: Vec<ListItemNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListItemNode {
pub span: Span,
pub symbol: ListSymbol,
pub level: usize,
pub checkbox: Option<CheckboxState>,
pub children: Vec<InlineNode>,
pub sublist: Option<ListNode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ListSymbol {
Dash,
Star,
Hash,
Numeric,
NumericParen,
AlphaParen,
AlphaUpperParen,
RomanParen,
RomanUpperParen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CheckboxState {
Empty,
Quarter,
Half,
ThreeQuarters,
Done,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefinitionListNode {
pub span: Span,
pub items: Vec<DefinitionItemNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefinitionItemNode {
pub span: Span,
pub term: Option<Vec<InlineNode>>,
pub definitions: Vec<Vec<InlineNode>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableAlign {
Default,
Left,
Right,
Center,
}
impl Default for TableAlign {
fn default() -> Self {
Self::Default
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableNode {
pub span: Span,
pub rows: Vec<TableRowNode>,
pub has_header: bool,
/// One entry per column, taken from a Markdown-style header
/// separator row (`|:--|--:|:--:|`). Empty when no alignment was
/// specified; cells past the end inherit `Default`.
pub alignments: Vec<TableAlign>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableRowNode {
pub span: Span,
pub cells: Vec<TableCellNode>,
pub is_header: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableCellNode {
pub span: Span,
pub children: Vec<InlineNode>,
pub col_span: bool,
pub row_span: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentNode {
pub span: Span,
pub content: String,
}
/// Where a `TagNode` falls on the page, matching vimwiki's placement rules.
///
/// - `File` — line 0 or 1 of the document; tags the entire page.
/// - `Heading(i)` — within two lines below the i-th `HeadingNode` in the
/// document's `children`; tags that heading.
/// - `Standalone` — anywhere else; the tag is its own anchor on the source
/// line.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TagScope {
File,
Heading(usize),
Standalone,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagNode {
pub span: Span,
pub tags: Vec<String>,
pub scope: TagScope,
}
/// Resilient parse-failure node — emitted instead of aborting the document.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorNode {
pub span: Span,
pub raw: String,
pub message: String,
}
+591
View File
@@ -0,0 +1,591 @@
//! HTML export — pure helpers shared by the `nuwiki.export.*`
//! command handlers. Side-effecting work (reading the template from
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
//! since the dispatcher is the one with a `&Backend` and async context.
//!
//! Design split:
//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path.
//! - [`template_for`] — locate the right template file on disk.
//! - [`fallback_template`] — minimal stand-in when nothing is found.
//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`).
//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable.
//! - [`compute_root_path`] — relative path from a page's output to
//! `html_path` for stylesheet hrefs in nested pages.
//! - [`is_excluded`] — glob match against `exclude_files`.
//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the
//! above wired up; returns the final HTML string.
use std::collections::HashMap;
use std::path::PathBuf;
use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::render::{HtmlRenderer, Renderer};
use crate::config::HtmlConfig;
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
/// output path under `html_path`. Honours
/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs
/// — when enabled, only the final path segment is slugified (so the
/// `diary/` subdir survives).
pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf {
let segments: Vec<String> = name
.split('/')
.map(|s| {
if cfg.html_filename_parameterization {
slugify(s)
} else {
s.to_string()
}
})
.collect();
let mut p = cfg.html_path.clone();
for (i, seg) in segments.iter().enumerate() {
if i + 1 == segments.len() {
p.push(format!("{seg}.html"));
} else {
p.push(seg);
}
}
p
}
/// Locate the template body for a document. Resolution order:
/// 1. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
/// 2. `<template_path>/<template_default>.<template_ext>`.
/// 3. [`fallback_template`].
///
/// The on-disk read is performed by the caller (`commands.rs`) via the
/// [`TemplateSource`] returned here. Keeps this fn pure for tests.
pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource {
let primary = doc.metadata.template.as_deref().map(|name| {
cfg.template_path
.join(format!("{name}{}", cfg.template_ext))
});
let fallback = cfg
.template_path
.join(format!("{}{}", cfg.template_default, cfg.template_ext));
TemplateSource { primary, fallback }
}
/// Two-tier lookup result. Caller tries `primary` first, then `fallback`,
/// then falls back to [`fallback_template`] when neither file exists.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateSource {
pub primary: Option<PathBuf>,
pub fallback: PathBuf,
}
/// Minimal valid HTML page used when no template file is found on disk.
/// Includes the same `{{title}}` / `{{content}}` placeholders as a real
/// template so the renderer's substitution still works.
pub fn fallback_template(css_name: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{{{title}}}}</title>
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
</head>
<body>
{{{{content}}}}
</body>
</html>
"#
)
}
/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month),
/// `%d` (2-digit day), `%%` (literal percent). Anything else passes
/// through. The diary use case never produces time-of-day output, so
/// `%H`/`%M`/`%S` are intentionally omitted.
pub fn format_date(date: &DiaryDate, fmt: &str) -> String {
let mut out = String::with_capacity(fmt.len() + 4);
let bytes = fmt.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 1 < bytes.len() {
match bytes[i + 1] {
b'Y' => out.push_str(&format!("{:04}", date.year)),
b'm' => out.push_str(&format!("{:02}", date.month)),
b'd' => out.push_str(&format!("{:02}", date.day)),
b'%' => out.push('%'),
other => {
out.push('%');
out.push(other as char);
}
}
i += 2;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Render the page's headings as a nested `<ul>` for the `{{toc}}`
/// template variable. Returns an empty string when the document has
/// no headings.
pub fn build_toc_html(doc: &DocumentNode) -> String {
let headings = collect_headings(doc);
if headings.is_empty() {
return String::new();
}
let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
let mut out = String::from("<ul class=\"toc\">");
let mut depth = 0usize;
for h in &headings {
let target = h.level.saturating_sub(min_level) as usize;
while depth < target {
out.push_str("<ul>");
depth += 1;
}
while depth > target {
out.push_str("</ul>");
depth -= 1;
}
// Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must
// match the `id` the renderer puts on the heading element.
let anchor = escape_html(&h.title);
out.push_str(&format!("<li><a href=\"#{anchor}\">{anchor}</a></li>"));
}
while depth > 0 {
out.push_str("</ul>");
depth -= 1;
}
out.push_str("</ul>");
out
}
/// Compute the relative path back to `html_path` from the directory
/// holding the output file for `name`. Used as the `{{root_path}}`
/// prefix so a nested page's `<link>`/`<script>` hrefs still find
/// the shared CSS at the export root.
pub fn compute_root_path(name: &str) -> String {
let depth = name.matches('/').count();
if depth == 0 {
String::new()
} else {
let mut s = String::with_capacity(depth * 3);
for _ in 0..depth {
s.push_str("../");
}
s
}
}
/// True if `name` matches any of the `exclude_files` glob patterns.
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
/// segments including `/`), `?` (one char). Anything else is literal.
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
patterns.iter().any(|p| glob_match(p, name))
}
/// Pure HTML render pass. The caller has already loaded the template
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
/// plus arbitrary extras passed via `extra_vars`.
///
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
/// When set, it is stripped from wiki/interwiki link targets so a link
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
/// matching how the LSP resolves the same link for in-editor navigation.
// Every parameter is a distinct, cohesive render input; bundling them into a
// struct would add an abstraction without simplifying the single caller.
#[allow(clippy::too_many_arguments)]
pub fn render_page_html(
doc: &DocumentNode,
template: Option<String>,
name: &str,
today: DiaryDate,
cfg: &HtmlConfig,
wiki_extension: Option<&str>,
extra_vars: HashMap<String, String>,
) -> std::io::Result<String> {
let date_str = match doc.metadata.date.as_deref() {
Some(s) => match DiaryDate::parse(s) {
Some(d) => format_date(&d, &cfg.template_date_format),
None => s.to_string(),
},
None => format_date(&today, &cfg.template_date_format),
};
let root_path = compute_root_path(name);
let toc = build_toc_html(doc);
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("date".into(), date_str);
vars.insert("root_path".into(), root_path);
vars.insert("toc".into(), toc);
vars.insert("css".into(), cfg.css_name.clone());
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
// templates resolve. nuwiki's own templates use `{{css}}`.
vars.insert("wiki_css".into(), cfg.css_name.clone());
for (k, v) in extra_vars {
vars.insert(k, v);
}
let mut r = HtmlRenderer::new();
{
// Same-page anchor links (`[[#Section]]`) resolve to the *current*
// page's html file plus the fragment (`index.html#Section`), matching
// vimwiki — rather than a bare `#Section`.
let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
// Strip the wiki extension from page links before the default resolver
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
// file:/local: paths keep their literal extension.
let ext = wiki_extension
.filter(|e| !e.trim_start_matches('.').is_empty())
.map(|e| e.to_string());
r = r.with_link_resolver(move |target| {
if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor {
Some(a) => format!("{current_html}#{a}"),
None => current_html.clone(),
};
}
let mut t = target.clone();
if let Some(ext) = &ext {
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
if let Some(p) = t.path.take() {
t.path =
Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
}
}
}
nuwiki_core::render::html::default_link_resolver(&t)
});
}
if let Some(t) = template {
r = r.with_template(t);
}
r = r.with_vars(vars);
if !cfg.color_dic.is_empty() {
r = r.with_colors(cfg.color_dic.clone());
}
if !cfg.color_tag_template.is_empty() {
r = r.with_color_template(cfg.color_tag_template.clone());
}
if cfg.html_header_numbering > 0 {
r = r.with_header_numbering(
cfg.html_header_numbering,
cfg.html_header_numbering_sym.clone(),
);
}
r = r.with_toc_header(&cfg.toc_header);
if !cfg.valid_html_tags.is_empty() {
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
}
r = r.with_emoji(cfg.emoji_enable);
r = r.with_newline_handling(cfg.text_ignore_newline, cfg.list_ignore_newline);
r.render_to_string(doc)
}
// ===== Internals =====
fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_dash = false;
for ch in s.chars() {
if ch.is_alphanumeric() {
for c in ch.to_lowercase() {
out.push(c);
}
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
"untitled".into()
} else {
out
}
}
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
let mut out = Vec::new();
for b in &doc.children {
if let BlockNode::Heading(h) = b {
out.push(HeadingProj {
level: h.level,
title: nuwiki_core::ast::inline_text(&h.children),
});
}
}
out
}
struct HeadingProj {
level: u8,
title: String,
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(ch),
}
}
out
}
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
/// literal characters. No character classes — those aren't needed for
/// "exclude foo/*.tmp" style patterns.
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
glob_impl(pattern.as_bytes(), text.as_bytes())
}
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
let mut pi = 0;
let mut ti = 0;
let mut star: Option<(usize, usize)> = None;
let mut globstar = false;
while ti < t.len() {
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
globstar = true;
star = Some((pi, ti));
pi += 2;
// Allow optional `/` after globstar.
if pi < p.len() && p[pi] == b'/' {
pi += 1;
}
continue;
} else if pi < p.len() && p[pi] == b'*' {
star = Some((pi, ti));
pi += 1;
globstar = false;
continue;
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
continue;
} else if let Some((s_pi, s_ti)) = star {
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
// single-star can't cross a `/`
return false;
}
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
ti = s_ti + 1;
star = Some((s_pi, ti));
continue;
} else {
return false;
}
}
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
pi += 1;
}
pi == p.len()
}
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
/// renderer references via `{{root_path}}{{css}}`.
pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
cfg.html_path.join(&cfg.css_name)
}
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
/// licensed) so exports look identical to upstream out of the box and the
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
pub const DEFAULT_CSS: &str = r#"body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
margin: 2em 4em 2em 4em;
font-size: 120%;
line-height: 130%;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
line-height:100%;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
h1 {font-size: 2em; color: #000000;}
h2 {font-size: 1.8em; color: #404040;}
h3 {font-size: 1.6em; color: #707070;}
h4 {font-size: 1.4em; color: #909090;}
h5 {font-size: 1.2em; color: #989898;}
h6 {font-size: 1em; color: #9c9c9c;}
p, pre, blockquote, table, ul, ol, dl {
margin-top: 1em;
margin-bottom: 1em;
}
ul ul, ul ol, ol ol, ol ul {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
li { margin: 0.3em auto; }
ul {
margin-left: 2em;
padding-left: 0;
}
dt { font-weight: bold; }
img { border: none; }
pre {
border-left: 5px solid #dcdcdc;
background-color: #f5f5f5;
padding-left: 1em;
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
font-size: 0.8em;
border-radius: 6px;
}
p > a {
color: white;
text-decoration: none;
font-size: 0.7em;
padding: 3px 6px;
border-radius: 3px;
background-color: #1e90ff;
text-transform: uppercase;
font-weight: bold;
}
p > a:hover {
color: #dcdcdc;
background-color: #484848;
}
li > a {
color: #1e90ff;
font-weight: bold;
text-decoration: none;
}
li > a:hover { color: #ff4500; }
blockquote {
color: #686868;
font-size: 0.8em;
line-height: 120%;
padding: 0.8em;
border-left: 5px solid #dcdcdc;
}
th, td {
border: 1px solid #ccc;
padding: 0.3em;
}
th { background-color: #f0f0f0; }
hr {
border: none;
border-top: 1px solid #ccc;
width: 100%;
}
del {
text-decoration: line-through;
color: #777777;
}
.toc li { list-style-type: none; }
.todo {
font-weight: bold;
background-color: #ff4500 ;
color: white;
font-size: 0.8em;
padding: 3px 6px;
border-radius: 3px;
}
.justleft { text-align: left; }
.justright { text-align: right; }
.justcenter { text-align: center; }
.center {
margin-left: auto;
margin-right: auto;
}
.tag {
background-color: #eeeeee;
font-family: monospace;
padding: 2px;
}
.header a {
text-decoration: none;
color: inherit;
}
/* classes for items of todo lists */
.rejected {
/* list-style: none; */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: 0 .2em;
padding-left: 1.5em;
}
.done0 {
/* list-style: none; */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 0 .2em;
padding-left: 1.5em;
}
.done1 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done2 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done3 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done4 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
code {
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
-moz-background-clip: padding;
-webkit-background-clip: padding-box;
background-clip: padding-box;
padding: 0px 3px;
display: inline-block;
color: #52595d;
border: 1px solid #ccc;
background-color: #f9f9f9;
}
"#;
+1232
View File
File diff suppressed because it is too large Load Diff
+834
View File
@@ -0,0 +1,834 @@
//! HTML export commands.
//!
//! Drives the pure `export::*` helpers directly. The side-effecting
//! `export_ops::write_page` / `write_rss` paths get exercised via a
//! per-test temp dir so we know writes land where they should and
//! the rendered HTML contains the substituted vars.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::{HtmlConfig, WikiConfig};
use nuwiki_lsp::export;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn cfg(root: &str) -> WikiConfig {
WikiConfig::from_root(PathBuf::from(root))
}
fn temp_root(suffix: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("nuwiki-p17-{suffix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
// ===== HtmlConfig defaults =====
#[test]
fn html_config_defaults_match_vimwiki() {
let c = HtmlConfig::default();
assert_eq!(c.template_default, "default");
assert_eq!(c.template_ext, ".tpl");
assert_eq!(c.template_date_format, "%Y-%m-%d");
assert_eq!(c.css_name, "style.css");
assert!(!c.auto_export);
assert!(!c.html_filename_parameterization);
assert!(c.exclude_files.is_empty());
assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off
assert!(c.html_header_numbering_sym.is_empty());
}
#[test]
fn html_config_from_root_uses_underscore_dirs() {
let h = HtmlConfig::from_root(Path::new("/tmp/wiki"));
assert_eq!(h.html_path, PathBuf::from("/tmp/wiki/_html"));
assert_eq!(h.template_path, PathBuf::from("/tmp/wiki/_templates"));
}
#[test]
fn wiki_config_picks_up_html_paths() {
let c = cfg("/tmp/x");
assert_eq!(c.html.html_path, PathBuf::from("/tmp/x/_html"));
}
#[test]
fn config_from_json_honours_explicit_html_keys() {
use nuwiki_lsp::config::config_from_json;
let c = config_from_json(serde_json::json!({
"wikis": [{
"root": "/tmp/y",
"html_path": "/tmp/y/site",
"template_default": "post",
"template_ext": ".tmpl",
"auto_export": true,
"html_filename_parameterization": true,
"exclude_files": ["_drafts/**"],
}],
}));
let h = &c.wikis[0].html;
assert_eq!(h.html_path, PathBuf::from("/tmp/y/site"));
assert_eq!(h.template_default, "post");
assert_eq!(h.template_ext, ".tmpl");
assert!(h.auto_export);
assert!(h.html_filename_parameterization);
assert_eq!(h.exclude_files, vec!["_drafts/**"]);
}
// ===== format_date =====
#[test]
fn format_date_strftime_subset() {
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
assert_eq!(export::format_date(&d, "%Y-%m-%d"), "2026-05-11");
assert_eq!(export::format_date(&d, "%Y"), "2026");
assert_eq!(export::format_date(&d, "%m/%d/%Y"), "05/11/2026");
assert_eq!(export::format_date(&d, "literal text"), "literal text");
assert_eq!(
export::format_date(&d, "100%% done"),
"100% done",
"%% escapes to a literal %"
);
}
#[test]
fn format_date_unknown_specifier_passes_through() {
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
// We support Y/m/d/%; %H is not supported and should pass through verbatim.
assert_eq!(export::format_date(&d, "%H:00"), "%H:00");
}
// ===== compute_root_path =====
#[test]
fn compute_root_path_is_empty_at_top_level() {
assert_eq!(export::compute_root_path("Home"), "");
}
#[test]
fn compute_root_path_adds_one_dotdot_per_subdir() {
assert_eq!(export::compute_root_path("diary/2026-05-11"), "../");
assert_eq!(export::compute_root_path("a/b/c"), "../../");
}
// ===== output_path_for =====
#[test]
fn output_path_for_basic() {
let c = cfg("/tmp/x");
assert_eq!(
export::output_path_for(&c.html, "Home"),
PathBuf::from("/tmp/x/_html/Home.html")
);
}
#[test]
fn output_path_for_nested_preserves_subdirs() {
let c = cfg("/tmp/x");
assert_eq!(
export::output_path_for(&c.html, "diary/2026-05-11"),
PathBuf::from("/tmp/x/_html/diary/2026-05-11.html")
);
}
#[test]
fn output_path_for_slugifies_only_the_filename_when_enabled() {
let mut c = cfg("/tmp/x");
c.html.html_filename_parameterization = true;
let out = export::output_path_for(&c.html, "Notes/My Page!");
// subdir keeps casing, filename becomes lowercase + url-safe.
assert_eq!(out, PathBuf::from("/tmp/x/_html/notes/my-page.html"));
}
// ===== is_excluded =====
#[test]
fn is_excluded_matches_globstar() {
let patterns = vec!["_drafts/**".into()];
assert!(export::is_excluded(&patterns, "_drafts/foo"));
assert!(export::is_excluded(&patterns, "_drafts/x/y"));
assert!(!export::is_excluded(&patterns, "notes/x"));
}
#[test]
fn is_excluded_matches_simple_glob() {
let patterns = vec!["*.tmp".into()];
assert!(export::is_excluded(&patterns, "foo.tmp"));
assert!(!export::is_excluded(&patterns, "foo.wiki"));
}
#[test]
fn is_excluded_matches_question_mark() {
let patterns = vec!["??".into()];
assert!(export::is_excluded(&patterns, "ab"));
assert!(!export::is_excluded(&patterns, "abc"));
}
#[test]
fn is_excluded_no_match_returns_false() {
let patterns = vec!["foo".into()];
assert!(!export::is_excluded(&patterns, "bar"));
}
// ===== TOC HTML =====
#[test]
fn build_toc_html_produces_nested_lists() {
let ast = parse("= One =\n== Two ==\n=== Three ===\n= Four =\n");
let html = export::build_toc_html(&ast);
assert!(html.contains("ul class=\"toc\""));
assert!(html.contains(">One<"));
assert!(html.contains(">Two<"));
assert!(html.contains(">Three<"));
assert!(html.contains(">Four<"));
// Verify the nesting: Two should be inside an inner <ul> after One.
let one_idx = html.find(">One<").unwrap();
let two_idx = html.find(">Two<").unwrap();
let three_idx = html.find(">Three<").unwrap();
assert!(one_idx < two_idx && two_idx < three_idx);
}
#[test]
fn build_toc_html_empty_for_no_headings() {
let ast = parse("Just a paragraph.\n");
assert!(export::build_toc_html(&ast).is_empty());
}
#[test]
fn build_toc_html_escapes_special_chars_in_title() {
let ast = parse("= A & B <ok> =\n");
let html = export::build_toc_html(&ast);
assert!(html.contains("A &amp; B &lt;ok&gt;"));
}
// ===== template_for =====
#[test]
fn template_for_prefers_metadata_template() {
let c = cfg("/tmp/x");
let ast = parse("%template post\n= Hi =\n");
let src = export::template_for(&c.html, &ast);
assert_eq!(
src.primary,
Some(PathBuf::from("/tmp/x/_templates/post.tpl"))
);
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
}
#[test]
fn template_for_falls_back_when_no_directive() {
let c = cfg("/tmp/x");
let ast = parse("= Hi =\n");
let src = export::template_for(&c.html, &ast);
assert!(src.primary.is_none());
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
}
// ===== render_page_html =====
#[test]
fn render_page_html_passes_through_valid_html_tags() {
// `<sub>`/`<kbd>` are in the default valid_html_tags → verbatim; `<script>`
// is not → escaped.
let ast = parse("H<sub>2</sub>O press <kbd>Ctrl</kbd> <script>x</script>\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("H<sub>2</sub>O"), "sub verbatim: {html}");
assert!(html.contains("<kbd>Ctrl</kbd>"), "kbd verbatim: {html}");
assert!(html.contains("&lt;script&gt;"), "script escaped: {html}");
}
#[test]
fn render_page_html_ignore_newline_controls_soft_breaks() {
// Paragraph + list item, each with a soft (single) newline inside.
let ast = parse("alpha\nbeta\n\n- one\n two\n");
// Default (true/true): soft breaks render as spaces, no <br>.
let c = cfg("/tmp/x");
let def = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(!def.contains("<br"), "default joins with spaces: {def}");
assert!(def.contains("alpha beta"), "para space: {def}");
// false/false: soft breaks become <br /> in both paragraph and list item.
let mut c2 = cfg("/tmp/x");
c2.html.text_ignore_newline = false;
c2.html.list_ignore_newline = false;
let br = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c2.html,
None,
HashMap::new(),
)
.unwrap();
assert!(br.contains("alpha<br />beta"), "para <br>: {br}");
assert!(
br.contains("one<br />two") || br.contains("one<br /> two"),
"list <br>: {br}"
);
}
#[test]
fn render_page_html_substitutes_emoji_when_enabled() {
let ast = parse("ship it :rocket: and :tada:\n");
let c = cfg("/tmp/x"); // emoji_enable defaults true
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("🚀"), "rocket: {html}");
assert!(html.contains("🎉"), "tada: {html}");
assert!(!html.contains(":rocket:"), "shortcode replaced: {html}");
}
#[test]
fn render_page_html_emoji_off_leaves_shortcodes() {
let ast = parse("plain :rocket: here\n");
let mut c = cfg("/tmp/x");
c.html.emoji_enable = false;
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains(":rocket:"), "left as-is: {html}");
}
#[test]
fn render_page_html_substitutes_title_content_and_extras() {
let ast = parse("%title My Page\n= One =\nbody text\n");
let c = cfg("/tmp/x");
let template = "<title>{{title}}</title><body>{{content}}</body><footer>{{date}}</footer>";
let extras = HashMap::new();
let html = export::render_page_html(
&ast,
Some(template.into()),
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
extras,
)
.unwrap();
assert!(html.contains("<title>My Page</title>"));
assert!(html.contains("<footer>2026-05-11</footer>"));
assert!(html.contains("body text"));
}
#[test]
fn render_page_html_uses_metadata_date_when_set() {
let ast = parse("%date 2025-01-02\n= Hi =\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("DATE={{date}}".into()),
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
}
#[test]
fn render_page_html_numbers_headers_when_enabled() {
let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n");
let mut c = cfg("/tmp/x");
c.html.html_header_numbering = 1; // number from h1
c.html.html_header_numbering_sym = ".".into();
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains(
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"
),
"got: {html}"
);
}
#[test]
fn render_page_html_numbering_skips_headers_above_start_level() {
let ast = parse("= Title =\n== Section ==\n=== Sub ===\n");
let mut c = cfg("/tmp/x");
c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered
// empty sym → number followed by a bare space
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains(
"<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"
),
"h1 unnumbered, got: {html}"
);
assert!(
html.contains(
"<div id=\"Title-Section\"><h2 id=\"Section\" class=\"header\"><a href=\"#Title-Section\">1 Section</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Title-Section-Sub\"><h3 id=\"Sub\" class=\"header\"><a href=\"#Title-Section-Sub\">1.1 Sub</a></h3></div>"
),
"got: {html}"
);
}
#[test]
fn render_page_html_same_page_anchor_uses_current_filename() {
// vimwiki parity (#4): `[[#Section]]` resolves to `<page>.html#Section`,
// not a bare `#Section`, where <page> is the file being rendered.
let ast = parse("= Contents =\n\n[[#Contents]]\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
None,
"index",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<a href=\"index.html#Contents\">"),
"got: {html}"
);
}
#[test]
fn render_page_html_no_header_numbering_by_default() {
let ast = parse("= Intro =\n== Sub ==\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains(
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">Intro</a></h1></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"
),
"got: {html}"
);
}
#[test]
fn render_page_html_root_path_for_nested_pages() {
let ast = parse("= D =\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("CSS={{root_path}}{{css}}".into()),
"diary/2026-05-11",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("CSS=../style.css"), "got: {html}");
}
#[test]
fn render_page_html_extra_var_overrides_default() {
let ast = parse("= H =\n");
let c = cfg("/tmp/x");
let mut extras = HashMap::new();
extras.insert("date".into(), "OVERRIDE".into());
let html = export::render_page_html(
&ast,
Some("D={{date}}".into()),
"H",
DiaryDate::today_utc(),
&c.html,
None,
extras,
)
.unwrap();
assert!(html.contains("D=OVERRIDE"));
}
#[test]
fn render_page_html_substitutes_vimwiki_percent_template() {
// A stock vimwiki template (%title%/%content%/%root_path%/%wiki_css%/%date%)
// must render fully — every placeholder resolved, no literal `%…%` left.
let ast = parse("%title My Page\n= One =\nbody text\n");
let c = cfg("/tmp/x");
let template = "<title>%title%</title>\
<link href=\"%root_path%%wiki_css%\">\
<body>%content%</body><footer>%date%</footer>";
let html = export::render_page_html(
&ast,
Some(template.into()),
"diary/2026-05-11",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("<title>My Page</title>"), "title: {html}");
assert!(html.contains("body text"), "content: {html}");
assert!(
html.contains("href=\"../style.css\""),
"root_path+css: {html}"
);
assert!(html.contains("<footer>2026-05-11</footer>"), "date: {html}");
assert!(!html.contains('%'), "placeholder left behind: {html}");
}
#[test]
fn render_page_html_strips_wiki_extension_from_links() {
// A link written with the extension (`[[todo.wiki]]`) must export to
// `todo.html`, not `todo.wiki.html` — matching in-editor navigation.
let ast = parse("[[todo.wiki|Tasks]] and [[posts/index|Posts]]\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"index",
DiaryDate::today_utc(),
&c.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("href=\"todo.html\""), "stripped: {html}");
assert!(!html.contains("todo.wiki.html"), "not double-ext: {html}");
// A link without the extension is unaffected.
assert!(html.contains("href=\"posts/index.html\""), "plain: {html}");
}
// ===== fallback_template + DEFAULT_CSS =====
#[test]
fn fallback_template_references_root_path_and_css() {
let t = export::fallback_template("custom.css");
assert!(t.contains("{{title}}"));
assert!(t.contains("{{content}}"));
assert!(t.contains("{{root_path}}custom.css"));
}
#[test]
fn default_css_is_non_empty() {
assert!(!export::DEFAULT_CSS.is_empty());
assert!(export::DEFAULT_CSS.contains("font-family"));
}
// ===== write_page (disk-touching) =====
#[test]
fn write_page_writes_html_to_disk() {
let root = temp_root("write");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let ast = parse("= Hello =\nworld\n");
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c, false).unwrap();
assert_eq!(outcome.page, "Hello");
assert!(outcome.output_path.exists());
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
assert!(body.contains("Hello"));
}
#[test]
fn write_page_creates_subdirs() {
let root = temp_root("subdir");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let ast = parse("= Daily =\n");
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c, false).unwrap();
assert!(outcome.output_path.exists());
assert!(outcome
.output_path
.to_string_lossy()
.contains("diary/2026-05-11.html"));
}
#[test]
fn write_page_uses_template_file_when_present() {
let root = temp_root("template");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
// Write a template file the renderer should pick up.
std::fs::create_dir_all(&c.html.template_path).unwrap();
std::fs::write(
c.html.template_path.join("default.tpl"),
"<x>{{title}}|{{content}}</x>",
)
.unwrap();
let ast = parse("%title T\n= H =\nbody\n");
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c, false).unwrap();
let body = std::fs::read_to_string(outcome.output_path).unwrap();
assert!(body.starts_with("<x>T|"));
assert!(body.ends_with("</x>"));
}
#[test]
fn ensure_css_writes_default_when_missing() {
let root = temp_root("css");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
assert!(res.created);
assert!(res.path.exists());
}
#[test]
fn ensure_css_is_idempotent_when_present() {
let root = temp_root("css2");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
assert!(!res.created);
}
// ===== RSS =====
#[test]
fn write_rss_emits_valid_xml_with_entries() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.name = "Test Wiki".into();
let entries = vec![
DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
},
DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-10.wiki").unwrap(),
},
];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<title>Test Wiki</title>"));
// Newest first.
let i11 = xml.find("2026-05-11").unwrap();
let i10 = xml.find("2026-05-10").unwrap();
assert!(i11 < i10);
// No base_url → item links keep the file:// URI.
assert!(xml.contains("<link>file:///tmp/diary/2026-05-11.wiki</link>"));
}
#[test]
fn write_rss_uses_base_url_for_public_links() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_base");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.base_url = "https://example.com/wiki/".into();
c.diary_rel_path = "diary".into();
let entries = vec![DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
}];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap();
// Channel link points at the diary index page (upstream fidelity); item
// link is the public per-entry URL.
assert!(
xml.contains("<link>https://example.com/wiki/diary/diary.html</link>"),
"channel link: {xml}"
);
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
// guid is the bare date, isPermaLink=false.
assert!(
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
"guid: {xml}"
);
// atom:link self + a pubDate are present.
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
assert!(xml.contains("<pubDate>"), "pubDate: {xml}");
assert!(!xml.contains("file:///tmp/diary"));
}
#[test]
fn prune_orphan_html_deletes_sourceless_keeps_protected() {
let root = temp_root("prune");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.user_htmls = vec!["keep.html".into()];
let html = &c.html.html_path;
std::fs::create_dir_all(html).unwrap();
// A sourced page (Home.wiki exists) → kept.
std::fs::write(root.join("Home.wiki"), "= Home =\n").unwrap();
std::fs::write(html.join("Home.html"), "<h1>Home</h1>").unwrap();
// An orphan (no source) → pruned.
std::fs::write(html.join("Ghost.html"), "<h1>Ghost</h1>").unwrap();
// A user_htmls-protected orphan → kept.
std::fs::write(html.join("keep.html"), "<p>manual</p>").unwrap();
// The CSS file → never pruned.
std::fs::write(html.join(&c.html.css_name), "body{}").unwrap();
let pruned = nuwiki_lsp::commands::export_ops::prune_orphan_html(&c);
assert_eq!(pruned.len(), 1, "only the orphan: {pruned:?}");
assert!(!html.join("Ghost.html").exists(), "orphan deleted");
assert!(html.join("Home.html").exists(), "sourced kept");
assert!(html.join("keep.html").exists(), "user_htmls kept");
assert!(html.join(&c.html.css_name).exists(), "css kept");
}
#[test]
fn write_rss_honours_rss_name_and_max_items() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_cap");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.rss_name = "feed.xml".into();
c.html.rss_max_items = 2;
let entries: Vec<DiaryEntry> = (1..=5)
.map(|d| DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, d).unwrap(),
uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(),
})
.collect();
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
assert!(path.ends_with("feed.xml"), "rss_name: {path:?}");
let xml = std::fs::read_to_string(&path).unwrap();
// Capped at 2 items (newest first: 05-05, 05-04).
assert_eq!(xml.matches("<item>").count(), 2, "cap: {xml}");
assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04"));
assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}");
}
#[test]
fn write_page_invokes_custom_wiki2html_converter() {
let root = temp_root("custom_w2h");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
// Source file must exist on disk — the converter receives its path.
std::fs::write(root.join("Hello.wiki"), "= Hello =\nworld\n").unwrap();
// A tiny shell "converter": writes a marker into the output path it is told
// to produce. nuwiki appends args positionally after `_` ($0), so
// $1=force, $2=syntax, $3=ext, $4=output_dir, $5=input_file, …
let script = "mkdir -p \"$4\" && printf 'CUSTOM:%s' \"$5\" > \"$4\"/Hello.html";
c.html.custom_wiki2html = format!("sh -c {} _", shell_words_quote(script));
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&parse("= Hello =\n"), "Hello", &c, true)
.unwrap();
assert_eq!(outcome.page, "Hello");
assert!(
outcome.output_path.exists(),
"converter must produce output"
);
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
assert!(
body.starts_with("CUSTOM:"),
"marker from converter, got {body}"
);
}
/// Minimal single-quote shell escaping for the test command above.
fn shell_words_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
// ===== COMMANDS list completeness =====
#[test]
fn commands_list_includes_export_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.export.currentToHtml",
"nuwiki.export.allToHtml",
"nuwiki.export.allToHtmlForce",
"nuwiki.export.browse",
"nuwiki.export.rss",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
+597
View File
@@ -0,0 +1,597 @@
//! 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"));
// 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}");
}
}
#[test]
fn centered_heading_gets_centered_class() {
let out = render(" = T =\n");
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
}
#[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}");
}
#[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_with_raw_fence_attrs() {
// vimwiki drops the verbatim fence text into the <pre> tag (`{{{rust` →
// `<pre rust>`), with no inner <code> wrapper.
let out = render("{{{rust\nfn main() {}\n}}}\n");
assert!(out.contains("<pre rust>"), "got: {out}");
assert!(out.contains("fn main() {}"));
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}");
}
#[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>"));
}
#[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&lt;u32&gt;</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!(
out.contains(
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
<a href=\"#My Section\">My Section</a></h1></div>"
),
"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();
assert!(
out.contains(
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
<a href=\"#Intro\">Intro</a></h2></div>"
),
"got: {out}"
);
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 &lt; b &gt; c &amp; d &quot;e&quot;"));
}
// ===== 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><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
}
#[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("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
}
#[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!(
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
"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 [
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
"<strong>bold</strong>",
"<em>italic</em>",
"<code>code</code>",
"<ul>",
"<blockquote>",
"<hr>",
"<table>",
"<pre rust>",
] {
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="));
}
+205
View File
@@ -0,0 +1,205 @@
//! Inline AST nodes.
//!
//! The link-related variants (`WikiLink`, `ExternalLink`,
//! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`.
use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
use super::span::Span;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Keyword {
Todo,
Done,
Started,
Fixme,
Fixed,
Xxx,
Stopped,
}
impl Keyword {
/// The literal source text of the keyword (e.g. `"TODO"`). Used both for
/// the rendered span and for anchor/id derivation via [`inline_text`].
pub fn label(self) -> &'static str {
match self {
Keyword::Todo => "TODO",
Keyword::Done => "DONE",
Keyword::Started => "STARTED",
Keyword::Fixme => "FIXME",
Keyword::Fixed => "FIXED",
Keyword::Xxx => "XXX",
Keyword::Stopped => "STOPPED",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InlineNode {
Text(TextNode),
Bold(BoldNode),
Italic(ItalicNode),
BoldItalic(BoldItalicNode),
Strikethrough(StrikethroughNode),
Code(CodeNode),
Superscript(SuperscriptNode),
Subscript(SubscriptNode),
MathInline(MathInlineNode),
Keyword(KeywordNode),
Color(ColorNode),
WikiLink(WikiLinkNode),
ExternalLink(ExternalLinkNode),
Transclusion(TransclusionNode),
RawUrl(RawUrlNode),
/// A soft line break joining two content lines within a paragraph or
/// list item. Consumers treat it as whitespace; the HTML renderer emits
/// a space (vimwiki `*_ignore_newline = 1`, the default) or `<br />`
/// (`= 0`).
SoftBreak(SoftBreakNode),
}
impl InlineNode {
/// The source [`Span`] this node covers. Every variant wraps a node with
/// its own `span` field; this is the one place that maps variant → span.
pub fn span(&self) -> Span {
match self {
InlineNode::Text(n) => n.span,
InlineNode::Bold(n) => n.span,
InlineNode::Italic(n) => n.span,
InlineNode::BoldItalic(n) => n.span,
InlineNode::Strikethrough(n) => n.span,
InlineNode::Code(n) => n.span,
InlineNode::Superscript(n) => n.span,
InlineNode::Subscript(n) => n.span,
InlineNode::MathInline(n) => n.span,
InlineNode::Keyword(n) => n.span,
InlineNode::Color(n) => n.span,
InlineNode::WikiLink(n) => n.span,
InlineNode::ExternalLink(n) => n.span,
InlineNode::Transclusion(n) => n.span,
InlineNode::RawUrl(n) => n.span,
InlineNode::SoftBreak(n) => n.span,
}
}
}
/// Concatenate the plain-text content of a run of inlines, descending into
/// formatting containers. Links/external links use their description, falling
/// back to the target path / URL. This is the canonical heading/anchor text —
/// the HTML renderer uses it for heading `id`s and the LSP for anchor matching,
/// so the two never drift.
pub fn inline_text(inlines: &[InlineNode]) -> String {
let mut out = String::new();
push_inline_text(inlines, &mut out);
out.trim().to_string()
}
fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
for n in inlines {
match n {
InlineNode::Text(t) => out.push_str(&t.content),
InlineNode::Bold(b) => push_inline_text(&b.children, out),
InlineNode::Italic(i) => push_inline_text(&i.children, out),
InlineNode::BoldItalic(bi) => push_inline_text(&bi.children, out),
InlineNode::Strikethrough(s) => push_inline_text(&s.children, out),
InlineNode::Code(c) => out.push_str(&c.content),
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
InlineNode::Color(c) => push_inline_text(&c.children, out),
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
InlineNode::WikiLink(w) => match &w.description {
Some(d) => push_inline_text(d, out),
None => {
if let Some(p) = &w.target.path {
out.push_str(p);
}
}
},
InlineNode::ExternalLink(e) => match &e.description {
Some(d) => push_inline_text(d, out),
None => out.push_str(&e.url),
},
_ => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextNode {
pub span: Span,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SoftBreakNode {
pub span: Span,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoldNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItalicNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoldItalicNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrikethroughNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeNode {
pub span: Span,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuperscriptNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptNode {
pub span: Span,
pub children: Vec<InlineNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MathInlineNode {
pub span: Span,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeywordNode {
pub span: Span,
pub keyword: Keyword,
}
/// A named colour span (`color` is a `color_dic` key, `children` the wrapped
/// inline content).
///
/// This is an **extension point**, not a node the vimwiki parser emits: in
/// the editor, colour comes from the `:Colorize` family writing literal
/// `<span style="color:…">` markup, which round-trips through export via
/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future
/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template`
/// path directly; see `HtmlRenderer::render_color`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColorNode {
pub span: Span,
pub color: String,
pub children: Vec<InlineNode>,
}
+1292
View File
File diff suppressed because it is too large Load Diff
+212 -16
View File
@@ -227,17 +227,32 @@ pub fn render_page_html(
}
let mut r = HtmlRenderer::new();
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
{
// Same-page anchor links (`[[#Section]]`) resolve to the *current*
// page's html file plus the fragment (`index.html#Section`), matching
// vimwiki — rather than a bare `#Section`.
let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
// Strip the wiki extension from page links before the default resolver
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
// file:/local: paths keep their literal extension.
let ext = ext.to_string();
let ext = wiki_extension
.filter(|e| !e.trim_start_matches('.').is_empty())
.map(|e| e.to_string());
r = r.with_link_resolver(move |target| {
if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor {
Some(a) => format!("{current_html}#{a}"),
None => current_html.clone(),
};
}
let mut t = target.clone();
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
if let Some(p) = t.path.take() {
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
if let Some(ext) = &ext {
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
if let Some(p) = t.path.take() {
t.path =
Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
}
}
}
nuwiki_core::render::html::default_link_resolver(&t)
@@ -382,14 +397,195 @@ pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
cfg.html_path.join(&cfg.css_name)
}
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
/// file exists yet at [`css_path`]. Deliberately tiny — users are
/// expected to replace it with their own.
pub const DEFAULT_CSS: &str =
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
table{border-collapse:collapse}\
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
ul.toc{padding-left:1.5em}\n";
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
/// licensed) so exports look identical to upstream out of the box and the
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
pub const DEFAULT_CSS: &str = r#"body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
margin: 2em 4em 2em 4em;
font-size: 120%;
line-height: 130%;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
line-height:100%;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
h1 {font-size: 2em; color: #000000;}
h2 {font-size: 1.8em; color: #404040;}
h3 {font-size: 1.6em; color: #707070;}
h4 {font-size: 1.4em; color: #909090;}
h5 {font-size: 1.2em; color: #989898;}
h6 {font-size: 1em; color: #9c9c9c;}
p, pre, blockquote, table, ul, ol, dl {
margin-top: 1em;
margin-bottom: 1em;
}
ul ul, ul ol, ol ol, ol ul {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
li { margin: 0.3em auto; }
ul {
margin-left: 2em;
padding-left: 0;
}
dt { font-weight: bold; }
img { border: none; }
pre {
border-left: 5px solid #dcdcdc;
background-color: #f5f5f5;
padding-left: 1em;
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
font-size: 0.8em;
border-radius: 6px;
}
p > a {
color: white;
text-decoration: none;
font-size: 0.7em;
padding: 3px 6px;
border-radius: 3px;
background-color: #1e90ff;
text-transform: uppercase;
font-weight: bold;
}
p > a:hover {
color: #dcdcdc;
background-color: #484848;
}
li > a {
color: #1e90ff;
font-weight: bold;
text-decoration: none;
}
li > a:hover { color: #ff4500; }
blockquote {
color: #686868;
font-size: 0.8em;
line-height: 120%;
padding: 0.8em;
border-left: 5px solid #dcdcdc;
}
th, td {
border: 1px solid #ccc;
padding: 0.3em;
}
th { background-color: #f0f0f0; }
hr {
border: none;
border-top: 1px solid #ccc;
width: 100%;
}
del {
text-decoration: line-through;
color: #777777;
}
.toc li { list-style-type: none; }
.todo {
font-weight: bold;
background-color: #ff4500 ;
color: white;
font-size: 0.8em;
padding: 3px 6px;
border-radius: 3px;
}
.justleft { text-align: left; }
.justright { text-align: right; }
.justcenter { text-align: center; }
.center {
margin-left: auto;
margin-right: auto;
}
.tag {
background-color: #eeeeee;
font-family: monospace;
padding: 2px;
}
.header a {
text-decoration: none;
color: inherit;
}
/* classes for items of todo lists */
.rejected {
/* list-style: none; */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: 0 .2em;
padding-left: 1.5em;
}
.done0 {
/* list-style: none; */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 0 .2em;
padding-left: 1.5em;
}
.done1 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done2 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done3 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done4 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
code {
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
-moz-background-clip: padding;
-webkit-background-clip: padding-box;
background-clip: padding-box;
padding: 0px 3px;
display: inline-block;
color: #52595d;
border: 1px solid #ccc;
background-color: #f9f9f9;
}
"#;
+1436
View File
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
//! Tag tests: lexer + parser + renderer behaviour on
//! `:tag:` lines, plus the scope-detection rules.
use nuwiki_core::ast::{BlockNode, TagScope};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiSyntax, VimwikiTokenKind};
use nuwiki_core::syntax::Lexer;
use nuwiki_core::syntax::SyntaxPlugin;
fn lex(src: &str) -> Vec<VimwikiTokenKind> {
VimwikiLexer::new()
.lex(src)
.into_iter()
.map(|t| t.kind)
.collect()
}
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn render(src: &str) -> String {
let doc = parse(src);
HtmlRenderer::new().render_to_string(&doc).unwrap()
}
// ===== Lexer =====
#[test]
fn lexer_emits_tag_for_single_tag_line() {
let toks = lex(":todo:\n");
assert!(toks
.iter()
.any(|t| matches!(t, VimwikiTokenKind::Tag(v) if v == &vec!["todo".to_string()])));
}
#[test]
fn lexer_emits_tag_for_multiple_chained_tags() {
let toks = lex(":one:two:three:\n");
let names = toks
.iter()
.find_map(|t| match t {
VimwikiTokenKind::Tag(v) => Some(v.clone()),
_ => None,
})
.expect("tag token");
assert_eq!(names, vec!["one", "two", "three"]);
}
#[test]
fn lexer_rejects_empty_segment() {
// `::tag::` — empty leading segment between the doubled colons.
let toks = lex("::tag::\n");
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
#[test]
fn lexer_rejects_tag_with_whitespace() {
let toks = lex(":one tag:other:\n");
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
#[test]
fn lexer_does_not_collide_with_definition_term() {
// `Term::` is a definition-term marker (text *before* the `::`); a
// tag line has nothing before its leading `:`. The two patterns must
// not steal each other's lines.
let toks = lex("Term:: Definition\n:foo:\n");
assert!(toks
.iter()
.any(|t| matches!(t, VimwikiTokenKind::DefinitionTermMarker)));
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
#[test]
fn lexer_accepts_indented_tag_line() {
let toks = lex(" :indented:\n");
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
// ===== Parser scope rules =====
#[test]
fn scope_is_file_when_tag_is_on_line_0_or_1() {
let doc = parse(":todo:other:\n= Heading =\n");
let BlockNode::Tag(t) = &doc.children[0] else {
panic!(
"expected first block to be a Tag, got {:?}",
doc.children[0]
);
};
assert_eq!(t.scope, TagScope::File);
assert_eq!(t.tags, vec!["todo", "other"]);
assert_eq!(doc.metadata.tags, vec!["todo", "other"]);
}
#[test]
fn scope_is_file_for_tag_on_line_1() {
let doc = parse("a paragraph\n:todo:\n");
// Tag is on line 1, so still file-level.
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
assert_eq!(tag.scope, TagScope::File);
assert!(doc.metadata.tags.contains(&"todo".to_string()));
}
#[test]
fn scope_is_heading_when_tag_is_one_or_two_lines_below_heading() {
// Heading at line 0; tag at line 1 (one below) → Heading(0).
let doc = parse("= H1 =\n:hot:\n");
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
// Tag at line 1 is *also* line ≤ 1 → file rule wins (matches vimwiki:
// file rule applies to lines 0-1 regardless of preceding heading).
assert_eq!(tag.scope, TagScope::File);
}
#[test]
fn scope_is_heading_for_tag_two_lines_below_a_deeper_heading() {
// Heading at line 3; tag at line 4 (one below).
let src = "first\n\n\n= Section =\n:scoped:\n";
let doc = parse(src);
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
// Heading is the only one seen; index 0.
assert_eq!(tag.scope, TagScope::Heading(0));
// Heading-scope tags do NOT contribute to file-level metadata.
assert!(doc.metadata.tags.is_empty());
}
#[test]
fn scope_is_standalone_for_distant_tag() {
// Heading at line 3; tag at line 6 → 3 lines below → out of the
// heading window (>2), so standalone.
let src = "x\n\n\n= H =\n\n\n:wandering:\n";
let doc = parse(src);
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
assert_eq!(tag.scope, TagScope::Standalone);
}
#[test]
fn scope_is_standalone_when_no_heading_precedes() {
// No heading at all and not on lines 0-1.
let doc = parse("a\nb\nc\n:lonely:\n");
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
assert_eq!(tag.scope, TagScope::Standalone);
}
#[test]
fn file_tags_are_deduped_in_metadata() {
let doc = parse(":a:b:\n:b:c:\n");
assert_eq!(doc.metadata.tags, vec!["a", "b", "c"]);
}
// ===== Renderer =====
#[test]
fn renderer_emits_div_with_tag_spans() {
let html = render(":todo:done:\n");
assert!(html.contains("<div class=\"tags\">"));
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
}
#[test]
fn renderer_escapes_html_inside_tag_names() {
// Tags shouldn't contain `<`/`>` per the lexer rule (whitespace
// forbidden, but `<` isn't), so this checks the renderer's escaping
// fires anyway as defence-in-depth.
let html = render(":a<b:\n");
assert!(html.contains("a&lt;b"));
}