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(|character| {
17 match character {
18 '*' | '<' | '>' | '[' | ']' | ':' | '"' | ' ' => '_',
19 character => character,
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('\\') || p.is_absolute() {
57 return false;
58 }
59
60 !p.components()
61 .any(|component| matches!(component, std::path::Component::ParentDir))
62}
63
64#[cfg(feature = "nixpkgs")]
66struct IncludeDirective {
67 custom_output: Option<String>,
68 include_type: Option<String>,
69 auto_id_prefix: Option<String>,
70}
71
72#[cfg(feature = "nixpkgs")]
73fn parse_include_directive(line: &str) -> IncludeDirective {
74 let after_marker = line.strip_prefix("```{=include=}").unwrap_or(line).trim();
75 let include_type = after_marker
76 .split_whitespace()
77 .find(|part| {
78 !part.starts_with("html:into-file=")
79 && !part.starts_with("auto-id-prefix=")
80 })
81 .map(str::to_string);
82
83 let custom_output = directive_value(line, "html:into-file=");
84 let auto_id_prefix = directive_value(line, "auto-id-prefix=");
85
86 IncludeDirective {
87 custom_output,
88 include_type,
89 auto_id_prefix,
90 }
91}
92
93#[cfg(feature = "nixpkgs")]
94fn directive_value(line: &str, marker: &str) -> Option<String> {
95 line.find(marker).map(|start| {
96 let start = start + marker.len();
97 line[start..].find(' ').map_or_else(
98 || line[start..].trim().to_string(),
99 |end| line[start..start + end].to_string(),
100 )
101 })
102}
103
104#[cfg(feature = "nixpkgs")]
105fn apply_auto_id_prefix(content: &str, prefix: &str) -> String {
106 if prefix.is_empty() {
107 return content.to_string();
108 }
109
110 let mut result = String::with_capacity(content.len());
111 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
112 let mut heading_numbers = Vec::new();
113
114 for line in content.lines() {
115 fence_tracker = fence_tracker.process_line(line);
116 if fence_tracker.in_code_block() {
117 result.push_str(line);
118 } else if let Some(line) =
119 add_auto_id_to_heading(line, prefix, &mut heading_numbers)
120 {
121 result.push_str(&line);
122 } else {
123 result.push_str(line);
124 }
125 result.push('\n');
126 }
127
128 result
129}
130
131#[cfg(feature = "nixpkgs")]
132fn add_auto_id_to_heading(
133 line: &str,
134 prefix: &str,
135 heading_numbers: &mut Vec<usize>,
136) -> Option<String> {
137 let leading_len = line.len() - line.trim_start().len();
138 if leading_len > 3 {
139 return None;
140 }
141
142 let trimmed = line.trim_start();
143 let level = trimmed.chars().take_while(|&ch| ch == '#').count();
144 if !(1..=6).contains(&level) {
145 return None;
146 }
147
148 let after_hashes = &trimmed[level..];
149 if !after_hashes.is_empty() && !after_hashes.starts_with(char::is_whitespace)
150 {
151 return None;
152 }
153
154 let heading = after_hashes.trim();
155 if heading.is_empty() {
156 return None;
157 }
158
159 if level > heading_numbers.len() {
160 heading_numbers.resize(level, 0);
161 }
162 heading_numbers.truncate(level);
163 heading_numbers[level - 1] += 1;
164
165 if heading.contains("{#") {
166 return None;
167 }
168
169 let id = heading_numbers
170 .iter()
171 .map(usize::to_string)
172 .collect::<Vec<_>>()
173 .join(".");
174
175 Some(format!(
176 "{}{} {{#{}-{}}}",
177 &line[..leading_len],
178 trimmed,
179 prefix,
180 id
181 ))
182}
183
184#[cfg(feature = "nixpkgs")]
185fn render_options_include(content: &str) -> Option<String> {
186 let data: serde_json::Value = serde_json::from_str(content).ok()?;
187 let options = data.as_object()?;
188 let mut result = String::new();
189
190 for (name, value) in options {
191 let option_data = value.as_object()?;
192 let option_id = sanitize_option_id(name);
193 let _ = writeln!(
194 result,
195 "<div class=\"option\" id=\"{}\">",
196 encode_double_quoted_attribute(&option_id)
197 );
198 let _ = writeln!(
199 result,
200 " <h3 class=\"option-name\"><a href=\"#{}\" \
201 class=\"option-anchor\">{}</a></h3>",
202 encode_double_quoted_attribute(&option_id),
203 encode_text(name)
204 );
205
206 if let Some(type_name) = option_data.get("type").and_then(|v| v.as_str()) {
207 let _ = writeln!(
208 result,
209 " <div class=\"option-type\">Type: <code>{}</code></div>",
210 encode_text(type_name)
211 );
212 }
213
214 if let Some(description) = option_data.get("description") {
215 let description = match description {
216 serde_json::Value::String(value) => value.as_str(),
217 serde_json::Value::Object(object)
218 if object.get("_type").and_then(|v| v.as_str())
219 == Some("literalMD") =>
220 {
221 object.get("text").and_then(|v| v.as_str()).unwrap_or("")
222 },
223 _ => "",
224 };
225
226 if !description.is_empty() {
227 let _ = writeln!(
228 result,
229 " <div class=\"option-description\">{}</div>",
230 encode_text(description)
231 );
232 }
233 }
234
235 result.push_str("</div>\n");
236 }
237
238 Some(result)
239}
240
241#[cfg(feature = "nixpkgs")]
242fn read_options_includes(
243 listing: &str,
244 base_dir: &Path,
245 included_files: &mut Vec<crate::types::IncludedFile>,
246) -> String {
247 if let Some(source) = parse_options_source(listing) {
248 return read_options_file(&source, base_dir, included_files);
249 }
250
251 let mut result = String::new();
252
253 for line in listing.lines() {
254 let trimmed = line.trim();
255 if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
256 continue;
257 }
258
259 let full_path = base_dir.join(trimmed);
260 if let Ok(content) = fs::read_to_string(&full_path) {
261 if let Some(rendered) = render_options_include(&content) {
262 result.push_str(&rendered);
263 } else {
264 let _ = writeln!(
265 result,
266 "<!-- ndg: could not parse options include: {} -->",
267 full_path.display()
268 );
269 }
270 } else {
271 let _ = writeln!(
272 result,
273 "<!-- ndg: could not include file: {} -->",
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
283 result
284}
285
286#[cfg(feature = "nixpkgs")]
287fn parse_options_source(listing: &str) -> Option<String> {
288 let mut source = None;
289 for line in listing.lines() {
290 let (key, value) = line.split_once(':')?;
291 if key.trim() == "source" {
292 source = Some(value.trim().to_string());
293 }
294 }
295 source
296}
297
298#[cfg(feature = "nixpkgs")]
299fn read_options_file(
300 source: &str,
301 base_dir: &Path,
302 included_files: &mut Vec<crate::types::IncludedFile>,
303) -> String {
304 let mut result = String::new();
305 if !is_safe_path(source, base_dir) {
306 return result;
307 }
308
309 let full_path = base_dir.join(source);
310 if let Ok(content) = fs::read_to_string(&full_path) {
311 if let Some(rendered) = render_options_include(&content) {
312 result.push_str(&rendered);
313 } else {
314 let _ = writeln!(
315 result,
316 "<!-- ndg: could not parse options include: {} -->",
317 full_path.display()
318 );
319 }
320 } else {
321 let _ = writeln!(
322 result,
323 "<!-- ndg: could not include file: {} -->",
324 full_path.display()
325 );
326 }
327 included_files.push(crate::types::IncludedFile {
328 path: source.to_string(),
329 custom_output: None,
330 });
331
332 result
333}
334
335#[cfg(feature = "nixpkgs")]
337#[expect(
338 clippy::needless_pass_by_value,
339 reason = "Owned value needed for cloning in loop"
340)]
341fn read_includes(
342 listing: &str,
343 base_dir: &Path,
344 custom_output: Option<String>,
345 auto_id_prefix: Option<String>,
346 included_files: &mut Vec<crate::types::IncludedFile>,
347 depth: usize,
348) -> Result<String, String> {
349 let mut result = String::new();
350
351 for (line_index, line) in listing.lines().enumerate() {
352 let trimmed = line.trim();
353 if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
354 continue;
355 }
356 let full_path = base_dir.join(trimmed);
357 log::info!("Including file: {}", full_path.display());
358
359 if let Ok(content) = fs::read_to_string(&full_path) {
360 let file_dir = full_path.parent().unwrap_or(base_dir);
361 let (processed_content, nested_includes) =
362 process_file_includes(&content, file_dir, depth + 1)?;
363
364 let processed_content = if let Some(prefix) = auto_id_prefix.as_deref() {
365 apply_auto_id_prefix(
366 &processed_content,
367 &format!("{}-{}", prefix, line_index + 1),
368 )
369 } else {
370 processed_content
371 };
372
373 if custom_output.is_none() {
374 result.push_str(&processed_content);
375 if !processed_content.ends_with('\n') {
376 result.push('\n');
377 }
378 result.push_str(INCLUDE_BOUNDARY_MARKER);
379 result.push('\n');
380 }
381
382 included_files.push(crate::types::IncludedFile {
383 path: trimmed.to_string(),
384 custom_output: custom_output.clone(),
385 });
386
387 for nested in nested_includes {
389 let nested_full_path = file_dir.join(&nested.path);
390 if let Ok(normalized_path) = nested_full_path.strip_prefix(base_dir) {
391 included_files.push(crate::types::IncludedFile {
392 path: normalized_path.to_string_lossy().to_string(),
393 custom_output: nested.custom_output,
394 });
395 }
396 }
397 } else {
398 let _ = writeln!(
399 result,
400 "<!-- ndg: could not include file: {} -->",
401 full_path.display()
402 );
403 included_files.push(crate::types::IncludedFile {
404 path: trimmed.to_string(),
405 custom_output: custom_output.clone(),
406 });
407 }
408 }
409 Ok(result)
410}
411
412#[cfg(feature = "nixpkgs")]
443pub fn process_file_includes(
444 markdown: &str,
445 base_dir: &std::path::Path,
446 depth: usize,
447) -> Result<(String, Vec<crate::types::IncludedFile>), String> {
448 if depth >= MAX_INCLUDE_DEPTH {
450 return Err(format!(
451 "Maximum include recursion depth ({MAX_INCLUDE_DEPTH}) exceeded. This \
452 likely indicates a cycle in file includes."
453 ));
454 }
455
456 let mut output = String::new();
457 let mut lines = markdown.lines();
458 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
459 let mut all_included_files: Vec<crate::types::IncludedFile> = Vec::new();
460
461 while let Some(line) = lines.next() {
462 if line.trim() == INCLUDE_BOUNDARY_MARKER {
463 continue;
464 }
465
466 let trimmed = line.trim_start();
467
468 if !fence_tracker.in_code_block() && trimmed.starts_with("```{=include=}") {
469 let directive = parse_include_directive(trimmed);
470
471 let mut include_listing = String::new();
472 for next_line in lines.by_ref() {
473 if next_line.trim_start().starts_with("```") {
474 break;
475 }
476 include_listing.push_str(next_line);
477 include_listing.push('\n');
478 }
479
480 let included = if directive.include_type.as_deref() == Some("options") {
481 read_options_includes(
482 &include_listing,
483 base_dir,
484 &mut all_included_files,
485 )
486 } else {
487 read_includes(
488 &include_listing,
489 base_dir,
490 directive.custom_output,
491 directive.auto_id_prefix,
492 &mut all_included_files,
493 depth,
494 )?
495 };
496 output.push_str(&included);
497 continue;
498 }
499
500 fence_tracker = fence_tracker.process_line(line);
502
503 output.push_str(line);
504 output.push('\n');
505 }
506
507 Ok((output, all_included_files))
508}
509
510#[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
525#[must_use]
526#[expect(
527 clippy::implicit_hasher,
528 reason = "Standard HashMap/HashSet sufficient for this use case"
529)]
530pub fn process_role_markup(
531 content: &str,
532 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
533 auto_link_options: bool,
534 valid_options: Option<&rustc_hash::FxHashSet<String>>,
535) -> String {
536 let mut result = String::new();
537 let mut chars = content.chars().peekable();
538 let mut tracker = crate::utils::codeblock::InlineTracker::new();
539
540 while let Some(ch) = chars.next() {
541 if ch == '`' {
543 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
544 tracker = new_tracker;
545
546 result.push_str(&"`".repeat(tick_count));
548 continue;
549 }
550
551 if ch == '~' && chars.peek() == Some(&'~') {
553 let (new_tracker, tilde_count) = tracker.process_tildes(&mut chars);
554 tracker = new_tracker;
555
556 result.push_str(&"~".repeat(tilde_count));
557 continue;
558 }
559
560 if ch == '\n' {
562 tracker = tracker.process_newline();
563 result.push(ch);
564 continue;
565 }
566
567 if ch == '{' && !tracker.in_any_code() {
569 let remaining: Vec<char> = chars.clone().collect();
571 let remaining_str: String = remaining.iter().collect();
572 let mut temp_chars = remaining_str.chars().peekable();
573
574 if let Some(role_markup) = parse_role_markup(
575 &mut temp_chars,
576 manpage_urls,
577 auto_link_options,
578 valid_options,
579 ) {
580 let remaining_after_parse: String = temp_chars.collect();
582 let consumed = remaining_str.len() - remaining_after_parse.len();
583 for _ in 0..consumed {
584 chars.next();
585 }
586 result.push_str(&role_markup);
587 } else {
588 result.push(ch);
590 }
591 } else {
592 result.push(ch);
593 }
594 }
595
596 result
597}
598
599fn parse_role_markup(
605 chars: &mut std::iter::Peekable<std::str::Chars>,
606 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
607 auto_link_options: bool,
608 valid_options: Option<&rustc_hash::FxHashSet<String>>,
609) -> Option<String> {
610 let mut role_name = String::new();
611
612 while let Some(&ch) = chars.peek() {
614 if ch.is_ascii_lowercase() {
615 role_name.push(ch);
616 chars.next();
617 } else {
618 break;
619 }
620 }
621
622 if role_name.is_empty() {
624 return None;
625 }
626
627 if chars.peek() != Some(&'}') {
629 return None;
630 }
631 chars.next(); if chars.peek() != Some(&'`') {
635 return None;
636 }
637 chars.next(); let mut content = String::new();
641 for ch in chars.by_ref() {
642 if ch == '`' {
643 if content.is_empty() && !matches!(role_name.as_str(), "manpage") {
646 return None; }
648 return Some(format_role_markup(
649 &role_name,
650 &content,
651 manpage_urls,
652 auto_link_options,
653 valid_options,
654 ));
655 }
656 content.push(ch);
657 }
658
659 None
661}
662
663#[must_use]
665#[expect(
666 clippy::option_if_let_else,
667 reason = "Nested options clearer with if-let"
668)]
669#[expect(
670 clippy::implicit_hasher,
671 reason = "Standard HashMap/HashSet sufficient for this use case"
672)]
673pub fn format_role_markup(
674 role_type: &str,
675 content: &str,
676 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
677 auto_link_options: bool,
678 valid_options: Option<&rustc_hash::FxHashSet<String>>,
679) -> String {
680 let escaped_content = encode_text(content);
681 match role_type {
682 "manpage" => {
683 if let Some(urls) = manpage_urls {
684 if let Some(url) = urls.get(content) {
685 format!(
686 "<a href=\"{url}\" \
687 class=\"manpage-reference\">{escaped_content}</a>"
688 )
689 } else {
690 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
691 }
692 } else {
693 format!("<span class=\"manpage-reference\">{escaped_content}</span>")
694 }
695 },
696 "command" => format!("<code class=\"command\">{escaped_content}</code>"),
697 "env" => format!("<code class=\"env-var\">{escaped_content}</code>"),
698 "file" => format!("<code class=\"file-path\">{escaped_content}</code>"),
699 "option" => {
700 if cfg!(feature = "ndg-flavored") && auto_link_options {
701 let should_link =
703 valid_options.is_none_or(|opts| opts.contains(content)); if should_link {
706 let option_id = sanitize_option_id(content);
707 format!(
708 "<a class=\"option-reference\" \
709 href=\"options.html#{option_id}\"><code \
710 class=\"nixos-option\">{escaped_content}</code></a>"
711 )
712 } else {
713 format!("<code class=\"nixos-option\">{escaped_content}</code>")
714 }
715 } else {
716 format!("<code class=\"nixos-option\">{escaped_content}</code>")
717 }
718 },
719 "var" => format!("<code class=\"nix-var\">{escaped_content}</code>"),
720 _ => format!("<span class=\"{role_type}-markup\">{escaped_content}</span>"),
721 }
722}
723
724#[must_use]
738pub fn process_myst_autolinks(content: &str) -> String {
739 let mut result = String::with_capacity(content.len());
740 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
741
742 for line in content.lines() {
743 fence_tracker = fence_tracker.process_line(line);
745
746 if fence_tracker.in_code_block() {
748 result.push_str(line);
749 } else {
750 result.push_str(&process_line_myst_autolinks(line));
751 }
752 result.push('\n');
753 }
754
755 result
756}
757
758fn process_line_myst_autolinks(line: &str) -> String {
760 let mut result = String::with_capacity(line.len());
761 let mut chars = line.chars().peekable();
762 let mut tracker = crate::utils::codeblock::InlineTracker::new();
763
764 while let Some(ch) = chars.next() {
765 if ch == '`' {
766 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
767 tracker = new_tracker;
768 result.push_str(&"`".repeat(tick_count));
769 continue;
770 }
771
772 if ch == '[' && chars.peek() == Some(&']') && !tracker.in_any_code() {
773 chars.next(); if chars.peek() == Some(&'{') {
778 result.push_str("[]");
780 continue;
781 }
782
783 if chars.peek() == Some(&'(') {
784 chars.next(); let mut url = String::new();
788 let mut found_closing = false;
789 while let Some(&next_ch) = chars.peek() {
790 if next_ch == ')' {
791 chars.next(); found_closing = true;
793 break;
794 }
795 url.push(next_ch);
796 chars.next();
797 }
798
799 if found_closing && !url.is_empty() {
800 if url.starts_with('#') {
802 let _ = write!(result, "[{{{{ANCHOR}}}}]({url})");
804 } else if url.starts_with("http://") || url.starts_with("https://") {
805 let _ = write!(result, "<{url}>");
807 } else {
808 let _ = write!(result, "[]({url})");
810 }
811 } else {
812 result.push_str("](");
814 result.push_str(&url);
815 }
816 } else {
817 result.push(']');
819 }
820 } else {
821 result.push(ch);
822 }
823 }
824
825 result
826}
827
828#[cfg(feature = "nixpkgs")]
841#[must_use]
842pub fn process_inline_anchors(content: &str) -> String {
843 let mut result = String::with_capacity(content.len() + 100);
844 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
845
846 for line in content.lines() {
847 let trimmed = line.trim_start();
848
849 fence_tracker = fence_tracker.process_line(line);
851
852 if fence_tracker.in_code_block() {
854 result.push_str(line);
856 } else {
857 if let Some(anchor_start) = find_list_item_anchor(trimmed)
860 && let Some(processed_line) =
861 process_list_item_anchor(line, anchor_start)
862 {
863 result.push_str(&processed_line);
864 result.push('\n');
865 continue;
866 }
867
868 result.push_str(&process_line_anchors(line));
870 }
871 result.push('\n');
872 }
873
874 result
875}
876
877#[cfg(feature = "nixpkgs")]
879#[must_use]
880pub fn process_bracketed_spans(content: &str) -> String {
881 let mut result = String::with_capacity(content.len());
882 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
883
884 for line in content.lines() {
885 fence_tracker = fence_tracker.process_line(line);
886 if fence_tracker.in_code_block() {
887 result.push_str(line);
888 } else {
889 result.push_str(&process_line_bracketed_spans(line));
890 }
891 result.push('\n');
892 }
893
894 result
895}
896
897#[cfg(feature = "nixpkgs")]
898fn process_line_bracketed_spans(line: &str) -> String {
899 let mut result = String::with_capacity(line.len());
900 let mut chars = line.chars().peekable();
901 let mut tracker = crate::utils::codeblock::InlineTracker::new();
902 let mut previous = None;
903
904 while let Some(ch) = chars.next() {
905 if ch == '`' {
906 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
907 tracker = new_tracker;
908 result.push_str(&"`".repeat(tick_count));
909 previous = Some('`');
910 continue;
911 }
912
913 if ch == '[' && previous != Some('!') && !tracker.in_any_code() {
914 let remaining: String = chars.clone().collect();
915 if let Some((html, consumed)) = parse_bracketed_span(&remaining) {
916 for _ in 0..consumed {
917 chars.next();
918 }
919 result.push_str(&html);
920 previous = Some('>');
921 continue;
922 }
923 }
924
925 result.push(ch);
926 previous = Some(ch);
927 }
928
929 result
930}
931
932#[cfg(feature = "nixpkgs")]
933fn parse_bracketed_span(input: &str) -> Option<(String, usize)> {
934 let close_text = input.find(']')?;
935 if close_text == 0 {
936 return None;
937 }
938 let text = &input[..close_text];
939 let after_text = &input[close_text + 1..];
940 if !after_text.starts_with('{') {
941 return None;
942 }
943 let close_attrs = after_text.find('}')?;
944 let attrs = &after_text[1..close_attrs];
945 let html_attrs = render_span_attrs(attrs)?;
946 let html = format!("<span{html_attrs}>{}</span>", encode_text(text));
947 Some((html, close_text + 1 + close_attrs + 1))
948}
949
950#[cfg(feature = "nixpkgs")]
951fn render_span_attrs(attrs: &str) -> Option<String> {
952 let mut id = None;
953 let mut classes = Vec::new();
954 let mut pairs = Vec::new();
955
956 for attr in attrs.split_whitespace() {
957 if let Some(value) = attr.strip_prefix('#') {
958 if !value.is_empty() {
959 id = Some(value);
960 }
961 } else if let Some(value) = attr.strip_prefix('.') {
962 if !value.is_empty() {
963 classes.push(value);
964 }
965 } else if let Some((key, value)) = attr.split_once('=')
966 && key
967 .chars()
968 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
969 {
970 pairs.push((key, value.trim_matches('"')));
971 }
972 }
973
974 if id.is_none() && classes.is_empty() && pairs.is_empty() {
975 return None;
976 }
977
978 let mut rendered = String::new();
979 if let Some(id) = id {
980 let _ = write!(rendered, " id=\"{}\"", encode_double_quoted_attribute(id));
981 }
982 if !classes.is_empty() {
983 let _ = write!(
984 rendered,
985 " class=\"{}\"",
986 encode_double_quoted_attribute(&classes.join(" "))
987 );
988 }
989 for (key, value) in pairs {
990 let _ = write!(
991 rendered,
992 " {key}=\"{}\"",
993 encode_double_quoted_attribute(value)
994 );
995 }
996
997 Some(rendered)
998}
999
1000fn find_list_item_anchor(trimmed: &str) -> Option<usize> {
1002 if (trimmed.starts_with("- ")
1004 || trimmed.starts_with("* ")
1005 || trimmed.starts_with("+ "))
1006 && trimmed.len() > 2
1007 {
1008 let after_marker = &trimmed[2..];
1009 if after_marker.starts_with("[]{#") {
1010 return Some(2);
1011 }
1012 }
1013
1014 let digit_end = trimmed
1016 .char_indices()
1017 .find(|(_, c)| !c.is_ascii_digit())
1018 .map_or(trimmed.len(), |(i, _)| i);
1019 if digit_end > 0
1020 && digit_end < trimmed.len() - 1
1021 && trimmed.as_bytes().get(digit_end) == Some(&b'.')
1022 {
1023 let after_marker = &trimmed[digit_end + 1..];
1024 if after_marker.starts_with(" []{#") {
1025 return Some(digit_end + 2);
1026 }
1027 }
1028
1029 None
1030}
1031
1032fn process_list_item_anchor(line: &str, anchor_start: usize) -> Option<String> {
1034 let before_anchor = &line[..anchor_start];
1035 let after_marker = &line[anchor_start..];
1036
1037 if !after_marker.starts_with("[]{#") {
1038 return None;
1039 }
1040
1041 if let Some(anchor_end) = after_marker.find('}') {
1043 let id = &after_marker[4..anchor_end]; let remaining_content = &after_marker[anchor_end + 1..]; if id
1048 .chars()
1049 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1050 && !id.is_empty()
1051 {
1052 return Some(format!(
1053 "{before_anchor}<span id=\"{id}\" \
1054 class=\"nixos-anchor\"></span>{remaining_content}"
1055 ));
1056 }
1057 }
1058
1059 None
1060}
1061
1062#[expect(
1064 clippy::excessive_nesting,
1065 reason = "complex character parsing algorithm, refactoring would reduce \
1066 readability"
1067)]
1068fn process_line_anchors(line: &str) -> String {
1069 let mut result = String::with_capacity(line.len());
1070 let mut chars = line.chars().peekable();
1071 let mut tracker = crate::utils::codeblock::InlineTracker::new();
1072
1073 while let Some(ch) = chars.next() {
1074 if ch == '`' {
1075 let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
1076 tracker = new_tracker;
1077 result.push_str(&"`".repeat(tick_count));
1078 continue;
1079 }
1080
1081 if ch == '[' && chars.peek() == Some(&']') && !tracker.in_any_code() {
1082 chars.next(); if chars.peek() == Some(&'{') {
1086 chars.next(); if chars.peek() == Some(&'#') {
1088 chars.next(); let mut id = String::new();
1092 while let Some(&next_ch) = chars.peek() {
1093 if next_ch == '}' {
1094 chars.next(); if !id.is_empty()
1098 && id
1099 .chars()
1100 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1101 {
1102 let _ = write!(
1103 result,
1104 "<span id=\"{id}\" class=\"nixos-anchor\"></span>"
1105 );
1106 } else {
1107 let _ = write!(result, "[]{{{{#{id}}}}}");
1109 }
1110 break;
1111 } else if next_ch.is_ascii_alphanumeric()
1112 || next_ch == '-'
1113 || next_ch == '_'
1114 {
1115 id.push(next_ch);
1116 chars.next();
1117 } else {
1118 let _ = write!(result, "[]{{{{#{id}");
1120 break;
1121 }
1122 }
1123 } else {
1124 result.push_str("]{");
1126 }
1127 } else {
1128 result.push(']');
1130 }
1131 } else {
1132 result.push(ch);
1133 }
1134 }
1135
1136 result
1137}
1138
1139#[cfg(feature = "nixpkgs")]
1153#[must_use]
1154pub fn process_block_elements(content: &str) -> String {
1155 let mut result = Vec::new();
1156 let mut lines = content.lines().peekable();
1157 let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
1158
1159 while let Some(line) = lines.next() {
1160 if line.trim() == INCLUDE_BOUNDARY_MARKER {
1161 continue;
1162 }
1163
1164 fence_tracker = fence_tracker.process_line(line);
1166
1167 if !fence_tracker.in_code_block() {
1169 if let Some((callout_type, initial_content)) = parse_github_callout(line)
1171 {
1172 let content =
1173 collect_github_callout_content(&mut lines, &initial_content);
1174 let admonition = render_admonition(&callout_type, None, &content);
1175 result.push(admonition);
1176 continue;
1177 }
1178
1179 if let Some(admonition_start) = parse_fenced_admonition_start(line) {
1181 let indent = leading_whitespace(line);
1182 let (content, trailing) = collect_fenced_content(
1183 &mut lines,
1184 indent,
1185 admonition_start.fence_len,
1186 );
1187 let content = process_block_elements(&content);
1188 let admonition = indent_block(
1189 &render_admonition(
1190 &admonition_start.adm_type,
1191 admonition_start.id.as_deref(),
1192 &content,
1193 ),
1194 indent,
1195 );
1196 result.push(admonition);
1197 if let Some(trailing_content) = trailing {
1200 result.push(trailing_content);
1201 }
1202 continue;
1203 }
1204
1205 if let Some((id, title, content)) = parse_figure_block(line, &mut lines) {
1207 let figure = render_figure(id.as_deref(), &title, &content);
1208 result.push(figure);
1209 continue;
1210 }
1211 }
1212
1213 result.push(line.to_string());
1215 }
1216
1217 result.join("\n")
1218}
1219
1220fn parse_github_callout(line: &str) -> Option<(String, String)> {
1222 let trimmed = line.trim_start();
1223 if !trimmed.starts_with("> [!") {
1224 return None;
1225 }
1226
1227 if let Some(close_bracket) = trimmed.find(']')
1229 && close_bracket > 4
1230 {
1231 let callout_type = &trimmed[4..close_bracket];
1232
1233 match callout_type {
1235 "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION" | "DANGER" => {
1236 let content = trimmed[close_bracket + 1..].trim();
1237 return Some((callout_type.to_lowercase(), content.to_string()));
1238 },
1239 _ => return None,
1240 }
1241 }
1242
1243 None
1244}
1245
1246fn is_atx_header(line: &str) -> bool {
1263 let mut chars = line.chars();
1264 let mut hash_count = 0;
1265
1266 while let Some(c) = chars.next() {
1268 if c == '#' {
1269 hash_count += 1;
1270 if hash_count > 6 {
1271 return false;
1272 }
1273 } else {
1274 return (1..=6).contains(&hash_count)
1276 && (c.is_whitespace() || chars.as_str().is_empty());
1277 }
1278 }
1279
1280 (1..=6).contains(&hash_count)
1282}
1283
1284fn collect_github_callout_content(
1286 lines: &mut std::iter::Peekable<std::str::Lines>,
1287 initial_content: &str,
1288) -> String {
1289 let mut content = String::new();
1290
1291 if !initial_content.is_empty() {
1292 content.push_str(initial_content);
1293 content.push('\n');
1294 }
1295
1296 while let Some(line) = lines.peek() {
1297 let trimmed = line.trim_start();
1298
1299 if trimmed.is_empty() {
1301 break;
1302 }
1303
1304 let content_part = if trimmed.starts_with('>') {
1306 trimmed.strip_prefix('>').unwrap_or("").trim_start()
1307 } else {
1308 let starts_new_block = is_atx_header(trimmed)
1312 || trimmed.starts_with("```")
1313 || trimmed.starts_with("~~~")
1314 || (trimmed.starts_with("---")
1315 && trimmed.chars().all(|c| c == '-' || c.is_whitespace()))
1316 || (trimmed.starts_with("===")
1317 && trimmed.chars().all(|c| c == '=' || c.is_whitespace()))
1318 || (trimmed.starts_with("***")
1319 && trimmed.chars().all(|c| c == '*' || c.is_whitespace()));
1320
1321 if starts_new_block {
1322 break;
1323 }
1324
1325 trimmed
1330 };
1331
1332 content.push_str(content_part);
1333 content.push('\n');
1334 lines.next(); }
1336
1337 content.trim().to_string()
1338}
1339
1340struct AdmonitionStart {
1342 adm_type: String,
1343 id: Option<String>,
1344 fence_len: usize,
1345}
1346
1347fn parse_fenced_admonition_start(line: &str) -> Option<AdmonitionStart> {
1348 let trimmed = line.trim();
1349 if !trimmed.starts_with(":::") {
1350 return None;
1351 }
1352
1353 let fence_len = trimmed.chars().take_while(|&ch| ch == ':').count();
1354 if fence_len < 3 {
1355 return None;
1356 }
1357
1358 let after_colons = trimmed[fence_len..].trim_start();
1359 if !after_colons.starts_with('{') {
1360 return None;
1361 }
1362
1363 if let Some(close_brace) = after_colons.find('}') {
1365 let content = &after_colons[1..close_brace]; let mut first_class = None;
1370 let mut adm_type = None;
1371 let mut id = None;
1372 for part in content.split_whitespace() {
1373 if let Some(value) = part.strip_prefix('.') {
1374 let value = value.to_ascii_lowercase();
1375 first_class.get_or_insert_with(|| value.clone());
1376 if matches!(
1377 value.as_str(),
1378 "note" | "tip" | "important" | "warning" | "caution" | "danger"
1379 ) {
1380 adm_type.get_or_insert(value);
1381 }
1382 } else if let Some(value) = part.strip_prefix('#') {
1383 id.get_or_insert_with(|| value.to_string());
1384 }
1385 }
1386
1387 if let Some(adm_type) = adm_type.or(first_class) {
1388 return Some(AdmonitionStart {
1389 adm_type,
1390 id,
1391 fence_len,
1392 });
1393 }
1394 }
1395
1396 None
1397}
1398
1399fn leading_whitespace(line: &str) -> &str {
1400 let end = line
1401 .char_indices()
1402 .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
1403 .unwrap_or(line.len());
1404 &line[..end]
1405}
1406
1407fn strip_indent<'a>(line: &'a str, indent: &str) -> &'a str {
1408 line.strip_prefix(indent).unwrap_or(line)
1409}
1410
1411fn indent_block(block: &str, indent: &str) -> String {
1412 if indent.is_empty() {
1413 return block.to_string();
1414 }
1415
1416 block
1417 .lines()
1418 .map(|line| format!("{indent}{line}"))
1419 .collect::<Vec<_>>()
1420 .join("\n")
1421}
1422
1423fn collect_fenced_content(
1431 lines: &mut std::iter::Peekable<std::str::Lines>,
1432 indent: &str,
1433 fence_len: usize,
1434) -> (String, Option<String>) {
1435 let mut content = String::new();
1436
1437 for line in lines.by_ref() {
1438 let line = strip_indent(line, indent);
1439 let trimmed = line.trim();
1440 if trimmed == INCLUDE_BOUNDARY_MARKER {
1441 return (content.trim().to_string(), None);
1442 }
1443
1444 let closing_len = trimmed.chars().take_while(|&ch| ch == ':').count();
1445 if closing_len >= fence_len {
1446 let after_colons = &trimmed[closing_len..];
1448 if !after_colons.is_empty() {
1449 return (content.trim().to_string(), Some(after_colons.to_string()));
1451 }
1452 break;
1453 }
1454 content.push_str(line);
1455 content.push('\n');
1456 }
1457
1458 (content.trim().to_string(), None)
1459}
1460
1461#[expect(
1463 clippy::option_if_let_else,
1464 reason = "Nested options clearer with if-let"
1465)]
1466fn parse_figure_block(
1467 line: &str,
1468 lines: &mut std::iter::Peekable<std::str::Lines>,
1469) -> Option<(Option<String>, String, String)> {
1470 let trimmed = line.trim();
1471 if !trimmed.starts_with(":::") {
1472 return None;
1473 }
1474
1475 let after_colons = trimmed[3..].trim_start();
1476 if !after_colons.starts_with("{.figure") {
1477 return None;
1478 }
1479
1480 let id = if let Some(hash_pos) = after_colons.find('#') {
1482 if let Some(close_brace) = after_colons.find('}') {
1483 if hash_pos < close_brace {
1484 Some(after_colons[hash_pos + 1..close_brace].trim().to_string())
1485 } else {
1486 None
1487 }
1488 } else {
1489 None
1490 }
1491 } else {
1492 None
1493 };
1494
1495 let title = lines.next()?.trim().strip_prefix('#')?.trim().to_string();
1497
1498 let mut content = String::new();
1500 for line in lines.by_ref() {
1501 let trimmed = line.trim();
1502 if trimmed == INCLUDE_BOUNDARY_MARKER || trimmed.starts_with(":::") {
1503 break;
1504 }
1505 content.push_str(line);
1506 content.push('\n');
1507 }
1508
1509 Some((id, title, content.trim().to_string()))
1510}
1511
1512fn render_admonition(
1514 adm_type: &str,
1515 id: Option<&str>,
1516 content: &str,
1517) -> String {
1518 let capitalized_type = crate::utils::capitalize_first(adm_type);
1519 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
1520
1521 let opening = format!(
1522 "<div class=\"admonition {adm_type}\"{id_attr}>\n<p \
1523 class=\"admonition-title\">{capitalized_type}</p>"
1524 );
1525 format!("{opening}\n\n{content}\n\n</div>\n")
1526}
1527
1528fn render_figure(id: Option<&str>, title: &str, content: &str) -> String {
1530 let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
1531
1532 format!(
1533 "<figure{id_attr}>\n<figcaption>{title}</figcaption>\n{content}\n</figure>"
1534 )
1535}
1536
1537#[cfg(feature = "nixpkgs")]
1550#[must_use]
1551#[expect(
1552 clippy::implicit_hasher,
1553 reason = "Standard HashMap sufficient for this use case"
1554)]
1555pub fn process_manpage_references(
1556 html: &str,
1557 manpage_urls: Option<&rustc_hash::FxHashMap<String, String>>,
1558) -> String {
1559 process_safe(
1560 html,
1561 |html| {
1562 use kuchikikiki::NodeRef;
1563 use tendril::TendrilSink;
1564
1565 let document = kuchikikiki::parse_html().one(html);
1566 let mut to_replace = Vec::new();
1567
1568 for span_node in safe_select(&document, "span.manpage-reference") {
1570 let span_el = span_node;
1571 let span_text = span_el.text_contents();
1572
1573 if let Some(urls) = manpage_urls {
1574 if let Some(url) = urls.get(&span_text) {
1576 let clean_url = extract_url_from_html(url);
1577 let link = NodeRef::new_element(
1578 markup5ever::QualName::new(
1579 None,
1580 markup5ever::ns!(html),
1581 markup5ever::local_name!("a"),
1582 ),
1583 vec![
1584 (
1585 kuchikikiki::ExpandedName::new("", "href"),
1586 kuchikikiki::Attribute {
1587 prefix: None,
1588 value: clean_url.into(),
1589 },
1590 ),
1591 (
1592 kuchikikiki::ExpandedName::new("", "class"),
1593 kuchikikiki::Attribute {
1594 prefix: None,
1595 value: "manpage-reference".into(),
1596 },
1597 ),
1598 ],
1599 );
1600 link.append(NodeRef::new_text(span_text.clone()));
1601 to_replace.push((span_el.clone(), link));
1602 }
1603 }
1604 }
1605
1606 for (old, new) in to_replace {
1608 old.insert_before(new);
1609 old.detach();
1610 }
1611
1612 let mut out = Vec::new();
1613 let _ = document.serialize(&mut out);
1614 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
1615 },
1616 "",
1618 )
1619}
1620
1621#[cfg(feature = "ndg-flavored")]
1636#[must_use]
1637#[expect(
1638 clippy::implicit_hasher,
1639 reason = "Standard HashSet sufficient for this use case"
1640)]
1641pub fn process_option_references(
1642 html: &str,
1643 valid_options: Option<&rustc_hash::FxHashSet<String>>,
1644) -> String {
1645 use kuchikikiki::{Attribute, ExpandedName, NodeRef};
1646 use markup5ever::{QualName, local_name, ns};
1647 use tendril::TendrilSink;
1648
1649 process_safe(
1650 html,
1651 |html| {
1652 let document = kuchikikiki::parse_html().one(html);
1653
1654 let mut to_replace = vec![];
1655
1656 for code_node in safe_select(&document, "code.nixos-option") {
1659 let code_el = code_node;
1660 let code_text = code_el.text_contents();
1661
1662 let mut is_already_option_ref = false;
1664 let mut current = code_el.parent();
1665 while let Some(parent) = current {
1666 if let Some(element) = parent.as_element()
1667 && element.name.local == local_name!("a")
1668 && let Some(class_attr) =
1669 element.attributes.borrow().get(local_name!("class"))
1670 && class_attr.contains("option-reference")
1671 {
1672 is_already_option_ref = true;
1673 break;
1674 }
1675 current = parent.parent();
1676 }
1677
1678 if !is_already_option_ref {
1679 let should_link =
1682 valid_options.is_none_or(|opts| opts.contains(code_text.as_str()));
1683
1684 if should_link {
1685 let option_id = sanitize_option_id(code_text.as_str());
1686 let attrs = vec![
1687 (ExpandedName::new("", "href"), Attribute {
1688 prefix: None,
1689 value: format!("options.html#{option_id}"),
1690 }),
1691 (ExpandedName::new("", "class"), Attribute {
1692 prefix: None,
1693 value: "option-reference".into(),
1694 }),
1695 ];
1696 let a = NodeRef::new_element(
1697 QualName::new(None, ns!(html), local_name!("a")),
1698 attrs,
1699 );
1700 let code = NodeRef::new_element(
1701 QualName::new(None, ns!(html), local_name!("code")),
1702 vec![],
1703 );
1704 code.append(NodeRef::new_text(code_text.clone()));
1705 a.append(code);
1706 to_replace.push((code_el.clone(), a));
1707 }
1708 }
1710 }
1711
1712 for (old, new) in to_replace {
1713 old.insert_before(new);
1714 old.detach();
1715 }
1716
1717 let mut out = Vec::new();
1718 let _ = document.serialize(&mut out);
1719 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
1720 },
1721 "",
1723 )
1724}
1725
1726fn extract_url_from_html(url_or_html: &str) -> &str {
1729 if url_or_html.starts_with("<a href=\"") {
1731 if let Some(start) = url_or_html.find("href=\"") {
1733 let start = start + 6; if let Some(end) = url_or_html[start..].find('"') {
1735 return &url_or_html[start..start + end];
1736 }
1737 }
1738 }
1739
1740 url_or_html
1742}
1743
1744#[cfg(feature = "wiki")]
1761#[must_use]
1762pub fn process_wikilinks(content: &str) -> String {
1763 use crate::utils::codeblock::FenceTracker;
1764
1765 let mut result = String::with_capacity(content.len());
1766 let lines = content.lines();
1767 let mut tracker = FenceTracker::new();
1768
1769 for line in lines {
1770 tracker = tracker.process_line(line);
1771
1772 if tracker.in_code_block() {
1773 result.push_str(line);
1774 } else {
1775 result.push_str(&process_line_wikilinks(line));
1776 }
1777 result.push('\n');
1778 }
1779
1780 result.trim_end().to_string()
1781}
1782
1783#[cfg(feature = "wiki")]
1785fn process_line_wikilinks(line: &str) -> String {
1786 let mut result = String::with_capacity(line.len());
1787 let mut chars = line.chars().peekable();
1788
1789 while let Some(ch) = chars.next() {
1790 if ch == '[' && chars.peek() == Some(&'[') {
1791 chars.next();
1792
1793 let mut inner = String::new();
1794 let mut found_double_close = false;
1795
1796 while let Some(&next_ch) = chars.peek() {
1797 chars.next();
1798 if next_ch == ']' && chars.peek() == Some(&']') {
1799 chars.next();
1800 found_double_close = true;
1801 break;
1802 }
1803 inner.push(next_ch);
1804 }
1805
1806 if found_double_close {
1807 if inner.is_empty() {
1808 result.push_str("[[]]");
1809 } else if inner.contains('|') {
1810 let parts: Vec<&str> = inner.splitn(2, '|').collect();
1811 let name = parts[0].trim();
1812 let url = parts.get(1).unwrap_or(&name).trim();
1813 let escaped_name = encode_text(name);
1814 let escaped_url = encode_text(url);
1815 let _ = write!(
1816 result,
1817 "<a href=\"{escaped_url}\" class=\"wikilink\">{escaped_name}</a>"
1818 );
1819 } else {
1820 let page = inner.trim();
1821 let escaped_page = encode_text(page);
1822 let link_target = format!("{page}.html");
1823 let _ = write!(
1824 result,
1825 "<a href=\"{link_target}\" \
1826 class=\"obsidian-link\">{escaped_page}</a>"
1827 );
1828 }
1829 } else {
1830 result.push_str("[[");
1831 result.push_str(&inner);
1832 }
1833 } else {
1834 result.push(ch);
1835 }
1836 }
1837
1838 result
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843 use super::*;
1844
1845 #[test]
1846 fn test_is_atx_header_valid_headers() {
1847 assert!(is_atx_header("# Header"));
1849 assert!(is_atx_header("## Header"));
1850 assert!(is_atx_header("### Header"));
1851 assert!(is_atx_header("#### Header"));
1852 assert!(is_atx_header("##### Header"));
1853 assert!(is_atx_header("###### Header"));
1854
1855 assert!(is_atx_header("#\tHeader"));
1857 assert!(is_atx_header("##\tHeader"));
1858
1859 assert!(is_atx_header("#"));
1861 assert!(is_atx_header("##"));
1862 assert!(is_atx_header("###"));
1863 assert!(is_atx_header("####"));
1864 assert!(is_atx_header("#####"));
1865 assert!(is_atx_header("######"));
1866
1867 assert!(is_atx_header("# Header with multiple spaces"));
1869 assert!(is_atx_header("## Header"));
1870 }
1871
1872 #[test]
1873 fn test_is_atx_header_invalid_headers() {
1874 assert!(!is_atx_header("####### Too many hashes"));
1876 assert!(!is_atx_header("######## Even more"));
1877
1878 assert!(!is_atx_header("#NoSpace"));
1880 assert!(!is_atx_header("##NoSpace"));
1881
1882 assert!(!is_atx_header("Not # a header"));
1884
1885 assert!(!is_atx_header(""));
1887
1888 assert!(!is_atx_header("Regular text"));
1890
1891 assert!(!is_atx_header("#hashtag"));
1893 assert!(!is_atx_header("##hashtag"));
1894 assert!(!is_atx_header("#123"));
1895 assert!(!is_atx_header("##abc"));
1896
1897 assert!(!is_atx_header("#!important"));
1899 assert!(!is_atx_header("#@mention"));
1900 assert!(!is_atx_header("#$variable"));
1901 }
1902
1903 #[test]
1904 fn test_is_atx_header_edge_cases() {
1905 assert!(!is_atx_header(" # Header"));
1908 assert!(!is_atx_header(" ## Header"));
1909
1910 assert!(is_atx_header("# "));
1912 assert!(is_atx_header("## "));
1913
1914 assert!(is_atx_header("# Header\n"));
1916 assert!(is_atx_header("## Header\n"));
1917
1918 assert!(is_atx_header("# \t Header"));
1920 assert!(is_atx_header("## \tHeader"));
1921 }
1922
1923 #[test]
1924 fn test_is_atx_header_blockquote_context() {
1925 assert!(is_atx_header("# New Section"));
1928 assert!(is_atx_header("## Subsection"));
1929
1930 assert!(!is_atx_header("#tag"));
1932 assert!(!is_atx_header("##issue-123"));
1933 assert!(!is_atx_header("###no-space"));
1934
1935 assert!(is_atx_header("###### Level 6"));
1937
1938 assert!(!is_atx_header("####### Not valid"));
1940 }
1941
1942 #[cfg(feature = "wiki")]
1943 #[test]
1944 fn test_wikilink_obsidian_basic() {
1945 let input = "Check out [[Some Page]] for details.";
1946 let result = process_wikilinks(input);
1947 assert!(result.contains("href=\"Some Page.html\""));
1948 assert!(result.contains("class=\"obsidian-link\""));
1949 assert!(result.contains(">Some Page<"));
1950 }
1951
1952 #[cfg(feature = "wiki")]
1953 #[test]
1954 fn test_wikilink_with_url() {
1955 let input = "See [[Custom Name|https://example.com]]";
1956 let result = process_wikilinks(input);
1957 assert!(result.contains("href=\"https://example.com\""));
1958 assert!(result.contains("class=\"wikilink\""));
1959 assert!(result.contains(">Custom Name<"));
1960 }
1961
1962 #[cfg(feature = "wiki")]
1963 #[test]
1964 fn test_wikilink_with_spaces() {
1965 let input = "[[My Page Name]]";
1966 let result = process_wikilinks(input);
1967 assert!(result.contains("href=\"My Page Name.html\""));
1968 }
1969
1970 #[cfg(feature = "wiki")]
1971 #[test]
1972 fn test_wikilink_in_code_block() {
1973 let input = "```\n[[Wiki Link]]\n```\nThen [[Another]]";
1974 let result = process_wikilinks(input);
1975 assert!(result.contains("[[Wiki Link]]"));
1976 assert!(result.contains("href=\"Another.html\""));
1977 }
1978
1979 #[cfg(feature = "wiki")]
1980 #[test]
1981 fn test_wikilink_empty() {
1982 let input = "[[]]";
1983 let result = process_wikilinks(input);
1984 assert!(result.contains("[[]]"));
1985 }
1986
1987 #[cfg(feature = "wiki")]
1988 #[test]
1989 fn test_wikilink_malformed() {
1990 let input = "[[ incomplete";
1991 let result = process_wikilinks(input);
1992 assert!(result.contains("[[ incomplete"));
1993 }
1994
1995 #[cfg(feature = "wiki")]
1996 #[test]
1997 fn test_wikilink_html_escaping() {
1998 let input = "See [[Page With <script>]] for info";
1999 let result = process_wikilinks(input);
2000 assert!(result.contains("<script>"));
2001 assert!(!result.contains(">Page With <script><"));
2002 }
2003}