Text

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

#social#links#user-content#notifications

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) → any

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.

mentions_names(text: str) → list

The people mentioned, once each, lower-cased, in order: who to notify.

mentions_hashtags(text: str) → list

The hashtags, once each (compared without case), as first written: for tagging a post.

mentions_html(text: str, options: any) → str

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).

Get it

$ quillo install mentions               # mentions.qwer into this folder$ quillo install mentions --example     # and its example, mentions.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/mentions.qwer$ curl -O https://quillolang.org/plugins/files/mentions.qwe

Then, in your program, all of it or only what you call:

main.qwe
use mentions.qweruse mentions_extract, mentions_names from mentions      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
linkify = "=0.10.0"serde_json = { version = "1", features = ["preserve_order"] }

Example

mentions.qwe
// 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

mentions.qwer
//! 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")}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
69Useful? Like it, or add to its thread below.

Thread 0 posts

On Mentions and hashtags: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.