1233 lines
45 KiB
Rust
1233 lines
45 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 `color_tag_template`: the HTML emitted for a colour span whose
|
||
/// name resolves in `colors`. `__STYLE__` expands to the inline style
|
||
/// (`color:<css>`) and `__CONTENT__` to the rendered inner HTML. The
|
||
/// default matches upstream's `<span style="…">…</span>`.
|
||
color_template: String,
|
||
/// vimwiki `html_header_numbering`: the heading level at which automatic
|
||
/// section numbering begins (`0` = off, the default). When `>= 1`, every
|
||
/// heading at that level or deeper is prefixed with a dotted section
|
||
/// number (`1`, `1.1`, `1.2`, …); shallower headings stay unnumbered.
|
||
header_numbering: u8,
|
||
/// vimwiki `html_header_numbering_sym`: a symbol appended after the
|
||
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
||
header_numbering_sym: String,
|
||
/// vimwiki `toc_header`: the heading text that identifies the TOC
|
||
/// section. A heading matching this value (case-insensitive) is wrapped
|
||
/// in `<div class="toc">…</div>` on export, matching upstream so vimwiki's
|
||
/// stylesheet applies.
|
||
toc_header: String,
|
||
/// vimwiki `valid_html_tags`: inline HTML tag names passed through to
|
||
/// export verbatim (instead of escaping `<`/`>`). Empty = escape all.
|
||
/// `span` is always allowed so colour spans render.
|
||
valid_html_tags: Vec<String>,
|
||
/// vimwiki `emoji_enable` (default `false` in the renderer; set from
|
||
/// config): substitute `:alias:` shortcodes with their emoji glyph.
|
||
emoji_enable: bool,
|
||
/// vimwiki `text_ignore_newline` (default `true`): a soft break in a
|
||
/// paragraph renders as a space; `false` → `<br />`.
|
||
text_ignore_newline: bool,
|
||
/// vimwiki `list_ignore_newline` (default `true`): a soft break in a list
|
||
/// item renders as a space; `false` → `<br />`.
|
||
list_ignore_newline: bool,
|
||
}
|
||
|
||
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(),
|
||
color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(),
|
||
header_numbering: 0,
|
||
header_numbering_sym: String::new(),
|
||
toc_header: String::new(),
|
||
valid_html_tags: Vec::new(),
|
||
emoji_enable: false,
|
||
text_ignore_newline: true,
|
||
list_ignore_newline: true,
|
||
}
|
||
}
|
||
|
||
/// Set vimwiki `text_ignore_newline` / `list_ignore_newline`. When `false`,
|
||
/// a soft line break in a paragraph / list item renders as `<br />`
|
||
/// instead of a space.
|
||
pub fn with_newline_handling(mut self, text_ignore: bool, list_ignore: bool) -> Self {
|
||
self.text_ignore_newline = text_ignore;
|
||
self.list_ignore_newline = list_ignore;
|
||
self
|
||
}
|
||
|
||
/// Set the inline HTML tags allowed through export unescaped (vimwiki
|
||
/// `valid_html_tags`). `span` is always added so colour spans render.
|
||
pub fn with_valid_html_tags(mut self, mut tags: Vec<String>) -> Self {
|
||
if !tags.iter().any(|t| t.eq_ignore_ascii_case("span")) {
|
||
tags.push("span".to_string());
|
||
}
|
||
self.valid_html_tags = tags;
|
||
self
|
||
}
|
||
|
||
/// Enable `:alias:` → emoji substitution during export (vimwiki
|
||
/// `emoji_enable`).
|
||
pub fn with_emoji(mut self, enable: bool) -> Self {
|
||
self.emoji_enable = enable;
|
||
self
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Override the colour-span template (vimwiki `color_tag_template`).
|
||
/// `__STYLE__` is replaced with the resolved `color:<css>` style and
|
||
/// `__CONTENT__` with the rendered inner HTML. An empty string keeps the
|
||
/// built-in default.
|
||
pub fn with_color_template(mut self, template: impl Into<String>) -> Self {
|
||
let template = template.into();
|
||
if !template.is_empty() {
|
||
self.color_template = template;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Enable vimwiki-style HTML section numbering. `level` is the heading
|
||
/// level at which numbering starts (`0` disables it); `sym` is appended
|
||
/// after the number. Mirrors `html_header_numbering` /
|
||
/// `html_header_numbering_sym`.
|
||
pub fn with_header_numbering(mut self, level: u8, sym: impl Into<String>) -> Self {
|
||
self.header_numbering = level;
|
||
self.header_numbering_sym = sym.into();
|
||
self
|
||
}
|
||
|
||
/// Set the vimwiki TOC heading text. When a heading matches this value
|
||
/// (case-insensitive) it gets `class="toc"` in HTML export.
|
||
pub fn with_toc_header(mut self, header: impl Into<String>) -> Self {
|
||
self.toc_header = header.into();
|
||
self
|
||
}
|
||
}
|
||
|
||
/// Running state for vimwiki-style HTML section numbering
|
||
/// (`html_header_numbering`). Headings are visited in document order; each
|
||
/// at or below the start level bumps its depth's counter, resets deeper
|
||
/// counters, and yields a dotted prefix (`1`, `1.1`, `2`, …).
|
||
struct HeadingNumberer {
|
||
/// Heading level numbering starts at (`0` disables it).
|
||
start: u8,
|
||
/// Symbol appended after the dotted number.
|
||
sym: String,
|
||
/// Per-depth counters, indexed by `level - start` (levels clamp to 1–6,
|
||
/// so the depth is always `0..=5`).
|
||
counters: [u32; 6],
|
||
}
|
||
|
||
impl HeadingNumberer {
|
||
fn new(start: u8, sym: &str) -> Self {
|
||
Self {
|
||
start,
|
||
sym: sym.to_string(),
|
||
counters: [0; 6],
|
||
}
|
||
}
|
||
|
||
/// Advance to a heading at `level` (1–6) and return its numeric prefix,
|
||
/// including the trailing symbol and a separating space (e.g. `"1.2. "`).
|
||
/// Returns an empty string when numbering is off or the heading is
|
||
/// shallower than the start level.
|
||
fn prefix(&mut self, level: u8) -> String {
|
||
if self.start == 0 || level < self.start {
|
||
return String::new();
|
||
}
|
||
let depth = (level - self.start) as usize;
|
||
self.counters[depth] += 1;
|
||
for c in self.counters.iter_mut().skip(depth + 1) {
|
||
*c = 0;
|
||
}
|
||
let num = self.counters[..=depth]
|
||
.iter()
|
||
.map(|c| c.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join(".");
|
||
format!("{num}{} ", self.sym)
|
||
}
|
||
}
|
||
|
||
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<()> {
|
||
// Section numbering runs over top-level headings in document order,
|
||
// mirroring vimwiki's line-by-line scan. Nested headings (in lists,
|
||
// quotes) go through `render_block` and are left unnumbered, matching
|
||
// upstream which only numbers document-level headers.
|
||
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
|
||
// Stack of (level, anchor) for the headings enclosing the current one,
|
||
// so each heading can carry vimwiki's hierarchical id (the parent
|
||
// anchors joined by `-`, e.g. `Title-Section`).
|
||
let mut ancestors: Vec<(u8, String)> = Vec::new();
|
||
for block in &doc.children {
|
||
if let BlockNode::Heading(n) = block {
|
||
let level = n.level.clamp(1, 6);
|
||
let number = numberer.prefix(level);
|
||
let anchor = crate::ast::inline_text(&n.children);
|
||
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
|
||
ancestors.pop();
|
||
}
|
||
let mut hier = String::new();
|
||
for (_, a) in &ancestors {
|
||
hier.push_str(a);
|
||
hier.push('-');
|
||
}
|
||
hier.push_str(&anchor);
|
||
ancestors.push((level, anchor));
|
||
self.render_heading(n, &number, &hier, w)?;
|
||
} else {
|
||
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="…"` (bare tag name, matching vimwiki) lets `[[Page#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=\"")?;
|
||
write_escaped(name, w)?;
|
||
w.write_all(b"\">")?;
|
||
write_escaped(name, w)?;
|
||
w.write_all(b"</span>")?;
|
||
}
|
||
w.write_all(b"</div>\n")
|
||
}
|
||
|
||
/// Render a heading. `number` is the optional section-number prefix
|
||
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
|
||
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id
|
||
/// (parent anchors joined by `-`); pass `""` to default it to the flat id.
|
||
fn render_heading(
|
||
&self,
|
||
n: &HeadingNode,
|
||
number: &str,
|
||
hier_id: &str,
|
||
w: &mut dyn Write,
|
||
) -> io::Result<()> {
|
||
let level = n.level.clamp(1, 6);
|
||
let anchor = crate::ast::inline_text(&n.children);
|
||
// Inside a heading, render TODO/DONE/… as plain text rather than a
|
||
// keyword badge — a section title that *is* a keyword (`== TODO ==`)
|
||
// should look like a header, not an inline tag. (The keyword text is
|
||
// still part of `anchor` above, so the id stays correct.)
|
||
let body = flatten_keywords(&n.children);
|
||
|
||
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
||
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
|
||
// stylesheet rules apply. Kept in its simple form.
|
||
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
|
||
if is_toc {
|
||
w.write_all(b"<div class=\"toc\">")?;
|
||
write!(w, "<h{level} id=\"")?;
|
||
write_escaped(&anchor, w)?;
|
||
write!(w, "\">")?;
|
||
if !number.is_empty() {
|
||
write_escaped(number, w)?;
|
||
}
|
||
self.render_inlines(&body, w)?;
|
||
write!(w, "</h{level}></div>")?;
|
||
return writeln!(w);
|
||
}
|
||
|
||
// Centered headings keep their `class="centered"` form.
|
||
if n.centered {
|
||
write!(w, "<h{level} class=\"centered\" id=\"")?;
|
||
write_escaped(&anchor, w)?;
|
||
write!(w, "\">")?;
|
||
if !number.is_empty() {
|
||
write_escaped(number, w)?;
|
||
}
|
||
self.render_inlines(&body, w)?;
|
||
write!(w, "</h{level}>")?;
|
||
return writeln!(w);
|
||
}
|
||
|
||
// Regular heading: vimwiki's structure — a wrapping `<div>` carrying the
|
||
// hierarchical id, `class="header"` on the heading, and an in-heading
|
||
// self-anchor linking to that id. The heading keeps the flat id so
|
||
// existing intra-page TOC / `#anchor` links still resolve.
|
||
let hier = if hier_id.is_empty() { &anchor } else { hier_id };
|
||
w.write_all(b"<div id=\"")?;
|
||
write_escaped(hier, w)?;
|
||
write!(w, "\"><h{level} id=\"")?;
|
||
write_escaped(&anchor, w)?;
|
||
w.write_all(b"\" class=\"header\"><a href=\"#")?;
|
||
write_escaped(hier, w)?;
|
||
write!(w, "\">")?;
|
||
if !number.is_empty() {
|
||
write_escaped(number, w)?;
|
||
}
|
||
self.render_inlines(&body, w)?;
|
||
write!(w, "</a></h{level}></div>")?;
|
||
writeln!(w)
|
||
}
|
||
|
||
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
||
w.write_all(b"<p>")?;
|
||
let sb = if self.text_ignore_newline {
|
||
" "
|
||
} else {
|
||
"<br />"
|
||
};
|
||
self.render_inlines_break(&n.children, sb, 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<()> {
|
||
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
|
||
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
|
||
// We mirror that so highlighters wired for vimwiki output still apply.
|
||
if n.attrs.is_empty() {
|
||
w.write_all(b"<pre>")?;
|
||
} else {
|
||
write!(w, "<pre {}>", n.attrs)?;
|
||
}
|
||
write_escaped(&n.content, w)?;
|
||
w.write_all(b"</pre>\n")
|
||
}
|
||
|
||
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||
// No actual TeX rendering — caller is expected to run MathJax / KaTeX
|
||
// over the wrapped node. The delimiters match vimwiki convention.
|
||
match &n.environment {
|
||
Some(env) => writeln!(
|
||
w,
|
||
"<div class=\"math-block\" data-env=\"{}\">\\begin{{{env}}}",
|
||
escape(env)
|
||
)?,
|
||
None => w.write_all(b"<div class=\"math-block\">\\[\n")?,
|
||
}
|
||
write_escaped(&n.content, w)?;
|
||
match &n.environment {
|
||
Some(env) => write!(w, "\n\\end{{{env}}}</div>\n", env = escape(env))?,
|
||
None => w.write_all(b"\n\\]</div>\n")?,
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> {
|
||
let tag = if n.ordered { "ol" } else { "ul" };
|
||
writeln!(w, "<{tag}>")?;
|
||
for item in &n.items {
|
||
self.render_list_item(item, w)?;
|
||
}
|
||
writeln!(w, "</{tag}>")
|
||
}
|
||
|
||
fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> {
|
||
match n.checkbox {
|
||
Some(state) => {
|
||
// 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>")?,
|
||
}
|
||
let sb = if self.list_ignore_newline {
|
||
" "
|
||
} else {
|
||
"<br />"
|
||
};
|
||
self.render_inlines_break(&n.children, sb, w)?;
|
||
if let Some(sublist) = &n.sublist {
|
||
w.write_all(b"\n")?;
|
||
self.render_list(sublist, w)?;
|
||
}
|
||
w.write_all(b"</li>\n")
|
||
}
|
||
|
||
fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> {
|
||
w.write_all(b"<dl>\n")?;
|
||
for item in &n.items {
|
||
self.render_definition_item(item, w)?;
|
||
}
|
||
w.write_all(b"</dl>\n")
|
||
}
|
||
|
||
fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> {
|
||
if let Some(term) = &n.term {
|
||
w.write_all(b"<dt>")?;
|
||
self.render_inlines(term, w)?;
|
||
w.write_all(b"</dt>\n")?;
|
||
}
|
||
for def in &n.definitions {
|
||
w.write_all(b"<dd>")?;
|
||
self.render_inlines(def, w)?;
|
||
w.write_all(b"</dd>\n")?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
|
||
// 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(())
|
||
}
|
||
|
||
/// Like [`render_inlines`] but renders top-level soft breaks as `soft_break`
|
||
/// (a space or `<br />`). Used by paragraphs / list items, which are the
|
||
/// only blocks that contain `SoftBreak` nodes.
|
||
fn render_inlines_break(
|
||
&self,
|
||
nodes: &[InlineNode],
|
||
soft_break: &str,
|
||
w: &mut dyn Write,
|
||
) -> io::Result<()> {
|
||
for n in nodes {
|
||
match n {
|
||
InlineNode::SoftBreak(_) => w.write_all(soft_break.as_bytes())?,
|
||
_ => 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),
|
||
// Default rendering (e.g. inside a heading): a space. Paragraphs
|
||
// and list items use render_inlines_break for `<br />` support.
|
||
InlineNode::SoftBreak(_) => w.write_all(b" "),
|
||
}
|
||
}
|
||
|
||
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
|
||
let content = if self.emoji_enable {
|
||
substitute_emoji(&n.content)
|
||
} else {
|
||
std::borrow::Cow::Borrowed(n.content.as_str())
|
||
};
|
||
if self.valid_html_tags.is_empty() {
|
||
write_escaped(&content, w)
|
||
} else {
|
||
write_escaped_allowing(&content, &self.valid_html_tags, 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 class = match n.keyword {
|
||
Keyword::Todo => "todo",
|
||
Keyword::Done => "done",
|
||
Keyword::Started => "started",
|
||
Keyword::Fixme => "fixme",
|
||
Keyword::Fixed => "fixed",
|
||
Keyword::Xxx => "xxx",
|
||
Keyword::Stopped => "stopped",
|
||
};
|
||
write!(w, "<span class=\"{class}\">{}</span>", n.keyword.label())
|
||
}
|
||
|
||
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||
// Render the inner content up front so it can be slotted into the
|
||
// colour template.
|
||
let mut content = Vec::new();
|
||
self.render_inlines(&n.children, &mut content)?;
|
||
let content = String::from_utf8_lossy(&content);
|
||
|
||
if let Some(css) = self.colors.get(&n.color) {
|
||
// Known colour name: expand `color_tag_template` with the resolved
|
||
// inline style and rendered content.
|
||
let mut style = b"color:".to_vec();
|
||
write_escaped(css, &mut style)?;
|
||
let style = String::from_utf8_lossy(&style);
|
||
let html = self
|
||
.color_template
|
||
.replace("__STYLE__", &style)
|
||
.replace("__CONTENT__", &content);
|
||
w.write_all(html.as_bytes())
|
||
} else {
|
||
// Unknown name: fall back to a class hook the stylesheet can target
|
||
// (the template only models the inline-style case).
|
||
w.write_all(b"<span class=\"color-")?;
|
||
write_escaped(&n.color, w)?;
|
||
w.write_all(b"\">")?;
|
||
w.write_all(content.as_bytes())?;
|
||
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.
|
||
/// Return a copy of `nodes` with every `Keyword` replaced by its literal text.
|
||
/// Used for heading content so a keyword in a title renders as plain text (with
|
||
/// the heading's own styling) rather than as an inline keyword badge.
|
||
fn flatten_keywords(nodes: &[InlineNode]) -> Vec<InlineNode> {
|
||
nodes
|
||
.iter()
|
||
.map(|n| match n {
|
||
InlineNode::Keyword(k) => InlineNode::Text(TextNode {
|
||
span: k.span,
|
||
content: k.keyword.label().to_string(),
|
||
}),
|
||
InlineNode::Bold(b) => InlineNode::Bold(BoldNode {
|
||
span: b.span,
|
||
children: flatten_keywords(&b.children),
|
||
}),
|
||
InlineNode::Italic(i) => InlineNode::Italic(ItalicNode {
|
||
span: i.span,
|
||
children: flatten_keywords(&i.children),
|
||
}),
|
||
InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode {
|
||
span: b.span,
|
||
children: flatten_keywords(&b.children),
|
||
}),
|
||
InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode {
|
||
span: s.span,
|
||
children: flatten_keywords(&s.children),
|
||
}),
|
||
InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode {
|
||
span: s.span,
|
||
children: flatten_keywords(&s.children),
|
||
}),
|
||
InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode {
|
||
span: s.span,
|
||
children: flatten_keywords(&s.children),
|
||
}),
|
||
InlineNode::Color(c) => InlineNode::Color(ColorNode {
|
||
span: c.span,
|
||
color: c.color.clone(),
|
||
children: flatten_keywords(&c.children),
|
||
}),
|
||
other => other.clone(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
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 =====
|
||
|
||
/// Escape `s` for HTML, but pass through inline tags whose name is in
|
||
/// `allowed` (vimwiki `valid_html_tags`) verbatim — mirroring upstream's
|
||
/// render-time `s:safe_html_line` regex rather than parsing raw HTML.
|
||
fn write_escaped_allowing(s: &str, allowed: &[String], w: &mut dyn Write) -> io::Result<()> {
|
||
let bytes = s.as_bytes();
|
||
let mut i = 0;
|
||
let mut last = 0;
|
||
while i < bytes.len() {
|
||
if bytes[i] == b'<' {
|
||
if let Some(end) = match_allowed_tag(bytes, i, allowed) {
|
||
write_escaped(&s[last..i], w)?;
|
||
w.write_all(&bytes[i..end])?;
|
||
i = end;
|
||
last = end;
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
write_escaped(&s[last..], w)
|
||
}
|
||
|
||
/// If `bytes[lt]` (`<`) begins a `<tag…>` / `</tag>` whose name is in
|
||
/// `allowed`, return the index just past the closing `>`. Otherwise `None`.
|
||
fn match_allowed_tag(bytes: &[u8], lt: usize, allowed: &[String]) -> Option<usize> {
|
||
let mut p = lt + 1;
|
||
if bytes.get(p) == Some(&b'/') {
|
||
p += 1;
|
||
}
|
||
let name_begin = p;
|
||
while p < bytes.len() && (bytes[p] as char).is_ascii_alphabetic() {
|
||
p += 1;
|
||
}
|
||
if p == name_begin {
|
||
return None;
|
||
}
|
||
let name = std::str::from_utf8(&bytes[name_begin..p]).ok()?;
|
||
if !allowed.iter().any(|t| t.eq_ignore_ascii_case(name)) {
|
||
return None;
|
||
}
|
||
// Scan to the closing `>` (bail on a nested `<` — not a well-formed tag).
|
||
while p < bytes.len() && bytes[p] != b'>' {
|
||
if bytes[p] == b'<' {
|
||
return None;
|
||
}
|
||
p += 1;
|
||
}
|
||
if p >= bytes.len() {
|
||
return None;
|
||
}
|
||
Some(p + 1)
|
||
}
|
||
|
||
/// Replace `:alias:` emoji shortcodes with their glyph (vimwiki
|
||
/// `emoji_enable`). Only a curated common subset is supported; unknown
|
||
/// shortcodes are left untouched.
|
||
fn substitute_emoji(s: &str) -> std::borrow::Cow<'_, str> {
|
||
if !s.contains(':') {
|
||
return std::borrow::Cow::Borrowed(s);
|
||
}
|
||
let mut out = String::with_capacity(s.len());
|
||
let mut rest = s;
|
||
let mut changed = false;
|
||
while let Some(open) = rest.find(':') {
|
||
out.push_str(&rest[..open]);
|
||
let after = &rest[open + 1..];
|
||
if let Some(close) = after.find(':') {
|
||
let alias = &after[..close];
|
||
if !alias.is_empty()
|
||
&& alias
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-')
|
||
{
|
||
if let Some(glyph) = emoji_for(alias) {
|
||
out.push_str(glyph);
|
||
rest = &after[close + 1..];
|
||
changed = true;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
// Not a shortcode — keep the `:` and continue past it.
|
||
out.push(':');
|
||
rest = after;
|
||
}
|
||
out.push_str(rest);
|
||
if changed {
|
||
std::borrow::Cow::Owned(out)
|
||
} else {
|
||
std::borrow::Cow::Borrowed(s)
|
||
}
|
||
}
|
||
|
||
/// A curated subset of GitHub-style emoji shortcodes.
|
||
fn emoji_for(alias: &str) -> Option<&'static str> {
|
||
Some(match alias {
|
||
"smile" => "😄",
|
||
"smiley" => "😃",
|
||
"grin" => "😁",
|
||
"laughing" | "satisfied" => "😆",
|
||
"wink" => "😉",
|
||
"blush" => "😊",
|
||
"heart" => "❤️",
|
||
"broken_heart" => "💔",
|
||
"thumbsup" | "+1" => "👍",
|
||
"thumbsdown" | "-1" => "👎",
|
||
"ok_hand" => "👌",
|
||
"wave" => "👋",
|
||
"clap" => "👏",
|
||
"fire" => "🔥",
|
||
"star" => "⭐",
|
||
"sparkles" => "✨",
|
||
"zap" => "⚡",
|
||
"boom" => "💥",
|
||
"tada" => "🎉",
|
||
"rocket" => "🚀",
|
||
"bulb" => "💡",
|
||
"bug" => "🐛",
|
||
"warning" => "⚠️",
|
||
"white_check_mark" | "check" => "✅",
|
||
"x" => "❌",
|
||
"question" => "❓",
|
||
"exclamation" => "❗",
|
||
"eyes" => "👀",
|
||
"100" => "💯",
|
||
"pray" => "🙏",
|
||
"muscle" => "💪",
|
||
"coffee" => "☕",
|
||
"book" => "📖",
|
||
"memo" | "pencil" => "📝",
|
||
"lock" => "🔒",
|
||
"key" => "🔑",
|
||
"bell" => "🔔",
|
||
"hourglass" => "⌛",
|
||
"calendar" => "📅",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> {
|
||
let bytes = s.as_bytes();
|
||
let mut last = 0;
|
||
for (i, &b) in bytes.iter().enumerate() {
|
||
let replacement: &[u8] = match b {
|
||
b'<' => b"<",
|
||
b'>' => b">",
|
||
b'&' => b"&",
|
||
b'"' => b""",
|
||
b'\'' => b"'",
|
||
_ => continue,
|
||
};
|
||
if i > last {
|
||
w.write_all(&bytes[last..i])?;
|
||
}
|
||
w.write_all(replacement)?;
|
||
last = i + 1;
|
||
}
|
||
if last < bytes.len() {
|
||
w.write_all(&bytes[last..])?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn escape(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
for c in s.chars() {
|
||
match c {
|
||
'<' => out.push_str("<"),
|
||
'>' => out.push_str(">"),
|
||
'&' => out.push_str("&"),
|
||
'"' => out.push_str("""),
|
||
'\'' => out.push_str("'"),
|
||
c => out.push(c),
|
||
}
|
||
}
|
||
out
|
||
}
|