Skip to main content

markdown_link/
href.rs

1//! Writing a link: from one vault-relative file to another, as a relative
2//! markdown link a renderer with no vault can follow.
3//!
4//! The encoding follows JavaScript's `encodeURIComponent` and `encodeURI`, so
5//! a link written here reads back the same in a browser and in a TypeScript
6//! tool.
7
8use std::fmt::Write;
9
10/// The relative URL from the file `from` to the file `to`, both
11/// vault-relative and `/`-separated, with an optional `#fragment`.
12///
13/// The path is relative to `from`'s folder, `path.posix.relative` style, and
14/// each segment is encoded with [`encode_uri_component`]. A file linking
15/// itself gets its own name. The fragment is encoded with
16/// [`encode_uri_fragment`], so a `#` inside it cannot start a second one.
17///
18/// # Examples
19///
20/// ```
21/// use markdown_link::relative_link;
22///
23/// assert_eq!(relative_link("a/b.md", "a/c d.md", None), "c%20d.md");
24/// assert_eq!(relative_link("a/b/x.md", "c/y.md", Some("Part 1")), "../../c/y.md#Part%201");
25/// assert_eq!(relative_link("a/x.md", "a/x.md", Some("H#2")), "x.md#H%232");
26/// ```
27pub fn relative_link(from: &str, to: &str, fragment: Option<&str>) -> String {
28    let from_dir = from.rfind('/').map_or("", |at| &from[..at]);
29    let mut relative = posix_relative(from_dir, to);
30    if relative.is_empty() {
31        // A file linking itself: the walk yields "", and a link needs a target.
32        relative = to.rsplit('/').next().unwrap_or(to).to_owned();
33    }
34    let encoded: Vec<String> = relative
35        .split('/')
36        .map(|segment| {
37            if segment == ".." || segment == "." {
38                segment.to_owned()
39            } else {
40                encode_uri_component(segment)
41            }
42        })
43        .collect();
44    match fragment {
45        Some(fragment) => format!("{}#{}", encoded.join("/"), encode_uri_fragment(fragment)),
46        None => encoded.join("/"),
47    }
48}
49
50/// `path.posix.relative` for `/`-separated paths: up out of `from_dir`'s
51/// unshared segments, then down `to`'s.
52fn posix_relative(from_dir: &str, to: &str) -> String {
53    let from: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
54    let to: Vec<&str> = to.split('/').filter(|s| !s.is_empty()).collect();
55    let shared = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
56    let mut out: Vec<&str> = vec![".."; from.len() - shared];
57    out.extend(&to[shared..]);
58    out.join("/")
59}
60
61/// JavaScript's `encodeURIComponent`: every byte of the UTF-8 text except
62/// `A-Z a-z 0-9 - _ . ! ~ * ' ( )` is percent-encoded.
63///
64/// # Examples
65///
66/// ```
67/// use markdown_link::encode_uri_component;
68///
69/// assert_eq!(encode_uri_component("a b/c#d"), "a%20b%2Fc%23d");
70/// assert_eq!(encode_uri_component("é"), "%C3%A9");
71/// ```
72pub fn encode_uri_component(text: &str) -> String {
73    percent_encode(text, |b| {
74        b.is_ascii_alphanumeric()
75            || matches!(
76                b,
77                b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')'
78            )
79    })
80}
81
82/// JavaScript's `encodeURI`, with `#` also encoded, for a fragment. URI
83/// structure characters (`/ ? : @ & = + $ ; ,`) are kept, so a fragment such
84/// as `page=2` reads back as written.
85///
86/// # Examples
87///
88/// ```
89/// use markdown_link::encode_uri_fragment;
90///
91/// assert_eq!(encode_uri_fragment("page=2"), "page=2");
92/// assert_eq!(encode_uri_fragment("A heading #2"), "A%20heading%20%232");
93/// ```
94pub fn encode_uri_fragment(fragment: &str) -> String {
95    percent_encode(fragment, |b| {
96        b.is_ascii_alphanumeric()
97            || matches!(
98                b,
99                b'-' | b'_'
100                    | b'.'
101                    | b'!'
102                    | b'~'
103                    | b'*'
104                    | b'\''
105                    | b'('
106                    | b')'
107                    | b';'
108                    | b','
109                    | b'/'
110                    | b'?'
111                    | b':'
112                    | b'@'
113                    | b'&'
114                    | b'='
115                    | b'+'
116                    | b'$'
117            )
118    })
119}
120
121/// Markdown link text with `\`, `[` and `]` escaped, so a label cannot end or
122/// nest the link it sits in.
123///
124/// # Examples
125///
126/// ```
127/// use markdown_link::escape_label;
128///
129/// assert_eq!(escape_label("a [b] \\ c"), "a \\[b\\] \\\\ c");
130/// ```
131pub fn escape_label(label: &str) -> String {
132    let mut out = String::with_capacity(label.len());
133    for c in label.chars() {
134        if matches!(c, '\\' | '[' | ']') {
135            out.push('\\');
136        }
137        out.push(c);
138    }
139    out
140}
141
142fn percent_encode(text: &str, keep: impl Fn(u8) -> bool) -> String {
143    let mut out = String::with_capacity(text.len());
144    for byte in text.bytes() {
145        if keep(byte) {
146            out.push(byte as char);
147        } else {
148            let _ = write!(out, "%{byte:02X}");
149        }
150    }
151    out
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn a_link_climbs_out_of_unshared_folders_only() {
160        assert_eq!(relative_link("x.md", "a/b.md", None), "a/b.md");
161        assert_eq!(relative_link("a/x.md", "b.md", None), "../b.md");
162        assert_eq!(relative_link("a/b/x.md", "a/c/y.md", None), "../c/y.md");
163    }
164
165    #[test]
166    fn segments_are_encoded_and_dot_segments_are_not() {
167        assert_eq!(
168            relative_link("a/x.md", "My Notes/(draft) é.md", None),
169            "../My%20Notes/(draft)%20%C3%A9.md"
170        );
171    }
172
173    #[test]
174    fn a_self_link_uses_its_own_name() {
175        assert_eq!(relative_link("x.md", "x.md", None), "x.md");
176        assert_eq!(relative_link("a/x.md", "a/x.md", Some("^b1")), "x.md#%5Eb1");
177    }
178
179    #[test]
180    fn the_two_encodings_differ_on_uri_structure() {
181        assert_eq!(encode_uri_component("a/b?c=d"), "a%2Fb%3Fc%3Dd");
182        assert_eq!(encode_uri_fragment("a/b?c=d"), "a/b?c=d");
183        assert_eq!(encode_uri_fragment("#"), "%23");
184    }
185}