1use std::{fmt::Write, fs, path::Path};
3
4use html_escape::encode_text;
5
6use super::{dom::safe_select, process::process_safe};
7
8fn sanitize_option_id(name: &str) -> String {
14 let sanitized: String = name
15 .chars()
16 .map(|c| {
17 match c {
18 '*' | '<' | '>' | '[' | ']' | ':' | '"' | ' ' => '_',
19 c => c,
20 }
21 })
22 .collect();
23 format!("option-{sanitized}")
24}
25
26#[cfg(feature = "gfm")]
39#[must_use]
40pub fn apply_gfm_extensions(markdown: &str) -> String {
41 markdown.to_owned()
44}
45
46const MAX_INCLUDE_DEPTH: usize = 8;
48
49#[cfg(feature = "nixpkgs")]
52fn is_safe_path(path: &str, _base_dir: &Path) -> bool {
53 let p = Path::new(path);
54 if p.is_absolute() || path.contains('\\') {
55 return false;
56 }
57
58 for component in p.components() {
60 if matches!(component, std::path::Component::ParentDir) {
61 return false;
62 }
63 }
64
65 true
66}
67
68#[cfg(feature = "nixpkgs")]
70#[allow(
71 clippy::option_if_let_else,
72 reason = "Nested options are clearer with if-let"
73)]
74fn parse_include_directive(line: &str) -> Option<String> {
75 if let Some(start) = line.find("html:into-file=") {
76 let start = start + "html:into-file=".len();
77 if let Some(end) = line[start..].find(' ') {
78 Some(line[start..start + end].to_string())
79 } else {
80 Some(line[start..].trim().to_string())
81 }
82 } else {
83 None
84 }
85}
86
87#[cfg(feature = "nixpkgs")]
89#[allow(
90 clippy::needless_pass_by_value,
91 reason = "Owned value needed for cloning in loop"
92)]
93fn read_includes(
94 listing: &str,
95 base_dir: &Path,
96 custom_output: Option<String>,
97 included_files: &mut Vec<crate::types::IncludedFile>,
98 depth: usize,
99) -> Result<String, String> {
100 let mut result = String::new();
101
102 for line in listing.lines() {
103 let trimmed = line.trim();
104 if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
105 continue;
106 }
107 let full_path = base_dir.join(trimmed);
108 log::info!("Including file: {}", full_path.display());
109
110 match fs::read_to_string(&full_path) {
111 Ok(content) => {
112 let file_dir = full_path.parent().unwrap_or(base_dir);
113 let (processed_content, nested_includes) =
114 process_file_includes(&content, file_dir, depth + 1)?;
115
116 result.push_str(&processed_content);
117 if !processed_content.ends_with('\n') {
118 result.push('\n');
119 }
120
121 included_files.push(crate::types::IncludedFile {
122 path: trimmed.to_string(),
123 custom_output: custom_output.clone(),
124 });
125
126 for nested in nested_includes {
128 let nested_full_path = file_dir.join(&nested.path);
129 if let Ok(normalized_path) = nested_full_path.strip_prefix(base_dir) {
130 included_files.push(crate::types::IncludedFile {
131 path: normalized_path.to_string_lossy().to_string(),
132 custom_output: nested.custom_output,
133 });
134 }
135 }
136 },
137 Err(_) => {
138 let _ = writeln!(
139 result,
140 "<!-- ndg: could not include file: {} -->",
141 full_path.display()
142 );
143 },
144 }
145 }
146 Ok(result)
147}
148
149#[cfg(feature = "nixpkgs")]
180pub fn process_file_includes(
181 markdown: &str,
182 base_dir: &std::path::Path,
183 depth: usize,
184) -> Result<(String, Vec<crate::types::IncludedFile>), String> {
185 if depth >= MAX_INCLUDE_DEPTH {
187 return Err(format!(
188 "Maximum include recursion depth ({MAX_INCLUDE_DEPTH}) exceeded. This \
189 likely indicates a cycle in file includes."
190 ));
191 }
192
193 let mut output = String::new();
194 let mut lines = markdown.lines();
195 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
196 let mut all_included_files: Vec<crate::types::IncludedFile> = Vec::new();
197
198 while let Some(line) = lines.next() {
199 let trimmed = line.trim_start();
200
201 if !fence_tracker.in_code_block() && trimmed.starts_with("```{=include=}") {
202 let custom_output = parse_include_directive(trimmed);
203
204 let mut include_listing = String::new();
205 for next_line in lines.by_ref() {
206 if next_line.trim_start().starts_with("```") {
207 break;
208 }
209 include_listing.push_str(next_line);
210 include_listing.push('\n');
211 }
212
213 let included = read_includes(
214 &include_listing,
215 base_dir,
216 custom_output,
217 &mut all_included_files,
218 depth,
219 )?;
220 output.push_str(&included);
221 continue;
222 }
223
224 fence_tracker = fence_tracker.process_line(line);
226
227 output.push_str(line);
228 output.push('\n');
229 }
230
231 Ok((output, all_included_files))
232}
233
234#[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
249#[must_use]
250#[allow(
251 clippy::implicit_hasher,
252 reason = "Standard HashMap/HashSet sufficient for this use case"
253)]
254pub fn process_role_markup(
255 content: &str,
256 manpage_urls: Option<&std::collections::HashMap<String, String>>,
257 auto_link_options: bool,
258 valid_options: Option<&std::collections::HashSet<String>>,
259) -> String {
260 let mut result = String::new();
261 let mut chars = content.chars().peekable();
262 let mut tracker = crate::utils::codeblock::InlineTracker::new();
263
264 while let Some(ch) = chars.next() {
265 if ch == '`' {
267 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
268 tracker = new_tracker;
269
270 result.push_str(&"`".repeat(tick_count));
272 continue;
273 }
274
275 if ch == '~' && chars.peek() == Some(&'~') {
277 let (new_tracker, tilde_count) = tracker.process_tildes(&mut chars);
278 tracker = new_tracker;
279
280 result.push_str(&"~".repeat(tilde_count));
281 continue;
282 }
283
284 if ch == '\n' {
286 tracker = tracker.process_newline();
287 result.push(ch);
288 continue;
289 }
290
291 if ch == '{' && !tracker.in_any_code() {
293 let remaining: Vec<char> = chars.clone().collect();
295 let remaining_str: String = remaining.iter().collect();
296 let mut temp_chars = remaining_str.chars().peekable();
297
298 if let Some(role_markup) = parse_role_markup(
299 &mut temp_chars,
300 manpage_urls,
301 auto_link_options,
302 valid_options,
303 ) {
304 let remaining_after_parse: String = temp_chars.collect();
306 let consumed = remaining_str.len() - remaining_after_parse.len();
307 for _ in 0..consumed {
308 chars.next();
309 }
310 result.push_str(&role_markup);
311 } else {
312 result.push(ch);
314 }
315 } else {
316 result.push(ch);
317 }
318 }
319
320 result
321}
322
323fn parse_role_markup(
329 chars: &mut std::iter::Peekable<std::str::Chars>,
330 manpage_urls: Option<&std::collections::HashMap<String, String>>,
331 auto_link_options: bool,
332 valid_options: Option<&std::collections::HashSet<String>>,
333) -> Option<String> {
334 let mut role_name = String::new();
335
336 while let Some(&ch) = chars.peek() {
338 if ch.is_ascii_lowercase() {
339 role_name.push(ch);
340 chars.next();
341 } else {
342 break;
343 }
344 }
345
346 if role_name.is_empty() {
348 return None;
349 }
350
351 if chars.peek() != Some(&'}') {
353 return None;
354 }
355 chars.next(); if chars.peek() != Some(&'`') {
359 return None;
360 }
361 chars.next(); let mut content = String::new();
365 for ch in chars.by_ref() {
366 if ch == '`' {
367 if content.is_empty() && !matches!(role_name.as_str(), "manpage") {
370 return None; }
372 return Some(format_role_markup(
373 &role_name,
374 &content,
375 manpage_urls,
376 auto_link_options,
377 valid_options,
378 ));
379 }
380 content.push(ch);
381 }
382
383 None
385}
386
387#[must_use]
389#[allow(
390 clippy::option_if_let_else,
391 reason = "Nested options clearer with if-let"
392)]
393#[allow(
394 clippy::implicit_hasher,
395 reason = "Standard HashMap/HashSet sufficient for this use case"
396)]
397pub fn format_role_markup(
398 role_type: &str,
399 content: &str,
400 manpage_urls: Option<&std::collections::HashMap<String, String>>,
401 auto_link_options: bool,
402 valid_options: Option<&std::collections::HashSet<String>>,
403) -> String {
404 let escaped_content = encode_text(content);
405 match role_type {
406 "manpage" => {
407 if let Some(urls) = manpage_urls {
408 if let Some(url) = urls.get(content) {
409 format!(
410 "<a href=\"{url}\" \
411 class=\"manpage-reference\">{escaped_content}</a>"
412 )
413 } else {
414 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
415 }
416 } else {
417 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
418 }
419 },
420 "command" => format!("<code class=\"command\">{escaped_content}</code>"),
421 "env" => format!("<code class=\"env-var\">{escaped_content}</code>"),
422 "file" => format!("<code class=\"file-path\">{escaped_content}</code>"),
423 "option" => {
424 if cfg!(feature = "ndg-flavored") && auto_link_options {
425 let should_link =
427 valid_options.is_none_or(|opts| opts.contains(content)); if should_link {
430 let option_id = sanitize_option_id(content);
431 format!(
432 "<a class=\"option-reference\" \
433 href=\"options.html#{option_id}\"><code \
434 class=\"nixos-option\">{escaped_content}</code></a>"
435 )
436 } else {
437 format!("<code class=\"nixos-option\">{escaped_content}</code>")
438 }
439 } else {
440 format!("<code class=\"nixos-option\">{escaped_content}</code>")
441 }
442 },
443 "var" => format!("<code class=\"nix-var\">{escaped_content}</code>"),
444 _ => format!("<span class=\"{role_type}-markup\">{escaped_content}</span>"),
445 }
446}
447
448#[must_use]
462pub fn process_myst_autolinks(content: &str) -> String {
463 let mut result = String::with_capacity(content.len());
464 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
465
466 for line in content.lines() {
467 fence_tracker = fence_tracker.process_line(line);
469
470 if fence_tracker.in_code_block() {
472 result.push_str(line);
473 } else {
474 result.push_str(&process_line_myst_autolinks(line));
475 }
476 result.push('\n');
477 }
478
479 result
480}
481
482fn process_line_myst_autolinks(line: &str) -> String {
484 let mut result = String::with_capacity(line.len());
485 let mut chars = line.chars().peekable();
486
487 while let Some(ch) = chars.next() {
488 if ch == '[' && chars.peek() == Some(&']') {
489 chars.next(); if chars.peek() == Some(&'{') {
494 result.push_str("[]");
496 continue;
497 }
498
499 if chars.peek() == Some(&'(') {
500 chars.next(); let mut url = String::new();
504 let mut found_closing = false;
505 while let Some(&next_ch) = chars.peek() {
506 if next_ch == ')' {
507 chars.next(); found_closing = true;
509 break;
510 }
511 url.push(next_ch);
512 chars.next();
513 }
514
515 if found_closing && !url.is_empty() {
516 if url.starts_with('#') {
518 let _ = write!(result, "[{{{{ANCHOR}}}}]({url})");
520 } else if url.starts_with("http://") || url.starts_with("https://") {
521 let _ = write!(result, "<{url}>");
523 } else {
524 let _ = write!(result, "[]({url})");
526 }
527 } else {
528 result.push_str("](");
530 result.push_str(&url);
531 }
532 } else {
533 result.push(']');
535 }
536 } else {
537 result.push(ch);
538 }
539 }
540
541 result
542}
543
544#[cfg(feature = "nixpkgs")]
557#[must_use]
558pub fn process_inline_anchors(content: &str) -> String {
559 let mut result = String::with_capacity(content.len() + 100);
560 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
561
562 for line in content.lines() {
563 let trimmed = line.trim_start();
564
565 fence_tracker = fence_tracker.process_line(line);
567
568 if fence_tracker.in_code_block() {
570 result.push_str(line);
572 } else {
573 if let Some(anchor_start) = find_list_item_anchor(trimmed)
576 && let Some(processed_line) =
577 process_list_item_anchor(line, anchor_start)
578 {
579 result.push_str(&processed_line);
580 result.push('\n');
581 continue;
582 }
583
584 result.push_str(&process_line_anchors(line));
586 }
587 result.push('\n');
588 }
589
590 result
591}
592
593fn find_list_item_anchor(trimmed: &str) -> Option<usize> {
595 if (trimmed.starts_with("- ")
597 || trimmed.starts_with("* ")
598 || trimmed.starts_with("+ "))
599 && trimmed.len() > 2
600 {
601 let after_marker = &trimmed[2..];
602 if after_marker.starts_with("[]{#") {
603 return Some(2);
604 }
605 }
606
607 let digit_end = trimmed
609 .char_indices()
610 .find(|(_, c)| !c.is_ascii_digit())
611 .map_or(trimmed.len(), |(i, _)| i);
612 if digit_end > 0
613 && digit_end < trimmed.len() - 1
614 && trimmed.as_bytes().get(digit_end) == Some(&b'.')
615 {
616 let after_marker = &trimmed[digit_end + 1..];
617 if after_marker.starts_with(" []{#") {
618 return Some(digit_end + 2);
619 }
620 }
621
622 None
623}
624
625fn process_list_item_anchor(line: &str, anchor_start: usize) -> Option<String> {
627 let before_anchor = &line[..anchor_start];
628 let after_marker = &line[anchor_start..];
629
630 if !after_marker.starts_with("[]{#") {
631 return None;
632 }
633
634 if let Some(anchor_end) = after_marker.find('}') {
636 let id = &after_marker[4..anchor_end]; let remaining_content = &after_marker[anchor_end + 1..]; if id
641 .chars()
642 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
643 && !id.is_empty()
644 {
645 return Some(format!(
646 "{before_anchor}<span id=\"{id}\" \
647 class=\"nixos-anchor\"></span>{remaining_content}"
648 ));
649 }
650 }
651
652 None
653}
654
655fn process_line_anchors(line: &str) -> String {
657 let mut result = String::with_capacity(line.len());
658 let mut chars = line.chars().peekable();
659
660 while let Some(ch) = chars.next() {
661 if ch == '[' && chars.peek() == Some(&']') {
662 chars.next(); if chars.peek() == Some(&'{') {
666 chars.next(); if chars.peek() == Some(&'#') {
668 chars.next(); let mut id = String::new();
672 while let Some(&next_ch) = chars.peek() {
673 if next_ch == '}' {
674 chars.next(); if !id.is_empty()
678 && id
679 .chars()
680 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
681 {
682 let _ = write!(
683 result,
684 "<span id=\"{id}\" class=\"nixos-anchor\"></span>"
685 );
686 } else {
687 let _ = write!(result, "[]{{{{#{id}}}}}");
689 }
690 break;
691 } else if next_ch.is_ascii_alphanumeric()
692 || next_ch == '-'
693 || next_ch == '_'
694 {
695 id.push(next_ch);
696 chars.next();
697 } else {
698 let _ = write!(result, "[]{{{{#{id}");
700 break;
701 }
702 }
703 } else {
704 result.push_str("]{");
706 }
707 } else {
708 result.push(']');
710 }
711 } else {
712 result.push(ch);
713 }
714 }
715
716 result
717}
718
719#[cfg(feature = "nixpkgs")]
733#[must_use]
734pub fn process_block_elements(content: &str) -> String {
735 let mut result = Vec::new();
736 let mut lines = content.lines().peekable();
737 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
738
739 while let Some(line) = lines.next() {
740 fence_tracker = fence_tracker.process_line(line);
742
743 if !fence_tracker.in_code_block() {
745 if let Some((callout_type, initial_content)) = parse_github_callout(line)
747 {
748 let content =
749 collect_github_callout_content(&mut lines, &initial_content);
750 let admonition = render_admonition(&callout_type, None, &content);
751 result.push(admonition);
752 continue;
753 }
754
755 if let Some((adm_type, id)) = parse_fenced_admonition_start(line) {
757 let (content, trailing) = collect_fenced_content(&mut lines);
758 let admonition = render_admonition(&adm_type, id.as_deref(), &content);
759 result.push(admonition);
760 if let Some(trailing_content) = trailing {
763 result.push(trailing_content);
764 }
765 continue;
766 }
767
768 if let Some((id, title, content)) = parse_figure_block(line, &mut lines) {
770 let figure = render_figure(id.as_deref(), &title, &content);
771 result.push(figure);
772 continue;
773 }
774 }
775
776 result.push(line.to_string());
778 }
779
780 result.join("\n")
781}
782
783fn parse_github_callout(line: &str) -> Option<(String, String)> {
785 let trimmed = line.trim_start();
786 if !trimmed.starts_with("> [!") {
787 return None;
788 }
789
790 if let Some(close_bracket) = trimmed.find(']')
792 && close_bracket > 4
793 {
794 let callout_type = &trimmed[4..close_bracket];
795
796 match callout_type {
798 "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION" | "DANGER" => {
799 let content = trimmed[close_bracket + 1..].trim();
800 return Some((callout_type.to_lowercase(), content.to_string()));
801 },
802 _ => return None,
803 }
804 }
805
806 None
807}
808
809fn is_atx_header(line: &str) -> bool {
826 let mut chars = line.chars();
827 let mut hash_count = 0;
828
829 while let Some(c) = chars.next() {
831 if c == '#' {
832 hash_count += 1;
833 if hash_count > 6 {
834 return false;
835 }
836 } else {
837 return (1..=6).contains(&hash_count)
839 && (c.is_whitespace() || chars.as_str().is_empty());
840 }
841 }
842
843 (1..=6).contains(&hash_count)
845}
846
847fn collect_github_callout_content(
849 lines: &mut std::iter::Peekable<std::str::Lines>,
850 initial_content: &str,
851) -> String {
852 let mut content = String::new();
853
854 if !initial_content.is_empty() {
855 content.push_str(initial_content);
856 content.push('\n');
857 }
858
859 while let Some(line) = lines.peek() {
860 let trimmed = line.trim_start();
861
862 if trimmed.is_empty() {
864 break;
865 }
866
867 let content_part = if trimmed.starts_with('>') {
869 trimmed.strip_prefix('>').unwrap_or("").trim_start()
870 } else {
871 let starts_new_block = is_atx_header(trimmed)
875 || trimmed.starts_with("```")
876 || trimmed.starts_with("~~~")
877 || (trimmed.starts_with("---")
878 && trimmed.chars().all(|c| c == '-' || c.is_whitespace()))
879 || (trimmed.starts_with("===")
880 && trimmed.chars().all(|c| c == '=' || c.is_whitespace()))
881 || (trimmed.starts_with("***")
882 && trimmed.chars().all(|c| c == '*' || c.is_whitespace()));
883
884 if starts_new_block {
885 break;
886 }
887
888 trimmed
893 };
894
895 content.push_str(content_part);
896 content.push('\n');
897 lines.next(); }
899
900 content.trim().to_string()
901}
902
903fn parse_fenced_admonition_start(
905 line: &str,
906) -> Option<(String, Option<String>)> {
907 let trimmed = line.trim();
908 if !trimmed.starts_with(":::") {
909 return None;
910 }
911
912 let after_colons = trimmed[3..].trim_start();
913 if !after_colons.starts_with("{.") {
914 return None;
915 }
916
917 if let Some(close_brace) = after_colons.find('}') {
919 let content = &after_colons[2..close_brace]; let parts: Vec<&str> = content.split_whitespace().collect();
923 if let Some(&adm_type) = parts.first() {
924 let id = parts
925 .iter()
926 .find(|part| part.starts_with('#'))
927 .map(|id_part| id_part[1..].to_string()); return Some((adm_type.to_string(), id));
930 }
931 }
932
933 None
934}
935
936fn collect_fenced_content(
944 lines: &mut std::iter::Peekable<std::str::Lines>,
945) -> (String, Option<String>) {
946 let mut content = String::new();
947
948 for line in lines.by_ref() {
949 let trimmed = line.trim();
950 if trimmed.starts_with(":::") {
951 let after_colons = trimmed.strip_prefix(":::").unwrap_or("");
953 if !after_colons.is_empty() {
954 return (content.trim().to_string(), Some(after_colons.to_string()));
956 }
957 break;
958 }
959 content.push_str(line);
960 content.push('\n');
961 }
962
963 (content.trim().to_string(), None)
964}
965
966#[allow(
968 clippy::option_if_let_else,
969 reason = "Nested options clearer with if-let"
970)]
971fn parse_figure_block(
972 line: &str,
973 lines: &mut std::iter::Peekable<std::str::Lines>,
974) -> Option<(Option<String>, String, String)> {
975 let trimmed = line.trim();
976 if !trimmed.starts_with(":::") {
977 return None;
978 }
979
980 let after_colons = trimmed[3..].trim_start();
981 if !after_colons.starts_with("{.figure") {
982 return None;
983 }
984
985 let id = if let Some(hash_pos) = after_colons.find('#') {
987 if let Some(close_brace) = after_colons.find('}') {
988 if hash_pos < close_brace {
989 Some(after_colons[hash_pos + 1..close_brace].trim().to_string())
990 } else {
991 None
992 }
993 } else {
994 None
995 }
996 } else {
997 None
998 };
999
1000 let title = if let Some(title_line) = lines.next() {
1002 let trimmed_title = title_line.trim();
1003 if let Some(this) = trimmed_title.strip_prefix('#') {
1004 { this.trim_matches(char::is_whitespace) }.to_string()
1005 } else {
1006 return None;
1008 }
1009 } else {
1010 return None;
1011 };
1012
1013 let mut content = String::new();
1015 for line in lines.by_ref() {
1016 if line.trim().starts_with(":::") {
1017 break;
1018 }
1019 content.push_str(line);
1020 content.push('\n');
1021 }
1022
1023 Some((id, title, content.trim().to_string()))
1024}
1025
1026fn render_admonition(
1028 adm_type: &str,
1029 id: Option<&str>,
1030 content: &str,
1031) -> String {
1032 let capitalized_type = crate::utils::capitalize_first(adm_type);
1033 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
1034
1035 let opening = format!(
1036 "<div class=\"admonition {adm_type}\"{id_attr}>\n<p \
1037 class=\"admonition-title\">{capitalized_type}</p>"
1038 );
1039 format!("{opening}\n\n{content}\n\n</div>\n")
1040}
1041
1042fn render_figure(id: Option<&str>, title: &str, content: &str) -> String {
1044 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
1045
1046 format!(
1047 "<figure{id_attr}>\n<figcaption>{title}</figcaption>\n{content}\n</figure>"
1048 )
1049}
1050
1051#[cfg(feature = "nixpkgs")]
1064#[must_use]
1065#[allow(
1066 clippy::implicit_hasher,
1067 reason = "Standard HashMap sufficient for this use case"
1068)]
1069pub fn process_manpage_references(
1070 html: &str,
1071 manpage_urls: Option<&std::collections::HashMap<String, String>>,
1072) -> String {
1073 process_safe(
1074 html,
1075 |html| {
1076 use kuchikikiki::NodeRef;
1077 use tendril::TendrilSink;
1078
1079 let document = kuchikikiki::parse_html().one(html);
1080 let mut to_replace = Vec::new();
1081
1082 for span_node in safe_select(&document, "span.manpage-reference") {
1084 let span_el = span_node;
1085 let span_text = span_el.text_contents();
1086
1087 if let Some(urls) = manpage_urls {
1088 if let Some(url) = urls.get(&span_text) {
1090 let clean_url = extract_url_from_html(url);
1091 let link = NodeRef::new_element(
1092 markup5ever::QualName::new(
1093 None,
1094 markup5ever::ns!(html),
1095 markup5ever::local_name!("a"),
1096 ),
1097 vec![
1098 (
1099 kuchikikiki::ExpandedName::new("", "href"),
1100 kuchikikiki::Attribute {
1101 prefix: None,
1102 value: clean_url.into(),
1103 },
1104 ),
1105 (
1106 kuchikikiki::ExpandedName::new("", "class"),
1107 kuchikikiki::Attribute {
1108 prefix: None,
1109 value: "manpage-reference".into(),
1110 },
1111 ),
1112 ],
1113 );
1114 link.append(NodeRef::new_text(span_text.clone()));
1115 to_replace.push((span_el.clone(), link));
1116 }
1117 }
1118 }
1119
1120 for (old, new) in to_replace {
1122 old.insert_before(new);
1123 old.detach();
1124 }
1125
1126 let mut out = Vec::new();
1127 let _ = document.serialize(&mut out);
1128 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
1129 },
1130 "",
1132 )
1133}
1134
1135#[cfg(feature = "ndg-flavored")]
1150#[must_use]
1151#[allow(
1152 clippy::implicit_hasher,
1153 reason = "Standard HashSet sufficient for this use case"
1154)]
1155pub fn process_option_references(
1156 html: &str,
1157 valid_options: Option<&std::collections::HashSet<String>>,
1158) -> String {
1159 use kuchikikiki::{Attribute, ExpandedName, NodeRef};
1160 use markup5ever::{QualName, local_name, ns};
1161 use tendril::TendrilSink;
1162
1163 process_safe(
1164 html,
1165 |html| {
1166 let document = kuchikikiki::parse_html().one(html);
1167
1168 let mut to_replace = vec![];
1169
1170 for code_node in safe_select(&document, "code.nixos-option") {
1173 let code_el = code_node;
1174 let code_text = code_el.text_contents();
1175
1176 let mut is_already_option_ref = false;
1178 let mut current = code_el.parent();
1179 while let Some(parent) = current {
1180 if let Some(element) = parent.as_element()
1181 && element.name.local == local_name!("a")
1182 && let Some(class_attr) =
1183 element.attributes.borrow().get(local_name!("class"))
1184 && class_attr.contains("option-reference")
1185 {
1186 is_already_option_ref = true;
1187 break;
1188 }
1189 current = parent.parent();
1190 }
1191
1192 if !is_already_option_ref {
1193 let should_link =
1196 valid_options.is_none_or(|opts| opts.contains(code_text.as_str()));
1197
1198 if should_link {
1199 let option_id = sanitize_option_id(code_text.as_str());
1200 let attrs = vec![
1201 (ExpandedName::new("", "href"), Attribute {
1202 prefix: None,
1203 value: format!("options.html#{option_id}"),
1204 }),
1205 (ExpandedName::new("", "class"), Attribute {
1206 prefix: None,
1207 value: "option-reference".into(),
1208 }),
1209 ];
1210 let a = NodeRef::new_element(
1211 QualName::new(None, ns!(html), local_name!("a")),
1212 attrs,
1213 );
1214 let code = NodeRef::new_element(
1215 QualName::new(None, ns!(html), local_name!("code")),
1216 vec![],
1217 );
1218 code.append(NodeRef::new_text(code_text.clone()));
1219 a.append(code);
1220 to_replace.push((code_el.clone(), a));
1221 }
1222 }
1224 }
1225
1226 for (old, new) in to_replace {
1227 old.insert_before(new);
1228 old.detach();
1229 }
1230
1231 let mut out = Vec::new();
1232 let _ = document.serialize(&mut out);
1233 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
1234 },
1235 "",
1237 )
1238}
1239
1240fn extract_url_from_html(url_or_html: &str) -> &str {
1243 if url_or_html.starts_with("<a href=\"") {
1245 if let Some(start) = url_or_html.find("href=\"") {
1247 let start = start + 6; if let Some(end) = url_or_html[start..].find('"') {
1249 return &url_or_html[start..start + end];
1250 }
1251 }
1252 }
1253
1254 url_or_html
1256}
1257
1258#[cfg(feature = "wiki")]
1275#[must_use]
1276pub fn process_wikilinks(content: &str) -> String {
1277 use crate::utils::codeblock::FenceTracker;
1278
1279 let mut result = String::with_capacity(content.len());
1280 let lines = content.lines();
1281 let mut tracker = FenceTracker::new();
1282
1283 for line in lines {
1284 tracker = tracker.process_line(line);
1285
1286 if tracker.in_code_block() {
1287 result.push_str(line);
1288 } else {
1289 result.push_str(&process_line_wikilinks(line));
1290 }
1291 result.push('\n');
1292 }
1293
1294 result.trim_end().to_string()
1295}
1296
1297#[cfg(feature = "wiki")]
1299fn process_line_wikilinks(line: &str) -> String {
1300 let mut result = String::with_capacity(line.len());
1301 let mut chars = line.chars().peekable();
1302
1303 while let Some(ch) = chars.next() {
1304 if ch == '[' && chars.peek() == Some(&'[') {
1305 chars.next();
1306
1307 let mut inner = String::new();
1308 let mut found_double_close = false;
1309
1310 while let Some(&next_ch) = chars.peek() {
1311 chars.next();
1312 if next_ch == ']' && chars.peek() == Some(&']') {
1313 chars.next();
1314 found_double_close = true;
1315 break;
1316 }
1317 inner.push(next_ch);
1318 }
1319
1320 if found_double_close {
1321 if inner.is_empty() {
1322 result.push_str("[[]]");
1323 } else if inner.contains('|') {
1324 let parts: Vec<&str> = inner.splitn(2, '|').collect();
1325 let name = parts[0].trim();
1326 let url = parts.get(1).unwrap_or(&name).trim();
1327 let escaped_name = encode_text(name);
1328 let escaped_url = encode_text(url);
1329 let _ = write!(
1330 result,
1331 "<a href=\"{escaped_url}\" class=\"wikilink\">{escaped_name}</a>"
1332 );
1333 } else {
1334 let page = inner.trim();
1335 let escaped_page = encode_text(page);
1336 let link_target = format!("{page}.html");
1337 let _ = write!(
1338 result,
1339 "<a href=\"{link_target}\" \
1340 class=\"obsidian-link\">{escaped_page}</a>"
1341 );
1342 }
1343 } else {
1344 result.push_str("[[");
1345 result.push_str(&inner);
1346 }
1347 } else {
1348 result.push(ch);
1349 }
1350 }
1351
1352 result
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357 use super::*;
1358
1359 #[test]
1360 fn test_is_atx_header_valid_headers() {
1361 assert!(is_atx_header("# Header"));
1363 assert!(is_atx_header("## Header"));
1364 assert!(is_atx_header("### Header"));
1365 assert!(is_atx_header("#### Header"));
1366 assert!(is_atx_header("##### Header"));
1367 assert!(is_atx_header("###### Header"));
1368
1369 assert!(is_atx_header("#\tHeader"));
1371 assert!(is_atx_header("##\tHeader"));
1372
1373 assert!(is_atx_header("#"));
1375 assert!(is_atx_header("##"));
1376 assert!(is_atx_header("###"));
1377 assert!(is_atx_header("####"));
1378 assert!(is_atx_header("#####"));
1379 assert!(is_atx_header("######"));
1380
1381 assert!(is_atx_header("# Header with multiple spaces"));
1383 assert!(is_atx_header("## Header"));
1384 }
1385
1386 #[test]
1387 fn test_is_atx_header_invalid_headers() {
1388 assert!(!is_atx_header("####### Too many hashes"));
1390 assert!(!is_atx_header("######## Even more"));
1391
1392 assert!(!is_atx_header("#NoSpace"));
1394 assert!(!is_atx_header("##NoSpace"));
1395
1396 assert!(!is_atx_header("Not # a header"));
1398
1399 assert!(!is_atx_header(""));
1401
1402 assert!(!is_atx_header("Regular text"));
1404
1405 assert!(!is_atx_header("#hashtag"));
1407 assert!(!is_atx_header("##hashtag"));
1408 assert!(!is_atx_header("#123"));
1409 assert!(!is_atx_header("##abc"));
1410
1411 assert!(!is_atx_header("#!important"));
1413 assert!(!is_atx_header("#@mention"));
1414 assert!(!is_atx_header("#$variable"));
1415 }
1416
1417 #[test]
1418 fn test_is_atx_header_edge_cases() {
1419 assert!(!is_atx_header(" # Header"));
1422 assert!(!is_atx_header(" ## Header"));
1423
1424 assert!(is_atx_header("# "));
1426 assert!(is_atx_header("## "));
1427
1428 assert!(is_atx_header("# Header\n"));
1430 assert!(is_atx_header("## Header\n"));
1431
1432 assert!(is_atx_header("# \t Header"));
1434 assert!(is_atx_header("## \tHeader"));
1435 }
1436
1437 #[test]
1438 fn test_is_atx_header_blockquote_context() {
1439 assert!(is_atx_header("# New Section"));
1442 assert!(is_atx_header("## Subsection"));
1443
1444 assert!(!is_atx_header("#tag"));
1446 assert!(!is_atx_header("##issue-123"));
1447 assert!(!is_atx_header("###no-space"));
1448
1449 assert!(is_atx_header("###### Level 6"));
1451
1452 assert!(!is_atx_header("####### Not valid"));
1454 }
1455
1456 #[cfg(feature = "wiki")]
1457 #[test]
1458 fn test_wikilink_obsidian_basic() {
1459 let input = "Check out [[Some Page]] for details.";
1460 let result = process_wikilinks(input);
1461 assert!(result.contains("href=\"Some Page.html\""));
1462 assert!(result.contains("class=\"obsidian-link\""));
1463 assert!(result.contains(">Some Page<"));
1464 }
1465
1466 #[cfg(feature = "wiki")]
1467 #[test]
1468 fn test_wikilink_with_url() {
1469 let input = "See [[Custom Name|https://example.com]]";
1470 let result = process_wikilinks(input);
1471 assert!(result.contains("href=\"https://example.com\""));
1472 assert!(result.contains("class=\"wikilink\""));
1473 assert!(result.contains(">Custom Name<"));
1474 }
1475
1476 #[cfg(feature = "wiki")]
1477 #[test]
1478 fn test_wikilink_with_spaces() {
1479 let input = "[[My Page Name]]";
1480 let result = process_wikilinks(input);
1481 assert!(result.contains("href=\"My Page Name.html\""));
1482 }
1483
1484 #[cfg(feature = "wiki")]
1485 #[test]
1486 fn test_wikilink_in_code_block() {
1487 let input = "```\n[[Wiki Link]]\n```\nThen [[Another]]";
1488 let result = process_wikilinks(input);
1489 assert!(result.contains("[[Wiki Link]]"));
1490 assert!(result.contains("href=\"Another.html\""));
1491 }
1492
1493 #[cfg(feature = "wiki")]
1494 #[test]
1495 fn test_wikilink_empty() {
1496 let input = "[[]]";
1497 let result = process_wikilinks(input);
1498 assert!(result.contains("[[]]"));
1499 }
1500
1501 #[cfg(feature = "wiki")]
1502 #[test]
1503 fn test_wikilink_malformed() {
1504 let input = "[[ incomplete";
1505 let result = process_wikilinks(input);
1506 assert!(result.contains("[[ incomplete"));
1507 }
1508
1509 #[cfg(feature = "wiki")]
1510 #[test]
1511 fn test_wikilink_html_escaping() {
1512 let input = "See [[Page With <script>]] for info";
1513 let result = process_wikilinks(input);
1514 assert!(result.contains("<script>"));
1515 assert!(!result.contains(">Page With <script><"));
1516 }
1517}