Skip to main content

ndg_commonmark/processor/
extensions.rs

1//! Feature-specific Markdown processing extensions.
2use 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
8/// Sanitize an option name into a valid HTML ID, matching nixos-render-docs
9/// XML ID format.
10///
11/// Translates `*`, `<`, `>`, `[`, `]`, `:`, `"`, and space to `_`.
12/// Dots are preserved to match nixos-render-docs behavior.
13fn 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/// Apply GitHub Flavored Markdown (GFM) extensions to the input markdown.
27///
28/// This is a placeholder for future GFM-specific preprocessing or AST
29/// transformations. In practice, most GFM features are enabled via comrak
30/// options, but additional logic (such as custom tables, task lists, etc.) can
31/// be added here.
32///
33/// # Arguments
34/// * `markdown` - The input markdown text
35///
36/// # Returns
37/// The processed markdown text with GFM extensions applied
38#[cfg(feature = "gfm")]
39#[must_use]
40pub fn apply_gfm_extensions(markdown: &str) -> String {
41  // XXX: Comrak already supports GFM, but if there is any feature in the spec
42  // that is not implemented as we'd like for it to be, we can add it here.
43  markdown.to_owned()
44}
45
46/// Maximum recursion depth for file includes to prevent infinite recursion.
47const MAX_INCLUDE_DEPTH: usize = 8;
48
49/// Internal sentinel inserted between included files before block processing.
50const INCLUDE_BOUNDARY_MARKER: &str = "<!-- ndg:include-boundary -->";
51
52/// Check whether an include path stays below its markdown source directory.
53#[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/// Parse the custom output directive from an include block.
65#[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/// Read and process files listed in an include block.
336#[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      // Normalize nested include paths relative to original base_dir
388      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/// Process file includes in Nixpkgs/NixOS documentation.
413///
414/// This function processes file include syntax:
415///
416/// ````markdown
417/// ```{=include=}
418/// path/to/file1.md
419/// path/to/file2.md
420/// ```
421/// ````
422///
423/// # Arguments
424///
425/// * `markdown` - The input markdown text
426/// * `base_dir` - The base directory for resolving relative file paths
427/// * `depth` - Current recursion depth (use 0 for initial call)
428///
429/// # Returns
430///
431/// Returns `Ok((processed_markdown, included_files))` where `included_files` is
432/// a list of all successfully included files.
433///
434/// # Errors
435///
436/// Returns `Err(message)` if recursion depth exceeds [`MAX_INCLUDE_DEPTH`],
437/// which likely indicates a circular include cycle.
438///
439/// # Safety
440///
441/// Only relative paths without ".." are allowed for security.
442#[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  // Check recursion depth limit
449  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    // Update fence tracking state
501    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/// Process role markup in markdown content.
511///
512/// This function processes role syntax like `{command}ls -la`
513///
514/// # Arguments
515///
516/// * `content` - The markdown content to process
517/// * `manpage_urls` - Optional mapping of manpage names to URLs
518/// * `auto_link_options` - Whether to convert {option} roles to links
519/// * `valid_options` - Optional set of valid option names for validation
520///
521/// # Returns
522///
523/// The processed markdown with role markup converted to HTML
524#[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    // Handle backticks (code fences and inline code)
542    if ch == '`' {
543      let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
544      tracker = new_tracker;
545
546      // Add all the backticks
547      result.push_str(&"`".repeat(tick_count));
548      continue;
549    }
550
551    // Handle tilde code fences (~~~)
552    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    // Handle newlines
561    if ch == '\n' {
562      tracker = tracker.process_newline();
563      result.push(ch);
564      continue;
565    }
566
567    // Process role markup only if we're not in any kind of code
568    if ch == '{' && !tracker.in_any_code() {
569      // Collect remaining characters to test parsing
570      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        // Valid role markup found, advance the main iterator
581        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        // Not a valid role markup, keep the original character
589        result.push(ch);
590      }
591    } else {
592      result.push(ch);
593    }
594  }
595
596  result
597}
598
599/// Parse a role markup from the character iterator.
600///
601/// # Returns
602///
603/// `Some(html)` if a valid role markup is found, `None` otherwise.
604fn 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  // Parse role name (lowercase letters only)
613  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  // Must have a non-empty role name
623  if role_name.is_empty() {
624    return None;
625  }
626
627  // Expect closing brace
628  if chars.peek() != Some(&'}') {
629    return None;
630  }
631  chars.next(); // consume '}'
632
633  // Expect opening backtick
634  if chars.peek() != Some(&'`') {
635    return None;
636  }
637  chars.next(); // consume '`'
638
639  // Parse content until closing backtick
640  let mut content = String::new();
641  for ch in chars.by_ref() {
642    if ch == '`' {
643      // Found closing backtick, validate content
644      // Most role types should not have empty content
645      if content.is_empty() && !matches!(role_name.as_str(), "manpage") {
646        return None; // reject empty content for most roles
647      }
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  // No closing backtick found
660  None
661}
662
663/// Format the role markup as HTML based on the role type and content.
664#[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        // Check if validation is enabled and option is valid
702        let should_link =
703          valid_options.is_none_or(|opts| opts.contains(content)); // If no validation set, link all options
704
705        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/// Process MyST-style autolinks in markdown content.
725///
726/// Converts MyST-like autolinks supported by Nixpkgs-flavored commonmark:
727/// - `[](#anchor)` -> `[](#anchor) -> {{ANCHOR}}` (placeholder for comrak)
728/// - `[](https://url)` -> `<https://url>` (converted to standard autolink)
729///
730/// # Arguments
731///
732/// * `content` - The markdown content to process
733///
734/// # Returns
735///
736/// The processed markdown with `MyST` autolinks converted as a [`String`]
737#[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    // Update fence tracking state
744    fence_tracker = fence_tracker.process_line(line);
745
746    // Only process MyST autolinks if we're not in a code block
747    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
758/// Process `MyST` autolinks in a single line.
759fn 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(); // consume ']'
774
775      // Check if this is []{#...} syntax (inline anchor, not autolink)
776      // Nice pit, would be a shame if someone was to... fall into it.
777      if chars.peek() == Some(&'{') {
778        // This is inline anchor syntax, not autolink, keep as-is
779        result.push_str("[]");
780        continue;
781      }
782
783      if chars.peek() == Some(&'(') {
784        chars.next(); // consume '('
785
786        // Collect URL until ')'
787        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(); // consume ')'
792            found_closing = true;
793            break;
794          }
795          url.push(next_ch);
796          chars.next();
797        }
798
799        if found_closing && !url.is_empty() {
800          // Check if it's an anchor link (starts with #) or a URL
801          if url.starts_with('#') {
802            // Add placeholder text for comrak to parse it as a link
803            let _ = write!(result, "[{{{{ANCHOR}}}}]({url})");
804          } else if url.starts_with("http://") || url.starts_with("https://") {
805            // Convert URL autolinks to standard <url> format
806            let _ = write!(result, "<{url}>");
807          } else {
808            // Keep other patterns as-is
809            let _ = write!(result, "[]({url})");
810          }
811        } else {
812          // Malformed, put back what we consumed
813          result.push_str("](");
814          result.push_str(&url);
815        }
816      } else {
817        // Not a link, put back consumed character
818        result.push(']');
819      }
820    } else {
821      result.push(ch);
822    }
823  }
824
825  result
826}
827
828/// Process inline anchors in markdown content.
829///
830/// This function processes inline anchor syntax like `[]{#my-anchor}` while
831/// being code-block aware to avoid processing inside code fences.
832///
833/// # Arguments
834///
835/// * `content` - The markdown content to process
836///
837/// # Returns
838///
839/// The processed markdown with inline anchors converted to HTML spans
840#[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    // Update fence tracking state
850    fence_tracker = fence_tracker.process_line(line);
851
852    // Only process inline anchors if we're not in a code block
853    if fence_tracker.in_code_block() {
854      // In code block, keep line as-is
855      result.push_str(line);
856    } else {
857      // Check for list items with anchors:
858      // "- []{#id} content" or "1. []{#id} content"
859      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      // Process regular inline anchors in the line
869      result.push_str(&process_line_anchors(line));
870    }
871    result.push('\n');
872  }
873
874  result
875}
876
877/// Process Pandoc/CommonMark bracketed spans: `[text]{#id .class key=value}`.
878#[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
1000/// Find if a line starts with a list marker followed by an anchor.
1001fn find_list_item_anchor(trimmed: &str) -> Option<usize> {
1002  // Check for unordered list: "- []{#id}" or "* []{#id}" or "+ []{#id}"
1003  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  // Check for ordered list: "1. []{#id}" or "123. []{#id}".
1015  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
1032/// Process a list item line that contains an anchor.
1033fn 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  // Find the end of the anchor: []{#id}
1042  if let Some(anchor_end) = after_marker.find('}') {
1043    let id = &after_marker[4..anchor_end]; // skip "[]{#" and take until '}'
1044    let remaining_content = &after_marker[anchor_end + 1..]; // skip '}'
1045
1046    // Validate ID contains only allowed characters
1047    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/// Process inline anchors in a single line.
1063#[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(); // consume ']'
1083
1084      // Check for {#id} pattern
1085      if chars.peek() == Some(&'{') {
1086        chars.next(); // consume '{'
1087        if chars.peek() == Some(&'#') {
1088          chars.next(); // consume '#'
1089
1090          // Collect the ID
1091          let mut id = String::new();
1092          while let Some(&next_ch) = chars.peek() {
1093            if next_ch == '}' {
1094              chars.next(); // consume '}'
1095
1096              // Validate ID and create span
1097              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                // Invalid ID, put back original text
1108                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              // Invalid character, put back original text
1119              let _ = write!(result, "[]{{{{#{id}");
1120              break;
1121            }
1122          }
1123        } else {
1124          // Not an anchor, put back consumed characters
1125          result.push_str("]{");
1126        }
1127      } else {
1128        // Not an anchor, put back consumed character
1129        result.push(']');
1130      }
1131    } else {
1132      result.push(ch);
1133    }
1134  }
1135
1136  result
1137}
1138
1139/// Process block elements in markdown content.
1140///
1141/// This function processes block elements including admonitions, figures, and
1142/// definition lists while being code-block aware to avoid processing inside
1143/// code fences.
1144///
1145/// # Arguments
1146///
1147/// * `content` - The markdown content to process
1148///
1149/// # Returns
1150///
1151/// The processed markdown with block elements converted to HTML
1152#[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    // Update fence tracking state
1165    fence_tracker = fence_tracker.process_line(line);
1166
1167    // Only process block elements if we're not in a code block
1168    if !fence_tracker.in_code_block() {
1169      // Check for GitHub-style callouts: > [!TYPE]
1170      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      // Check for fenced admonitions: ::: {.type}
1180      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 there's trailing content after the closing :::, add it as a new
1198        // line
1199        if let Some(trailing_content) = trailing {
1200          result.push(trailing_content);
1201        }
1202        continue;
1203      }
1204
1205      // Check for figures: ::: {.figure #id}
1206      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    // Regular line, keep as-is
1214    result.push(line.to_string());
1215  }
1216
1217  result.join("\n")
1218}
1219
1220/// Parse GitHub-style callout syntax: > [!TYPE] content
1221fn 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  // Find the closing bracket
1228  if let Some(close_bracket) = trimmed.find(']')
1229    && close_bracket > 4
1230  {
1231    let callout_type = &trimmed[4..close_bracket];
1232
1233    // Validate callout type
1234    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
1246/// Check if a line starts with a valid ATX header (1-6 '#' followed by
1247/// whitespace or EOL).
1248///
1249/// Per `CommonMark` spec, an ATX header requires 1-6 '#' characters followed by
1250/// either:
1251///
1252/// - A whitespace character (space, tab, etc.)
1253/// - End of line (the string ends)
1254///
1255/// # Arguments
1256///
1257/// * `line` - The line to check
1258///
1259/// # Returns
1260///
1261/// `true` if the line starts with a valid ATX header marker
1262fn is_atx_header(line: &str) -> bool {
1263  let mut chars = line.chars();
1264  let mut hash_count = 0;
1265
1266  // Count leading '#' characters (max 6)
1267  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      // Found a non-'#' character, check if it's whitespace or we're at EOL
1275      return (1..=6).contains(&hash_count)
1276        && (c.is_whitespace() || chars.as_str().is_empty());
1277    }
1278  }
1279
1280  // Reached end of string, check if we have 1-6 hashes
1281  (1..=6).contains(&hash_count)
1282}
1283
1284/// Collect content for GitHub-style callouts
1285fn 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    // Empty line ends the blockquote
1300    if trimmed.is_empty() {
1301      break;
1302    }
1303
1304    // Check if this is a continuation line with `>`
1305    let content_part = if trimmed.starts_with('>') {
1306      trimmed.strip_prefix('>').unwrap_or("").trim_start()
1307    } else {
1308      // Check if this line starts a new block element that cannot be
1309      // lazy-continued ATX headers, setext header underlines, code
1310      // fences, and thematic breaks
1311      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      // Lazy continuation
1326      // Mind you, "lazy" doesn't refer to me being lazy but the GFM feature for
1327      // a line without `>` that continues the blockquote
1328      // paragraph
1329      trimmed
1330    };
1331
1332    content.push_str(content_part);
1333    content.push('\n');
1334    lines.next(); // consume the line
1335  }
1336
1337  content.trim().to_string()
1338}
1339
1340/// Parse fenced admonition start: ::: {.type #id}
1341struct 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  // Find the closing brace
1364  if let Some(close_brace) = after_colons.find('}') {
1365    let content = &after_colons[1..close_brace]; // skip "{"
1366
1367    // Parse type and optional ID. Attribute lists allow either order:
1368    // `{.warning #id}` and `{#id .warning}` are equivalent.
1369    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
1423/// Collect content until closing :::
1424///
1425/// # Returns
1426///
1427/// Tuple of (`admonition_content`, `trailing_content`). If there's content
1428/// after the closing `:::` on the same line, it's returned as
1429/// `trailing_content`.
1430fn 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      // check if there's content after the closing :::
1447      let after_colons = &trimmed[closing_len..];
1448      if !after_colons.is_empty() {
1449        // there's trailing content on the same line as the closing delimiter
1450        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/// Parse figure block: ::: {.figure #id}
1462#[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  // Extract ID if present
1481  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  // Get title from next line (should start with #)
1496  let title = lines.next()?.trim().strip_prefix('#')?.trim().to_string();
1497
1498  // Collect figure content
1499  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
1512/// Render an admonition as HTML
1513fn 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
1528/// Render a figure as HTML
1529fn 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/// Process manpage references in HTML content. Pocesses manpage references by
1538/// finding span elements with manpage-reference class and converting them to
1539/// links when URLs are available.
1540///
1541/// # Arguments
1542///
1543/// * `html` - The HTML content to process
1544/// * `manpage_urls` - Optional mapping of manpage names to URLs
1545///
1546/// # Returns
1547///
1548/// The processed HTML with manpage references converted to links
1549#[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      // Find all spans with class "manpage-reference"
1569      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          // Check for direct URL match
1575          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      // Apply replacements
1607      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    // Return original HTML on error
1617    "",
1618  )
1619}
1620
1621/// Process option references
1622/// Converts {option} role markup into links to the options page.
1623///
1624/// This processes `<code>` elements that have the `nixos-option` class, i.e.,
1625/// {option} role markup and convert them into links to the options page.
1626///
1627/// # Arguments
1628///
1629/// * `html` - The HTML string to process.
1630/// * `valid_options` - Optional set of valid option names for validation.
1631///
1632/// # Returns
1633///
1634/// The HTML string with option references rewritten as links.
1635#[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      // Only process code elements that already have the nixos-option class
1657      // from {option} role syntax
1658      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        // Skip if already wrapped in an option-reference link
1663        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          // Check if validation is enabled and option is valid. If no
1680          // validation set, link all options
1681          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          // If should_link is false, leave the code element as-is (no wrapping)
1709        }
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    // Return original HTML on error
1722    "",
1723  )
1724}
1725
1726/// Extract URL from HTML anchor tag or return the string as-is if it's a plain
1727/// URL
1728fn extract_url_from_html(url_or_html: &str) -> &str {
1729  // Check if it looks like HTML (starts with <a href=")
1730  if url_or_html.starts_with("<a href=\"") {
1731    // Extract the URL from href attribute
1732    if let Some(start) = url_or_html.find("href=\"") {
1733      let start = start + 6; // Skip 'href="'
1734      if let Some(end) = url_or_html[start..].find('"') {
1735        return &url_or_html[start..start + end];
1736      }
1737    }
1738  }
1739
1740  // Return as-is if not HTML or if extraction fails
1741  url_or_html
1742}
1743
1744/// Process wikilinks and Obsidian-style links in markdown content.
1745///
1746/// Converts:
1747///
1748/// - `[[page]]` (Obsidian link) -> `[page](page.html)`
1749/// - `[[name|url]]` (Wiki link) -> `[name](url)`
1750///
1751/// Being code-block aware to avoid processing inside fenced code blocks.
1752///
1753/// # Arguments
1754///
1755/// * `content` - The markdown content to process
1756///
1757/// # Returns
1758///
1759/// The processed markdown with wiki/Obsidian links converted to HTML
1760#[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/// Process wikilinks in a single line.
1784#[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    // valid ATX headers with 1-6 hashes followed by space
1848    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    // valid ATX headers with tab after hashes
1856    assert!(is_atx_header("#\tHeader"));
1857    assert!(is_atx_header("##\tHeader"));
1858
1859    // valid ATX headers with just hashes (no content after)
1860    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    // valid ATX headers with multiple spaces
1868    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    // more than 6 hashes
1875    assert!(!is_atx_header("####### Too many hashes"));
1876    assert!(!is_atx_header("######## Even more"));
1877
1878    // no space after hash
1879    assert!(!is_atx_header("#NoSpace"));
1880    assert!(!is_atx_header("##NoSpace"));
1881
1882    // hash in the middle
1883    assert!(!is_atx_header("Not # a header"));
1884
1885    // empty string
1886    assert!(!is_atx_header(""));
1887
1888    // no hash at all
1889    assert!(!is_atx_header("Regular text"));
1890
1891    // hash with non-whitespace immediately after
1892    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    // special characters immediately after hash
1898    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    // whitespace before hash is handled by caller (trimmed)
1906    // but testing it here to ensure robustness
1907    assert!(!is_atx_header(" # Header"));
1908    assert!(!is_atx_header("  ## Header"));
1909
1910    // only spaces after hash (should be valid)
1911    assert!(is_atx_header("#     "));
1912    assert!(is_atx_header("##    "));
1913
1914    // newline handling (string ends after valid header marker)
1915    assert!(is_atx_header("# Header\n"));
1916    assert!(is_atx_header("## Header\n"));
1917
1918    // mixed whitespace after hash
1919    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    // these are the types of strings that would be passed from
1926    // collect_github_callout_content after trim_start()
1927    assert!(is_atx_header("# New Section"));
1928    assert!(is_atx_header("## Subsection"));
1929
1930    // non-headers that should not break blockquote
1931    assert!(!is_atx_header("#tag"));
1932    assert!(!is_atx_header("##issue-123"));
1933    assert!(!is_atx_header("###no-space"));
1934
1935    // edge case: exactly 6 hashes (valid)
1936    assert!(is_atx_header("###### Level 6"));
1937
1938    // edge case: 7 hashes (invalid)
1939    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("&lt;script&gt;"));
2001    assert!(!result.contains(">Page With <script><"));
2002  }
2003}