markdown_that/plugins/extra/
linkify.rs

1//! Find urls and emails, and turn them into links
2
3use crate::parser::core::{CoreRule, Root};
4use crate::parser::extset::RootExt;
5use crate::parser::inline::builtin::InlineParserRule;
6use crate::parser::inline::{InlineRule, InlineState, TextSpecial};
7use crate::{MarkdownThat, Node, NodeValue, Renderer};
8use linkify::{LinkFinder, LinkKind};
9use regex::Regex;
10use std::cmp::Ordering;
11use std::sync::LazyLock;
12
13static SCHEME_RE: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(r"(?i)(?:^|[^a-z0-9.+-])([a-z][a-z0-9.+-]*)$").unwrap());
15
16#[derive(Debug)]
17pub struct Linkified {
18    pub url: String,
19}
20
21impl NodeValue for Linkified {
22    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
23        let mut attrs = node.attrs.clone();
24        attrs.push(("href", self.url.clone()));
25
26        fmt.open("a", &attrs);
27        fmt.contents(&node.children);
28        fmt.close("a");
29    }
30}
31
32pub fn add(md: &mut MarkdownThat) {
33    md.add_rule::<LinkifyPrescan>().before::<InlineParserRule>();
34
35    md.inline.add_rule::<LinkifyScanner>();
36}
37
38type LinkifyState = Vec<LinkifyPosition>;
39impl RootExt for LinkifyState {}
40
41#[derive(Debug, Clone, Copy)]
42struct LinkifyPosition {
43    start: usize,
44    end: usize,
45    //email: bool,
46}
47
48#[doc(hidden)]
49pub struct LinkifyPrescan;
50impl CoreRule for LinkifyPrescan {
51    fn run(root: &mut Node, _: &MarkdownThat) {
52        let root_data = root.cast_mut::<Root>().unwrap();
53        let source = root_data.content.as_str();
54        let finder = LinkFinder::new();
55        let positions = finder
56            .links(source)
57            .filter_map(|link| {
58                if *link.kind() == LinkKind::Url {
59                    Some(LinkifyPosition {
60                        start: link.start(),
61                        end: link.end(),
62                        //email: *link.kind() == LinkKind::Email,
63                    })
64                } else {
65                    None
66                }
67            })
68            .collect::<Vec<_>>();
69        root_data.ext.insert(positions);
70    }
71}
72
73#[doc(hidden)]
74pub struct LinkifyScanner;
75impl InlineRule for LinkifyScanner {
76    const MARKER: char = ':';
77
78    fn run(state: &mut InlineState) -> Option<(Node, usize)> {
79        let mut chars = state.src[state.pos..state.pos_max].chars();
80        if chars.next().unwrap() != ':' {
81            return None;
82        }
83        if state.link_level > 0 {
84            return None;
85        }
86
87        let trailing = state.trailing_text_get();
88        if !SCHEME_RE.is_match(trailing) {
89            return None;
90        }
91
92        let map = state.get_map(state.pos, state.pos_max)?;
93        let (start, _) = map.get_byte_offsets();
94
95        let positions = state.root_ext.get::<LinkifyState>().unwrap();
96
97        let found_idx = positions
98            .binary_search_by(|x| {
99                if x.start >= start {
100                    Ordering::Greater
101                } else if x.end <= start {
102                    Ordering::Less
103                } else {
104                    Ordering::Equal
105                }
106            })
107            .ok()?;
108
109        let found = positions[found_idx];
110        let proto_size = start - found.start;
111        if proto_size > trailing.len() {
112            return None;
113        }
114
115        debug_assert_eq!(
116            &trailing[trailing.len() - proto_size..],
117            &state.src[state.pos - proto_size..state.pos]
118        );
119
120        let url_start = state.pos - proto_size;
121        let url_end = state.pos - proto_size + found.end - found.start;
122        if url_end > state.pos_max {
123            return None;
124        }
125
126        let url = &state.src[url_start..url_end];
127        let full_url = state.md.link_formatter.normalize_link(url);
128
129        state.md.link_formatter.validate_link(&full_url)?;
130
131        let content = state.md.link_formatter.normalize_link_text(url);
132
133        let mut inner_node = Node::new(TextSpecial {
134            content: content.clone(),
135            markup: content,
136            info: "autolink",
137        });
138        inner_node.srcmap = state.get_map(url_start, url_end);
139
140        let mut node = Node::new(Linkified { url: full_url });
141        node.children.push(inner_node);
142
143        state.trailing_text_pop(proto_size);
144        state.pos -= proto_size;
145        Some((node, url_end - url_start))
146    }
147}