Web

Link finder

URLs and emails found in plain text, with trailing punctuation handled, and the text turned into safe HTML with clickable, nofollow links.

Version 1.0.0 · 4 functions · 1 crate

#text#links#comments#email#html

Functions

What use link_extract.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use links_find, links_html from link_extract (Quillo 0.6).

Get it

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

Or with curl:

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

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

main.qwe
use link_extract.qweruse links_find, links_html from link_extract      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
linkify = "0.10"

Example

link_extract.qwe
// Comments shown with their links made clickable (and nofollow), and a check of where they point.////   quillo run link_extract.qwe      then open http://localhost:8080/commentuse "link_extract.qwer"let COMMENT = "Great post! The docs (https://quillolang.org/docs/routes) helped.\nMail me at Ada@Example.com, or see www.example.org/talk. <b>not bold</b>"route GET /comment = raw(`<p>${links_html(COMMENT, true, true)?}</p>`)route GET /links = {    found: links_find(COMMENT, true)? | map((l) => `${l.kind}: ${l.href} at ${l.start}`),    hosts: links_hosts(COMMENT, true)?,    emails: links_emails("Ada <ada@example.com>; bob@example.org, ADA@example.com")?,}
$ quillo run link_extract.qwe$ curl 'localhost:8080/comment'$ curl 'localhost:8080/links'

Source

link_extract.qwer
//! URLs and email addresses found in plain text (comments, chat messages, notes), with trailing punctuation and brackets handled as people write them, and the text turned into safe HTML with the links made clickable.//! version: 1.0.0//!//! [dependencies]//! linkify = "0.10"use linkify::{LinkFinder, LinkKind};use serde_json::{json, Value};const MAX_TEXT: usize = 2 << 20;fn finder(bare: bool) -> LinkFinder {    let mut f = LinkFinder::new();    f.kinds(&[LinkKind::Url, LinkKind::Email]);    f.url_must_have_scheme(!bare);    f}fn size(text: &str) -> Result<(), String> {    if text.len() > MAX_TEXT {        return Err("the text is at most 2 MB".into());    }    Ok(())}fn href(s: &str, kind: &LinkKind) -> String {    match kind {        LinkKind::Email => format!("mailto:{}", s),        _ if s.contains("://") => s.to_string(),        _ => format!("https://{}", s),    }}fn escape(s: &str) -> String {    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")}/// The links in a text, in order: `[{ kind, text, href, start, end }]`, `kind` "url" or "email", `href` ready for an `<a>` (`https://` added to a bare domain, `mailto:` to an email),/// `start` and `end` counted in characters as `slice` counts. `bare` true also finds links without a scheme (`example.com/page`), which can catch words like "file.txt".pub fn links_find(text: &str, bare: bool) -> Result<Vec<Value>, String> {    size(text)?;    let mut out = Vec::new();    for l in finder(bare).links(text) {        let kind = if matches!(l.kind(), LinkKind::Email) { "email" } else { "url" };        let start = text[..l.start()].chars().count();        out.push(json!({ "kind": kind, "text": l.as_str(), "href": href(l.as_str(), l.kind()), "start": start, "end": start + l.as_str().chars().count() }));        if out.len() >= 10_000 {            break;        }    }    Ok(out)}/// The text as HTML, escaped, with its URLs and emails as `<a>` links and line breaks as `<br>`: safe for what visitors write./// `nofollow` adds `rel="nofollow ugc noopener"` and `target="_blank"` to the web links, as comment sections should.pub fn links_html(text: &str, nofollow: bool, bare: bool) -> Result<String, String> {    size(text)?;    let mut out = String::with_capacity(text.len() + 64);    for span in finder(bare).spans(text) {        match span.kind() {            Some(k) => {                let h = href(span.as_str(), k);                let rel = if nofollow && !matches!(k, LinkKind::Email) { " rel=\"nofollow ugc noopener\" target=\"_blank\"" } else { "" };                out.push_str(&format!("<a href=\"{}\"{}>{}</a>", escape(&h), rel, escape(span.as_str())));            }            None => out.push_str(&escape(span.as_str()).replace("\r\n", "\n").replace('\n', "<br>\n")),        }    }    Ok(out)}/// The distinct email addresses in a text, in lower case, in the order first found: for pulling contacts out of a pasted list or a signature.pub fn links_emails(text: &str) -> Result<Vec<String>, String> {    size(text)?;    let mut f = LinkFinder::new();    f.kinds(&[LinkKind::Email]);    let mut out: Vec<String> = Vec::new();    for l in f.links(text) {        let e = l.as_str().to_lowercase();        if !out.contains(&e) {            out.push(e);        }    }    Ok(out)}/// The distinct hosts the text links to (`example.com`, `www.` kept), for checking comments against a block list.pub fn links_hosts(text: &str, bare: bool) -> Result<Vec<String>, String> {    size(text)?;    let mut out: Vec<String> = Vec::new();    for l in finder(bare).links(text).filter(|l| matches!(l.kind(), LinkKind::Url)) {        let s = l.as_str();        let rest = s.split_once("://").map(|x| x.1).unwrap_or(s);        let host = rest.split(['/', '?', '#']).next().unwrap_or("").rsplit('@').next().unwrap_or("").split(':').next().unwrap_or("").to_lowercase();        if !host.is_empty() && !out.contains(&host) {            out.push(host);        }    }    Ok(out)}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

On Link finder: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.