diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim
index dc4ad85..6e215cb 100644
--- a/autoload/nuwiki/commands.vim
+++ b/autoload/nuwiki/commands.vim
@@ -1085,8 +1085,14 @@ function! nuwiki#commands#colorize(...) abort
if l:color ==# ''
return
endif
- let l:open_tag = ''
- let l:close_tag = ''
+ " vimwiki `color_tag_template`: split on __CONTENT__ into open/close, with
+ " __COLORFG__ replaced by the colour. Configurable via g:nuwiki_color_tag_template.
+ let l:tpl = get(g:, 'nuwiki_color_tag_template',
+ \ '__CONTENT__')
+ let l:tpl = substitute(l:tpl, '__COLORFG__', l:color, 'g')
+ let l:parts = split(l:tpl, '__CONTENT__', 1)
+ let l:open_tag = get(l:parts, 0, '')
+ let l:close_tag = get(l:parts, 1, '')
if l:visual
let l:sp = getpos("'<")
diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs
index 60ad350..ed652b3 100644
--- a/crates/nuwiki-core/src/render/html.rs
+++ b/crates/nuwiki-core/src/render/html.rs
@@ -54,6 +54,13 @@ pub struct HtmlRenderer {
/// 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 `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,
+ /// vimwiki `emoji_enable` (default `false` in the renderer; set from
+ /// config): substitute `:alias:` shortcodes with their emoji glyph.
+ emoji_enable: bool,
}
impl Default for HtmlRenderer {
@@ -71,9 +78,28 @@ impl HtmlRenderer {
colors: HashMap::new(),
header_numbering: 0,
header_numbering_sym: String::new(),
+ valid_html_tags: Vec::new(),
+ emoji_enable: false,
}
}
+ /// 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) -> 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(mut self, f: F) -> Self
@@ -521,7 +547,16 @@ impl HtmlRenderer {
}
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
- write_escaped(&n.content, w)
+ 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<()> {
@@ -811,6 +846,145 @@ pub fn default_link_resolver(target: &LinkTarget) -> String {
// ===== 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 `` / `` 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 {
+ 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;
diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs
index eac8dd3..d5a6a44 100644
--- a/crates/nuwiki-lsp/src/export.rs
+++ b/crates/nuwiki-lsp/src/export.rs
@@ -257,6 +257,10 @@ pub fn render_page_html(
cfg.html_header_numbering_sym.clone(),
);
}
+ 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.render_to_string(doc)
}
diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs
index e8c628f..65f4d97 100644
--- a/crates/nuwiki-lsp/tests/html_export.rs
+++ b/crates/nuwiki-lsp/tests/html_export.rs
@@ -232,6 +232,64 @@ fn template_for_falls_back_when_no_directive() {
// ===== render_page_html =====
+#[test]
+fn render_page_html_passes_through_valid_html_tags() {
+ // ``/`` are in the default valid_html_tags → verbatim; `\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("H2O"), "sub verbatim: {html}");
+ assert!(html.contains("Ctrl"), "kbd verbatim: {html}");
+ assert!(html.contains("<script>"), "script escaped: {html}");
+}
+
+#[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");
diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua
index eee6e6d..99651ab 100644
--- a/lua/nuwiki/commands.lua
+++ b/lua/nuwiki/commands.lua
@@ -1213,8 +1213,17 @@ function M.colorize(color, visual)
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local line = vim.api.nvim_get_current_line()
- local open_tag = string.format('', color)
- local close_tag = ''
+ -- vimwiki `color_tag_template`: split on __CONTENT__ into open/close, with
+ -- __COLORFG__ replaced by the colour. Configurable via the setup option /
+ -- g:nuwiki_color_tag_template.
+ local tpl = (require('nuwiki.config').options or {}).color_tag_template
+ or vim.g.nuwiki_color_tag_template
+ or '__CONTENT__'
+ tpl = tpl:gsub('__COLORFG__', color)
+ local open_tag, close_tag = tpl:match('^(.*)__CONTENT__(.*)$')
+ if not open_tag then
+ open_tag, close_tag = string.format('', color), ''
+ end
if visual then
local sl, sc, el, ec = visual_selection_range()
if sl and sl == el then