1use std::{fmt::Write, fs, path::Path};
3
4use html_escape;
5
6use super::process::process_safe;
7
8fn safe_select(
10 document: &kuchikikiki::NodeRef,
11 selector: &str,
12) -> Vec<kuchikikiki::NodeRef> {
13 match document.select(selector) {
14 Ok(selections) => selections.map(|sel| sel.as_node().clone()).collect(),
15 Err(e) => {
16 log::warn!("DOM selector '{selector}' failed: {e:?}");
17 Vec::new()
18 },
19 }
20}
21
22#[cfg(feature = "gfm")]
35#[must_use]
36pub fn apply_gfm_extensions(markdown: &str) -> String {
37 markdown.to_owned()
40}
41
42const MAX_INCLUDE_DEPTH: usize = 8;
44
45#[cfg(feature = "nixpkgs")]
48fn is_safe_path(path: &str, _base_dir: &Path) -> bool {
49 let p = Path::new(path);
50 if p.is_absolute() || path.contains('\\') {
51 return false;
52 }
53
54 for component in p.components() {
56 if matches!(component, std::path::Component::ParentDir) {
57 return false;
58 }
59 }
60
61 true
62}
63
64#[cfg(feature = "nixpkgs")]
66#[allow(
67 clippy::option_if_let_else,
68 reason = "Nested options are clearer with if-let"
69)]
70fn parse_include_directive(line: &str) -> Option<String> {
71 if let Some(start) = line.find("html:into-file=") {
72 let start = start + "html:into-file=".len();
73 if let Some(end) = line[start..].find(' ') {
74 Some(line[start..start + end].to_string())
75 } else {
76 Some(line[start..].trim().to_string())
77 }
78 } else {
79 None
80 }
81}
82
83#[cfg(feature = "nixpkgs")]
85#[allow(
86 clippy::needless_pass_by_value,
87 reason = "Owned value needed for cloning in loop"
88)]
89fn read_includes(
90 listing: &str,
91 base_dir: &Path,
92 custom_output: Option<String>,
93 included_files: &mut Vec<crate::types::IncludedFile>,
94 depth: usize,
95) -> Result<String, String> {
96 let mut result = String::new();
97
98 for line in listing.lines() {
99 let trimmed = line.trim();
100 if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
101 continue;
102 }
103 let full_path = base_dir.join(trimmed);
104 log::info!("Including file: {}", full_path.display());
105
106 match fs::read_to_string(&full_path) {
107 Ok(content) => {
108 let file_dir = full_path.parent().unwrap_or(base_dir);
109 let (processed_content, nested_includes) =
110 process_file_includes(&content, file_dir, depth + 1)?;
111
112 result.push_str(&processed_content);
113 if !processed_content.ends_with('\n') {
114 result.push('\n');
115 }
116
117 included_files.push(crate::types::IncludedFile {
118 path: trimmed.to_string(),
119 custom_output: custom_output.clone(),
120 });
121
122 for nested in nested_includes {
124 let nested_full_path = file_dir.join(&nested.path);
125 if let Ok(normalized_path) = nested_full_path.strip_prefix(base_dir) {
126 included_files.push(crate::types::IncludedFile {
127 path: normalized_path.to_string_lossy().to_string(),
128 custom_output: nested.custom_output,
129 });
130 }
131 }
132 },
133 Err(_) => {
134 let _ = writeln!(
135 result,
136 "<!-- ndg: could not include file: {} -->",
137 full_path.display()
138 );
139 },
140 }
141 }
142 Ok(result)
143}
144
145#[cfg(feature = "nixpkgs")]
176pub fn process_file_includes(
177 markdown: &str,
178 base_dir: &std::path::Path,
179 depth: usize,
180) -> Result<(String, Vec<crate::types::IncludedFile>), String> {
181 if depth >= MAX_INCLUDE_DEPTH {
183 return Err(format!(
184 "Maximum include recursion depth ({MAX_INCLUDE_DEPTH}) exceeded. This \
185 likely indicates a cycle in file includes."
186 ));
187 }
188
189 let mut output = String::new();
190 let mut lines = markdown.lines();
191 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
192 let mut all_included_files: Vec<crate::types::IncludedFile> = Vec::new();
193
194 while let Some(line) = lines.next() {
195 let trimmed = line.trim_start();
196
197 if !fence_tracker.in_code_block() && trimmed.starts_with("```{=include=}") {
198 let custom_output = parse_include_directive(trimmed);
199
200 let mut include_listing = String::new();
201 for next_line in lines.by_ref() {
202 if next_line.trim_start().starts_with("```") {
203 break;
204 }
205 include_listing.push_str(next_line);
206 include_listing.push('\n');
207 }
208
209 let included = read_includes(
210 &include_listing,
211 base_dir,
212 custom_output,
213 &mut all_included_files,
214 depth,
215 )?;
216 output.push_str(&included);
217 continue;
218 }
219
220 fence_tracker = fence_tracker.process_line(line);
222
223 output.push_str(line);
224 output.push('\n');
225 }
226
227 Ok((output, all_included_files))
228}
229
230#[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
245#[must_use]
246#[allow(
247 clippy::implicit_hasher,
248 reason = "Standard HashMap/HashSet sufficient for this use case"
249)]
250pub fn process_role_markup(
251 content: &str,
252 manpage_urls: Option<&std::collections::HashMap<String, String>>,
253 auto_link_options: bool,
254 valid_options: Option<&std::collections::HashSet<String>>,
255) -> String {
256 let mut result = String::new();
257 let mut chars = content.chars().peekable();
258 let mut tracker = crate::utils::codeblock::InlineTracker::new();
259
260 while let Some(ch) = chars.next() {
261 if ch == '`' {
263 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
264 tracker = new_tracker;
265
266 result.push_str(&"`".repeat(tick_count));
268 continue;
269 }
270
271 if ch == '~' && chars.peek() == Some(&'~') {
273 let (new_tracker, tilde_count) = tracker.process_tildes(&mut chars);
274 tracker = new_tracker;
275
276 result.push_str(&"~".repeat(tilde_count));
277 continue;
278 }
279
280 if ch == '\n' {
282 tracker = tracker.process_newline();
283 result.push(ch);
284 continue;
285 }
286
287 if ch == '{' && !tracker.in_any_code() {
289 let remaining: Vec<char> = chars.clone().collect();
291 let remaining_str: String = remaining.iter().collect();
292 let mut temp_chars = remaining_str.chars().peekable();
293
294 if let Some(role_markup) = parse_role_markup(
295 &mut temp_chars,
296 manpage_urls,
297 auto_link_options,
298 valid_options,
299 ) {
300 let remaining_after_parse: String = temp_chars.collect();
302 let consumed = remaining_str.len() - remaining_after_parse.len();
303 for _ in 0..consumed {
304 chars.next();
305 }
306 result.push_str(&role_markup);
307 } else {
308 result.push(ch);
310 }
311 } else {
312 result.push(ch);
313 }
314 }
315
316 result
317}
318
319fn parse_role_markup(
325 chars: &mut std::iter::Peekable<std::str::Chars>,
326 manpage_urls: Option<&std::collections::HashMap<String, String>>,
327 auto_link_options: bool,
328 valid_options: Option<&std::collections::HashSet<String>>,
329) -> Option<String> {
330 let mut role_name = String::new();
331
332 while let Some(&ch) = chars.peek() {
334 if ch.is_ascii_lowercase() {
335 role_name.push(ch);
336 chars.next();
337 } else {
338 break;
339 }
340 }
341
342 if role_name.is_empty() {
344 return None;
345 }
346
347 if chars.peek() != Some(&'}') {
349 return None;
350 }
351 chars.next(); if chars.peek() != Some(&'`') {
355 return None;
356 }
357 chars.next(); let mut content = String::new();
361 for ch in chars.by_ref() {
362 if ch == '`' {
363 if content.is_empty() && !matches!(role_name.as_str(), "manpage") {
366 return None; }
368 return Some(format_role_markup(
369 &role_name,
370 &content,
371 manpage_urls,
372 auto_link_options,
373 valid_options,
374 ));
375 }
376 content.push(ch);
377 }
378
379 None
381}
382
383#[must_use]
385#[allow(
386 clippy::option_if_let_else,
387 reason = "Nested options clearer with if-let"
388)]
389#[allow(
390 clippy::implicit_hasher,
391 reason = "Standard HashMap/HashSet sufficient for this use case"
392)]
393pub fn format_role_markup(
394 role_type: &str,
395 content: &str,
396 manpage_urls: Option<&std::collections::HashMap<String, String>>,
397 auto_link_options: bool,
398 valid_options: Option<&std::collections::HashSet<String>>,
399) -> String {
400 let escaped_content = html_escape::encode_text(content);
401 match role_type {
402 "manpage" => {
403 if let Some(urls) = manpage_urls {
404 if let Some(url) = urls.get(content) {
405 format!(
406 "<a href=\"{url}\" \
407 class=\"manpage-reference\">{escaped_content}</a>"
408 )
409 } else {
410 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
411 }
412 } else {
413 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
414 }
415 },
416 "command" => format!("<code class=\"command\">{escaped_content}</code>"),
417 "env" => format!("<code class=\"env-var\">{escaped_content}</code>"),
418 "file" => format!("<code class=\"file-path\">{escaped_content}</code>"),
419 "option" => {
420 if cfg!(feature = "ndg-flavored") && auto_link_options {
421 let should_link =
423 valid_options.is_none_or(|opts| opts.contains(content)); if should_link {
426 let option_id = format!("option-{}", content.replace('.', "-"));
427 format!(
428 "<a class=\"option-reference\" \
429 href=\"options.html#{option_id}\"><code \
430 class=\"nixos-option\">{escaped_content}</code></a>"
431 )
432 } else {
433 format!("<code class=\"nixos-option\">{escaped_content}</code>")
434 }
435 } else {
436 format!("<code class=\"nixos-option\">{escaped_content}</code>")
437 }
438 },
439 "var" => format!("<code class=\"nix-var\">{escaped_content}</code>"),
440 _ => format!("<span class=\"{role_type}-markup\">{escaped_content}</span>"),
441 }
442}
443
444#[must_use]
458pub fn process_myst_autolinks(content: &str) -> String {
459 let mut result = String::with_capacity(content.len());
460 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
461
462 for line in content.lines() {
463 fence_tracker = fence_tracker.process_line(line);
465
466 if fence_tracker.in_code_block() {
468 result.push_str(line);
469 } else {
470 result.push_str(&process_line_myst_autolinks(line));
471 }
472 result.push('\n');
473 }
474
475 result
476}
477
478fn process_line_myst_autolinks(line: &str) -> String {
480 let mut result = String::with_capacity(line.len());
481 let mut chars = line.chars().peekable();
482
483 while let Some(ch) = chars.next() {
484 if ch == '[' && chars.peek() == Some(&']') {
485 chars.next(); if chars.peek() == Some(&'{') {
490 result.push_str("[]");
492 continue;
493 }
494
495 if chars.peek() == Some(&'(') {
496 chars.next(); let mut url = String::new();
500 let mut found_closing = false;
501 while let Some(&next_ch) = chars.peek() {
502 if next_ch == ')' {
503 chars.next(); found_closing = true;
505 break;
506 }
507 url.push(next_ch);
508 chars.next();
509 }
510
511 if found_closing && !url.is_empty() {
512 if url.starts_with('#') {
514 let _ = write!(result, "[{{{{ANCHOR}}}}]({url})");
516 } else if url.starts_with("http://") || url.starts_with("https://") {
517 let _ = write!(result, "<{url}>");
519 } else {
520 let _ = write!(result, "[]({url})");
522 }
523 } else {
524 result.push_str("](");
526 result.push_str(&url);
527 }
528 } else {
529 result.push(']');
531 }
532 } else {
533 result.push(ch);
534 }
535 }
536
537 result
538}
539
540#[cfg(feature = "nixpkgs")]
558#[must_use]
559pub fn process_inline_anchors(content: &str) -> String {
560 let mut result = String::with_capacity(content.len() + 100);
561 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
562
563 for line in content.lines() {
564 let trimmed = line.trim_start();
565
566 fence_tracker = fence_tracker.process_line(line);
568
569 if fence_tracker.in_code_block() {
571 result.push_str(line);
573 } else {
574 if let Some(anchor_start) = find_list_item_anchor(trimmed)
577 && let Some(processed_line) =
578 process_list_item_anchor(line, anchor_start)
579 {
580 result.push_str(&processed_line);
581 result.push('\n');
582 continue;
583 }
584
585 result.push_str(&process_line_anchors(line));
587 }
588 result.push('\n');
589 }
590
591 result
592}
593
594fn find_list_item_anchor(trimmed: &str) -> Option<usize> {
596 if (trimmed.starts_with("- ")
598 || trimmed.starts_with("* ")
599 || trimmed.starts_with("+ "))
600 && trimmed.len() > 2
601 {
602 let after_marker = &trimmed[2..];
603 if after_marker.starts_with("[]{#") {
604 return Some(2);
605 }
606 }
607
608 let mut i = 0;
610 while i < trimmed.len()
611 && trimmed.chars().nth(i).unwrap_or(' ').is_ascii_digit()
612 {
613 i += 1;
614 }
615 if i > 0 && i < trimmed.len() - 1 && trimmed.chars().nth(i) == Some('.') {
616 let after_marker = &trimmed[i + 1..];
617 if after_marker.starts_with(" []{#") {
618 return Some(i + 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")]
736#[must_use]
737pub fn process_block_elements(content: &str) -> String {
738 let mut result = Vec::new();
739 let mut lines = content.lines().peekable();
740 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
741
742 while let Some(line) = lines.next() {
743 fence_tracker = fence_tracker.process_line(line);
745
746 if !fence_tracker.in_code_block() {
748 if let Some((callout_type, initial_content)) = parse_github_callout(line)
750 {
751 let content =
752 collect_github_callout_content(&mut lines, &initial_content);
753 let admonition = render_admonition(&callout_type, None, &content);
754 result.push(admonition);
755 continue;
756 }
757
758 if let Some((adm_type, id)) = parse_fenced_admonition_start(line) {
760 let content = collect_fenced_content(&mut lines);
761 let admonition = render_admonition(&adm_type, id.as_deref(), &content);
762 result.push(admonition);
763 continue;
764 }
765
766 if let Some((id, title, content)) = parse_figure_block(line, &mut lines) {
768 let figure = render_figure(id.as_deref(), &title, &content);
769 result.push(figure);
770 continue;
771 }
772 }
773
774 result.push(line.to_string());
776 }
777
778 result.join("\n")
779}
780
781fn parse_github_callout(line: &str) -> Option<(String, String)> {
783 let trimmed = line.trim_start();
784 if !trimmed.starts_with("> [!") {
785 return None;
786 }
787
788 if let Some(close_bracket) = trimmed.find(']')
790 && close_bracket > 4
791 {
792 let callout_type = &trimmed[4..close_bracket];
793
794 match callout_type {
796 "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION" | "DANGER" => {
797 let content = trimmed[close_bracket + 1..].trim();
798 return Some((callout_type.to_lowercase(), content.to_string()));
799 },
800 _ => return None,
801 }
802 }
803
804 None
805}
806
807fn collect_github_callout_content(
809 lines: &mut std::iter::Peekable<std::str::Lines>,
810 initial_content: &str,
811) -> String {
812 let mut content = String::new();
813
814 if !initial_content.is_empty() {
815 content.push_str(initial_content);
816 content.push('\n');
817 }
818
819 while let Some(line) = lines.peek() {
820 let trimmed = line.trim_start();
821 if trimmed.starts_with('>') {
822 let content_part = trimmed.strip_prefix('>').unwrap_or("").trim_start();
823 content.push_str(content_part);
824 content.push('\n');
825 lines.next(); } else {
827 break;
828 }
829 }
830
831 content.trim().to_string()
832}
833
834fn parse_fenced_admonition_start(
836 line: &str,
837) -> Option<(String, Option<String>)> {
838 let trimmed = line.trim();
839 if !trimmed.starts_with(":::") {
840 return None;
841 }
842
843 let after_colons = trimmed[3..].trim_start();
844 if !after_colons.starts_with("{.") {
845 return None;
846 }
847
848 if let Some(close_brace) = after_colons.find('}') {
850 let content = &after_colons[2..close_brace]; let parts: Vec<&str> = content.split_whitespace().collect();
854 if let Some(&adm_type) = parts.first() {
855 let id = parts
856 .iter()
857 .find(|part| part.starts_with('#'))
858 .map(|id_part| id_part[1..].to_string()); return Some((adm_type.to_string(), id));
861 }
862 }
863
864 None
865}
866
867fn collect_fenced_content(
869 lines: &mut std::iter::Peekable<std::str::Lines>,
870) -> String {
871 let mut content = String::new();
872
873 for line in lines.by_ref() {
874 if line.trim().starts_with(":::") {
875 break;
876 }
877 content.push_str(line);
878 content.push('\n');
879 }
880
881 content.trim().to_string()
882}
883
884#[allow(
886 clippy::option_if_let_else,
887 reason = "Nested options clearer with if-let"
888)]
889fn parse_figure_block(
890 line: &str,
891 lines: &mut std::iter::Peekable<std::str::Lines>,
892) -> Option<(Option<String>, String, String)> {
893 let trimmed = line.trim();
894 if !trimmed.starts_with(":::") {
895 return None;
896 }
897
898 let after_colons = trimmed[3..].trim_start();
899 if !after_colons.starts_with("{.figure") {
900 return None;
901 }
902
903 let id = if let Some(hash_pos) = after_colons.find('#') {
905 if let Some(close_brace) = after_colons.find('}') {
906 if hash_pos < close_brace {
907 Some(after_colons[hash_pos + 1..close_brace].trim().to_string())
908 } else {
909 None
910 }
911 } else {
912 None
913 }
914 } else {
915 None
916 };
917
918 let title = if let Some(title_line) = lines.next() {
920 let trimmed_title = title_line.trim();
921 if let Some(this) = trimmed_title.strip_prefix('#') {
922 { this.trim_matches(char::is_whitespace) }.to_string()
923 } else {
924 return None;
926 }
927 } else {
928 return None;
929 };
930
931 let mut content = String::new();
933 for line in lines.by_ref() {
934 if line.trim().starts_with(":::") {
935 break;
936 }
937 content.push_str(line);
938 content.push('\n');
939 }
940
941 Some((id, title, content.trim().to_string()))
942}
943
944fn render_admonition(
946 adm_type: &str,
947 id: Option<&str>,
948 content: &str,
949) -> String {
950 let capitalized_type = crate::utils::capitalize_first(adm_type);
951 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
952
953 format!(
954 "<div class=\"admonition {adm_type}\"{id_attr}>\n<p \
955 class=\"admonition-title\">{capitalized_type}</p>\n\n{content}\n\n</div>"
956 )
957}
958
959fn render_figure(id: Option<&str>, title: &str, content: &str) -> String {
961 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
962
963 format!(
964 "<figure{id_attr}>\n<figcaption>{title}</figcaption>\n{content}\n</figure>"
965 )
966}
967
968#[cfg(feature = "nixpkgs")]
981#[must_use]
982#[allow(
983 clippy::implicit_hasher,
984 reason = "Standard HashMap sufficient for this use case"
985)]
986pub fn process_manpage_references(
987 html: &str,
988 manpage_urls: Option<&std::collections::HashMap<String, String>>,
989) -> String {
990 process_safe(
991 html,
992 |html| {
993 use kuchikikiki::NodeRef;
994 use tendril::TendrilSink;
995
996 let document = kuchikikiki::parse_html().one(html);
997 let mut to_replace = Vec::new();
998
999 for span_node in safe_select(&document, "span.manpage-reference") {
1001 let span_el = span_node;
1002 let span_text = span_el.text_contents();
1003
1004 if let Some(urls) = manpage_urls {
1005 if let Some(url) = urls.get(&span_text) {
1007 let clean_url = extract_url_from_html(url);
1008 let link = NodeRef::new_element(
1009 markup5ever::QualName::new(
1010 None,
1011 markup5ever::ns!(html),
1012 markup5ever::local_name!("a"),
1013 ),
1014 vec![
1015 (
1016 kuchikikiki::ExpandedName::new("", "href"),
1017 kuchikikiki::Attribute {
1018 prefix: None,
1019 value: clean_url.into(),
1020 },
1021 ),
1022 (
1023 kuchikikiki::ExpandedName::new("", "class"),
1024 kuchikikiki::Attribute {
1025 prefix: None,
1026 value: "manpage-reference".into(),
1027 },
1028 ),
1029 ],
1030 );
1031 link.append(NodeRef::new_text(span_text.clone()));
1032 to_replace.push((span_el.clone(), link));
1033 }
1034 }
1035 }
1036
1037 for (old, new) in to_replace {
1039 old.insert_before(new);
1040 old.detach();
1041 }
1042
1043 let mut out = Vec::new();
1044 let _ = document.serialize(&mut out);
1045 String::from_utf8(out).unwrap_or_default()
1046 },
1047 "",
1049 )
1050}
1051
1052#[cfg(feature = "ndg-flavored")]
1067#[must_use]
1068#[allow(
1069 clippy::implicit_hasher,
1070 reason = "Standard HashSet sufficient for this use case"
1071)]
1072pub fn process_option_references(
1073 html: &str,
1074 valid_options: Option<&std::collections::HashSet<String>>,
1075) -> String {
1076 use kuchikikiki::{Attribute, ExpandedName, NodeRef};
1077 use markup5ever::{QualName, local_name, ns};
1078 use tendril::TendrilSink;
1079
1080 process_safe(
1081 html,
1082 |html| {
1083 let document = kuchikikiki::parse_html().one(html);
1084
1085 let mut to_replace = vec![];
1086
1087 for code_node in safe_select(&document, "code.nixos-option") {
1090 let code_el = code_node;
1091 let code_text = code_el.text_contents();
1092
1093 let mut is_already_option_ref = false;
1095 let mut current = code_el.parent();
1096 while let Some(parent) = current {
1097 if let Some(element) = parent.as_element()
1098 && element.name.local == local_name!("a")
1099 && let Some(class_attr) =
1100 element.attributes.borrow().get(local_name!("class"))
1101 && class_attr.contains("option-reference")
1102 {
1103 is_already_option_ref = true;
1104 break;
1105 }
1106 current = parent.parent();
1107 }
1108
1109 if !is_already_option_ref {
1110 let should_link =
1112 valid_options.is_none_or(|opts| opts.contains(code_text.as_str())); if should_link {
1115 let option_id = format!("option-{}", code_text.replace('.', "-"));
1116 let attrs = vec![
1117 (ExpandedName::new("", "href"), Attribute {
1118 prefix: None,
1119 value: format!("options.html#{option_id}"),
1120 }),
1121 (ExpandedName::new("", "class"), Attribute {
1122 prefix: None,
1123 value: "option-reference".into(),
1124 }),
1125 ];
1126 let a = NodeRef::new_element(
1127 QualName::new(None, ns!(html), local_name!("a")),
1128 attrs,
1129 );
1130 let code = NodeRef::new_element(
1131 QualName::new(None, ns!(html), local_name!("code")),
1132 vec![],
1133 );
1134 code.append(NodeRef::new_text(code_text.clone()));
1135 a.append(code);
1136 to_replace.push((code_el.clone(), a));
1137 }
1138 }
1140 }
1141
1142 for (old, new) in to_replace {
1143 old.insert_before(new);
1144 old.detach();
1145 }
1146
1147 let mut out = Vec::new();
1148 let _ = document.serialize(&mut out);
1149 String::from_utf8(out).unwrap_or_default()
1150 },
1151 "",
1153 )
1154}
1155
1156fn extract_url_from_html(url_or_html: &str) -> &str {
1159 if url_or_html.starts_with("<a href=\"") {
1161 if let Some(start) = url_or_html.find("href=\"") {
1163 let start = start + 6; if let Some(end) = url_or_html[start..].find('"') {
1165 return &url_or_html[start..start + end];
1166 }
1167 }
1168 }
1169
1170 url_or_html
1172}