Files
nuwiki/crates/nuwiki-core/src/render/html.rs
T
gffranco 0741c349db feat(core): support configurable list_margin in HTML list rendering
Add `HtmlRenderer::with_list_margin`. When the margin is >= 0 the
outermost list gets an inline `margin-left:<n>em`; nested lists and the
default of -1 stay unstyled (deferring to the stylesheet). Wires the
per-wiki vimwiki `list_margin` key into the export pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 23:11:56 -03:00

791 lines
28 KiB
Rust

//! HTML renderer.
//!
//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is
//! configured with a link resolver (callback that turns a `LinkTarget` into
//! a URL string) and, optionally, an enclosing template with substitution
//! placeholders.
//!
//! Substitution model: `{{content}}` and `{{title}}` are computed from
//! the rendered body and the document's metadata; `with_var(k, v)` /
//! `with_vars(map)` add arbitrary key→value pairs (`{{date}}`,
//! `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is
//! substituted first so a body that happens to contain a literal
//! `{{title}}` isn't itself rewritten in the next pass.
//!
//! Both delimiter styles are recognised for every placeholder: nuwiki's
//! native `{{key}}` and vimwiki's `%key%` (`%title%`, `%content%`,
//! `%root_path%`, `%date%`, `%wiki_css%`, …). This lets stock vimwiki
//! templates render unchanged.
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;
use crate::ast::{
BlockNode, BlockquoteNode, BoldItalicNode, BoldNode, CheckboxState, CodeNode, ColorNode,
CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode,
HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind,
LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode,
PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
TableNode, TableRowNode, TagNode, TextNode, TransclusionNode, WikiLinkNode,
};
use super::Renderer;
/// Boxed link-resolution callback. Held in an `Arc` so the renderer is
/// cheap to clone and stays `Send + Sync`.
pub type LinkResolver = Arc<dyn Fn(&LinkTarget) -> String + Send + Sync>;
#[derive(Clone)]
pub struct HtmlRenderer {
link_resolver: LinkResolver,
template: Option<String>,
vars: HashMap<String, String>,
/// `color_dic`-style override: vimwiki colour-tag names → CSS
/// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified
/// names fall through to the default `class="color-<name>"`
/// rendering. Matches vimwiki's `color_dic`.
colors: HashMap<String, String>,
/// vimwiki `list_margin`: left margin (in `em`) applied to the
/// outermost list. `-1` (the default) emits no inline style and
/// defers to the stylesheet; `>= 0` forces `margin-left:<n>em`.
list_margin: i32,
}
impl Default for HtmlRenderer {
fn default() -> Self {
Self::new()
}
}
impl HtmlRenderer {
pub fn new() -> Self {
Self {
link_resolver: Arc::new(default_link_resolver),
template: None,
vars: HashMap::new(),
colors: HashMap::new(),
list_margin: -1,
}
}
/// Override the link resolver. The callback is invoked for every
/// wikilink and is expected to return a URL string.
pub fn with_link_resolver<F>(mut self, f: F) -> Self
where
F: Fn(&LinkTarget) -> String + Send + Sync + 'static,
{
self.link_resolver = Arc::new(f);
self
}
/// Wrap the rendered body in a template. `{{title}}` and `{{content}}`
/// are substituted automatically; additional `{{key}}` placeholders
/// resolve via `with_var` / `with_vars`. Anything unresolved passes
/// through verbatim.
pub fn with_template(mut self, template: impl Into<String>) -> Self {
self.template = Some(template.into());
self
}
/// Add a single template variable. Re-use to overwrite.
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
/// Replace the entire template variable map.
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.vars = vars;
self
}
/// Set the vimwiki `list_margin`. `-1` defers to the stylesheet;
/// `>= 0` forces a `margin-left:<n>em` on the outermost list.
pub fn with_list_margin(mut self, margin: i32) -> Self {
self.list_margin = margin;
self
}
/// Supply a `color_dic`: vimwiki colour-tag names mapped to their
/// CSS values. A name listed here is rendered as `style="color:V"`;
/// missing names fall through to `class="color-<name>"`.
pub fn with_colors(mut self, colors: HashMap<String, String>) -> Self {
self.colors = colors;
self
}
}
impl Renderer for HtmlRenderer {
fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
if let Some(template) = &self.template {
let mut body = Vec::new();
self.render_body(doc, &mut body)?;
let body_str = std::str::from_utf8(&body)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let title = doc.metadata.title.as_deref().unwrap_or("");
// Substitute `content` before `title` so a body that legitimately
// contains a literal title placeholder isn't rewritten. Both the
// native `{{key}}` and vimwiki's `%key%` delimiters are accepted so
// stock vimwiki templates render unchanged.
let mut out = template.replace("{{content}}", body_str);
out = out.replace("%content%", body_str);
out = out.replace("{{title}}", title);
out = out.replace("%title%", title);
for (k, v) in &self.vars {
out = out.replace(&format!("{{{{{k}}}}}"), v);
out = out.replace(&format!("%{k}%"), v);
}
w.write_all(out.as_bytes())?;
} else {
self.render_body(doc, w)?;
}
Ok(())
}
}
impl HtmlRenderer {
fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
for block in &doc.children {
self.render_block(block, w)?;
}
Ok(())
}
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
match block {
BlockNode::Heading(n) => self.render_heading(n, w),
BlockNode::Paragraph(n) => self.render_paragraph(n, w),
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
BlockNode::Preformatted(n) => self.render_preformatted(n, w),
BlockNode::MathBlock(n) => self.render_math_block(n, w),
BlockNode::List(n) => self.render_list(n, w),
BlockNode::DefinitionList(n) => self.render_definition_list(n, w),
BlockNode::Table(n) => self.render_table(n, w),
BlockNode::Comment(n) => self.render_comment(n, w),
BlockNode::Tag(n) => self.render_tag(n, w),
BlockNode::Error(n) => self.render_error(n, w),
}
}
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
w.write_all(b"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() {
if i > 0 {
w.write_all(b" ")?;
}
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
write_escaped(name, w)?;
w.write_all(b"\">")?;
write_escaped(name, w)?;
w.write_all(b"</span>")?;
}
w.write_all(b"</div>\n")
}
fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> {
let level = n.level.clamp(1, 6);
let class = if n.centered {
" class=\"centered\""
} else {
""
};
write!(w, "<h{level}{class}>")?;
self.render_inlines(&n.children, w)?;
writeln!(w, "</h{level}>")
}
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<p>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</p>\n")
}
fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<hr>\n")
}
fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<blockquote>\n")?;
for child in &n.children {
self.render_block(child, w)?;
}
w.write_all(b"</blockquote>\n")
}
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
match &n.language {
Some(lang) => {
w.write_all(b"<pre><code class=\"language-")?;
write_escaped(lang, w)?;
w.write_all(b"\">")?;
}
None => w.write_all(b"<pre><code>")?,
}
write_escaped(&n.content, w)?;
w.write_all(b"</code></pre>\n")
}
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
// No actual TeX rendering — caller is expected to run MathJax / KaTeX
// over the wrapped node. The delimiters match vimwiki convention.
match &n.environment {
Some(env) => writeln!(
w,
"<div class=\"math-block\" data-env=\"{}\">\\begin{{{env}}}",
escape(env)
)?,
None => w.write_all(b"<div class=\"math-block\">\\[\n")?,
}
write_escaped(&n.content, w)?;
match &n.environment {
Some(env) => write!(w, "\n\\end{{{env}}}</div>\n", env = escape(env))?,
None => w.write_all(b"\n\\]</div>\n")?,
}
Ok(())
}
fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> {
self.render_list_nested(n, w, true)
}
fn render_list_nested(&self, n: &ListNode, w: &mut dyn Write, top: bool) -> io::Result<()> {
let tag = if n.ordered { "ol" } else { "ul" };
if top && self.list_margin >= 0 {
writeln!(w, "<{tag} style=\"margin-left:{}em\">", self.list_margin)?;
} else {
writeln!(w, "<{tag}>")?;
}
for item in &n.items {
self.render_list_item(item, w)?;
}
writeln!(w, "</{tag}>")
}
fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> {
match n.checkbox {
Some(state) => {
// vimwiki-compatible checkbox classes: `[ ]`→done0, `[.]`→done1,
// `[o]`→done2, `[O]`→done3, `[X]`→done4, `[-]`→rejected. The
// checkbox glyph/progress fill is drawn entirely by the
// stylesheet (`li.doneN::before`); vimwiki emits no `<input>`,
// so neither do we — an input would double-render against it.
let class = match state {
CheckboxState::Empty => "done0",
CheckboxState::Quarter => "done1",
CheckboxState::Half => "done2",
CheckboxState::ThreeQuarters => "done3",
CheckboxState::Done => "done4",
CheckboxState::Rejected => "rejected",
};
write!(w, "<li class=\"{class}\">")?;
}
None => w.write_all(b"<li>")?,
}
self.render_inlines(&n.children, w)?;
if let Some(sublist) = &n.sublist {
w.write_all(b"\n")?;
self.render_list_nested(sublist, w, false)?;
}
w.write_all(b"</li>\n")
}
fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<dl>\n")?;
for item in &n.items {
self.render_definition_item(item, w)?;
}
w.write_all(b"</dl>\n")
}
fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> {
if let Some(term) = &n.term {
w.write_all(b"<dt>")?;
self.render_inlines(term, w)?;
w.write_all(b"</dt>\n")?;
}
for def in &n.definitions {
w.write_all(b"<dd>")?;
self.render_inlines(def, w)?;
w.write_all(b"</dd>\n")?;
}
Ok(())
}
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
// Pre-compute per-cell rowspan/colspan + suppression so we can
// emit valid HTML colspan/rowspan attributes instead of CSS
// class markers. `col_span` cells get folded into the colspan
// of the lead cell to their left; `row_span` cells get folded
// into the rowspan of the lead cell directly above.
let layout = table_spans(n);
w.write_all(b"<table>\n")?;
let mut in_thead = false;
let mut in_tbody = false;
for (row_idx, row) in n.rows.iter().enumerate() {
if row.is_header {
if !in_thead {
w.write_all(b"<thead>\n")?;
in_thead = true;
}
} else {
if in_thead {
w.write_all(b"</thead>\n")?;
in_thead = false;
}
if !in_tbody {
w.write_all(b"<tbody>\n")?;
in_tbody = true;
}
}
self.render_table_row(row, row_idx, &layout, &n.alignments, w)?;
}
if in_thead {
w.write_all(b"</thead>\n")?;
}
if in_tbody {
w.write_all(b"</tbody>\n")?;
}
w.write_all(b"</table>\n")
}
fn render_table_row(
&self,
n: &TableRowNode,
row_idx: usize,
layout: &[Vec<CellLayout>],
alignments: &[crate::ast::TableAlign],
w: &mut dyn Write,
) -> io::Result<()> {
w.write_all(b"<tr>")?;
for (col_idx, cell) in n.cells.iter().enumerate() {
let info = layout
.get(row_idx)
.and_then(|r| r.get(col_idx))
.copied()
.unwrap_or(CellLayout::Lead {
colspan: 1,
rowspan: 1,
});
match info {
CellLayout::Skip => continue,
CellLayout::Lead { colspan, rowspan } => {
let align = alignments
.get(col_idx)
.copied()
.unwrap_or(crate::ast::TableAlign::Default);
self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?;
}
}
}
w.write_all(b"</tr>\n")
}
fn render_table_cell(
&self,
n: &TableCellNode,
is_header: bool,
colspan: u32,
rowspan: u32,
align: crate::ast::TableAlign,
w: &mut dyn Write,
) -> io::Result<()> {
let tag = if is_header { "th" } else { "td" };
write!(w, "<{tag}")?;
if colspan > 1 {
write!(w, " colspan=\"{colspan}\"")?;
}
if rowspan > 1 {
write!(w, " rowspan=\"{rowspan}\"")?;
}
let align_attr = match align {
crate::ast::TableAlign::Left => Some("left"),
crate::ast::TableAlign::Right => Some("right"),
crate::ast::TableAlign::Center => Some("center"),
crate::ast::TableAlign::Default => None,
};
if let Some(a) = align_attr {
write!(w, " style=\"text-align: {a};\"")?;
}
write!(w, ">")?;
self.render_inlines(&n.children, w)?;
write!(w, "</{tag}>")
}
fn render_comment(&self, n: &CommentNode, w: &mut dyn Write) -> io::Result<()> {
// Emit as an HTML comment so it survives round-trips through view-source
// but isn't rendered to the reader. `--` inside a comment is illegal
// in HTML, so collapse runs.
let safe = n.content.replace("--", "- -");
writeln!(w, "<!--{safe}-->")
}
fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> {
write!(w, "<span class=\"parse-error\" title=\"")?;
write_escaped(&n.message, w)?;
w.write_all(b"\">")?;
write_escaped(&n.raw, w)?;
w.write_all(b"</span>")
}
// ===== Inline =====
fn render_inlines(&self, nodes: &[InlineNode], w: &mut dyn Write) -> io::Result<()> {
for n in nodes {
self.render_inline(n, w)?;
}
Ok(())
}
fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> {
match n {
InlineNode::Text(t) => self.render_text(t, w),
InlineNode::Bold(t) => self.render_bold(t, w),
InlineNode::Italic(t) => self.render_italic(t, w),
InlineNode::BoldItalic(t) => self.render_bold_italic(t, w),
InlineNode::Strikethrough(t) => self.render_strikethrough(t, w),
InlineNode::Code(t) => self.render_code(t, w),
InlineNode::Superscript(t) => self.render_superscript(t, w),
InlineNode::Subscript(t) => self.render_subscript(t, w),
InlineNode::MathInline(t) => self.render_math_inline(t, w),
InlineNode::Keyword(t) => self.render_keyword(t, w),
InlineNode::Color(t) => self.render_color(t, w),
InlineNode::WikiLink(t) => self.render_wikilink(t, w),
InlineNode::ExternalLink(t) => self.render_external_link(t, w),
InlineNode::Transclusion(t) => self.render_transclusion(t, w),
InlineNode::RawUrl(t) => self.render_raw_url(t, w),
}
}
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
write_escaped(&n.content, w)
}
fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<strong>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</strong>")
}
fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<em>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</em>")
}
fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<strong><em>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</em></strong>")
}
fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<del>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</del>")
}
fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<code>")?;
write_escaped(&n.content, w)?;
w.write_all(b"</code>")
}
fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<sup>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</sup>")
}
fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<sub>")?;
self.render_inlines(&n.children, w)?;
w.write_all(b"</sub>")
}
fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<span class=\"math-inline\">\\(")?;
write_escaped(&n.content, w)?;
w.write_all(b"\\)</span>")
}
fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> {
// One class per keyword so stylesheets can colour them independently
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
// vimwiki `todo` class so stock vimwiki CSS still styles it.
let (label, class) = match n.keyword {
Keyword::Todo => ("TODO", "todo"),
Keyword::Done => ("DONE", "done"),
Keyword::Started => ("STARTED", "started"),
Keyword::Fixme => ("FIXME", "fixme"),
Keyword::Fixed => ("FIXED", "fixed"),
Keyword::Xxx => ("XXX", "xxx"),
Keyword::Stopped => ("STOPPED", "stopped"),
};
write!(w, "<span class=\"{class}\">{label}</span>")
}
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
if let Some(css) = self.colors.get(&n.color) {
w.write_all(b"<span style=\"color:")?;
write_escaped(css, w)?;
w.write_all(b"\">")?;
} else {
w.write_all(b"<span class=\"color-")?;
write_escaped(&n.color, w)?;
w.write_all(b"\">")?;
}
self.render_inlines(&n.children, w)?;
w.write_all(b"</span>")
}
fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> {
let href = (self.link_resolver)(&n.target);
w.write_all(b"<a href=\"")?;
write_escaped(&href, w)?;
w.write_all(b"\">")?;
match &n.description {
Some(desc) => self.render_inlines(desc, w)?,
None => {
let label = n
.target
.path
.clone()
.or_else(|| n.target.anchor.clone())
.unwrap_or_else(|| href.clone());
write_escaped(&label, w)?;
}
}
w.write_all(b"</a>")
}
fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<a href=\"")?;
write_escaped(&n.url, w)?;
w.write_all(b"\">")?;
match &n.description {
Some(desc) => self.render_inlines(desc, w)?,
None => write_escaped(&n.url, w)?,
}
w.write_all(b"</a>")
}
fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<img src=\"")?;
write_escaped(&n.url, w)?;
w.write_all(b"\" alt=\"")?;
write_escaped(n.alt.as_deref().unwrap_or(""), w)?;
w.write_all(b"\"")?;
// Emit any extra attrs verbatim; both key and value are escaped.
let mut keys: Vec<&String> = n.attrs.keys().collect();
keys.sort();
for k in keys {
let v = &n.attrs[k];
w.write_all(b" ")?;
write_escaped(k, w)?;
w.write_all(b"=\"")?;
write_escaped(v, w)?;
w.write_all(b"\"")?;
}
w.write_all(b">")
}
fn render_raw_url(&self, n: &RawUrlNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<a href=\"")?;
write_escaped(&n.url, w)?;
w.write_all(b"\">")?;
write_escaped(&n.url, w)?;
w.write_all(b"</a>")
}
}
// ===== Table colspan/rowspan layout =====
/// Per-cell render decision: emit a lead cell with the computed spans,
/// or skip (this cell is a continuation marker absorbed by its lead).
#[derive(Debug, Clone, Copy)]
enum CellLayout {
Lead { colspan: u32, rowspan: u32 },
Skip,
}
/// Walk every cell in the table and resolve `col_span` / `row_span`
/// markers into HTML `colspan="N"` / `rowspan="N"` attributes on the
/// preceding lead cell.
///
/// Rules:
/// - `col_span: true` (`>` in vimwiki source) bumps the colspan of
/// the lead cell to its *left* in the same row.
/// - `row_span: true` (`\\/` in vimwiki source) bumps the rowspan
/// of the lead cell *above* in the same column.
/// - Markers that don't have a valid lead cell (first column /
/// first row) render as a plain empty cell so the document still
/// parses cleanly.
fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
let rows = table.rows.len();
if rows == 0 {
return Vec::new();
}
let cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
let mut layout: Vec<Vec<CellLayout>> = (0..rows)
.map(|_| {
(0..cols)
.map(|_| CellLayout::Lead {
colspan: 1,
rowspan: 1,
})
.collect()
})
.collect();
for (ri, row) in table.rows.iter().enumerate() {
for (ci, cell) in row.cells.iter().enumerate() {
if cell.col_span {
// Find the lead cell to the left in this row (skipping
// already-skipped continuation slots) and bump it.
let mut k = ci;
while k > 0 {
k -= 1;
match layout[ri][k] {
CellLayout::Lead {
ref mut colspan, ..
} => {
*colspan += 1;
layout[ri][ci] = CellLayout::Skip;
break;
}
CellLayout::Skip => continue,
}
}
} else if cell.row_span {
// Walk up in the same column for the lead cell.
let mut k = ri;
while k > 0 {
k -= 1;
match layout[k][ci] {
CellLayout::Lead {
ref mut rowspan, ..
} => {
*rowspan += 1;
layout[ri][ci] = CellLayout::Skip;
break;
}
CellLayout::Skip => continue,
}
}
}
}
}
layout
}
// ===== Default link resolver =====
/// Default `LinkResolver`. Mirrors the vimwiki link conventions:
/// wiki pages become `path.html`; interwiki links land in sibling
/// directories; diary entries land under `diary/`; file/local schemes
/// use their literal path; anchor-only links collapse to a fragment.
pub fn default_link_resolver(target: &LinkTarget) -> String {
if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor {
Some(a) => format!("#{a}"),
None => String::new(),
};
}
let path = target.path.as_deref().unwrap_or("");
let mut out = String::new();
match target.kind {
LinkKind::Wiki => {
if target.is_absolute {
out.push('/');
}
out.push_str(path);
if target.is_directory {
out.push('/');
} else if !path.is_empty() {
out.push_str(".html");
}
}
LinkKind::Interwiki => {
if let Some(idx) = target.wiki_index {
out.push_str(&format!("../wiki{idx}/"));
} else if let Some(name) = &target.wiki_name {
out.push_str(&format!("../wn-{name}/"));
}
out.push_str(path);
if !target.is_directory {
out.push_str(".html");
}
}
LinkKind::Diary => {
out.push_str("diary/");
out.push_str(path);
out.push_str(".html");
}
LinkKind::File => {
out.push_str(path);
}
LinkKind::Local => {
out.push_str("file://");
if target.is_absolute && !path.starts_with('/') {
out.push('/');
}
out.push_str(path);
}
LinkKind::Raw => {
out.push_str(path);
}
LinkKind::AnchorOnly => unreachable!(),
}
if let Some(anchor) = &target.anchor {
out.push('#');
out.push_str(anchor);
}
out
}
// ===== HTML escaping =====
fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> {
let bytes = s.as_bytes();
let mut last = 0;
for (i, &b) in bytes.iter().enumerate() {
let replacement: &[u8] = match b {
b'<' => b"&lt;",
b'>' => b"&gt;",
b'&' => b"&amp;",
b'"' => b"&quot;",
b'\'' => b"&#39;",
_ => continue,
};
if i > last {
w.write_all(&bytes[last..i])?;
}
w.write_all(replacement)?;
last = i + 1;
}
if last < bytes.len() {
w.write_all(&bytes[last..])?;
}
Ok(())
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}