use crate::Channel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Emph {
Bold,
Italic,
Strike,
Code,
Tag(u8),
}
pub(crate) const INLINE_TAGS: [(&str, &str, &str); 16] = [
("sub", "<sub>", "</sub>"),
("sup", "<sup>", "</sup>"),
("b", "<b>", "</b>"),
("strong", "<strong>", "</strong>"),
("i", "<i>", "</i>"),
("em", "<em>", "</em>"),
("u", "<u>", "</u>"),
("s", "<s>", "</s>"),
("strike", "<strike>", "</strike>"),
("del", "<del>", "</del>"),
("code", "<code>", "</code>"),
("span", "<span>", "</span>"),
("small", "<small>", "</small>"),
("mark", "<mark>", "</mark>"),
("kbd", "<kbd>", "</kbd>"),
("ins", "<ins>", "</ins>"),
];
#[derive(Debug, Clone)]
pub(crate) struct Vocab {
pub channel: Channel,
pub limit: usize,
br: bool,
load_images: bool,
schemes: Option<std::rc::Rc<[String]>>,
}
impl Vocab {
pub fn new(channel: Channel) -> Self {
Self::from_options(channel, &crate::Options::default())
}
pub fn from_options(channel: Channel, o: &crate::Options) -> Self {
let limit = match o.limit {
Some(n) if channel != Channel::Html => n.max(crate::MIN_LIMIT),
_ => channel.limit(),
};
Self {
channel,
limit,
br: o.html.line_breaks == crate::LineBreaks::Br,
load_images: o.html.images == crate::Images::Load,
schemes: o.html.schemes.as_ref().map(|s| s.iter().map(|x| x.to_ascii_lowercase()).collect()),
}
}
fn allowed(&self, url: &str) -> bool {
let u = url.trim_start();
let Some(colon) = u.find(':') else { return false };
let scheme = &u[..colon];
match &self.schemes {
None => ["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s)),
Some(list) => list.iter().any(|s| scheme.eq_ignore_ascii_case(s)),
}
}
pub fn tables_native(&self) -> bool {
matches!(
self.channel,
Channel::SlackMarkdown | Channel::GithubMarkdown | Channel::NotionMarkdown | Channel::Html
)
}
pub fn xml_tables(&self) -> bool {
self.channel == Channel::NotionMarkdown
}
pub fn is_html(&self) -> bool {
self.channel == Channel::Html
}
pub(crate) fn html_out(&self) -> bool {
matches!(self.channel, Channel::TelegramHtml | Channel::Html)
}
pub fn line_break(&self) -> &'static str {
if self.is_html() && self.br { "<br>\n" } else { "\n" }
}
pub fn is_plain(&self) -> bool {
self.channel == Channel::Plain
}
pub fn open(&self, e: Emph) -> &'static str {
match (self.channel, e) {
(_, Emph::Tag(t)) => INLINE_TAGS[t as usize].1,
(Channel::TelegramHtml, Emph::Bold) => "<b>",
(Channel::TelegramHtml, Emph::Italic) => "<i>",
(Channel::TelegramHtml, Emph::Strike) => "<s>",
(Channel::TelegramHtml, Emph::Code) => "<code>",
(Channel::Plain, _) => "",
(Channel::Html, _) => self.open_html(e),
(_, Emph::Strike) => "~~",
(_, Emph::Bold) => "**",
(_, Emph::Italic) => "*",
(_, Emph::Code) => "`",
}
}
pub fn keeps_br(&self) -> bool {
self.channel == Channel::NotionMarkdown
}
pub fn line_emphasis(&self) -> bool {
self.channel == Channel::NotionMarkdown
}
pub fn html_emphasis(&self) -> bool {
self.channel == Channel::GithubMarkdown
}
pub fn joiner_emphasis(&self) -> bool {
self.channel == Channel::SlackMarkdown
}
pub fn open_html(&self, e: Emph) -> &'static str {
match e {
Emph::Bold => "<strong>",
Emph::Italic => "<em>",
Emph::Strike => "<del>",
Emph::Code => "<code>",
Emph::Tag(t) => INLINE_TAGS[t as usize].1,
}
}
pub fn close_html(&self, e: Emph) -> &'static str {
match e {
Emph::Bold => "</strong>",
Emph::Italic => "</em>",
Emph::Strike => "</del>",
Emph::Code => "</code>",
Emph::Tag(t) => INLINE_TAGS[t as usize].2,
}
}
pub fn close(&self, e: Emph) -> &'static str {
match (self.channel, e) {
(_, Emph::Tag(t)) => INLINE_TAGS[t as usize].2,
(Channel::TelegramHtml, Emph::Bold) => "</b>",
(Channel::TelegramHtml, Emph::Italic) => "</i>",
(Channel::TelegramHtml, Emph::Strike) => "</s>",
(Channel::TelegramHtml, Emph::Code) => "</code>",
(Channel::Html, _) => self.close_html(e),
_ => self.open(e),
}
}
pub fn escape_char(&self, c: char, out: &mut String) {
match c {
'&' if self.html_out() => out.push_str("&"),
'<' if self.html_out() => out.push_str("<"),
'>' if self.html_out() => out.push_str(">"),
_ if self.escapes(c) => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
pub fn escapes(&self, c: char) -> bool {
match self.channel {
Channel::GithubMarkdown => matches!(c, '~' | '<' | '*'),
Channel::NotionMarkdown => matches!(c, '*' | '\\'),
_ => false,
}
}
pub fn code_char(&self, c: char, out: &mut String) {
if self.html_out() {
self.escape_char(c, out);
} else {
out.push(c);
}
}
pub fn escape(&self, s: &str, out: &mut String) {
if !self.html_out() {
out.push_str(s);
return;
}
if !s.contains(['&', '<', '>']) {
out.push_str(s);
return;
}
for c in s.chars() {
self.escape_char(c, out);
}
}
pub fn link(&self, text: &str, url: &str, out: &mut String) {
match self.channel {
Channel::TelegramHtml => {
let markup = escaped_len(url) + "<a href=\"\"></a>".len();
if markup >= self.limit {
out.push_str(text);
if !url.is_empty() && !escaped_eq(text, url) {
out.push_str(" (");
self.escape(url, out);
out.push(')');
}
return;
}
out.push_str("<a href=\"");
push_attr(url, out);
out.push_str("\">");
out.push_str(text);
out.push_str("</a>");
}
Channel::Html => {
if !self.allowed(url) {
out.push_str(text);
if !url.is_empty() && !escaped_eq(text, url) {
out.push_str(" (");
self.escape(url, out);
out.push(')');
}
return;
}
out.push_str("<a href=\"");
push_attr(url, out);
out.push_str("\">");
out.push_str(text);
out.push_str("</a>");
}
Channel::Plain => {
out.push_str(text);
if !url.is_empty() && url != text {
out.push_str(" (");
out.push_str(url);
out.push(')');
}
}
_ => {
if text == url && url.contains("://") && self.channel == Channel::NotionMarkdown {
out.push('[');
for c in text.chars() {
if matches!(c, '\\' | '*' | '_' | '~' | '`' | '[' | ']' | '$') {
out.push('\\');
}
out.push(c);
}
out.push_str("](");
for c in url.chars() {
match c {
'(' => out.push_str("%28"),
')' => out.push_str("%29"),
' ' => out.push_str("%20"),
_ => out.push(c),
}
}
out.push(')');
return;
}
if text == url && url.contains("://") {
out.push('<');
out.push_str(url);
out.push('>');
return;
}
out.push('[');
out.push_str(text);
out.push_str("](");
out.push_str(url);
out.push(')');
}
}
}
pub fn image(&self, alt: &str, url: &str, out: &mut String) {
match self.channel {
Channel::Html if self.load_images && self.allowed(url) => {
out.push_str("<img src=\"");
push_attr(url, out);
out.push_str("\" alt=\"");
for c in alt.chars() {
if c == '"' {
out.push_str(""");
} else {
out.push(c);
}
}
out.push_str("\">");
}
Channel::Html | Channel::TelegramHtml | Channel::Plain => self.link(alt, url, out),
Channel::SlackMarkdown | Channel::GithubMarkdown | Channel::NotionMarkdown => {
out.push('!');
self.link(alt, url, out);
}
}
}
pub fn literal(&self, c: char, out: &mut String) {
match self.channel {
Channel::TelegramHtml | Channel::Plain | Channel::Html => self.escape_char(c, out),
_ if matches!(
c,
'*' | '_' | '~' | '`' | '\\' | '[' | ']' | '(' | ')' | '#' | '>' | '|' | '-'
| '+' | '.' | '!'
) || (c == '$' && self.channel == Channel::NotionMarkdown) =>
{
out.push('\\');
out.push(c);
}
_ => self.escape_char(c, out),
}
}
pub fn bullet(&self) -> &'static str {
match self.channel {
Channel::SlackMarkdown | Channel::GithubMarkdown | Channel::NotionMarkdown => "- ",
_ => "• ",
}
}
pub fn quote_prefix(&self) -> &'static str {
if self.html_out() { "" } else { "> " }
}
pub fn quote_open(&self) -> &'static str {
if self.html_out() { "<blockquote>" } else { "" }
}
pub fn quote_close(&self) -> &'static str {
if self.html_out() { "</blockquote>" } else { "" }
}
pub fn rule(&self) -> &'static str {
match self.channel {
Channel::TelegramHtml | Channel::Plain => "──────────",
Channel::Html => "<hr>",
_ => "---",
}
}
pub fn max_heading(&self) -> usize {
match self.channel {
Channel::SlackMarkdown => 3,
Channel::GithubMarkdown | Channel::Html => 6,
Channel::NotionMarkdown => 4,
Channel::TelegramHtml | Channel::Plain => 0,
}
}
pub fn verbatim_open(&self, info: &str, out: &mut String) {
match self.channel {
Channel::TelegramHtml | Channel::Html => {
out.push_str("<pre>");
let lang = fence_lang(info);
if !lang.is_empty() {
out.push_str("<code class=\"language-");
push_attr(lang, out);
out.push_str("\">");
}
}
Channel::Plain => {}
_ => {
out.push_str("```");
out.push_str(info);
}
}
}
pub fn verbatim_body_newline(&self) -> bool {
!matches!(self.channel, Channel::TelegramHtml | Channel::Plain | Channel::Html)
}
pub fn verbatim_close(&self, info: &str, out: &mut String) {
match self.channel {
Channel::TelegramHtml | Channel::Html => {
if !fence_lang(info).is_empty() {
out.push_str("</code>");
}
out.push_str("</pre>");
}
Channel::Plain => {}
_ => out.push_str("\n```"),
}
}
}
fn fence_lang(info: &str) -> &str {
let lang = info.split_whitespace().next().unwrap_or("");
if lang.chars().count() <= 32 { lang } else { "" }
}
fn push_attr(s: &str, out: &mut String) {
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
}
fn escaped_len(url: &str) -> usize {
url.chars()
.map(|c| match c {
'&' => 5,
'<' | '>' => 4,
'"' => 6,
_ => 1,
})
.sum()
}
fn escaped_eq(text: &str, url: &str) -> bool {
let mut t = text.chars();
for c in url.chars() {
let escaped = match c {
'&' => "&",
'<' => "<",
'>' => ">",
'"' => """,
_ => {
if t.next() != Some(c) {
return false;
}
continue;
}
};
for e in escaped.chars() {
if t.next() != Some(e) {
return false;
}
}
}
t.next().is_none()
}