Mentions and hashtags
Social text as Twitter and Mastodon read it: @mentions (fediverse ones too), #hashtags in any script, $cashtags and links found with their positions, and the text made into safe HTML with each one linked.
Version 1.0.0 · 4 functions · 2 crates
Functions
What use mentions.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use mentions_extract, mentions_names from mentions (Quillo 0.6).
mentions_extract(text: str) → anyEverything social in text: { mentions, hashtags, cashtags, urls }, each a list of { text, value, start, end } (positions in characters, as slice counts). value is the name without @ ("ana", or "ana@mastodon.social"), the tag without #, the symbol upper-cased, and a link's full URL (https:// added to www.example.com). An e-mail address is no mention; a # or @ inside a link is no tag.
mentions_names(text: str) → listThe people mentioned, once each, lower-cased, in order: who to notify.
mentions_hashtags(text: str) → listThe hashtags, once each (compared without case), as first written: for tagging a post.
mentions_html(text: str, options: any) → strtext as safe HTML (escaped, line breaks as <br>), with mentions, hashtags, cashtags and links made links. options give the addresses, {name} or {tag} filled in: mention ("/@{name}"), hashtag ("/tags/{tag}"), cashtag ("/stocks/{tag}"; "" to leave them as text); links open in a new tab with rel="nofollow noopener", shown cut to max_url characters (40).
Get it
$ quillo install mentions # mentions.qwer into this folder$ quillo install mentions --example # and its example, mentions.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/mentions.qwer$ curl -O https://quillolang.org/plugins/files/mentions.qweThen, in your program, all of it or only what you call:
use mentions.qweruse mentions_extract, mentions_names from mentions // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
linkify = "=0.10.0"serde_json = { version = "1", features = ["preserve_order"] }Example
// A community app's posts: who to notify, the tags to file it under, and the post shown with every mention, tag and link clickable.//// quillo run mentions.qwe then open http://localhost:8080/postuse "mentions.qwer"let POST = "Thanks @ana and @Ben_K for the #QuilloConf talk! Slides at www.example.com/slides?id=7#top 🎉 Mail me at me@example.com, not @ana. Also @lea@mastodon.social, #東京 #2026 and $AAPL up 3% <3"route GET /post { let found = mentions_extract(POST) { notify: mentions_names(POST), tags: mentions_hashtags(POST), cashtags: found.cashtags | map((c) => c.value), link: first(found.urls), first_mention: first(found.mentions), }}route GET /post.html = <html><body><p>{raw(mentions_html(POST, { mention: "/users/{name}", hashtag: "/search?tag={tag}" }))}</p></body></html>route GET /fragment = { html: mentions_html("Hi @ana, see #news at https://example.com/a-very-long-path/that/goes/on/and/on <b>!</b>", { cashtag: "" }) }$ quillo run mentions.qwe$ curl 'localhost:8080/post'$ curl 'localhost:8080/fragment'Source
//! Social text as Twitter, Mastodon and Slack read it: @mentions (fediverse @user@server too), #hashtags in any script, $cashtags and links found with their positions, and the text made into safe HTML with each one linked where you say.//! version: 1.0.0//!//! [dependencies]//! linkify = "=0.10.0"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};const TLDS: &[&str] = &["com", "org", "net", "io", "dev", "app", "co", "ai", "me", "info", "biz", "edu", "gov", "uk", "de", "fr", "nl", "be", "es", "it", "pt", "ch", "at", "se", "no", "dk", "fi", "pl", "cz", "ie", "eu", "us", "ca", "au", "nz", "jp", "cn", "in", "br", "mx", "ph", "sg", "za", "ru", "xyz", "tv", "ly", "gl", "gg", "social", "blog", "shop", "page", "site"];#[derive(Clone)]struct Entity { kind: &'static str, start: usize, end: usize, text: String, value: String }fn word_char(c: char) -> bool { c.is_alphanumeric() || c == '_' }fn find(text: &str) -> Vec<Entity> { let chars: Vec<char> = text.chars().collect(); // Byte offset of each char, to map linkify's byte spans to char positions. let mut byte_to_char = std::collections::HashMap::new(); for (ci, (bi, _)) in text.char_indices().enumerate() { byte_to_char.insert(bi, ci); } byte_to_char.insert(text.len(), chars.len()); let mut out: Vec<Entity> = Vec::new(); let mut lf = linkify::LinkFinder::new(); lf.kinds(&[linkify::LinkKind::Url]).url_must_have_scheme(false); for l in lf.links(text) { let s = l.as_str(); let has_scheme = s.contains("://"); let host = s.split("://").last().unwrap_or(s).split(['/', '?', '#', ':']).next().unwrap_or("").to_lowercase(); let tld = host.rsplit('.').next().unwrap_or(""); if has_scheme || host.starts_with("www.") || TLDS.contains(&tld) { let url = if has_scheme { s.to_string() } else { format!("https://{}", s) }; out.push(Entity { kind: "url", start: byte_to_char[&l.start()], end: byte_to_char[&l.end()], text: s.to_string(), value: url }); } } let in_url = |i: usize, out: &Vec<Entity>| out.iter().any(|e| e.kind == "url" && i >= e.start && i < e.end); let mut i = 0; while i < chars.len() { let c = chars[i]; let before = if i == 0 { None } else { Some(chars[i - 1]) }; let free = before.map(|b| !word_char(b) && !"@#$&/.!".contains(b)).unwrap_or(true); if (c == '@' || c == '@') && free && !in_url(i, &out) { let mut j = i + 1; while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') && j - i <= 30 { j += 1 } if j > i + 1 { let mut end = j; // A fediverse address: @user@server.example if j < chars.len() && chars[j] == '@' { let mut k = j + 1; while k < chars.len() && (chars[k].is_ascii_alphanumeric() || chars[k] == '-' || chars[k] == '.') { k += 1 } while k > j + 1 && chars[k - 1] == '.' { k -= 1 } if chars[j + 1..k].contains(&'.') { end = k } } if end == j && j < chars.len() && (word_char(chars[j]) || chars[j] == '@') { i = j; continue } let name: String = chars[i + 1..end].iter().collect(); out.push(Entity { kind: "mention", start: i, end, text: chars[i..end].iter().collect(), value: name }); i = end; continue; } } if (c == '#' || c == '#') && free && !in_url(i, &out) { let mut j = i + 1; while j < chars.len() && (word_char(chars[j]) || chars[j] == '\u{200C}' || chars[j] == '\u{200D}' || is_mark(chars[j])) && j - i <= 140 { j += 1 } let tag: String = chars[i + 1..j].iter().collect(); if j > i + 1 && tag.chars().any(|x| x.is_alphabetic()) { out.push(Entity { kind: "hashtag", start: i, end: j, text: chars[i..j].iter().collect(), value: tag }); i = j; continue; } } if c == '$' && free && !in_url(i, &out) { let mut j = i + 1; while j < chars.len() && chars[j].is_ascii_alphabetic() && j - i <= 6 { j += 1 } if j > i + 1 && j < chars.len() && (chars[j] == '.' || chars[j] == '_') && chars.get(j + 1).map(|x| x.is_ascii_alphabetic()).unwrap_or(false) { let mut k = j + 1; while k < chars.len() && chars[k].is_ascii_alphabetic() && k - j <= 2 { k += 1 } if k - j <= 3 { j = k } } if j > i + 1 && !chars.get(j).map(|x| word_char(*x)).unwrap_or(false) { let sym: String = chars[i + 1..j].iter().collect::<String>().to_uppercase(); out.push(Entity { kind: "cashtag", start: i, end: j, text: chars[i..j].iter().collect(), value: sym }); i = j; continue; } } i += 1; } out.sort_by_key(|e| e.start); out}fn is_mark(c: char) -> bool { // Combining marks, so hashtags in Hindi, Thai and Arabic stay whole. matches!(c as u32, 0x0300..=0x036F | 0x0610..=0x061A | 0x064B..=0x065F | 0x0900..=0x0903 | 0x093A..=0x094F | 0x0951..=0x0957 | 0x0962..=0x0963 | 0x0E31 | 0x0E34..=0x0E3A | 0x0E47..=0x0E4E | 0x3099..=0x309A)}fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")}fn url_part(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { if b.is_ascii_alphanumeric() || b"-_.~@".contains(&b) { out.push(b as char) } else { out.push_str(&format!("%{:02X}", b)) } } out}/// Everything social in `text`: `{ mentions, hashtags, cashtags, urls }`, each a list of `{ text, value, start, end }` (positions in characters, as `slice` counts)./// `value` is the name without `@` (`"ana"`, or `"ana@mastodon.social"`), the tag without `#`, the symbol upper-cased, and a link's full URL (`https://` added to `www.example.com`). An e-mail address is no mention; a `#` or `@` inside a link is no tag.pub fn mentions_extract(text: &str) -> Value { let mut m = Map::new(); for (key, kind) in [("mentions", "mention"), ("hashtags", "hashtag"), ("cashtags", "cashtag"), ("urls", "url")] { m.insert(key.into(), Value::Array(find(text).into_iter().filter(|e| e.kind == kind).map(|e| json!({ "text": e.text, "value": e.value, "start": e.start, "end": e.end })).collect())); } Value::Object(m)}/// The people mentioned, once each, lower-cased, in order: who to notify.pub fn mentions_names(text: &str) -> Vec<String> { let mut v: Vec<String> = Vec::new(); for e in find(text).into_iter().filter(|e| e.kind == "mention") { let n = e.value.to_lowercase(); if !v.contains(&n) { v.push(n) } } v}/// The hashtags, once each (compared without case), as first written: for tagging a post.pub fn mentions_hashtags(text: &str) -> Vec<String> { let mut v: Vec<String> = Vec::new(); for e in find(text).into_iter().filter(|e| e.kind == "hashtag") { if !v.iter().any(|x| x.to_lowercase() == e.value.to_lowercase()) { v.push(e.value) } } v}/// `text` as safe HTML (escaped, line breaks as `<br>`), with mentions, hashtags, cashtags and links made links. `options` give the addresses, `{name}` or `{tag}` filled in:/// `mention` (`"/@{name}"`), `hashtag` (`"/tags/{tag}"`), `cashtag` (`"/stocks/{tag}"`; `""` to leave them as text); links open in a new tab with `rel="nofollow noopener"`, shown cut to `max_url` characters (40).pub fn mentions_html(text: &str, options: serde_json::Value) -> String { let opt = |k: &str, d: &str| options.get(k).and_then(|v| v.as_str()).unwrap_or(d).to_string(); let (mention, hashtag, cashtag) = (opt("mention", "/@{name}"), opt("hashtag", "/tags/{tag}"), opt("cashtag", "/stocks/{tag}")); let max_url = options.get("max_url").and_then(|v| v.as_u64()).unwrap_or(40) as usize; let chars: Vec<char> = text.chars().collect(); let mut out = String::new(); let mut pos = 0; let plain = |s: &[char]| esc(&s.iter().collect::<String>()).replace("\r\n", "\n").replace('\n', "<br>"); for e in find(text) { if e.start < pos { continue } out.push_str(&plain(&chars[pos..e.start])); let link = match e.kind { "mention" if !mention.is_empty() => Some(format!("<a class=\"mention\" href=\"{}\">{}</a>", esc(&mention.replace("{name}", &url_part(&e.value))), esc(&e.text))), "hashtag" if !hashtag.is_empty() => Some(format!("<a class=\"hashtag\" href=\"{}\">{}</a>", esc(&hashtag.replace("{tag}", &url_part(&e.value))), esc(&e.text))), "cashtag" if !cashtag.is_empty() => Some(format!("<a class=\"cashtag\" href=\"{}\">{}</a>", esc(&cashtag.replace("{tag}", &url_part(&e.value))), esc(&e.text))), "url" => { let shown: String = if e.text.chars().count() > max_url { format!("{}…", e.text.chars().take(max_url.saturating_sub(1)).collect::<String>()) } else { e.text.clone() }; let shown = shown.trim_start_matches("https://").trim_start_matches("http://").to_string(); Some(format!("<a href=\"{}\" target=\"_blank\" rel=\"nofollow noopener\">{}</a>", esc(&e.value), esc(&shown))) } _ => None, }; match link { Some(l) => out.push_str(&l), None => out.push_str(&plain(&chars[e.start..e.end])) } pos = e.end; } out.push_str(&plain(&chars[pos..])); out}Thread 0 posts
On Mentions and hashtags: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.