1use std::{fmt::Write, fs, path::Path};
3
4use html_escape::{encode_double_quoted_attribute, 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
49const INCLUDE_BOUNDARY_MARKER: &str = "<!-- ndg:include-boundary -->";
51
52#[cfg(feature = "nixpkgs")]
54fn is_safe_path(path: &str, _base_dir: &Path) -> bool {
55 let p = Path::new(path);
56 if path.contains('\\') {
57 return false;
58 }
59
60 for component in p.components() {
62 if matches!(component, std::path::Component::ParentDir) {
63 return false;
64 }
65 }
66
67 true
68}
69
70#[cfg(feature = "nixpkgs")]
72struct IncludeDirective {
73 custom_output: Option<String>,
74 include_type: Option<String>,
75 auto_id_prefix: Option<String>,
76}
77
78#[cfg(feature = "nixpkgs")]
79fn parse_include_directive(line: &str) -> IncludeDirective {
80 let after_marker = line.strip_prefix("```{=include=}").unwrap_or(line).trim();
81 let include_type = after_marker
82 .split_whitespace()
83 .find(|part| {
84 !part.starts_with("html:into-file=")
85 && !part.starts_with("auto-id-prefix=")
86 })
87 .map(str::to_string);
88
89 let custom_output = directive_value(line, "html:into-file=");
90 let auto_id_prefix = directive_value(line, "auto-id-prefix=");
91
92 IncludeDirective {
93 custom_output,
94 include_type,
95 auto_id_prefix,
96 }
97}
98
99#[cfg(feature = "nixpkgs")]
100fn directive_value(line: &str, marker: &str) -> Option<String> {
101 line.find(marker).map(|start| {
102 let start = start + marker.len();
103 line[start..].find(' ').map_or_else(
104 || line[start..].trim().to_string(),
105 |end| line[start..start + end].to_string(),
106 )
107 })
108}
109
110#[cfg(feature = "nixpkgs")]
111fn apply_auto_id_prefix(content: &str, prefix: &str) -> String {
112 if prefix.is_empty() {
113 return content.to_string();
114 }
115
116 let mut result = String::with_capacity(content.len());
117 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
118 let mut heading_numbers = Vec::new();
119
120 for line in content.lines() {
121 fence_tracker = fence_tracker.process_line(line);
122 if fence_tracker.in_code_block() {
123 result.push_str(line);
124 } else if let Some(line) =
125 add_auto_id_to_heading(line, prefix, &mut heading_numbers)
126 {
127 result.push_str(&line);
128 } else {
129 result.push_str(line);
130 }
131 result.push('\n');
132 }
133
134 result
135}
136
137#[cfg(feature = "nixpkgs")]
138fn add_auto_id_to_heading(
139 line: &str,
140 prefix: &str,
141 heading_numbers: &mut Vec<usize>,
142) -> Option<String> {
143 let leading_len = line.len() - line.trim_start().len();
144 if leading_len > 3 {
145 return None;
146 }
147
148 let trimmed = line.trim_start();
149 let level = trimmed.chars().take_while(|&ch| ch == '#').count();
150 if !(1..=6).contains(&level) {
151 return None;
152 }
153
154 let after_hashes = &trimmed[level..];
155 if !after_hashes.is_empty() && !after_hashes.starts_with(char::is_whitespace)
156 {
157 return None;
158 }
159
160 let heading = after_hashes.trim();
161 if heading.is_empty() {
162 return None;
163 }
164
165 if level > heading_numbers.len() {
166 heading_numbers.resize(level, 0);
167 }
168 heading_numbers.truncate(level);
169 heading_numbers[level - 1] += 1;
170
171 if heading.contains("{#") {
172 return None;
173 }
174
175 let id = heading_numbers
176 .iter()
177 .map(usize::to_string)
178 .collect::<Vec<_>>()
179 .join(".");
180
181 Some(format!(
182 "{}{} {{#{}-{}}}",
183 &line[..leading_len],
184 trimmed,
185 prefix,
186 id
187 ))
188}
189
190#[cfg(feature = "nixpkgs")]
191fn render_options_include(content: &str) -> Option<String> {
192 let data: serde_json::Value = serde_json::from_str(content).ok()?;
193 let options = data.as_object()?;
194 let mut result = String::new();
195
196 for (name, value) in options {
197 let option_data = value.as_object()?;
198 let option_id = sanitize_option_id(name);
199 let _ = writeln!(
200 result,
201 "<div class=\"option\" id=\"{}\">",
202 encode_double_quoted_attribute(&option_id)
203 );
204 let _ = writeln!(
205 result,
206 " <h3 class=\"option-name\"><a href=\"#{}\" \
207 class=\"option-anchor\">{}</a></h3>",
208 encode_double_quoted_attribute(&option_id),
209 encode_text(name)
210 );
211
212 if let Some(type_name) = option_data.get("type").and_then(|v| v.as_str()) {
213 let _ = writeln!(
214 result,
215 " <div class=\"option-type\">Type: <code>{}</code></div>",
216 encode_text(type_name)
217 );
218 }
219
220 if let Some(description) = option_data.get("description") {
221 let description = match description {
222 serde_json::Value::String(value) => value.as_str(),
223 serde_json::Value::Object(object)
224 if object.get("_type").and_then(|v| v.as_str())
225 == Some("literalMD") =>
226 {
227 object.get("text").and_then(|v| v.as_str()).unwrap_or("")
228 },
229 _ => "",
230 };
231
232 if !description.is_empty() {
233 let _ = writeln!(
234 result,
235 " <div class=\"option-description\">{}</div>",
236 encode_text(description)
237 );
238 }
239 }
240
241 result.push_str("</div>\n");
242 }
243
244 Some(result)
245}
246
247#[cfg(feature = "nixpkgs")]
248fn read_options_includes(
249 listing: &str,
250 base_dir: &Path,
251 included_files: &mut Vec<crate::types::IncludedFile>,
252) -> String {
253 if let Some(source) = parse_options_source(listing) {
254 return read_options_file(&source, base_dir, included_files);
255 }
256
257 let mut result = String::new();
258
259 for line in listing.lines() {
260 let trimmed = line.trim();
261 if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
262 continue;
263 }
264
265 let full_path = base_dir.join(trimmed);
266 match fs::read_to_string(&full_path) {
267 Ok(content) => {
268 if let Some(rendered) = render_options_include(&content) {
269 result.push_str(&rendered);
270 } else {
271 let _ = writeln!(
272 result,
273 "<!-- ndg: could not parse options include: {} -->",
274 full_path.display()
275 );
276 }
277 included_files.push(crate::types::IncludedFile {
278 path: trimmed.to_string(),
279 custom_output: None,
280 });
281 },
282 Err(_) => {
283 let _ = writeln!(
284 result,
285 "<!-- ndg: could not include file: {} -->",
286 full_path.display()
287 );
288 },
289 }
290 }
291
292 result
293}
294
295#[cfg(feature = "nixpkgs")]
296fn parse_options_source(listing: &str) -> Option<String> {
297 let mut source = None;
298 for line in listing.lines() {
299 let (key, value) = line.split_once(':')?;
300 if key.trim() == "source" {
301 source = Some(value.trim().to_string());
302 }
303 }
304 source
305}
306
307#[cfg(feature = "nixpkgs")]
308fn read_options_file(
309 source: &str,
310 base_dir: &Path,
311 included_files: &mut Vec<crate::types::IncludedFile>,
312) -> String {
313 let mut result = String::new();
314 if !is_safe_path(source, base_dir) {
315 return result;
316 }
317
318 let full_path = base_dir.join(source);
319 match fs::read_to_string(&full_path) {
320 Ok(content) => {
321 if let Some(rendered) = render_options_include(&content) {
322 result.push_str(&rendered);
323 } else {
324 let _ = writeln!(
325 result,
326 "<!-- ndg: could not parse options include: {} -->",
327 full_path.display()
328 );
329 }
330 included_files.push(crate::types::IncludedFile {
331 path: source.to_string(),
332 custom_output: None,
333 });
334 },
335 Err(_) => {
336 let _ = writeln!(
337 result,
338 "<!-- ndg: could not include file: {} -->",
339 full_path.display()
340 );
341 },
342 }
343
344 result
345}
346
347#[cfg(feature = "nixpkgs")]
349#[expect(
350 clippy::needless_pass_by_value,
351 reason = "Owned value needed for cloning in loop"
352)]
353fn read_includes(
354 listing: &str,
355 base_dir: &Path,
356 custom_output: Option<String>,
357 auto_id_prefix: Option<String>,
358 included_files: &mut Vec<crate::types::IncludedFile>,
359 depth: usize,
360) -> Result<String, String> {
361 let mut result = String::new();
362
363 for (line_index, line) in listing.lines().enumerate() {
364 let trimmed = line.trim();
365 if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
366 continue;
367 }
368 let full_path = base_dir.join(trimmed);
369 log::info!("Including file: {}", full_path.display());
370
371 match fs::read_to_string(&full_path) {
372 Ok(content) => {
373 let file_dir = full_path.parent().unwrap_or(base_dir);
374 let (processed_content, nested_includes) =
375 process_file_includes(&content, file_dir, depth + 1)?;
376
377 let processed_content = if let Some(prefix) = auto_id_prefix.as_deref()
378 {
379 apply_auto_id_prefix(
380 &processed_content,
381 &format!("{}-{}", prefix, line_index + 1),
382 )
383 } else {
384 processed_content
385 };
386
387 if custom_output.is_none() {
388 result.push_str(&processed_content);
389 if !processed_content.ends_with('\n') {
390 result.push('\n');
391 }
392 result.push_str(INCLUDE_BOUNDARY_MARKER);
393 result.push('\n');
394 }
395
396 included_files.push(crate::types::IncludedFile {
397 path: trimmed.to_string(),
398 custom_output: custom_output.clone(),
399 });
400
401 for nested in nested_includes {
403 let nested_full_path = file_dir.join(&nested.path);
404 if let Ok(normalized_path) = nested_full_path.strip_prefix(base_dir) {
405 included_files.push(crate::types::IncludedFile {
406 path: normalized_path.to_string_lossy().to_string(),
407 custom_output: nested.custom_output,
408 });
409 }
410 }
411 },
412 Err(_) => {
413 let _ = writeln!(
414 result,
415 "<!-- ndg: could not include file: {} -->",
416 full_path.display()
417 );
418 },
419 }
420 }
421 Ok(result)
422}
423
424#[cfg(feature = "nixpkgs")]
455pub fn process_file_includes(
456 markdown: &str,
457 base_dir: &std::path::Path,
458 depth: usize,
459) -> Result<(String, Vec<crate::types::IncludedFile>), String> {
460 if depth >= MAX_INCLUDE_DEPTH {
462 return Err(format!(
463 "Maximum include recursion depth ({MAX_INCLUDE_DEPTH}) exceeded. This \
464 likely indicates a cycle in file includes."
465 ));
466 }
467
468 let mut output = String::new();
469 let mut lines = markdown.lines();
470 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
471 let mut all_included_files: Vec<crate::types::IncludedFile> = Vec::new();
472
473 while let Some(line) = lines.next() {
474 if line.trim() == INCLUDE_BOUNDARY_MARKER {
475 continue;
476 }
477
478 let trimmed = line.trim_start();
479
480 if !fence_tracker.in_code_block() && trimmed.starts_with("```{=include=}") {
481 let directive = parse_include_directive(trimmed);
482
483 let mut include_listing = String::new();
484 for next_line in lines.by_ref() {
485 if next_line.trim_start().starts_with("```") {
486 break;
487 }
488 include_listing.push_str(next_line);
489 include_listing.push('\n');
490 }
491
492 let included = if directive.include_type.as_deref() == Some("options") {
493 read_options_includes(
494 &include_listing,
495 base_dir,
496 &mut all_included_files,
497 )
498 } else {
499 read_includes(
500 &include_listing,
501 base_dir,
502 directive.custom_output,
503 directive.auto_id_prefix,
504 &mut all_included_files,
505 depth,
506 )?
507 };
508 output.push_str(&included);
509 continue;
510 }
511
512 fence_tracker = fence_tracker.process_line(line);
514
515 output.push_str(line);
516 output.push('\n');
517 }
518
519 Ok((output, all_included_files))
520}
521
522#[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
537#[must_use]
538#[expect(
539 clippy::implicit_hasher,
540 reason = "Standard HashMap/HashSet sufficient for this use case"
541)]
542pub fn process_role_markup(
543 content: &str,
544 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
545 auto_link_options: bool,
546 valid_options: Option<&rustc_hash::FxHashSet<String>>,
547) -> String {
548 let mut result = String::new();
549 let mut chars = content.chars().peekable();
550 let mut tracker = crate::utils::codeblock::InlineTracker::new();
551
552 while let Some(ch) = chars.next() {
553 if ch == '`' {
555 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
556 tracker = new_tracker;
557
558 result.push_str(&"`".repeat(tick_count));
560 continue;
561 }
562
563 if ch == '~' && chars.peek() == Some(&'~') {
565 let (new_tracker, tilde_count) = tracker.process_tildes(&mut chars);
566 tracker = new_tracker;
567
568 result.push_str(&"~".repeat(tilde_count));
569 continue;
570 }
571
572 if ch == '\n' {
574 tracker = tracker.process_newline();
575 result.push(ch);
576 continue;
577 }
578
579 if ch == '{' && !tracker.in_any_code() {
581 let remaining: Vec<char> = chars.clone().collect();
583 let remaining_str: String = remaining.iter().collect();
584 let mut temp_chars = remaining_str.chars().peekable();
585
586 if let Some(role_markup) = parse_role_markup(
587 &mut temp_chars,
588 manpage_urls,
589 auto_link_options,
590 valid_options,
591 ) {
592 let remaining_after_parse: String = temp_chars.collect();
594 let consumed = remaining_str.len() - remaining_after_parse.len();
595 for _ in 0..consumed {
596 chars.next();
597 }
598 result.push_str(&role_markup);
599 } else {
600 result.push(ch);
602 }
603 } else {
604 result.push(ch);
605 }
606 }
607
608 result
609}
610
611fn parse_role_markup(
617 chars: &mut std::iter::Peekable<std::str::Chars>,
618 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
619 auto_link_options: bool,
620 valid_options: Option<&rustc_hash::FxHashSet<String>>,
621) -> Option<String> {
622 let mut role_name = String::new();
623
624 while let Some(&ch) = chars.peek() {
626 if ch.is_ascii_lowercase() {
627 role_name.push(ch);
628 chars.next();
629 } else {
630 break;
631 }
632 }
633
634 if role_name.is_empty() {
636 return None;
637 }
638
639 if chars.peek() != Some(&'}') {
641 return None;
642 }
643 chars.next(); if chars.peek() != Some(&'`') {
647 return None;
648 }
649 chars.next(); let mut content = String::new();
653 for ch in chars.by_ref() {
654 if ch == '`' {
655 if content.is_empty() && !matches!(role_name.as_str(), "manpage") {
658 return None; }
660 return Some(format_role_markup(
661 &role_name,
662 &content,
663 manpage_urls,
664 auto_link_options,
665 valid_options,
666 ));
667 }
668 content.push(ch);
669 }
670
671 None
673}
674
675#[must_use]
677#[expect(
678 clippy::option_if_let_else,
679 reason = "Nested options clearer with if-let"
680)]
681#[expect(
682 clippy::implicit_hasher,
683 reason = "Standard HashMap/HashSet sufficient for this use case"
684)]
685pub fn format_role_markup(
686 role_type: &str,
687 content: &str,
688 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
689 auto_link_options: bool,
690 valid_options: Option<&rustc_hash::FxHashSet<String>>,
691) -> String {
692 let escaped_content = encode_text(content);
693 match role_type {
694 "manpage" => {
695 if let Some(urls) = manpage_urls {
696 if let Some(url) = urls.get(content) {
697 format!(
698 "<a href=\"{url}\" \
699 class=\"manpage-reference\">{escaped_content}</a>"
700 )
701 } else {
702 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
703 }
704 } else {
705 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
706 }
707 },
708 "command" => format!("<code class=\"command\">{escaped_content}</code>"),
709 "env" => format!("<code class=\"env-var\">{escaped_content}</code>"),
710 "file" => format!("<code class=\"file-path\">{escaped_content}</code>"),
711 "option" => {
712 if cfg!(feature = "ndg-flavored") && auto_link_options {
713 let should_link =
715 valid_options.is_none_or(|opts| opts.contains(content)); if should_link {
718 let option_id = sanitize_option_id(content);
719 format!(
720 "<a class=\"option-reference\" \
721 href=\"options.html#{option_id}\"><code \
722 class=\"nixos-option\">{escaped_content}</code></a>"
723 )
724 } else {
725 format!("<code class=\"nixos-option\">{escaped_content}</code>")
726 }
727 } else {
728 format!("<code class=\"nixos-option\">{escaped_content}</code>")
729 }
730 },
731 "var" => format!("<code class=\"nix-var\">{escaped_content}</code>"),
732 _ => format!("<span class=\"{role_type}-markup\">{escaped_content}</span>"),
733 }
734}
735
736#[must_use]
750pub fn process_myst_autolinks(content: &str) -> String {
751 let mut result = String::with_capacity(content.len());
752 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
753
754 for line in content.lines() {
755 fence_tracker = fence_tracker.process_line(line);
757
758 if fence_tracker.in_code_block() {
760 result.push_str(line);
761 } else {
762 result.push_str(&process_line_myst_autolinks(line));
763 }
764 result.push('\n');
765 }
766
767 result
768}
769
770fn process_line_myst_autolinks(line: &str) -> String {
772 let mut result = String::with_capacity(line.len());
773 let mut chars = line.chars().peekable();
774 let mut tracker = crate::utils::codeblock::InlineTracker::new();
775
776 while let Some(ch) = chars.next() {
777 if ch == '`' {
778 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
779 tracker = new_tracker;
780 result.push_str(&"`".repeat(tick_count));
781 continue;
782 }
783
784 if ch == '[' && chars.peek() == Some(&']') && !tracker.in_any_code() {
785 chars.next(); if chars.peek() == Some(&'{') {
790 result.push_str("[]");
792 continue;
793 }
794
795 if chars.peek() == Some(&'(') {
796 chars.next(); let mut url = String::new();
800 let mut found_closing = false;
801 while let Some(&next_ch) = chars.peek() {
802 if next_ch == ')' {
803 chars.next(); found_closing = true;
805 break;
806 }
807 url.push(next_ch);
808 chars.next();
809 }
810
811 if found_closing && !url.is_empty() {
812 if url.starts_with('#') {
814 let _ = write!(result, "[{{{{ANCHOR}}}}]({url})");
816 } else if url.starts_with("http://") || url.starts_with("https://") {
817 let _ = write!(result, "<{url}>");
819 } else {
820 let _ = write!(result, "[]({url})");
822 }
823 } else {
824 result.push_str("](");
826 result.push_str(&url);
827 }
828 } else {
829 result.push(']');
831 }
832 } else {
833 result.push(ch);
834 }
835 }
836
837 result
838}
839
840#[cfg(feature = "nixpkgs")]
853#[must_use]
854pub fn process_inline_anchors(content: &str) -> String {
855 let mut result = String::with_capacity(content.len() + 100);
856 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
857
858 for line in content.lines() {
859 let trimmed = line.trim_start();
860
861 fence_tracker = fence_tracker.process_line(line);
863
864 if fence_tracker.in_code_block() {
866 result.push_str(line);
868 } else {
869 if let Some(anchor_start) = find_list_item_anchor(trimmed)
872 && let Some(processed_line) =
873 process_list_item_anchor(line, anchor_start)
874 {
875 result.push_str(&processed_line);
876 result.push('\n');
877 continue;
878 }
879
880 result.push_str(&process_line_anchors(line));
882 }
883 result.push('\n');
884 }
885
886 result
887}
888
889#[cfg(feature = "nixpkgs")]
891#[must_use]
892pub fn process_bracketed_spans(content: &str) -> String {
893 let mut result = String::with_capacity(content.len());
894 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
895
896 for line in content.lines() {
897 fence_tracker = fence_tracker.process_line(line);
898 if fence_tracker.in_code_block() {
899 result.push_str(line);
900 } else {
901 result.push_str(&process_line_bracketed_spans(line));
902 }
903 result.push('\n');
904 }
905
906 result
907}
908
909#[cfg(feature = "nixpkgs")]
910fn process_line_bracketed_spans(line: &str) -> String {
911 let mut result = String::with_capacity(line.len());
912 let mut chars = line.chars().peekable();
913 let mut tracker = crate::utils::codeblock::InlineTracker::new();
914 let mut previous = None;
915
916 while let Some(ch) = chars.next() {
917 if ch == '`' {
918 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
919 tracker = new_tracker;
920 result.push_str(&"`".repeat(tick_count));
921 previous = Some('`');
922 continue;
923 }
924
925 if ch == '[' && previous != Some('!') && !tracker.in_any_code() {
926 let remaining: String = chars.clone().collect();
927 if let Some((html, consumed)) = parse_bracketed_span(&remaining) {
928 for _ in 0..consumed {
929 chars.next();
930 }
931 result.push_str(&html);
932 previous = Some('>');
933 continue;
934 }
935 }
936
937 result.push(ch);
938 previous = Some(ch);
939 }
940
941 result
942}
943
944#[cfg(feature = "nixpkgs")]
945fn parse_bracketed_span(input: &str) -> Option<(String, usize)> {
946 let close_text = input.find(']')?;
947 if close_text == 0 {
948 return None;
949 }
950 let text = &input[..close_text];
951 let after_text = &input[close_text + 1..];
952 if !after_text.starts_with('{') {
953 return None;
954 }
955 let close_attrs = after_text.find('}')?;
956 let attrs = &after_text[1..close_attrs];
957 let html_attrs = render_span_attrs(attrs)?;
958 let html = format!("<span{html_attrs}>{}</span>", encode_text(text));
959 Some((html, close_text + 1 + close_attrs + 1))
960}
961
962#[cfg(feature = "nixpkgs")]
963fn render_span_attrs(attrs: &str) -> Option<String> {
964 let mut id = None;
965 let mut classes = Vec::new();
966 let mut pairs = Vec::new();
967
968 for attr in attrs.split_whitespace() {
969 if let Some(value) = attr.strip_prefix('#') {
970 if !value.is_empty() {
971 id = Some(value);
972 }
973 } else if let Some(value) = attr.strip_prefix('.') {
974 if !value.is_empty() {
975 classes.push(value);
976 }
977 } else if let Some((key, value)) = attr.split_once('=')
978 && key
979 .chars()
980 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
981 {
982 pairs.push((key, value.trim_matches('"')));
983 }
984 }
985
986 if id.is_none() && classes.is_empty() && pairs.is_empty() {
987 return None;
988 }
989
990 let mut rendered = String::new();
991 if let Some(id) = id {
992 let _ = write!(rendered, " id=\"{}\"", encode_double_quoted_attribute(id));
993 }
994 if !classes.is_empty() {
995 let _ = write!(
996 rendered,
997 " class=\"{}\"",
998 encode_double_quoted_attribute(&classes.join(" "))
999 );
1000 }
1001 for (key, value) in pairs {
1002 let _ = write!(
1003 rendered,
1004 " {key}=\"{}\"",
1005 encode_double_quoted_attribute(value)
1006 );
1007 }
1008
1009 Some(rendered)
1010}
1011
1012fn find_list_item_anchor(trimmed: &str) -> Option<usize> {
1014 if (trimmed.starts_with("- ")
1016 || trimmed.starts_with("* ")
1017 || trimmed.starts_with("+ "))
1018 && trimmed.len() > 2
1019 {
1020 let after_marker = &trimmed[2..];
1021 if after_marker.starts_with("[]{#") {
1022 return Some(2);
1023 }
1024 }
1025
1026 let digit_end = trimmed
1028 .char_indices()
1029 .find(|(_, c)| !c.is_ascii_digit())
1030 .map_or(trimmed.len(), |(i, _)| i);
1031 if digit_end > 0
1032 && digit_end < trimmed.len() - 1
1033 && trimmed.as_bytes().get(digit_end) == Some(&b'.')
1034 {
1035 let after_marker = &trimmed[digit_end + 1..];
1036 if after_marker.starts_with(" []{#") {
1037 return Some(digit_end + 2);
1038 }
1039 }
1040
1041 None
1042}
1043
1044fn process_list_item_anchor(line: &str, anchor_start: usize) -> Option<String> {
1046 let before_anchor = &line[..anchor_start];
1047 let after_marker = &line[anchor_start..];
1048
1049 if !after_marker.starts_with("[]{#") {
1050 return None;
1051 }
1052
1053 if let Some(anchor_end) = after_marker.find('}') {
1055 let id = &after_marker[4..anchor_end]; let remaining_content = &after_marker[anchor_end + 1..]; if id
1060 .chars()
1061 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1062 && !id.is_empty()
1063 {
1064 return Some(format!(
1065 "{before_anchor}<span id=\"{id}\" \
1066 class=\"nixos-anchor\"></span>{remaining_content}"
1067 ));
1068 }
1069 }
1070
1071 None
1072}
1073
1074#[expect(
1076 clippy::excessive_nesting,
1077 reason = "complex character parsing algorithm, refactoring would reduce \
1078 readability"
1079)]
1080fn process_line_anchors(line: &str) -> String {
1081 let mut result = String::with_capacity(line.len());
1082 let mut chars = line.chars().peekable();
1083 let mut tracker = crate::utils::codeblock::InlineTracker::new();
1084
1085 while let Some(ch) = chars.next() {
1086 if ch == '`' {
1087 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
1088 tracker = new_tracker;
1089 result.push_str(&"`".repeat(tick_count));
1090 continue;
1091 }
1092
1093 if ch == '[' && chars.peek() == Some(&']') && !tracker.in_any_code() {
1094 chars.next(); if chars.peek() == Some(&'{') {
1098 chars.next(); if chars.peek() == Some(&'#') {
1100 chars.next(); let mut id = String::new();
1104 while let Some(&next_ch) = chars.peek() {
1105 if next_ch == '}' {
1106 chars.next(); if !id.is_empty()
1110 && id
1111 .chars()
1112 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1113 {
1114 let _ = write!(
1115 result,
1116 "<span id=\"{id}\" class=\"nixos-anchor\"></span>"
1117 );
1118 } else {
1119 let _ = write!(result, "[]{{{{#{id}}}}}");
1121 }
1122 break;
1123 } else if next_ch.is_ascii_alphanumeric()
1124 || next_ch == '-'
1125 || next_ch == '_'
1126 {
1127 id.push(next_ch);
1128 chars.next();
1129 } else {
1130 let _ = write!(result, "[]{{{{#{id}");
1132 break;
1133 }
1134 }
1135 } else {
1136 result.push_str("]{");
1138 }
1139 } else {
1140 result.push(']');
1142 }
1143 } else {
1144 result.push(ch);
1145 }
1146 }
1147
1148 result
1149}
1150
1151#[cfg(feature = "nixpkgs")]
1165#[must_use]
1166pub fn process_block_elements(content: &str) -> String {
1167 let mut result = Vec::new();
1168 let mut lines = content.lines().peekable();
1169 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
1170
1171 while let Some(line) = lines.next() {
1172 if line.trim() == INCLUDE_BOUNDARY_MARKER {
1173 continue;
1174 }
1175
1176 fence_tracker = fence_tracker.process_line(line);
1178
1179 if !fence_tracker.in_code_block() {
1181 if let Some((callout_type, initial_content)) = parse_github_callout(line)
1183 {
1184 let content =
1185 collect_github_callout_content(&mut lines, &initial_content);
1186 let admonition = render_admonition(&callout_type, None, &content);
1187 result.push(admonition);
1188 continue;
1189 }
1190
1191 if let Some(admonition_start) = parse_fenced_admonition_start(line) {
1193 let indent = leading_whitespace(line);
1194 let (content, trailing) = collect_fenced_content(
1195 &mut lines,
1196 indent,
1197 admonition_start.fence_len,
1198 );
1199 let content = process_block_elements(&content);
1200 let admonition = indent_block(
1201 &render_admonition(
1202 &admonition_start.adm_type,
1203 admonition_start.id.as_deref(),
1204 &content,
1205 ),
1206 indent,
1207 );
1208 result.push(admonition);
1209 if let Some(trailing_content) = trailing {
1212 result.push(trailing_content);
1213 }
1214 continue;
1215 }
1216
1217 if let Some((id, title, content)) = parse_figure_block(line, &mut lines) {
1219 let figure = render_figure(id.as_deref(), &title, &content);
1220 result.push(figure);
1221 continue;
1222 }
1223 }
1224
1225 result.push(line.to_string());
1227 }
1228
1229 result.join("\n")
1230}
1231
1232fn parse_github_callout(line: &str) -> Option<(String, String)> {
1234 let trimmed = line.trim_start();
1235 if !trimmed.starts_with("> [!") {
1236 return None;
1237 }
1238
1239 if let Some(close_bracket) = trimmed.find(']')
1241 && close_bracket > 4
1242 {
1243 let callout_type = &trimmed[4..close_bracket];
1244
1245 match callout_type {
1247 "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION" | "DANGER" => {
1248 let content = trimmed[close_bracket + 1..].trim();
1249 return Some((callout_type.to_lowercase(), content.to_string()));
1250 },
1251 _ => return None,
1252 }
1253 }
1254
1255 None
1256}
1257
1258fn is_atx_header(line: &str) -> bool {
1275 let mut chars = line.chars();
1276 let mut hash_count = 0;
1277
1278 while let Some(c) = chars.next() {
1280 if c == '#' {
1281 hash_count += 1;
1282 if hash_count > 6 {
1283 return false;
1284 }
1285 } else {
1286 return (1..=6).contains(&hash_count)
1288 && (c.is_whitespace() || chars.as_str().is_empty());
1289 }
1290 }
1291
1292 (1..=6).contains(&hash_count)
1294}
1295
1296fn collect_github_callout_content(
1298 lines: &mut std::iter::Peekable<std::str::Lines>,
1299 initial_content: &str,
1300) -> String {
1301 let mut content = String::new();
1302
1303 if !initial_content.is_empty() {
1304 content.push_str(initial_content);
1305 content.push('\n');
1306 }
1307
1308 while let Some(line) = lines.peek() {
1309 let trimmed = line.trim_start();
1310
1311 if trimmed.is_empty() {
1313 break;
1314 }
1315
1316 let content_part = if trimmed.starts_with('>') {
1318 trimmed.strip_prefix('>').unwrap_or("").trim_start()
1319 } else {
1320 let starts_new_block = is_atx_header(trimmed)
1324 || trimmed.starts_with("```")
1325 || trimmed.starts_with("~~~")
1326 || (trimmed.starts_with("---")
1327 && trimmed.chars().all(|c| c == '-' || c.is_whitespace()))
1328 || (trimmed.starts_with("===")
1329 && trimmed.chars().all(|c| c == '=' || c.is_whitespace()))
1330 || (trimmed.starts_with("***")
1331 && trimmed.chars().all(|c| c == '*' || c.is_whitespace()));
1332
1333 if starts_new_block {
1334 break;
1335 }
1336
1337 trimmed
1342 };
1343
1344 content.push_str(content_part);
1345 content.push('\n');
1346 lines.next(); }
1348
1349 content.trim().to_string()
1350}
1351
1352struct AdmonitionStart {
1354 adm_type: String,
1355 id: Option<String>,
1356 fence_len: usize,
1357}
1358
1359fn parse_fenced_admonition_start(line: &str) -> Option<AdmonitionStart> {
1360 let trimmed = line.trim();
1361 if !trimmed.starts_with(":::") {
1362 return None;
1363 }
1364
1365 let fence_len = trimmed.chars().take_while(|&ch| ch == ':').count();
1366 if fence_len < 3 {
1367 return None;
1368 }
1369
1370 let after_colons = trimmed[fence_len..].trim_start();
1371 if !after_colons.starts_with('{') {
1372 return None;
1373 }
1374
1375 if let Some(close_brace) = after_colons.find('}') {
1377 let content = &after_colons[1..close_brace]; let mut first_class = None;
1382 let mut adm_type = None;
1383 let mut id = None;
1384 for part in content.split_whitespace() {
1385 if let Some(value) = part.strip_prefix('.') {
1386 let value = value.to_ascii_lowercase();
1387 first_class.get_or_insert_with(|| value.clone());
1388 if matches!(
1389 value.as_str(),
1390 "note" | "tip" | "important" | "warning" | "caution" | "danger"
1391 ) {
1392 adm_type.get_or_insert(value);
1393 }
1394 } else if let Some(value) = part.strip_prefix('#') {
1395 id.get_or_insert_with(|| value.to_string());
1396 }
1397 }
1398
1399 if let Some(adm_type) = adm_type.or(first_class) {
1400 return Some(AdmonitionStart {
1401 adm_type,
1402 id,
1403 fence_len,
1404 });
1405 }
1406 }
1407
1408 None
1409}
1410
1411fn leading_whitespace(line: &str) -> &str {
1412 let end = line
1413 .char_indices()
1414 .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
1415 .unwrap_or(line.len());
1416 &line[..end]
1417}
1418
1419fn strip_indent<'a>(line: &'a str, indent: &str) -> &'a str {
1420 line.strip_prefix(indent).unwrap_or(line)
1421}
1422
1423fn indent_block(block: &str, indent: &str) -> String {
1424 if indent.is_empty() {
1425 return block.to_string();
1426 }
1427
1428 block
1429 .lines()
1430 .map(|line| format!("{indent}{line}"))
1431 .collect::<Vec<_>>()
1432 .join("\n")
1433}
1434
1435fn collect_fenced_content(
1443 lines: &mut std::iter::Peekable<std::str::Lines>,
1444 indent: &str,
1445 fence_len: usize,
1446) -> (String, Option<String>) {
1447 let mut content = String::new();
1448
1449 for line in lines.by_ref() {
1450 let line = strip_indent(line, indent);
1451 let trimmed = line.trim();
1452 if trimmed == INCLUDE_BOUNDARY_MARKER {
1453 return (content.trim().to_string(), None);
1454 }
1455
1456 let closing_len = trimmed.chars().take_while(|&ch| ch == ':').count();
1457 if closing_len >= fence_len {
1458 let after_colons = &trimmed[closing_len..];
1460 if !after_colons.is_empty() {
1461 return (content.trim().to_string(), Some(after_colons.to_string()));
1463 }
1464 break;
1465 }
1466 content.push_str(line);
1467 content.push('\n');
1468 }
1469
1470 (content.trim().to_string(), None)
1471}
1472
1473#[expect(
1475 clippy::option_if_let_else,
1476 reason = "Nested options clearer with if-let"
1477)]
1478fn parse_figure_block(
1479 line: &str,
1480 lines: &mut std::iter::Peekable<std::str::Lines>,
1481) -> Option<(Option<String>, String, String)> {
1482 let trimmed = line.trim();
1483 if !trimmed.starts_with(":::") {
1484 return None;
1485 }
1486
1487 let after_colons = trimmed[3..].trim_start();
1488 if !after_colons.starts_with("{.figure") {
1489 return None;
1490 }
1491
1492 let id = if let Some(hash_pos) = after_colons.find('#') {
1494 if let Some(close_brace) = after_colons.find('}') {
1495 if hash_pos < close_brace {
1496 Some(after_colons[hash_pos + 1..close_brace].trim().to_string())
1497 } else {
1498 None
1499 }
1500 } else {
1501 None
1502 }
1503 } else {
1504 None
1505 };
1506
1507 let title = if let Some(title_line) = lines.next() {
1509 let trimmed_title = title_line.trim();
1510 if let Some(this) = trimmed_title.strip_prefix('#') {
1511 { this.trim_matches(char::is_whitespace) }.to_string()
1512 } else {
1513 return None;
1515 }
1516 } else {
1517 return None;
1518 };
1519
1520 let mut content = String::new();
1522 for line in lines.by_ref() {
1523 let trimmed = line.trim();
1524 if trimmed == INCLUDE_BOUNDARY_MARKER || trimmed.starts_with(":::") {
1525 break;
1526 }
1527 content.push_str(line);
1528 content.push('\n');
1529 }
1530
1531 Some((id, title, content.trim().to_string()))
1532}
1533
1534fn render_admonition(
1536 adm_type: &str,
1537 id: Option<&str>,
1538 content: &str,
1539) -> String {
1540 let capitalized_type = crate::utils::capitalize_first(adm_type);
1541 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
1542
1543 let opening = format!(
1544 "<div class=\"admonition {adm_type}\"{id_attr}>\n<p \
1545 class=\"admonition-title\">{capitalized_type}</p>"
1546 );
1547 format!("{opening}\n\n{content}\n\n</div>\n")
1548}
1549
1550fn render_figure(id: Option<&str>, title: &str, content: &str) -> String {
1552 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
1553
1554 format!(
1555 "<figure{id_attr}>\n<figcaption>{title}</figcaption>\n{content}\n</figure>"
1556 )
1557}
1558
1559#[cfg(feature = "nixpkgs")]
1572#[must_use]
1573#[expect(
1574 clippy::implicit_hasher,
1575 reason = "Standard HashMap sufficient for this use case"
1576)]
1577pub fn process_manpage_references(
1578 html: &str,
1579 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
1580) -> String {
1581 process_safe(
1582 html,
1583 |html| {
1584 use kuchikikiki::NodeRef;
1585 use tendril::TendrilSink;
1586
1587 let document = kuchikikiki::parse_html().one(html);
1588 let mut to_replace = Vec::new();
1589
1590 for span_node in safe_select(&document, "span.manpage-reference") {
1592 let span_el = span_node;
1593 let span_text = span_el.text_contents();
1594
1595 if let Some(urls) = manpage_urls {
1596 if let Some(url) = urls.get(&span_text) {
1598 let clean_url = extract_url_from_html(url);
1599 let link = NodeRef::new_element(
1600 markup5ever::QualName::new(
1601 None,
1602 markup5ever::ns!(html),
1603 markup5ever::local_name!("a"),
1604 ),
1605 vec![
1606 (
1607 kuchikikiki::ExpandedName::new("", "href"),
1608 kuchikikiki::Attribute {
1609 prefix: None,
1610 value: clean_url.into(),
1611 },
1612 ),
1613 (
1614 kuchikikiki::ExpandedName::new("", "class"),
1615 kuchikikiki::Attribute {
1616 prefix: None,
1617 value: "manpage-reference".into(),
1618 },
1619 ),
1620 ],
1621 );
1622 link.append(NodeRef::new_text(span_text.clone()));
1623 to_replace.push((span_el.clone(), link));
1624 }
1625 }
1626 }
1627
1628 for (old, new) in to_replace {
1630 old.insert_before(new);
1631 old.detach();
1632 }
1633
1634 let mut out = Vec::new();
1635 let _ = document.serialize(&mut out);
1636 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
1637 },
1638 "",
1640 )
1641}
1642
1643#[cfg(feature = "ndg-flavored")]
1658#[must_use]
1659#[expect(
1660 clippy::implicit_hasher,
1661 reason = "Standard HashSet sufficient for this use case"
1662)]
1663pub fn process_option_references(
1664 html: &str,
1665 valid_options: Option<&rustc_hash::FxHashSet<String>>,
1666) -> String {
1667 use kuchikikiki::{Attribute, ExpandedName, NodeRef};
1668 use markup5ever::{QualName, local_name, ns};
1669 use tendril::TendrilSink;
1670
1671 process_safe(
1672 html,
1673 |html| {
1674 let document = kuchikikiki::parse_html().one(html);
1675
1676 let mut to_replace = vec![];
1677
1678 for code_node in safe_select(&document, "code.nixos-option") {
1681 let code_el = code_node;
1682 let code_text = code_el.text_contents();
1683
1684 let mut is_already_option_ref = false;
1686 let mut current = code_el.parent();
1687 while let Some(parent) = current {
1688 if let Some(element) = parent.as_element()
1689 && element.name.local == local_name!("a")
1690 && let Some(class_attr) =
1691 element.attributes.borrow().get(local_name!("class"))
1692 && class_attr.contains("option-reference")
1693 {
1694 is_already_option_ref = true;
1695 break;
1696 }
1697 current = parent.parent();
1698 }
1699
1700 if !is_already_option_ref {
1701 let should_link =
1704 valid_options.is_none_or(|opts| opts.contains(code_text.as_str()));
1705
1706 if should_link {
1707 let option_id = sanitize_option_id(code_text.as_str());
1708 let attrs = vec![
1709 (ExpandedName::new("", "href"), Attribute {
1710 prefix: None,
1711 value: format!("options.html#{option_id}"),
1712 }),
1713 (ExpandedName::new("", "class"), Attribute {
1714 prefix: None,
1715 value: "option-reference".into(),
1716 }),
1717 ];
1718 let a = NodeRef::new_element(
1719 QualName::new(None, ns!(html), local_name!("a")),
1720 attrs,
1721 );
1722 let code = NodeRef::new_element(
1723 QualName::new(None, ns!(html), local_name!("code")),
1724 vec![],
1725 );
1726 code.append(NodeRef::new_text(code_text.clone()));
1727 a.append(code);
1728 to_replace.push((code_el.clone(), a));
1729 }
1730 }
1732 }
1733
1734 for (old, new) in to_replace {
1735 old.insert_before(new);
1736 old.detach();
1737 }
1738
1739 let mut out = Vec::new();
1740 let _ = document.serialize(&mut out);
1741 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
1742 },
1743 "",
1745 )
1746}
1747
1748fn extract_url_from_html(url_or_html: &str) -> &str {
1751 if url_or_html.starts_with("<a href=\"") {
1753 if let Some(start) = url_or_html.find("href=\"") {
1755 let start = start + 6; if let Some(end) = url_or_html[start..].find('"') {
1757 return &url_or_html[start..start + end];
1758 }
1759 }
1760 }
1761
1762 url_or_html
1764}
1765
1766#[cfg(feature = "wiki")]
1783#[must_use]
1784pub fn process_wikilinks(content: &str) -> String {
1785 use crate::utils::codeblock::FenceTracker;
1786
1787 let mut result = String::with_capacity(content.len());
1788 let lines = content.lines();
1789 let mut tracker = FenceTracker::new();
1790
1791 for line in lines {
1792 tracker = tracker.process_line(line);
1793
1794 if tracker.in_code_block() {
1795 result.push_str(line);
1796 } else {
1797 result.push_str(&process_line_wikilinks(line));
1798 }
1799 result.push('\n');
1800 }
1801
1802 result.trim_end().to_string()
1803}
1804
1805#[cfg(feature = "wiki")]
1807fn process_line_wikilinks(line: &str) -> String {
1808 let mut result = String::with_capacity(line.len());
1809 let mut chars = line.chars().peekable();
1810
1811 while let Some(ch) = chars.next() {
1812 if ch == '[' && chars.peek() == Some(&'[') {
1813 chars.next();
1814
1815 let mut inner = String::new();
1816 let mut found_double_close = false;
1817
1818 while let Some(&next_ch) = chars.peek() {
1819 chars.next();
1820 if next_ch == ']' && chars.peek() == Some(&']') {
1821 chars.next();
1822 found_double_close = true;
1823 break;
1824 }
1825 inner.push(next_ch);
1826 }
1827
1828 if found_double_close {
1829 if inner.is_empty() {
1830 result.push_str("[[]]");
1831 } else if inner.contains('|') {
1832 let parts: Vec<&str> = inner.splitn(2, '|').collect();
1833 let name = parts[0].trim();
1834 let url = parts.get(1).unwrap_or(&name).trim();
1835 let escaped_name = encode_text(name);
1836 let escaped_url = encode_text(url);
1837 let _ = write!(
1838 result,
1839 "<a href=\"{escaped_url}\" class=\"wikilink\">{escaped_name}</a>"
1840 );
1841 } else {
1842 let page = inner.trim();
1843 let escaped_page = encode_text(page);
1844 let link_target = format!("{page}.html");
1845 let _ = write!(
1846 result,
1847 "<a href=\"{link_target}\" \
1848 class=\"obsidian-link\">{escaped_page}</a>"
1849 );
1850 }
1851 } else {
1852 result.push_str("[[");
1853 result.push_str(&inner);
1854 }
1855 } else {
1856 result.push(ch);
1857 }
1858 }
1859
1860 result
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865 use super::*;
1866
1867 #[test]
1868 fn test_is_atx_header_valid_headers() {
1869 assert!(is_atx_header("# Header"));
1871 assert!(is_atx_header("## Header"));
1872 assert!(is_atx_header("### Header"));
1873 assert!(is_atx_header("#### Header"));
1874 assert!(is_atx_header("##### Header"));
1875 assert!(is_atx_header("###### Header"));
1876
1877 assert!(is_atx_header("#\tHeader"));
1879 assert!(is_atx_header("##\tHeader"));
1880
1881 assert!(is_atx_header("#"));
1883 assert!(is_atx_header("##"));
1884 assert!(is_atx_header("###"));
1885 assert!(is_atx_header("####"));
1886 assert!(is_atx_header("#####"));
1887 assert!(is_atx_header("######"));
1888
1889 assert!(is_atx_header("# Header with multiple spaces"));
1891 assert!(is_atx_header("## Header"));
1892 }
1893
1894 #[test]
1895 fn test_is_atx_header_invalid_headers() {
1896 assert!(!is_atx_header("####### Too many hashes"));
1898 assert!(!is_atx_header("######## Even more"));
1899
1900 assert!(!is_atx_header("#NoSpace"));
1902 assert!(!is_atx_header("##NoSpace"));
1903
1904 assert!(!is_atx_header("Not # a header"));
1906
1907 assert!(!is_atx_header(""));
1909
1910 assert!(!is_atx_header("Regular text"));
1912
1913 assert!(!is_atx_header("#hashtag"));
1915 assert!(!is_atx_header("##hashtag"));
1916 assert!(!is_atx_header("#123"));
1917 assert!(!is_atx_header("##abc"));
1918
1919 assert!(!is_atx_header("#!important"));
1921 assert!(!is_atx_header("#@mention"));
1922 assert!(!is_atx_header("#$variable"));
1923 }
1924
1925 #[test]
1926 fn test_is_atx_header_edge_cases() {
1927 assert!(!is_atx_header(" # Header"));
1930 assert!(!is_atx_header(" ## Header"));
1931
1932 assert!(is_atx_header("# "));
1934 assert!(is_atx_header("## "));
1935
1936 assert!(is_atx_header("# Header\n"));
1938 assert!(is_atx_header("## Header\n"));
1939
1940 assert!(is_atx_header("# \t Header"));
1942 assert!(is_atx_header("## \tHeader"));
1943 }
1944
1945 #[test]
1946 fn test_is_atx_header_blockquote_context() {
1947 assert!(is_atx_header("# New Section"));
1950 assert!(is_atx_header("## Subsection"));
1951
1952 assert!(!is_atx_header("#tag"));
1954 assert!(!is_atx_header("##issue-123"));
1955 assert!(!is_atx_header("###no-space"));
1956
1957 assert!(is_atx_header("###### Level 6"));
1959
1960 assert!(!is_atx_header("####### Not valid"));
1962 }
1963
1964 #[cfg(feature = "wiki")]
1965 #[test]
1966 fn test_wikilink_obsidian_basic() {
1967 let input = "Check out [[Some Page]] for details.";
1968 let result = process_wikilinks(input);
1969 assert!(result.contains("href=\"Some Page.html\""));
1970 assert!(result.contains("class=\"obsidian-link\""));
1971 assert!(result.contains(">Some Page<"));
1972 }
1973
1974 #[cfg(feature = "wiki")]
1975 #[test]
1976 fn test_wikilink_with_url() {
1977 let input = "See [[Custom Name|https://example.com]]";
1978 let result = process_wikilinks(input);
1979 assert!(result.contains("href=\"https://example.com\""));
1980 assert!(result.contains("class=\"wikilink\""));
1981 assert!(result.contains(">Custom Name<"));
1982 }
1983
1984 #[cfg(feature = "wiki")]
1985 #[test]
1986 fn test_wikilink_with_spaces() {
1987 let input = "[[My Page Name]]";
1988 let result = process_wikilinks(input);
1989 assert!(result.contains("href=\"My Page Name.html\""));
1990 }
1991
1992 #[cfg(feature = "wiki")]
1993 #[test]
1994 fn test_wikilink_in_code_block() {
1995 let input = "```\n[[Wiki Link]]\n```\nThen [[Another]]";
1996 let result = process_wikilinks(input);
1997 assert!(result.contains("[[Wiki Link]]"));
1998 assert!(result.contains("href=\"Another.html\""));
1999 }
2000
2001 #[cfg(feature = "wiki")]
2002 #[test]
2003 fn test_wikilink_empty() {
2004 let input = "[[]]";
2005 let result = process_wikilinks(input);
2006 assert!(result.contains("[[]]"));
2007 }
2008
2009 #[cfg(feature = "wiki")]
2010 #[test]
2011 fn test_wikilink_malformed() {
2012 let input = "[[ incomplete";
2013 let result = process_wikilinks(input);
2014 assert!(result.contains("[[ incomplete"));
2015 }
2016
2017 #[cfg(feature = "wiki")]
2018 #[test]
2019 fn test_wikilink_html_escaping() {
2020 let input = "See [[Page With <script>]] for info";
2021 let result = process_wikilinks(input);
2022 assert!(result.contains("<script>"));
2023 assert!(!result.contains(">Page With <script><"));
2024 }
2025}