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