Skip to main content

ndg_commonmark/processor/
core.rs

1//! Core implementation of the Markdown processor.
2//!
3//! Main implementation of `MarkdownProcessor` and its methods focused on the
4//! core rendering pipeline and configuration management.
5#[expect(
6  clippy::disallowed_types,
7  reason = "Required for generic hasher abstraction"
8)]
9use std::collections::HashMap;
10use std::{
11  path::{Component, Path, PathBuf},
12  sync::LazyLock,
13};
14
15use comrak::{
16  Arena,
17  nodes::{AstNode, NodeHeading, NodeValue},
18  options::Options,
19  parse_document,
20};
21use log::trace;
22use markup5ever::local_name;
23use regex::Regex;
24use rustc_hash::FxHashMap;
25use walkdir::WalkDir;
26
27use super::{
28  dom::safe_select,
29  process::process_safe,
30  types::{
31    AstTransformer,
32    MarkdownExtension,
33    MarkdownOptions,
34    MarkdownProcessor,
35    PromptTransformer,
36  },
37};
38use crate::{
39  syntax::create_default_manager,
40  types::{Header, MarkdownResult},
41  utils,
42};
43
44static HEADER_ANCHOR_RE: LazyLock<Regex> = LazyLock::new(|| {
45  Regex::new(r"<h([1-6])>(.*?)\s*\{#([a-zA-Z0-9_.-]+)\}(.*?)</h[1-6]>")
46    .unwrap_or_else(|e| {
47      log::error!("Failed to compile HEADER_ANCHOR_RE regex: {e}");
48      utils::never_matching_regex().unwrap_or_else(|_| {
49        #[expect(
50          clippy::expect_used,
51          reason = "This pattern is guaranteed to be valid"
52        )]
53        Regex::new(r"[^\s\S]")
54          .expect("regex pattern [^\\s\\S] should always compile")
55      })
56    })
57});
58
59static HEADER_NO_ID_RE: LazyLock<Regex> = LazyLock::new(|| {
60  Regex::new(r"<h([1-6])>(.*?)</h[1-6]>").unwrap_or_else(|e| {
61    log::error!("Failed to compile HEADER_NO_ID_RE regex: {e}");
62    utils::never_matching_regex().unwrap_or_else(|_| {
63      #[expect(
64        clippy::expect_used,
65        reason = "This pattern is guaranteed to be valid"
66      )]
67      Regex::new(r"[^\s\S]")
68        .expect("regex pattern [^\\s\\S] should always compile")
69    })
70  })
71});
72
73static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
74  Regex::new(r"<[^>]+>").unwrap_or_else(|e| {
75    log::error!("Failed to compile HTML_TAG_RE regex: {e}");
76    utils::never_matching_regex().unwrap_or_else(|_| {
77      #[expect(
78        clippy::expect_used,
79        reason = "This pattern is guaranteed to be valid"
80      )]
81      Regex::new(r"[^\s\S]")
82        .expect("regex pattern [^\\s\\S] should always compile")
83    })
84  })
85});
86
87impl MarkdownProcessor {
88  /// Create a new `MarkdownProcessor` with the given options.
89  #[must_use]
90  pub fn new(options: MarkdownOptions) -> Self {
91    let manpage_urls = options.manpage_urls.clone();
92
93    let syntax_manager = if options.highlight_code {
94      match create_default_manager(
95        options
96          .syntax_queries_path
97          .as_deref()
98          .map(std::path::Path::new),
99      ) {
100        Ok(manager) => {
101          log::info!("Syntax highlighting initialized successfully");
102          Some(manager)
103        },
104        Err(e) => {
105          log::error!("Failed to initialize syntax highlighting: {e}");
106          log::warn!(
107            "Continuing without syntax highlighting - code blocks will not be \
108             highlighted"
109          );
110          None
111        },
112      }
113    } else {
114      None
115    };
116
117    Self {
118      options,
119      manpage_urls,
120      syntax_manager,
121      base_dir: std::path::PathBuf::from("."),
122    }
123  }
124
125  /// Access processor options.
126  #[must_use]
127  pub const fn options(&self) -> &MarkdownOptions {
128    &self.options
129  }
130
131  /// Set the base directory for resolving relative file paths.
132  #[must_use]
133  pub fn with_base_dir(mut self, base_dir: &std::path::Path) -> Self {
134    self.base_dir = base_dir.to_path_buf();
135    self
136  }
137
138  /// Check if a specific feature is enabled.
139  #[must_use]
140  pub const fn has_feature(&self, feature: ProcessorFeature) -> bool {
141    match feature {
142      ProcessorFeature::Gfm => self.options.gfm,
143      ProcessorFeature::Nixpkgs => self.options.nixpkgs,
144      ProcessorFeature::SyntaxHighlighting => self.options.highlight_code,
145      ProcessorFeature::ManpageUrls => self.manpage_urls.is_some(),
146    }
147  }
148
149  /// Get the manpage URLs mapping for use with standalone functions.
150  #[must_use]
151  pub const fn manpage_urls(&self) -> Option<&FxHashMap<String, String>> {
152    self.manpage_urls.as_ref()
153  }
154
155  /// Highlight all code blocks in HTML using the configured syntax highlighter
156  #[must_use]
157  pub fn highlight_codeblocks(&self, html: &str) -> String {
158    use kuchikikiki::parse_html;
159    use tendril::TendrilSink;
160
161    if !self.options.highlight_code || self.syntax_manager.is_none() {
162      return html.to_string();
163    }
164
165    let document = parse_html().one(html);
166    self.highlight_codeblocks_document(&document);
167
168    let mut buf = Vec::new();
169    if let Err(e) = document.serialize(&mut buf) {
170      log::warn!("DOM serialization failed: {e:?}");
171      return html.to_string(); // Return original HTML if serialization fails
172    }
173    String::from_utf8(buf).unwrap_or_else(|_| html.to_string())
174  }
175
176  /// Handle hard tabs in code blocks according to configuration
177  fn handle_hardtabs(&self, code: &str) -> String {
178    use super::types::TabStyle;
179
180    // Check if there are any hard tabs
181    if !code.contains('\t') {
182      return code.to_string();
183    }
184
185    match self.options.tab_style {
186      // Do nothing
187      TabStyle::None => code.to_string(),
188
189      // Warn, but do nothing.
190      TabStyle::Warn => {
191        log::warn!(
192          "Hard tabs detected in code block. Consider using spaces for \
193           consistency. Tools like editorconfig may help you normalize spaces \
194           in your documents."
195        );
196        code.to_string()
197      },
198
199      // Do not warn, only inform in debug mode. Then return
200      // the updated code.
201      TabStyle::Normalize => {
202        log::debug!("Replacing hard tabs with spaces");
203        code.replace('\t', "  ")
204      },
205    }
206  }
207
208  /// Process hard tabs in code blocks within markdown content
209  fn process_hardtabs(&self, markdown: &str) -> String {
210    use super::types::TabStyle;
211    use crate::utils::codeblock::FenceTracker;
212
213    // If no tab handling is needed, return as-is
214    if self.options.tab_style == TabStyle::None {
215      return markdown.to_string();
216    }
217
218    let mut result = String::with_capacity(markdown.len());
219    let mut lines = markdown.lines().peekable();
220    let mut tracker = FenceTracker::new();
221
222    while let Some(line) = lines.next() {
223      tracker = tracker.process_line(line);
224
225      // Only replace tabs inside fenced code blocks
226      let processed_line = if tracker.in_code_block() && line.contains('\t') {
227        self.handle_hardtabs(line)
228      } else {
229        line.to_string()
230      };
231
232      result.push_str(&processed_line);
233
234      // Add newline unless this is the last line
235      if lines.peek().is_some() {
236        result.push('\n');
237      }
238    }
239
240    result
241  }
242
243  /// Highlight code using the configured syntax highlighter, returns HTML
244  /// string
245  fn highlight_code_html(&self, code: &str, language: &str) -> Option<String> {
246    if !self.options.highlight_code {
247      return None;
248    }
249
250    let syntax_manager = self.syntax_manager.as_ref()?;
251
252    syntax_manager
253      .highlight_code(code, language, self.options.highlight_theme.as_deref())
254      .ok()
255  }
256
257  /// Render Markdown to HTML, extracting headers and title.
258  #[must_use]
259  pub fn render(&self, markdown: &str) -> MarkdownResult {
260    let (preprocessed, included_files) = self.preprocess(markdown);
261    let arena = Arena::new();
262    let options = self.comrak_options();
263    let root = parse_document(&arena, &preprocessed, &options);
264    let (headers, title) = Self::extract_headers_from_ast(root, &preprocessed);
265    let html = self.process_html_pipeline_from_ast(root, &options);
266
267    MarkdownResult {
268      html,
269      headers,
270      title,
271      included_files,
272    }
273  }
274
275  /// Render Markdown to HTML with anchor validation.
276  ///
277  /// Same as [`render()`](Self::render), but additionally validates that all
278  /// heading anchor IDs are unique. Returns an error if duplicates are found.
279  ///
280  /// # Errors
281  ///
282  /// Returns `Err(DuplicateAnchorError)` if any two headings share the same
283  /// anchor ID.
284  pub fn render_checked(
285    &self,
286    markdown: &str,
287  ) -> Result<MarkdownResult, crate::types::DuplicateAnchorError> {
288    let result = self.render(markdown);
289    crate::types::validate_rendered_anchor_ids(&result.headers, &result.html)?;
290    Ok(result)
291  }
292
293  fn process_html_pipeline_from_ast<'a>(
294    &self,
295    root: &'a AstNode<'a>,
296    options: &Options<'a>,
297  ) -> String {
298    let mut html = Self::format_html(root, options);
299    self.process_rendered_html(&mut html);
300    html
301  }
302
303  fn process_rendered_html(&self, html: &mut String) {
304    // Apply feature-specific post-processing
305    if cfg!(feature = "ndg-flavored") {
306      #[cfg(feature = "ndg-flavored")]
307      {
308        *html = super::extensions::process_option_references(
309          html,
310          self.options.valid_options.as_ref(),
311        );
312      }
313    }
314
315    if self.options.nixpkgs {
316      *html = self.process_manpage_references_html(html);
317    }
318
319    *html = self.kuchiki_postprocess(html);
320  }
321
322  /// Preprocess the markdown content with all enabled transformations.
323  fn preprocess(
324    &self,
325    content: &str,
326  ) -> (String, Vec<crate::types::IncludedFile>) {
327    let mut processed = content.to_string();
328    let mut included_files = Vec::new();
329
330    // Process MyST-style autolinks first
331    processed = super::extensions::process_myst_autolinks(&processed);
332
333    // Handle hard tabs in code blocks
334    processed = self.process_hardtabs(&processed);
335
336    if self.options.nixpkgs {
337      let (content, files) = self.apply_nixpkgs_preprocessing(&processed);
338      processed = content;
339      included_files = files;
340    }
341
342    #[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
343    if self.options.nixpkgs || cfg!(feature = "ndg-flavored") {
344      processed = super::extensions::process_role_markup(
345        &processed,
346        self.manpage_urls.as_ref(),
347        self.options.auto_link_options,
348        self.options.valid_options.as_ref(),
349      );
350    }
351
352    #[cfg(feature = "wiki")]
353    {
354      processed = super::extensions::process_wikilinks(&processed);
355    }
356
357    (processed, included_files)
358  }
359
360  /// Apply Nixpkgs-specific preprocessing steps.
361  #[cfg(feature = "nixpkgs")]
362  fn apply_nixpkgs_preprocessing(
363    &self,
364    content: &str,
365  ) -> (String, Vec<crate::types::IncludedFile>) {
366    let (with_includes, included_files) =
367      match super::extensions::process_file_includes(content, &self.base_dir, 0)
368      {
369        Ok(result) => result,
370        Err(e) => {
371          log::warn!(
372            "File include processing failed: {e}. Continuing without includes."
373          );
374          (content.to_string(), Vec::new())
375        },
376      };
377    let with_blocks = super::extensions::process_block_elements(&with_includes);
378    let with_spans = super::extensions::process_bracketed_spans(&with_blocks);
379    let processed = super::extensions::process_inline_anchors(&with_spans);
380    (processed, included_files)
381  }
382
383  /// Apply Nixpkgs-specific preprocessing steps (no-op when feature disabled).
384  #[cfg(not(feature = "nixpkgs"))]
385  fn apply_nixpkgs_preprocessing(
386    &self,
387    content: &str,
388  ) -> (String, Vec<crate::types::IncludedFile>) {
389    (content.to_string(), Vec::new())
390  }
391
392  /// Extract headers and title from the markdown content.
393  #[must_use]
394  pub fn extract_headers(
395    &self,
396    content: &str,
397  ) -> (Vec<Header>, Option<String>) {
398    let arena = Arena::new();
399    let options = self.comrak_options();
400    let root = parse_document(&arena, content, &options);
401    Self::extract_headers_from_ast(root, content)
402  }
403
404  /// Extract headings from the already-rendered Comrak tree.
405  fn extract_headers_from_ast<'a>(
406    root: &'a AstNode<'a>,
407    source: &str,
408  ) -> (Vec<Header>, Option<String>) {
409    let mut headers = Vec::new();
410    let mut title = None;
411    let ignored_lines = admonition_lines(source);
412    for node in root.descendants() {
413      let NodeValue::Heading(NodeHeading { level, .. }) =
414        &node.data.borrow().value
415      else {
416        continue;
417      };
418      let line = node.data.borrow().sourcepos.start.line;
419      if ignored_lines.get(line).copied().unwrap_or(false) {
420        continue;
421      }
422      let mut text = String::new();
423      let mut explicit_id = None;
424      for child in node.children() {
425        match &child.data.borrow().value {
426          NodeValue::Text(value) => text.push_str(value),
427          NodeValue::Code(value) => text.push_str(&value.literal),
428          NodeValue::Link(..)
429          | NodeValue::Emph
430          | NodeValue::Strong
431          | NodeValue::Subscript
432          | NodeValue::Strikethrough
433          | NodeValue::Superscript
434          | NodeValue::FootnoteReference(..) => {
435            text.push_str(&extract_inline_text(child));
436          },
437          NodeValue::HtmlInline(value) => {
438            if let Some(start) = value.find("{#")
439              && let Some(end) = value[start..].find('}')
440            {
441              explicit_id = Some(value[start + 2..start + end].to_string());
442            }
443          },
444          _ => {},
445        }
446      }
447      let trimmed = text.trim_end();
448      let (text, id) = if let Some(start) = trimmed.rfind("{#")
449        && let Some(end) = trimmed[start..].find('}')
450      {
451        (
452          trimmed[..start].trim_end().to_string(),
453          trimmed[start + 2..start + end].to_string(),
454        )
455      } else {
456        (
457          text.clone(),
458          explicit_id.unwrap_or_else(|| slugify_heading(&text)),
459        )
460      };
461      if *level == 1 && title.is_none() {
462        title = Some(text.clone());
463      }
464      headers.push((line, Header {
465        text,
466        level: *level,
467        id,
468      }));
469    }
470    let lines: Vec<_> = source.lines().collect();
471    let mut fence = utils::codeblock::FenceTracker::new();
472    for (index, line) in lines.iter().enumerate() {
473      fence = fence.process_line(line);
474      if ignored_lines.get(index + 1).copied().unwrap_or(false) {
475        continue;
476      }
477      if fence.in_code_block() {
478        continue;
479      }
480      let trimmed = line.trim();
481      if trimmed.starts_with('#')
482        || lines
483          .get(index + 1)
484          .is_some_and(|next| is_setext_heading_underline(next.trim()))
485      {
486        continue;
487      }
488      if let Some(start) = trimmed.rfind("{#")
489        && let Some(end) = trimmed[start..].find('}')
490      {
491        let text = trimmed[..start].trim_end();
492        let id = &trimmed[start + 2..start + end];
493        headers.push((index + 1, Header {
494          text:  text.to_string(),
495          level: 2,
496          id:    id.to_string(),
497        }));
498      }
499    }
500    headers.sort_by_key(|(line, _)| *line);
501    (
502      headers.into_iter().map(|(_, header)| header).collect(),
503      title,
504    )
505  }
506
507  fn format_html<'a>(root: &'a AstNode<'a>, options: &Options<'a>) -> String {
508    // Apply AST transformations
509    let prompt_transformer = PromptTransformer;
510    prompt_transformer.transform(root);
511
512    let mut html_output = String::new();
513    if let Err(e) = comrak::format_html(root, options, &mut html_output) {
514      log::error!("Failed to format HTML: {e}");
515    }
516
517    // Post-process HTML to handle header anchors
518    Self::process_header_anchors_html(&html_output)
519  }
520
521  /// Process header anchors in HTML by finding `{#id}` syntax and converting to
522  /// proper id attributes. Also adds auto-generated IDs to headers without
523  /// explicit anchors.
524  fn process_header_anchors_html(html: &str) -> String {
525    // First pass: explicit {#id} syntax
526    let result = HEADER_ANCHOR_RE
527      .replace_all(html, |caps: &regex::Captures| {
528        let level = &caps[1];
529        let prefix = &caps[2];
530        let id = &caps[3];
531        let suffix = &caps[4];
532        format!("<h{level} id=\"{id}\">{prefix}{suffix}</h{level}>")
533      })
534      .to_string();
535
536    // Second pass: add auto-generated IDs to headers without id attribute
537    HEADER_NO_ID_RE
538      .replace_all(&result, |caps: &regex::Captures| {
539        let level = &caps[1];
540        let content = &caps[2];
541        // Strip HTML tags and slugify the text content
542        let text_only = HTML_TAG_RE.replace_all(content, "");
543        let id = utils::slugify(&text_only);
544        if id.is_empty() {
545          // If slugify produces empty string, keep header without id
546          format!("<h{level}>{content}</h{level}>")
547        } else {
548          format!("<h{level} id=\"{id}\">{content}</h{level}>")
549        }
550      })
551      .to_string()
552  }
553
554  /// Build comrak options from `MarkdownOptions` and feature flags.
555  fn comrak_options(&self) -> Options<'_> {
556    let mut options = Options::default();
557    // Markdown features present in GFM.
558    if self.options.gfm {
559      options.extension.table = true;
560      options.extension.footnotes = true;
561      options.extension.strikethrough = true;
562      options.extension.tasklist = true;
563      options.extension.superscript = true;
564      options.extension.autolink = true;
565    }
566
567    for extension in &self.options.extensions {
568      match extension {
569        MarkdownExtension::Alerts => options.extension.alerts = true,
570        MarkdownExtension::Autolink => options.extension.autolink = true,
571        MarkdownExtension::BlockDirective => {
572          options.extension.block_directive = true;
573        },
574        MarkdownExtension::CjkFriendlyEmphasis => {
575          options.extension.cjk_friendly_emphasis = true;
576        },
577        MarkdownExtension::DescriptionLists => {
578          options.extension.description_lists = true;
579        },
580        MarkdownExtension::Footnotes => options.extension.footnotes = true,
581        MarkdownExtension::Greentext => options.extension.greentext = true,
582        MarkdownExtension::Highlight => options.extension.highlight = true,
583        MarkdownExtension::Insert => options.extension.insert = true,
584        MarkdownExtension::InlineFootnotes => {
585          options.extension.inline_footnotes = true;
586        },
587        MarkdownExtension::MathCode => options.extension.math_code = true,
588        MarkdownExtension::MathDollars => {
589          options.extension.math_dollars = true;
590        },
591        MarkdownExtension::MathLatex => options.extension.math_latex = true,
592        MarkdownExtension::MultilineBlockQuotes => {
593          options.extension.multiline_block_quotes = true;
594        },
595        MarkdownExtension::Spoiler => options.extension.spoiler = true,
596        MarkdownExtension::Strikethrough => {
597          options.extension.strikethrough = true;
598        },
599        MarkdownExtension::Subscript => options.extension.subscript = true,
600        MarkdownExtension::Subtext => options.extension.subtext = true,
601        MarkdownExtension::Superscript => options.extension.superscript = true,
602        MarkdownExtension::Table => options.extension.table = true,
603        MarkdownExtension::Tasklist => options.extension.tasklist = true,
604        MarkdownExtension::Underline => options.extension.underline = true,
605        MarkdownExtension::WikilinksTitleAfterPipe => {
606          options.extension.wikilinks_title_after_pipe = true;
607        },
608        MarkdownExtension::WikilinksTitleBeforePipe => {
609          options.extension.wikilinks_title_before_pipe = true;
610        },
611      }
612    }
613
614    // Enable unsafe HTML references. This is not a security concern
615    // as all input is assumed to be trusted.
616    options.render.r#unsafe = true;
617
618    // Enable description lists but keep custom header processing
619    options.extension.header_id_prefix = None;
620    options.extension.description_lists = true;
621    options
622  }
623
624  /// Post-process HTML to enhance manpage references with URL links.
625  #[cfg(feature = "nixpkgs")]
626  fn process_manpage_references_html(&self, html: &str) -> String {
627    super::extensions::process_manpage_references(
628      html,
629      self.manpage_urls.as_ref(),
630    )
631  }
632
633  /// Post-process HTML to enhance manpage references (no-op when feature
634  /// disabled).
635  #[cfg(not(feature = "nixpkgs"))]
636  fn process_manpage_references_html(&self, html: &str) -> String {
637    html.to_string()
638  }
639
640  /// HTML post-processing using kuchiki DOM manipulation.
641  fn kuchiki_postprocess(&self, html: &str) -> String {
642    // Use a standalone function to avoid borrowing issues
643    kuchiki_postprocess_html(html, |document| {
644      self.highlight_codeblocks_document(document);
645      Self::apply_dom_transformations(document);
646    })
647  }
648
649  fn highlight_codeblocks_document(&self, document: &kuchikikiki::NodeRef) {
650    if !self.options.highlight_code || self.syntax_manager.is_none() {
651      return;
652    }
653    let mut code_blocks = Vec::new();
654    for code_node in safe_select(document, "pre > code") {
655      if let Some(element) = code_node.as_element() {
656        let language = element
657          .attributes
658          .borrow()
659          .get("class")
660          .and_then(|class| class.strip_prefix("language-"))
661          .unwrap_or("text")
662          .to_string();
663        if let Some(pre_node) = code_node.parent() {
664          code_blocks.push((pre_node, code_node.text_contents(), language));
665        }
666      }
667    }
668    for (pre_node, code, language) in code_blocks {
669      if let Some(highlighted) = self.highlight_code_html(&code, &language) {
670        use tendril::TendrilSink;
671        let replacement = kuchikikiki::parse_html().one(format!(
672          r#"<pre class="highlight"><code class="language-{language}">{highlighted}</code></pre>"#
673        ));
674        pre_node.insert_after(replacement);
675        pre_node.detach();
676      }
677    }
678  }
679
680  /// Apply all DOM transformations to the parsed HTML document.
681  fn apply_dom_transformations(document: &kuchikikiki::NodeRef) {
682    Self::process_list_item_id_markers(document);
683    Self::process_header_anchor_comments(document);
684    Self::process_list_item_inline_anchors(document);
685    Self::process_paragraph_inline_anchors(document);
686    Self::process_remaining_inline_anchors(document);
687    Self::process_markdown_links(document);
688    Self::process_option_anchor_links(document);
689    Self::process_empty_auto_links(document);
690  }
691
692  /// Process list item ID markers: <li><!-- nixos-anchor-id:ID -->
693  fn process_list_item_id_markers(document: &kuchikikiki::NodeRef) {
694    let mut to_modify = Vec::new();
695
696    for comment in document.inclusive_descendants() {
697      if let Some(comment_node) = comment.as_comment() {
698        let comment_text = comment_node.borrow();
699        if let Some(id_start) = comment_text.find("nixos-anchor-id:") {
700          let id = comment_text[id_start + 16..].trim();
701          if !id.is_empty()
702            && id
703              .chars()
704              .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
705          {
706            // Check if this comment is inside an <li> element
707            if let Some(parent) = comment.parent()
708              && let Some(element) = parent.as_element()
709              && element.name.local.as_ref() == "li"
710            {
711              to_modify.push((comment.clone(), id.to_string()));
712            }
713          }
714        }
715      }
716    }
717
718    for (comment_node, id) in to_modify {
719      let span = kuchikikiki::NodeRef::new_element(
720        markup5ever::QualName::new(
721          None,
722          markup5ever::ns!(html),
723          local_name!("span"),
724        ),
725        vec![
726          (
727            kuchikikiki::ExpandedName::new("", "id"),
728            kuchikikiki::Attribute {
729              prefix: None,
730              value:  id,
731            },
732          ),
733          (
734            kuchikikiki::ExpandedName::new("", "class"),
735            kuchikikiki::Attribute {
736              prefix: None,
737              value:  "nixos-anchor".into(),
738            },
739          ),
740        ],
741      );
742      comment_node.insert_after(span);
743      comment_node.detach();
744    }
745  }
746
747  /// Process header anchors with comments: <h1>text<!-- anchor: id --></h1>
748  fn process_header_anchor_comments(document: &kuchikikiki::NodeRef) {
749    let mut to_modify = Vec::new();
750
751    for comment in document.inclusive_descendants() {
752      if let Some(comment_node) = comment.as_comment() {
753        let comment_text = comment_node.borrow();
754        if let Some(anchor_start) = comment_text.find("anchor:") {
755          let id = comment_text[anchor_start + 7..].trim();
756          if !id.is_empty()
757            && id
758              .chars()
759              .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
760          {
761            // Check if this comment is inside a header element
762            if let Some(parent) = comment.parent()
763              && let Some(element) = parent.as_element()
764              && matches!(
765                element.name.local.as_ref(),
766                "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
767              )
768            {
769              to_modify.push((parent.clone(), comment.clone(), id.to_string()));
770            }
771          }
772        }
773      }
774    }
775
776    for (header_element, comment_node, id) in to_modify {
777      if let Some(element) = header_element.as_element() {
778        element
779          .attributes
780          .borrow_mut()
781          .insert(local_name!("id"), id);
782        comment_node.detach();
783      }
784    }
785  }
786
787  /// Process remaining inline anchors in list items: <li>[]{#id}content</li>
788  fn process_list_item_inline_anchors(document: &kuchikikiki::NodeRef) {
789    for li_node in safe_select(document, "li") {
790      let li_element = li_node;
791
792      // Check if this list item contains code elements
793      let has_code = !safe_select(&li_element, "code, pre").is_empty();
794      if has_code {
795        continue; // Skip list items with code blocks
796      }
797
798      let text_content = li_element.text_contents();
799
800      if let Some(anchor_start) = text_content.find("[]{#")
801        && let Some(anchor_end) = text_content[anchor_start..].find('}')
802      {
803        let id = &text_content[anchor_start + 4..anchor_start + anchor_end];
804        if !id.is_empty()
805          && id
806            .chars()
807            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
808        {
809          let remaining_content =
810            &text_content[anchor_start + anchor_end + 1..];
811
812          // Clear current content and rebuild
813          for child in li_element.children() {
814            child.detach();
815          }
816
817          let span = kuchikikiki::NodeRef::new_element(
818            markup5ever::QualName::new(
819              None,
820              markup5ever::ns!(html),
821              local_name!("span"),
822            ),
823            vec![
824              (
825                kuchikikiki::ExpandedName::new("", "id"),
826                kuchikikiki::Attribute {
827                  prefix: None,
828                  value:  id.into(),
829                },
830              ),
831              (
832                kuchikikiki::ExpandedName::new("", "class"),
833                kuchikikiki::Attribute {
834                  prefix: None,
835                  value:  "nixos-anchor".into(),
836                },
837              ),
838            ],
839          );
840          li_element.append(span);
841          if !remaining_content.is_empty() {
842            li_element
843              .append(kuchikikiki::NodeRef::new_text(remaining_content));
844          }
845        }
846      }
847    }
848  }
849
850  /// Process inline anchors in paragraphs: <p>[]{#id}content</p>
851  fn process_paragraph_inline_anchors(document: &kuchikikiki::NodeRef) {
852    for p_node in safe_select(document, "p") {
853      let p_element = p_node;
854
855      // Check if this paragraph contains code elements
856      let has_code = !safe_select(&p_element, "code, pre").is_empty();
857      if has_code {
858        continue; // Skip paragraphs with code blocks
859      }
860
861      let text_content = p_element.text_contents();
862
863      if let Some(anchor_start) = text_content.find("[]{#")
864        && let Some(anchor_end) = text_content[anchor_start..].find('}')
865      {
866        let id = &text_content[anchor_start + 4..anchor_start + anchor_end];
867        if !id.is_empty()
868          && id
869            .chars()
870            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
871        {
872          let remaining_content =
873            &text_content[anchor_start + anchor_end + 1..];
874
875          // Clear current content and rebuild
876          for child in p_element.children() {
877            child.detach();
878          }
879
880          let span = kuchikikiki::NodeRef::new_element(
881            markup5ever::QualName::new(
882              None,
883              markup5ever::ns!(html),
884              local_name!("span"),
885            ),
886            vec![
887              (
888                kuchikikiki::ExpandedName::new("", "id"),
889                kuchikikiki::Attribute {
890                  prefix: None,
891                  value:  id.into(),
892                },
893              ),
894              (
895                kuchikikiki::ExpandedName::new("", "class"),
896                kuchikikiki::Attribute {
897                  prefix: None,
898                  value:  "nixos-anchor".into(),
899                },
900              ),
901            ],
902          );
903          p_element.append(span);
904          if !remaining_content.is_empty() {
905            p_element.append(kuchikikiki::NodeRef::new_text(remaining_content));
906          }
907        }
908      }
909    }
910  }
911
912  /// Process remaining standalone inline anchors throughout the document
913  fn process_remaining_inline_anchors(document: &kuchikikiki::NodeRef) {
914    let mut text_nodes_to_process = Vec::new();
915
916    for node in document.inclusive_descendants() {
917      if let Some(text_node) = node.as_text() {
918        // Check if this text node is inside a code block
919        let mut parent = node.parent();
920        let mut in_code = false;
921        while let Some(p) = parent {
922          if let Some(element) = p.as_element()
923            && (element.name.local == local_name!("code")
924              || element.name.local == local_name!("pre"))
925          {
926            in_code = true;
927            break;
928          }
929          parent = p.parent();
930        }
931
932        // Only process if not in code
933        if !in_code {
934          let text_content = text_node.borrow().clone();
935          if text_content.contains("[]{#") {
936            text_nodes_to_process.push((node.clone(), text_content));
937          }
938        }
939      }
940    }
941
942    for (text_node, text_content) in text_nodes_to_process {
943      let mut last_end = 0;
944      let mut new_children = Vec::new();
945
946      // Simple pattern matching for []{#id}
947      let chars = text_content.chars().collect::<Vec<_>>();
948      let mut i = 0;
949      while i < chars.len() {
950        if i + 4 < chars.len()
951          && chars[i] == '['
952          && chars[i + 1] == ']'
953          && chars[i + 2] == '{'
954          && chars[i + 3] == '#'
955        {
956          // Found start of anchor pattern
957          let anchor_start = i;
958          i += 4; // skip "[]{#"
959
960          let mut id = String::new();
961          while i < chars.len() && chars[i] != '}' {
962            if chars[i].is_alphanumeric()
963              || chars[i] == '-'
964              || chars[i] == '_'
965              || chars[i] == '.'
966            {
967              id.push(chars[i]);
968              i += 1;
969            } else {
970              break;
971            }
972          }
973
974          if i < chars.len() && chars[i] == '}' && !id.is_empty() {
975            // Valid anchor found
976            let anchor_end = i + 1;
977
978            // Add text before anchor
979            if anchor_start > last_end {
980              new_children.push(kuchikikiki::NodeRef::new_text(
981                chars[last_end..anchor_start].iter().collect::<String>(),
982              ));
983            }
984
985            // Add span element
986            let span = kuchikikiki::NodeRef::new_element(
987              markup5ever::QualName::new(
988                None,
989                markup5ever::ns!(html),
990                local_name!("span"),
991              ),
992              vec![
993                (
994                  kuchikikiki::ExpandedName::new("", "id"),
995                  kuchikikiki::Attribute {
996                    prefix: None,
997                    value:  id,
998                  },
999                ),
1000                (
1001                  kuchikikiki::ExpandedName::new("", "class"),
1002                  kuchikikiki::Attribute {
1003                    prefix: None,
1004                    value:  "nixos-anchor".into(),
1005                  },
1006                ),
1007              ],
1008            );
1009            new_children.push(span);
1010
1011            last_end = anchor_end;
1012            i = anchor_end;
1013          } else {
1014            i += 1;
1015          }
1016        } else {
1017          i += 1;
1018        }
1019      }
1020
1021      // Add remaining text
1022      if last_end < chars.len() {
1023        let after_text: String = chars[last_end..].iter().collect();
1024        if !after_text.is_empty() {
1025          new_children.push(kuchikikiki::NodeRef::new_text(after_text));
1026        }
1027      }
1028
1029      // Replace text node if we found anchors
1030      if !new_children.is_empty() {
1031        for child in new_children {
1032          text_node.insert_before(child);
1033        }
1034        text_node.detach();
1035      }
1036    }
1037  }
1038
1039  /// Process empty auto-links: [](#anchor) -> <a href="#anchor">Anchor</a>
1040  fn process_empty_auto_links(document: &kuchikikiki::NodeRef) {
1041    for link_node in safe_select(document, "a") {
1042      let link_element = link_node;
1043      if let Some(element) = link_element.as_element() {
1044        let href = element
1045          .attributes
1046          .borrow()
1047          .get(local_name!("href"))
1048          .map(std::string::ToString::to_string);
1049        let text_content = link_element.text_contents();
1050        // An element such as an image is content, although it has no text
1051        let has_element = link_element
1052          .children()
1053          .any(|child| child.as_element().is_some());
1054
1055        if let Some(href_value) = href
1056          && href_value.starts_with('#')
1057          && ((text_content.trim().is_empty() && !has_element)
1058            || text_content.trim() == "{{ANCHOR}}")
1059        {
1060          // Clear placeholder text if present
1061          if text_content.trim() == "{{ANCHOR}}" {
1062            for child in link_element.children() {
1063              child.detach();
1064            }
1065          }
1066          // Empty link with anchor - add humanized text
1067          let display_text = humanize_anchor(&href_value);
1068          link_element.append(kuchikikiki::NodeRef::new_text(display_text));
1069        }
1070      }
1071    }
1072  }
1073
1074  /// Process option anchor links: [](#opt-option.path) -> link to options.html
1075  fn process_option_anchor_links(document: &kuchikikiki::NodeRef) {
1076    let mut to_modify = Vec::new();
1077
1078    // Collect all option anchor links first
1079    for link_node in safe_select(document, "a[href^='#opt-']") {
1080      let link_element = link_node;
1081      if let Some(element) = link_element.as_element() {
1082        let href = element
1083          .attributes
1084          .borrow()
1085          .get(local_name!("href"))
1086          .map(std::string::ToString::to_string);
1087        let text_content = link_element.text_contents();
1088
1089        if let Some(href_value) = href
1090          && href_value.starts_with("#opt-")
1091        {
1092          let option_anchor = href_value[1..].to_string(); // remove the leading #
1093          let needs_text_replacement = text_content.trim().is_empty()
1094            || text_content.trim() == "{{ANCHOR}}";
1095          to_modify.push((
1096            link_element.clone(),
1097            option_anchor,
1098            needs_text_replacement,
1099          ));
1100        }
1101      }
1102    }
1103
1104    // Apply modifications
1105    for (link_element, option_anchor, needs_text_replacement) in to_modify {
1106      if let Some(element) = link_element.as_element() {
1107        let new_href = format!("options.html#{option_anchor}");
1108        element
1109          .attributes
1110          .borrow_mut()
1111          .insert(local_name!("href"), new_href);
1112
1113        if needs_text_replacement {
1114          // Clear existing content
1115          for child in link_element.children() {
1116            child.detach();
1117          }
1118
1119          // Extract option name from anchor
1120          // opt-services-nginx-enable -> services.nginx.enable
1121          if let Some(option_path) = option_anchor.strip_prefix("opt-") {
1122            let option_name = option_path.replace('-', ".");
1123            link_element.append(kuchikikiki::NodeRef::new_text(option_name));
1124          }
1125        }
1126      }
1127    }
1128  }
1129
1130  /// Process markdown file links: convert .md hrefs to .html
1131  fn process_markdown_links(document: &kuchikikiki::NodeRef) {
1132    for link_node in safe_select(document, "a") {
1133      let link_element = link_node;
1134      if let Some(element) = link_element.as_element() {
1135        let href = element
1136          .attributes
1137          .borrow()
1138          .get(local_name!("href"))
1139          .map(std::string::ToString::to_string);
1140
1141        if let Some(href_value) = href {
1142          // Only process relative links ending in .md (not absolute URLs, not
1143          // anchors)
1144          if !href_value.starts_with("http://")
1145            && !href_value.starts_with("https://")
1146            && !href_value.starts_with('#')
1147            && !href_value.starts_with("mailto:")
1148          {
1149            // Split off fragment (#) and query (?) to check the path extension
1150            let (path_part, suffix) = href_value
1151              .find(['#', '?'])
1152              .map_or((href_value.as_str(), ""), |idx| {
1153                href_value.split_at(idx)
1154              });
1155
1156            if std::path::Path::new(path_part)
1157              .extension()
1158              .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
1159            {
1160              let new_href =
1161                format!("{}.html{}", &path_part[..path_part.len() - 3], suffix);
1162              element
1163                .attributes
1164                .borrow_mut()
1165                .insert(local_name!("href"), new_href);
1166            }
1167          }
1168        }
1169      }
1170    }
1171  }
1172}
1173
1174/// Convert an anchor ID to human-readable text.
1175fn humanize_anchor(anchor: &str) -> String {
1176  let cleaned = anchor.trim_start_matches('#');
1177  let without_prefix = cleaned
1178    .trim_start_matches("sec-")
1179    .trim_start_matches("ssec-")
1180    .trim_start_matches("opt-");
1181  let spaced = without_prefix.replace(['-', '_'], " ");
1182  spaced
1183    .split_whitespace()
1184    .map(|word| {
1185      let mut chars = word.chars();
1186      chars.next().map_or_else(String::new, |c| {
1187        c.to_uppercase().collect::<String>() + chars.as_str()
1188      })
1189    })
1190    .collect::<Vec<String>>()
1191    .join(" ")
1192}
1193
1194/// Compute the relative URL from one output page to another.
1195///
1196/// E.g. `relative_page_path("guide/intro.html", "install.html")` →
1197/// `"../install.html"`.
1198fn relative_page_path(from_page: &str, to_page: &str) -> String {
1199  let from_dir = Path::new(from_page)
1200    .parent()
1201    .unwrap_or_else(|| Path::new(""));
1202  let to_path = Path::new(to_page);
1203
1204  let from_parts: Vec<_> = from_dir
1205    .components()
1206    .filter(|c| !matches!(c, Component::CurDir))
1207    .collect();
1208  let to_parts: Vec<_> = to_path
1209    .components()
1210    .filter(|c| !matches!(c, Component::CurDir))
1211    .collect();
1212
1213  let common = from_parts
1214    .iter()
1215    .zip(to_parts.iter())
1216    .take_while(|(a, b)| a == b)
1217    .count();
1218
1219  let ups = from_parts.len() - common;
1220  let remainder = &to_parts[common..];
1221
1222  let mut result = std::path::PathBuf::new();
1223  for _ in 0..ups {
1224    result.push("..");
1225  }
1226  for part in remainder {
1227    result.push(part);
1228  }
1229
1230  let s = result.to_string_lossy().to_string();
1231  if s.is_empty() { to_page.to_string() } else { s }
1232}
1233
1234/// Rewrite cross-page anchor links in rendered HTML.
1235///
1236/// For every `<a href="#anchor">` element:
1237/// - If `anchor` is found in `registry` and its owning page differs from
1238///   `current_page`, rewrites the `href` to the relative path to that page plus
1239///   the fragment (`other.html#anchor`).
1240/// - If the link text is empty, the `{{ANCHOR}}` placeholder, or the
1241///   auto-generated humanized slug, replaces it with the registry title.
1242/// - Same-page anchors and anchors absent from the registry are left as-is.
1243///
1244/// The `registry` maps anchor ID → `(owning_output_page, heading_title)`.
1245#[must_use]
1246#[expect(
1247  clippy::disallowed_types,
1248  reason = "Uses generic HashMap for hasher flexibility"
1249)]
1250pub fn rewrite_cross_page_anchor_links<S: std::hash::BuildHasher>(
1251  html: &str,
1252  current_page: &str,
1253  registry: &HashMap<String, (String, String), S>,
1254) -> String {
1255  if registry.is_empty() {
1256    return html.to_string();
1257  }
1258
1259  kuchiki_postprocess_html(html, |document| {
1260    // Collect modifications first to avoid borrow conflicts.
1261    let mut modifications: Vec<(kuchikikiki::NodeRef, String, Option<String>)> =
1262      Vec::new();
1263
1264    for link_node in safe_select(document, "a[href^='#']") {
1265      let Some(element) = link_node.as_element() else {
1266        continue;
1267      };
1268
1269      let href = element
1270        .attributes
1271        .borrow()
1272        .get(local_name!("href"))
1273        .map(std::string::ToString::to_string);
1274      let Some(href_val) = href else { continue };
1275
1276      let anchor_id = href_val.trim_start_matches('#');
1277      let Some((target_page, target_title)) = registry.get(anchor_id) else {
1278        continue;
1279      };
1280
1281      if target_page == current_page {
1282        continue;
1283      }
1284
1285      let rel = relative_page_path(current_page, target_page);
1286      let new_href = format!("{rel}#{anchor_id}");
1287
1288      let current_text = link_node.text_contents();
1289      let humanized = humanize_anchor(&href_val);
1290      let replace_text = current_text.trim().is_empty()
1291        || current_text.trim() == "{{ANCHOR}}"
1292        || current_text.trim() == humanized.trim();
1293
1294      let new_text = if replace_text {
1295        Some(target_title.clone())
1296      } else {
1297        None
1298      };
1299
1300      modifications.push((link_node, new_href, new_text));
1301    }
1302
1303    for (link_node, new_href, new_text) in modifications {
1304      if let Some(element) = link_node.as_element() {
1305        element
1306          .attributes
1307          .borrow_mut()
1308          .insert(local_name!("href"), new_href);
1309      }
1310      if let Some(text) = new_text {
1311        for child in link_node.children() {
1312          child.detach();
1313        }
1314        link_node.append(kuchikikiki::NodeRef::new_text(text));
1315      }
1316    }
1317  })
1318}
1319
1320/// Extract all inline text from a heading node.
1321pub fn extract_inline_text<'a>(node: &'a AstNode<'a>) -> String {
1322  fn inner<'a>(node: &'a AstNode<'a>) -> String {
1323    let mut text = String::new();
1324    for child in node.children() {
1325      match &child.data.borrow().value {
1326        NodeValue::Text(t) => text.push_str(t),
1327        NodeValue::Code(t) => text.push_str(&t.literal),
1328        NodeValue::Link(..)
1329        | NodeValue::Emph
1330        | NodeValue::Strong
1331        | NodeValue::Strikethrough
1332        | NodeValue::Superscript
1333        | NodeValue::Subscript
1334        | NodeValue::FootnoteReference(..) => {
1335          text.push_str(&inner(child));
1336        },
1337        #[expect(clippy::match_same_arms, reason = "Explicit for clarity")]
1338        NodeValue::HtmlInline(_) | NodeValue::Image(..) => {},
1339        _ => {},
1340      }
1341    }
1342    text
1343  }
1344  inner(node)
1345}
1346
1347/// Slugify heading text for use as a table-of-contents anchor.
1348///
1349/// The auto-generated heading `id` is produced by slugifying the *rendered*
1350/// HTML (see `process_header_anchors_html`), in which comrak has escaped any
1351/// markup characters: an option heading like `environments.<name>.deployment`
1352/// becomes `environments.&lt;name&gt;.deployment` before slugifying. The
1353/// table-of-contents, by contrast, slugifies the raw inline text (`<name>`),
1354/// which would yield a different slug and break "jump to header" for every
1355/// heading containing such characters.
1356///
1357/// To keep both in sync, escape the text the same way comrak does before
1358/// slugifying, so the TOC href matches the on-page heading `id`. The heading
1359/// `id` itself is intentionally left unchanged to preserve existing deep links.
1360#[must_use]
1361pub(crate) fn slugify_heading(text: &str) -> String {
1362  utils::slugify(&html_escape::encode_double_quoted_attribute(text))
1363}
1364
1365/// Collect all markdown files from the input directory
1366pub fn collect_markdown_files(input_dir: &Path) -> Vec<PathBuf> {
1367  let mut files = Vec::with_capacity(100);
1368
1369  for entry in WalkDir::new(input_dir)
1370    .follow_links(true)
1371    .into_iter()
1372    .filter_map(Result::ok)
1373  {
1374    let path = entry.path();
1375    if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
1376      files.push(path.to_owned());
1377    }
1378  }
1379
1380  trace!("Found {} markdown files to process", files.len());
1381  files
1382}
1383
1384/// Features that can be queried on a processor instance.
1385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1386pub enum ProcessorFeature {
1387  /// GitHub Flavored Markdown support
1388  Gfm,
1389  /// Nixpkgs documentation extensions
1390  Nixpkgs,
1391  /// Syntax highlighting for code blocks
1392  SyntaxHighlighting,
1393  /// Manpage URL mapping support
1394  ManpageUrls,
1395}
1396
1397fn admonition_lines(content: &str) -> Vec<bool> {
1398  let mut ignored = vec![false; content.lines().count() + 1];
1399  let mut depth = 0usize;
1400
1401  for (index, line) in content.lines().enumerate() {
1402    let trimmed = line.trim_start();
1403    if trimmed.starts_with("<div class=\"admonition ") {
1404      depth += 1;
1405    }
1406    if depth > 0 {
1407      ignored[index + 1] = true;
1408    }
1409    if depth > 0 && trimmed == "</div>" {
1410      depth -= 1;
1411    }
1412  }
1413
1414  ignored
1415}
1416
1417fn is_setext_heading_underline(line: &str) -> bool {
1418  !line.is_empty()
1419    && (line.chars().all(|ch| ch == '=' || ch.is_whitespace())
1420      || line.chars().all(|ch| ch == '-' || ch.is_whitespace()))
1421}
1422
1423/// Standalone HTML post-processing function to avoid borrowing issues.
1424fn kuchiki_postprocess_html<F>(html: &str, transform_fn: F) -> String
1425where
1426  F: FnOnce(&kuchikikiki::NodeRef),
1427{
1428  process_safe(
1429    html,
1430    |html| {
1431      use tendril::TendrilSink;
1432
1433      let document = kuchikikiki::parse_html().one(html);
1434      transform_fn(&document);
1435
1436      let mut out = Vec::new();
1437      let _ = document.serialize(&mut out);
1438      String::from_utf8_lossy(&out).into_owned()
1439    },
1440    html,
1441  )
1442}