1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4pub mod code_fence;
5pub mod frontmatter;
6pub mod heading;
7pub mod image;
8pub mod link;
9pub mod outline;
10pub mod plain_text;
11
12pub use code_fence::{MarkdownCodeFence, extract_code_fences};
13pub use frontmatter::{extract_frontmatter, has_frontmatter, strip_frontmatter};
14pub use heading::{MarkdownHeading, extract_headings};
15pub use image::{MarkdownImage, extract_images};
16pub use link::{MarkdownLink, extract_links};
17pub use outline::{MarkdownOutline, extract_outline};
18pub use plain_text::markdown_to_plain_text;
19
20pub fn heading_to_anchor(text: &str) -> String {
22 let mut anchor = String::new();
23 let mut previous_was_separator = false;
24
25 for character in text.trim().chars() {
26 if character.is_alphanumeric() {
27 for lowered in character.to_lowercase() {
28 anchor.push(lowered);
29 }
30 previous_was_separator = false;
31 continue;
32 }
33
34 if matches!(character, '\'' | '"' | '`') {
35 continue;
36 }
37
38 if !anchor.is_empty() && !previous_was_separator {
39 anchor.push('-');
40 previous_was_separator = true;
41 }
42 }
43
44 anchor.trim_matches('-').to_owned()
45}
46
47pub fn is_horizontal_rule(line: &str) -> bool {
49 let trimmed = line.trim();
50 if trimmed.len() < 3 {
51 return false;
52 }
53
54 let mut marker = None;
55 let mut count = 0usize;
56
57 for character in trimmed.chars() {
58 if character.is_whitespace() {
59 continue;
60 }
61
62 if !matches!(character, '-' | '*' | '_') {
63 return false;
64 }
65
66 match marker {
67 Some(existing) if existing != character => return false,
68 Some(_) => {},
69 None => marker = Some(character),
70 }
71
72 count += 1;
73 }
74
75 count >= 3
76}
77
78pub fn is_blockquote(line: &str) -> bool {
80 line.trim_start().starts_with('>')
81}
82
83pub fn is_unordered_list_item(line: &str) -> bool {
85 unordered_list_item_content(line).is_some()
86}
87
88pub fn is_ordered_list_item(line: &str) -> bool {
90 ordered_list_item_content(line).is_some()
91}
92
93pub(crate) fn strip_up_to_three_leading_spaces(line: &str) -> &str {
94 let mut removed = 0usize;
95 let mut index = 0usize;
96
97 for (byte_index, character) in line.char_indices() {
98 if removed < 3 && character == ' ' {
99 removed += 1;
100 index = byte_index + character.len_utf8();
101 } else {
102 return &line[index..];
103 }
104 }
105
106 &line[index..]
107}
108
109pub(crate) fn unordered_list_item_content(line: &str) -> Option<&str> {
110 let trimmed = line.trim_start();
111 let mut characters = trimmed.chars();
112 let marker = characters.next()?;
113 if !matches!(marker, '-' | '*' | '+') {
114 return None;
115 }
116
117 let marker_length = marker.len_utf8();
118 let remainder = &trimmed[marker_length..];
119 remainder
120 .chars()
121 .next()
122 .filter(|character| character.is_whitespace())
123 .map(|character| &remainder[character.len_utf8()..])
124}
125
126pub(crate) fn ordered_list_item_content(line: &str) -> Option<&str> {
127 let trimmed = line.trim_start();
128 let digit_count = trimmed
129 .chars()
130 .take_while(|character| character.is_ascii_digit())
131 .count();
132 if digit_count == 0 {
133 return None;
134 }
135
136 let marker_start = trimmed
137 .char_indices()
138 .nth(digit_count)
139 .map_or(trimmed.len(), |(index, _)| index);
140 let marker = trimmed[marker_start..].chars().next()?;
141 if !matches!(marker, '.' | ')') {
142 return None;
143 }
144
145 let after_marker = &trimmed[marker_start + marker.len_utf8()..];
146 after_marker
147 .chars()
148 .next()
149 .filter(|character| character.is_whitespace())
150 .map(|character| &after_marker[character.len_utf8()..])
151}