feat(config): P3 config batch — group 4 (valid_html_tags, emoji, color_tag_template)
- valid_html_tags: HTML export now passes the allowlisted inline tags through verbatim (b/i/sub/sup/kbd/div/center/strong/em/...) and escapes the rest, via a render-time tag-aware escaper (write_escaped_allowing/match_allowed_tag) — mirroring upstream's s:safe_html_line regex rather than a parser change. `span` is always allowed so colour spans render. - emoji_enable: render-time `:alias:` -> glyph substitution (curated common set), default on; with_emoji builder + substitute_emoji/emoji_for. - color_tag_template: :VimwikiColorize now wraps via a configurable template (g:nuwiki_color_tag_template / setup color_tag_template), splitting on __CONTENT__ with __COLORFG__ -> colour; both clients. Colour spans render in export via the valid_html_tags span passthrough. Default template reproduces the prior `<span style="color:NAME">` output (harness colorize tests green). Renderer gets with_valid_html_tags + with_emoji; export.rs wires both from HtmlConfig. Tests: valid_html_tags passthrough + escape, emoji on/off (html_export.rs). Full rust suite + both harnesses green (Neovim 307, Vim 301/18/21); clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1085,8 +1085,14 @@ function! nuwiki#commands#colorize(...) abort
|
|||||||
if l:color ==# ''
|
if l:color ==# ''
|
||||||
return
|
return
|
||||||
endif
|
endif
|
||||||
let l:open_tag = '<span style="color:' . l:color . '">'
|
" vimwiki `color_tag_template`: split on __CONTENT__ into open/close, with
|
||||||
let l:close_tag = '</span>'
|
" __COLORFG__ replaced by the colour. Configurable via g:nuwiki_color_tag_template.
|
||||||
|
let l:tpl = get(g:, 'nuwiki_color_tag_template',
|
||||||
|
\ '<span style="color:__COLORFG__">__CONTENT__</span>')
|
||||||
|
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
|
if l:visual
|
||||||
let l:sp = getpos("'<")
|
let l:sp = getpos("'<")
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ pub struct HtmlRenderer {
|
|||||||
/// vimwiki `html_header_numbering_sym`: a symbol appended after the
|
/// vimwiki `html_header_numbering_sym`: a symbol appended after the
|
||||||
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
||||||
header_numbering_sym: String,
|
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<String>,
|
||||||
|
/// 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 {
|
impl Default for HtmlRenderer {
|
||||||
@@ -71,9 +78,28 @@ impl HtmlRenderer {
|
|||||||
colors: HashMap::new(),
|
colors: HashMap::new(),
|
||||||
header_numbering: 0,
|
header_numbering: 0,
|
||||||
header_numbering_sym: String::new(),
|
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<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
|
/// Override the link resolver. The callback is invoked for every
|
||||||
/// wikilink and is expected to return a URL string.
|
/// wikilink and is expected to return a URL string.
|
||||||
pub fn with_link_resolver<F>(mut self, f: F) -> Self
|
pub fn with_link_resolver<F>(mut self, f: F) -> Self
|
||||||
@@ -521,7 +547,16 @@ impl HtmlRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
|
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<()> {
|
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 =====
|
// ===== 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<()> {
|
fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> {
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
let mut last = 0;
|
let mut last = 0;
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ pub fn render_page_html(
|
|||||||
cfg.html_header_numbering_sym.clone(),
|
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)
|
r.render_to_string(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,64 @@ fn template_for_falls_back_when_no_directive() {
|
|||||||
|
|
||||||
// ===== render_page_html =====
|
// ===== 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("<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]
|
#[test]
|
||||||
fn render_page_html_substitutes_title_content_and_extras() {
|
fn render_page_html_substitutes_title_content_and_extras() {
|
||||||
let ast = parse("%title My Page\n= One =\nbody text\n");
|
let ast = parse("%title My Page\n= One =\nbody text\n");
|
||||||
|
|||||||
+11
-2
@@ -1213,8 +1213,17 @@ function M.colorize(color, visual)
|
|||||||
end
|
end
|
||||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
local line = vim.api.nvim_get_current_line()
|
local line = vim.api.nvim_get_current_line()
|
||||||
local open_tag = string.format('<span style="color:%s">', color)
|
-- vimwiki `color_tag_template`: split on __CONTENT__ into open/close, with
|
||||||
local close_tag = '</span>'
|
-- __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 '<span style="color:__COLORFG__">__CONTENT__</span>'
|
||||||
|
tpl = tpl:gsub('__COLORFG__', color)
|
||||||
|
local open_tag, close_tag = tpl:match('^(.*)__CONTENT__(.*)$')
|
||||||
|
if not open_tag then
|
||||||
|
open_tag, close_tag = string.format('<span style="color:%s">', color), '</span>'
|
||||||
|
end
|
||||||
if visual then
|
if visual then
|
||||||
local sl, sc, el, ec = visual_selection_range()
|
local sl, sc, el, ec = visual_selection_range()
|
||||||
if sl and sl == el then
|
if sl and sl == el then
|
||||||
|
|||||||
Reference in New Issue
Block a user