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}