documents: shortcodes read from the source, so escapes, references and autolinks keep them; whole tag names, decoded attributes, only anchors link
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8d27f9088a
commit
2736a109b2
4 files changed
+195
-36
No files matched your search
+147
-28
@@ -1,3 +1,5 @@
|
||||
use markdown::{decode_named, decode_numeric};
|
||||
|
||||
use super::Style;
|
||||
|
||||
/// One HTML tag as written: its lowercase name and attributes.
|
||||
@@ -17,7 +19,7 @@ impl Tag {
|
||||
}
|
||||
}
|
||||
|
||||
/// How Typora shows an inline tag; others show their text.
|
||||
/// How Typora shows an inline tag; others show text.
|
||||
pub(crate) fn inline_style(name: &str) -> Option<Style> {
|
||||
match name {
|
||||
"u" => Some(Style::Underline),
|
||||
@@ -31,23 +33,23 @@ pub(crate) fn inline_style(name: &str) -> Option<Style> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `html` read as one tag; comments and the rest are none.
|
||||
/// `html` as one tag; comments and the rest are none.
|
||||
pub(crate) fn tag(html: &str) -> Option<Tag> {
|
||||
let inner = html.trim().strip_prefix('<')?.strip_suffix('>')?;
|
||||
let (closing, inner) = match inner.strip_prefix('/') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, inner),
|
||||
};
|
||||
let named = inner
|
||||
.find(|ch: char| !ch.is_ascii_alphanumeric())
|
||||
.unwrap_or(inner.len());
|
||||
if named == 0 || !inner.starts_with(|ch: char| ch.is_ascii_alphabetic()) {
|
||||
if !inner.starts_with(|ch: char| ch.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let named = inner
|
||||
.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
|
||||
.unwrap_or(inner.len());
|
||||
Some(Tag {
|
||||
name: inner[..named].to_ascii_lowercase(),
|
||||
closing,
|
||||
attributes: attributes(inner[named..].trim_end_matches('/')),
|
||||
attributes: attributes(&inner[named..]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,8 +58,13 @@ fn attributes(mut rest: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
rest = rest.trim_start();
|
||||
// A slash between attributes closes the tag itself.
|
||||
if let Some(after) = rest.strip_prefix('/') {
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let named = rest
|
||||
.find(|ch: char| ch.is_whitespace() || ch == '=')
|
||||
.find(|ch: char| ch.is_whitespace() || ch == '=' || ch == '/')
|
||||
.unwrap_or(rest.len());
|
||||
if named == 0 {
|
||||
return out;
|
||||
@@ -79,30 +86,26 @@ fn attributes(mut rest: &str) -> Vec<(String, String)> {
|
||||
(&after[..end], &after[end..])
|
||||
}
|
||||
};
|
||||
out.push((name, value.to_string()));
|
||||
out.push((name, decoded(value)));
|
||||
rest = left;
|
||||
}
|
||||
}
|
||||
|
||||
/// `:shortcode:` written as its emoji, as Typora shows it.
|
||||
pub(crate) fn emoji(text: &str) -> String {
|
||||
/// `text` with its character references decoded.
|
||||
fn decoded(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(open) = rest.find(':') {
|
||||
out.push_str(&rest[..open]);
|
||||
let after = &rest[open + 1..];
|
||||
let code = after
|
||||
.find(':')
|
||||
.map(|close| &after[..close])
|
||||
.filter(|code| !code.is_empty() && code.chars().all(shortcode_char));
|
||||
match code.and_then(emojis::get_by_shortcode) {
|
||||
Some(found) => {
|
||||
out.push_str(found.as_str());
|
||||
rest = &after[code.expect("a matched code").len() + 1..];
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
match reference(rest) {
|
||||
Some((value, len)) => {
|
||||
out.push_str(&value);
|
||||
rest = &rest[len..];
|
||||
}
|
||||
None => {
|
||||
out.push(':');
|
||||
rest = after;
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +113,98 @@ pub(crate) fn emoji(text: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The reference `text` starts with, as CommonMark reads one.
|
||||
fn reference(text: &str) -> Option<(String, usize)> {
|
||||
let body = text.strip_prefix('&')?;
|
||||
let end = body.find(';')?;
|
||||
let name = &body[..end];
|
||||
let value = match name.strip_prefix('#') {
|
||||
Some(number) => {
|
||||
let (digits, radix, most) = match number.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => (hex, 16, 6),
|
||||
None => (number, 10, 7),
|
||||
};
|
||||
let valid =
|
||||
(1..=most).contains(&digits.len()) && digits.chars().all(|ch| ch.is_digit(radix));
|
||||
valid.then(|| decode_numeric(digits, radix))?
|
||||
}
|
||||
None if !name.is_empty() && name.chars().all(|ch| ch.is_ascii_alphanumeric()) => {
|
||||
decode_named(name, true)?
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
Some((value, end + 2))
|
||||
}
|
||||
|
||||
/// `value` with each `:shortcode:` written plainly in `source` as emoji.
|
||||
pub(crate) fn emoji(source: &str, value: &str) -> String {
|
||||
let mut codes = literal_codes(source).into_iter().peekable();
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let (mut colons, mut rest) = (0, value);
|
||||
while let Some(at) = rest.find(':') {
|
||||
out.push_str(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
match codes.peek() {
|
||||
Some((ordinal, code, glyph))
|
||||
if *ordinal == colons
|
||||
&& rest[1..].starts_with(code)
|
||||
&& rest[1 + code.len()..].starts_with(':') =>
|
||||
{
|
||||
out.push_str(glyph);
|
||||
rest = &rest[code.len() + 2..];
|
||||
colons += 2;
|
||||
codes.next();
|
||||
}
|
||||
_ => {
|
||||
out.push(':');
|
||||
rest = &rest[1..];
|
||||
colons += 1;
|
||||
while codes.next_if(|(ordinal, _, _)| *ordinal < colons).is_some() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Plain shortcodes in `source`, with colons counted before each.
|
||||
fn literal_codes(source: &str) -> Vec<(usize, &str, &'static str)> {
|
||||
let (mut out, mut colons, mut at) = (Vec::new(), 0, 0);
|
||||
while let Some(ch) = source[at..].chars().next() {
|
||||
let (rest, after) = (&source[at..], &source[at + ch.len_utf8()..]);
|
||||
if ch == '\\'
|
||||
&& let Some(next) = after.chars().next().filter(char::is_ascii_punctuation)
|
||||
{
|
||||
colons += usize::from(next == ':');
|
||||
at += 1 + next.len_utf8();
|
||||
continue;
|
||||
}
|
||||
if ch == '&'
|
||||
&& let Some((text, len)) = reference(rest)
|
||||
{
|
||||
colons += text.matches(':').count();
|
||||
at += len;
|
||||
continue;
|
||||
}
|
||||
if ch == ':' {
|
||||
let found = after
|
||||
.find(':')
|
||||
.map(|end| &after[..end])
|
||||
.filter(|code| !code.is_empty() && code.chars().all(shortcode_char))
|
||||
.and_then(|code| emojis::get_by_shortcode(code).map(|found| (code, found)));
|
||||
if let Some((code, found)) = found {
|
||||
out.push((colons, code, found.as_str()));
|
||||
colons += 2;
|
||||
at += code.len() + 2;
|
||||
continue;
|
||||
}
|
||||
colons += 1;
|
||||
}
|
||||
at += ch.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn shortcode_char(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-')
|
||||
}
|
||||
@@ -139,15 +234,39 @@ mod tests {
|
||||
Some(("u".into(), true))
|
||||
);
|
||||
assert_eq!(tag("<br/>").map(|t| t.name), Some("br".into()));
|
||||
assert_eq!(tag("<u-box>").map(|t| t.name), Some("u-box".into()));
|
||||
assert_eq!(tag("<!-- note -->"), None);
|
||||
assert_eq!(tag("<1>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_become_emoji_and_others_stay() {
|
||||
assert_eq!(emoji("hi :smile: :+1:"), "hi 😄 👍");
|
||||
assert_eq!(emoji(":nope: at 10:30 and ::"), ":nope: at 10:30 and ::");
|
||||
assert_eq!(emoji("a:smile:b"), "a😄b");
|
||||
fn attribute_values_keep_slashes_and_decode_references() {
|
||||
let bare = tag("<a href=/path/>").expect("a tag");
|
||||
assert_eq!(bare.attribute("href"), Some("/path/"));
|
||||
let closed = tag("<img src=\"a.png\" />").expect("a tag");
|
||||
assert_eq!(closed.attribute("src"), Some("a.png"));
|
||||
let query = tag("<a href=\"https://x.test/?q=1&p=2&r=3&nope;\">");
|
||||
let query = query.expect("a tag");
|
||||
assert_eq!(
|
||||
query.attribute("href"),
|
||||
Some("https://x.test/?q=1&p=2&r=3&nope;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_written_plainly_become_emoji() {
|
||||
let same = |text: &str| emoji(text, text);
|
||||
assert_eq!(same("hi :smile: :+1:"), "hi 😄 👍");
|
||||
assert_eq!(same(":nope: at 10:30 and ::"), ":nope: at 10:30 and ::");
|
||||
assert_eq!(same("a:smile:b"), "a😄b");
|
||||
assert_eq!(same("é :smile: 中\\é"), "é 😄 中\\é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_or_referenced_colons_stay() {
|
||||
assert_eq!(emoji("\\:smile: :smile:", ":smile: :smile:"), ":smile: 😄");
|
||||
assert_eq!(emoji(":smile: :tada:", ":smile: :tada:"), ":smile: 🎉");
|
||||
assert_eq!(emoji(":smile:", ":smile:"), ":smile:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) struct Reader<'a> {
|
||||
notes: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// A style open in a run; `tag` names an HTML one.
|
||||
/// An open style; `tag` names an HTML one.
|
||||
struct Opened {
|
||||
start: usize,
|
||||
style: Style,
|
||||
@@ -85,6 +85,8 @@ struct Opened {
|
||||
struct Run {
|
||||
text: Inline,
|
||||
open: Vec<Opened>,
|
||||
/// Inside an autolink: its text stays as written.
|
||||
verbatim: bool,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
@@ -131,10 +133,13 @@ impl Run {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let url = tag.attribute("href").map(str::to_string);
|
||||
if style == Style::Link && url.is_none() {
|
||||
return;
|
||||
}
|
||||
let url = match style {
|
||||
Style::Link => match tag.attribute("href") {
|
||||
Some(href) => Some(href.to_string()),
|
||||
None => return,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
self.open.push(Opened {
|
||||
start: self.text.text.len(),
|
||||
style,
|
||||
@@ -304,8 +309,12 @@ impl<'a> Reader<'a> {
|
||||
let text = &mut run.text;
|
||||
let (children, style, url) = match node {
|
||||
mdast::Node::Text(words) => {
|
||||
let soft = words.value.replace("\r\n", " ").replace(['\r', '\n'], " ");
|
||||
return text.text.push_str(&html::emoji(&soft));
|
||||
let shown = match run.verbatim {
|
||||
true => words.value.clone(),
|
||||
false => html::emoji(&self.source[span(node)], &words.value),
|
||||
};
|
||||
let soft = shown.replace("\r\n", " ").replace(['\r', '\n'], " ");
|
||||
return run.text.text.push_str(&soft);
|
||||
}
|
||||
mdast::Node::Break(_) => return text.text.push('\n'),
|
||||
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
|
||||
@@ -350,9 +359,14 @@ impl<'a> Reader<'a> {
|
||||
url,
|
||||
tag: None,
|
||||
});
|
||||
let autolink =
|
||||
matches!(node, mdast::Node::Link(_)) && !self.source[span(node)].starts_with('[');
|
||||
let outer = run.verbatim;
|
||||
run.verbatim |= autolink;
|
||||
for child in children {
|
||||
self.inline_into(child, run, images);
|
||||
}
|
||||
run.verbatim = outer;
|
||||
// Tags left open end with their container.
|
||||
let at = run
|
||||
.open
|
||||
|
||||
@@ -261,3 +261,29 @@ fn footnotes_number_by_their_first_reference() {
|
||||
fn one_tilde_still_strikes() {
|
||||
assert_eq!(line("H~2~O").styles, [(1..2, Style::Strike)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_references_and_autolinks_keep_their_shortcodes() {
|
||||
assert_eq!(line(r"Show \:smile: now.").text, "Show :smile: now.");
|
||||
assert_eq!(line("Show :smile: now.").text, "Show :smile: now.");
|
||||
assert_eq!(
|
||||
line("<https://x.test/:smile:>").text,
|
||||
"https://x.test/:smile:"
|
||||
);
|
||||
assert_eq!(line("[go :smile:](https://x.test)").text, "go 😄");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tags_show_text_and_only_anchors_link() {
|
||||
let custom = line("a <u-box>word</u-box> <br-box>in</br-box> end");
|
||||
assert_eq!(
|
||||
(custom.text.as_str(), custom.styles.len()),
|
||||
("a word in end", 0)
|
||||
);
|
||||
let bold = line("See <b href=\"https://x.test/\">bold</b>.");
|
||||
assert_eq!(bold.styles, [(4..8, Style::Strong)]);
|
||||
assert!(bold.links.is_empty(), "{bold:?}");
|
||||
let query = line("<a href=\"https://x.test/?q=1&p=2\">r</a> <a href=/path/>s</a>");
|
||||
let urls: Vec<&str> = query.links.iter().map(|(_, url)| url.as_str()).collect();
|
||||
assert_eq!(urls, ["https://x.test/?q=1&p=2", "/path/"]);
|
||||
}
|
||||
Reference in new issue
Block a user