Skip to content

Commit ca546de

Browse files
committed
util_markdown: Support anchor link for headings (#53184)
zed-industries/zed#53184
1 parent 0e0ab18 commit ca546de

1 file changed

Lines changed: 108 additions & 0 deletions

File tree

‎crates/util/src/markdown.rs‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,62 @@
11
use std::fmt::{Display, Formatter};
22

3+
/// Generates a URL-friendly slug from heading text (e.g. "Hello World" → "hello-world").
4+
pub fn generate_heading_slug(text: &str) -> String {
5+
text.trim()
6+
.chars()
7+
.filter_map(|c| {
8+
if c.is_alphanumeric() || c == '-' || c == '_' {
9+
Some(c.to_lowercase().next().unwrap_or(c))
10+
} else if c == ' ' {
11+
Some('-')
12+
} else {
13+
None
14+
}
15+
})
16+
.collect()
17+
}
18+
19+
/// Returns true if the URL starts with a URI scheme (RFC 3986 §3.1).
20+
fn has_uri_scheme(url: &str) -> bool {
21+
let mut chars = url.chars();
22+
match chars.next() {
23+
Some(c) if c.is_ascii_alphabetic() => {}
24+
_ => return false,
25+
}
26+
for c in chars {
27+
if c == ':' {
28+
return true;
29+
}
30+
if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
31+
return false;
32+
}
33+
}
34+
false
35+
}
36+
37+
/// Splits a relative URL into its path and `#fragment` parts.
38+
/// Absolute URLs are returned as-is with no fragment.
39+
pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
40+
if has_uri_scheme(url) {
41+
return (url, None);
42+
}
43+
match url.find('#') {
44+
Some(pos) => {
45+
let path = &url[..pos];
46+
let fragment = &url[pos + 1..];
47+
(
48+
path,
49+
if fragment.is_empty() {
50+
None
51+
} else {
52+
Some(fragment)
53+
},
54+
)
55+
}
56+
None => (url, None),
57+
}
58+
}
59+
360
/// Indicates that the wrapped `String` is markdown text.
461
#[derive(Debug, Clone)]
562
pub struct MarkdownString(pub String);
@@ -265,4 +322,55 @@ mod tests {
265322
"it can't be downgraded later"
266323
);
267324
}
325+
326+
#[test]
327+
fn test_split_local_url_fragment() {
328+
assert_eq!(split_local_url_fragment("#heading"), ("", Some("heading")));
329+
assert_eq!(
330+
split_local_url_fragment("./file.md#heading"),
331+
("./file.md", Some("heading"))
332+
);
333+
assert_eq!(split_local_url_fragment("./file.md"), ("./file.md", None));
334+
assert_eq!(
335+
split_local_url_fragment("https://example.com#frag"),
336+
("https://example.com#frag", None)
337+
);
338+
assert_eq!(
339+
split_local_url_fragment("mailto:user@example.com"),
340+
("mailto:user@example.com", None)
341+
);
342+
assert_eq!(split_local_url_fragment("#"), ("", None));
343+
assert_eq!(
344+
split_local_url_fragment("../other.md#section"),
345+
("../other.md", Some("section"))
346+
);
347+
assert_eq!(
348+
split_local_url_fragment("123:not-a-scheme#frag"),
349+
("123:not-a-scheme", Some("frag"))
350+
);
351+
}
352+
353+
#[test]
354+
fn test_generate_heading_slug() {
355+
assert_eq!(generate_heading_slug("Hello World"), "hello-world");
356+
assert_eq!(generate_heading_slug("Hello World"), "hello--world");
357+
assert_eq!(generate_heading_slug("Hello-World"), "hello-world");
358+
assert_eq!(
359+
generate_heading_slug("Some **bold** text"),
360+
"some-bold-text"
361+
);
362+
assert_eq!(generate_heading_slug("Let's try with Ü"), "lets-try-with-ü");
363+
assert_eq!(
364+
generate_heading_slug("heading with 123 numbers"),
365+
"heading-with-123-numbers"
366+
);
367+
assert_eq!(
368+
generate_heading_slug("What about (parens)?"),
369+
"what-about-parens"
370+
);
371+
assert_eq!(
372+
generate_heading_slug(" leading spaces "),
373+
"leading-spaces"
374+
);
375+
}
268376
}

0 commit comments

Comments
 (0)