ndg_commonmark/utils/
mod.rs1use std::{
2 path::{Path, PathBuf},
3 sync::{LazyLock, OnceLock, RwLock},
4};
5
6use rustc_hash::FxHashMap;
7pub mod codeblock;
8
9use comrak::{
10 Arena,
11 nodes::{AstNode, NodeHeading, NodeValue},
12 options::Options,
13 parse_document,
14};
15use regex::Regex;
16
17#[derive(Debug, thiserror::Error)]
19pub enum UtilError {
20 #[error("Regex compilation failed: {0}")]
21 RegexError(#[from] regex::Error),
22}
23
24pub type UtilResult<T> = Result<T, UtilError>;
26
27#[must_use]
30pub fn slugify(text: &str) -> String {
31 static CACHE: LazyLock<RwLock<FxHashMap<String, String>>> =
32 LazyLock::new(|| RwLock::new(FxHashMap::default()));
33
34 {
35 let cache = CACHE
36 .read()
37 .unwrap_or_else(std::sync::PoisonError::into_inner);
38 if let Some(cached) = cache.get(text) {
39 return cached.clone();
40 }
41 }
42
43 let result = text
44 .to_lowercase()
45 .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
46 .trim_matches('-')
47 .to_string();
48
49 let mut cache = CACHE
50 .write()
51 .unwrap_or_else(std::sync::PoisonError::into_inner);
52 if cache.len() < 2048 {
53 cache.insert(text.to_string(), result.clone());
54 }
55
56 result
57}
58
59#[must_use]
62pub fn extract_markdown_title(content: &str) -> Option<String> {
63 let arena = Arena::new();
64 let mut options = Options::default();
65 options.extension.table = true;
66 options.extension.footnotes = true;
67 options.extension.strikethrough = true;
68 options.extension.tasklist = true;
69 options.extension.superscript = true;
70 options.render.r#unsafe = true;
71
72 let root = parse_document(&arena, content, &options);
73
74 for node in root.descendants() {
75 if let NodeValue::Heading(NodeHeading { level, .. }) =
76 &node.data.borrow().value
77 && *level == 1
78 {
79 let text = extract_inline_text_from_node(node);
80 if !text.trim().is_empty() {
81 return Some(text.trim().to_string());
82 }
83 }
84 }
85 None
86}
87
88fn extract_inline_text_from_node<'a>(node: &'a AstNode<'a>) -> String {
91 let mut text = String::new();
92 for child in node.children() {
93 match &child.data.borrow().value {
94 NodeValue::Text(t) => text.push_str(t),
95 NodeValue::Code(t) => text.push_str(&t.literal),
96 NodeValue::Link(..)
97 | NodeValue::Emph
98 | NodeValue::Strong
99 | NodeValue::Strikethrough
100 | NodeValue::Superscript
101 | NodeValue::FootnoteReference(..) => {
102 text.push_str(&extract_inline_text_from_node(child));
103 },
104 #[expect(clippy::match_same_arms, reason = "Explicit for clarity")]
105 NodeValue::HtmlInline(_) | NodeValue::Image(..) => {},
106 _ => {},
107 }
108 }
109 text
110}
111
112#[must_use]
127pub fn extract_markdown_title_and_id(
128 content: &str,
129) -> Option<(String, Option<String>)> {
130 let arena = Arena::new();
131 let mut options = Options::default();
132 options.extension.table = true;
133 options.extension.footnotes = true;
134 options.extension.strikethrough = true;
135 options.extension.tasklist = true;
136 options.render.r#unsafe = true;
137
138 let root = parse_document(&arena, content, &options);
139
140 #[expect(
142 clippy::items_after_statements,
143 reason = "Static is Scoped to function for clarity"
144 )]
145 static ANCHOR_RE: OnceLock<Regex> = OnceLock::new();
146 let anchor_re = ANCHOR_RE.get_or_init(|| {
147 Regex::new(r"(\[\])?\{#(.*?)\}").unwrap_or_else(|e| {
148 log::error!(
149 "Failed to compile ANCHOR_RE regex in extract_h1_title: {e}\n Falling \
150 back to never matching regex."
151 );
152 never_matching_regex().unwrap_or_else(|_| {
153 #[expect(
155 clippy::expect_used,
156 reason = "This pattern is guaranteed to be valid"
157 )]
158 Regex::new(r"[^\s\S]")
159 .expect("regex pattern [^\\s\\S] should always compile")
160 })
161 })
162 });
163
164 for node in root.descendants() {
165 if let NodeValue::Heading(NodeHeading { level, .. }) =
166 &node.data.borrow().value
167 && *level == 1
168 {
169 let text = extract_inline_text_from_node(node);
170 let anchor_id = anchor_re
172 .captures(&text)
173 .and_then(|caps| caps.get(2).map(|m| m.as_str().to_string()));
174 let clean_title = anchor_re.replace_all(&text, "").trim().to_string();
175 if !clean_title.is_empty() {
176 return Some((clean_title, anchor_id));
177 }
178 }
179 }
180 None
181}
182
183#[must_use]
191pub fn clean_anchor_patterns(text: &str) -> String {
192 static ANCHOR_PATTERN: OnceLock<Regex> = OnceLock::new();
193 let anchor_pattern = ANCHOR_PATTERN.get_or_init(|| {
194 Regex::new(r"\s*\{#[a-zA-Z0-9_-]+\}\s*$").unwrap_or_else(|e| {
195 log::error!(
196 "Failed to compile ANCHOR_PATTERN regex in clean_anchor_patterns: \
197 {e}\n Falling back to never matching regex."
198 );
199 never_matching_regex().unwrap_or_else(|_| {
200 #[expect(
202 clippy::expect_used,
203 reason = "This pattern is guaranteed to be valid"
204 )]
205 Regex::new(r"[^\s\S]")
206 .expect("regex pattern [^\\s\\S] should always compile")
207 })
208 })
209 });
210 anchor_pattern.replace_all(text.trim(), "").to_string()
211}
212
213#[must_use]
218pub fn strip_markdown(content: &str) -> String {
219 let arena = Arena::new();
220 let mut options = Options::default();
221 options.extension.table = true;
222 options.extension.footnotes = true;
223 options.extension.strikethrough = true;
224 options.extension.tasklist = true;
225 options.render.r#unsafe = true;
226
227 let root = parse_document(&arena, content, &options);
228
229 let mut plain_text = String::new();
230
231 #[expect(
232 clippy::items_after_statements,
233 reason = "Helper scoped for clarity"
234 )]
235 fn extract_text<'a>(
236 node: &'a AstNode<'a>,
237 plain_text: &mut String,
238 in_code_block: &mut bool,
239 ) {
240 match &node.data.borrow().value {
241 NodeValue::Paragraph => {
242 for child in node.children() {
243 extract_text(child, plain_text, in_code_block);
244 }
245 plain_text.push('\n');
247 },
248 NodeValue::Heading(_) => {
249 for child in node.children() {
250 extract_text(child, plain_text, in_code_block);
251 }
252 plain_text.push('\n');
254 },
255 NodeValue::Text(t) => {
256 if !*in_code_block {
257 plain_text.push_str(t);
258 }
259 },
260 NodeValue::CodeBlock(_) => {
261 *in_code_block = true;
262 for child in node.children() {
263 extract_text(child, plain_text, in_code_block);
264 }
265 *in_code_block = false;
266 },
267 NodeValue::SoftBreak => {
268 plain_text.push(' ');
269 },
270 NodeValue::LineBreak => {
271 plain_text.push('\n');
272 },
273 NodeValue::List(_) => {
274 for child in node.children() {
275 extract_text(child, plain_text, in_code_block);
276 }
277 plain_text.push('\n');
278 },
279 NodeValue::Code(c) => {
280 if !*in_code_block {
281 plain_text.push_str(&c.literal);
282 }
283 },
284 _ => {
285 for child in node.children() {
286 extract_text(child, plain_text, in_code_block);
287 }
288 },
289 }
290 }
291
292 let mut in_code_block = false;
293 extract_text(root, &mut plain_text, &mut in_code_block);
294 plain_text
295}
296
297pub fn capitalize_first(s: &str) -> String {
299 let mut chars = s.chars();
300 chars.next().map_or_else(String::new, |c| {
301 c.to_uppercase().collect::<String>() + chars.as_str()
302 })
303}
304
305#[must_use]
307pub fn is_markdown_header(line: &str) -> bool {
308 line.trim_start().starts_with('#')
309}
310
311#[derive(Debug, thiserror::Error)]
313pub enum ManpageUrlsError {
314 #[error("failed to read manpage URL mappings from {path}")]
316 Read {
317 path: PathBuf,
319 #[source]
321 source: std::io::Error,
322 },
323 #[error("failed to parse manpage URL mappings from {path}")]
325 Parse {
326 path: PathBuf,
328 #[source]
330 source: serde_json::Error,
331 },
332}
333
334pub fn load_manpage_urls(
340 path: impl AsRef<Path>,
341) -> Result<FxHashMap<String, String>, ManpageUrlsError> {
342 let path = path.as_ref();
343 let content = std::fs::read_to_string(path).map_err(|source| {
344 ManpageUrlsError::Read {
345 path: path.to_path_buf(),
346 source,
347 }
348 })?;
349 let mappings = serde_json::from_str(&content).map_err(|source| {
350 ManpageUrlsError::Parse {
351 path: path.to_path_buf(),
352 source,
353 }
354 })?;
355 Ok(mappings)
356}
357
358pub fn never_matching_regex() -> Result<regex::Regex, regex::Error> {
369 regex::Regex::new(r"[^\s\S]").or_else(|_| {
372 regex::Regex::new(r"^\b$")
375 })
376}