Skip to main content

ndg_commonmark/processor/
mod.rs

1//! Markdown processing module with modular organization.
2//!
3//! This module provides a comprehensive, trait-based architecture for
4//! processing Markdown content with support for various extensions and output
5//! formats.
6//!
7//! # Architecture
8//!
9//! The processor module is organized into focused submodules:
10//!
11//! - [`core`]: Main processor implementation and processing pipeline
12//! - [`dom`]: DOM extraction helpers
13//! - [`process`]: High-level processing functions
14//! - [`extensions`]: Feature-gated processing functions for different Markdown
15//!   flavors
16//! - [`types`]: Core type definitions and configuration structures
17pub mod core;
18pub mod dom;
19pub mod extensions;
20pub mod process;
21pub mod types;
22
23// Re-export commonly used types from submodules
24pub use core::{
25  ProcessorFeature,
26  collect_markdown_files,
27  extract_inline_text,
28  rewrite_cross_page_anchor_links,
29};
30
31// Re-export extension functions for third-party use
32#[cfg(feature = "gfm")]
33pub use extensions::apply_gfm_extensions;
34#[cfg(feature = "nixpkgs")]
35pub use extensions::process_manpage_references;
36pub use extensions::process_myst_autolinks;
37#[cfg(feature = "ndg-flavored")]
38pub use extensions::process_option_references;
39#[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
40pub use extensions::process_role_markup;
41#[cfg(feature = "wiki")]
42pub use extensions::process_wikilinks;
43#[cfg(feature = "nixpkgs")]
44pub use extensions::{
45  process_block_elements,
46  process_bracketed_spans,
47  process_file_includes,
48  process_inline_anchors,
49};
50pub use process::{
51  ProcessorPreset,
52  create_processor,
53  process_batch,
54  process_markdown_file,
55  process_markdown_file_with_basedir,
56  process_markdown_string,
57  process_safe,
58  process_with_recovery,
59};
60pub use types::{
61  AstTransformer,
62  MarkdownExtension,
63  MarkdownOptions,
64  MarkdownOptionsBuilder,
65  MarkdownProcessor,
66  PromptTransformer,
67};
68
69#[cfg(test)]
70#[expect(clippy::expect_used, reason = "Fine in tests")]
71mod tests {
72  use html_escape;
73
74  use super::{MarkdownOptions, MarkdownProcessor, types::TabStyle};
75
76  #[test]
77  fn test_html_escaped_roles() {
78    // Test that HTML characters in role content are properly escaped
79    #[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
80    {
81      let result = super::extensions::format_role_markup(
82        "option",
83        "hjem.users.<name>.enable",
84        None,
85        true,
86        None,
87      );
88
89      // Should escape < and > characters in content
90      assert!(result.contains("&lt;name&gt;"));
91      // Should not contain unescaped HTML in code content
92      assert!(!result.contains("<code>hjem.users.<name>.enable</code>"));
93      // Should contain escaped content in code with proper class
94      assert!(result.contains(
95        "<code class=\"nixos-option\">hjem.users.&lt;name&gt;.enable</code>"
96      ));
97      // Should have properly formatted option ID in href with sanitized special
98      // chars to remain compatible with n-r-d
99      assert!(result.contains("option-hjem.users._name_.enable"));
100    }
101  }
102
103  #[test]
104  fn test_html_escape_util() {
105    let input = "test<>&\"'";
106    let escaped = html_escape::encode_text(input);
107
108    // html-escape crate doesn't escape single quotes by default
109    assert_eq!(escaped, "test&lt;&gt;&amp;\"'");
110  }
111
112  #[test]
113  fn test_toc_anchor_matches_heading_id_for_angle_brackets() {
114    // Regression: a heading whose text contains markup characters such as
115    // `<name>` must have its table-of-contents anchor (`Header.id`) match the
116    // auto-generated `id` attribute on the rendered heading, otherwise
117    // "jump to header" links point at a non-existent anchor. The heading `id`
118    // slugifies the escaped HTML (`&lt;name&gt;`), so the TOC must too.
119    let processor = MarkdownProcessor::new(MarkdownOptions::default());
120    // The Nix options renderer emits the angle brackets backslash-escaped so
121    // comrak treats them as literal text rather than an inline HTML tag,
122    // yielding `environments.&lt;name&gt;.deployment` in the rendered heading.
123    let result = processor.render("## environments.\\<name\\>.deployment\n");
124
125    let header = result
126      .headers
127      .iter()
128      .find(|h| h.level == 2)
129      .expect("expected an h2 header");
130
131    // The heading id is the slug of the escaped HTML, not the raw `<name>`.
132    assert_eq!(header.id, "environments--lt-name-gt--deployment");
133    // The rendered HTML must carry the same id so the TOC anchor resolves.
134    assert!(
135      result.html.contains(&format!("id=\"{}\"", header.id)),
136      "rendered HTML {:?} is missing id={:?}",
137      result.html,
138      header.id
139    );
140  }
141
142  #[test]
143  fn test_various_role_types_with_html_characters() {
144    #[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
145    {
146      let content = "<script>alert('xss')</script>";
147
148      let command_result = super::extensions::format_role_markup(
149        "command", content, None, true, None,
150      );
151      assert!(command_result.contains("&lt;script&gt;"));
152      assert!(!command_result.contains("<script>alert"));
153
154      let env_result =
155        super::extensions::format_role_markup("env", content, None, true, None);
156      assert!(env_result.contains("&lt;script&gt;"));
157      assert!(!env_result.contains("<script>alert"));
158
159      let file_result = super::extensions::format_role_markup(
160        "file", content, None, true, None,
161      );
162      assert!(file_result.contains("&lt;script&gt;"));
163      assert!(!file_result.contains("<script>alert"));
164    }
165  }
166
167  #[test]
168  fn test_option_role_escaping() {
169    // Test the specific reported issue: {option}`hjem.users.<name>.enable`
170    #[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
171    {
172      let result = super::extensions::format_role_markup(
173        "option",
174        "hjem.users.<name>.enable",
175        None,
176        true,
177        None,
178      );
179
180      // Should not produce broken HTML like:
181      // <code>hjem.users.<name>.enable</name></code>
182      assert!(!result.contains("</name>"));
183
184      // Should properly escape the angle brackets in display text
185      assert!(result.contains("&lt;name&gt;"));
186
187      // Should produce valid HTML structure with proper class
188      assert!(result.contains(
189        "<code class=\"nixos-option\">hjem.users.&lt;name&gt;.enable</code>"
190      ));
191
192      // Should sanitize special characters in the option ID
193      assert!(result.contains("options.html#option-hjem.users._name_.enable"));
194    }
195  }
196
197  #[test]
198  fn test_option_role_special_chars_preserved() {
199    // Test that special characters are preserved in option IDs
200    #[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
201    {
202      let result = super::extensions::format_role_markup(
203        "option",
204        "services.foo.<bar>.enable",
205        None,
206        true,
207        None,
208      );
209
210      // Option ID should sanitize angle brackets to underscores
211      assert!(result.contains("option-services.foo._bar_.enable"));
212
213      // Display text should be HTML escaped
214      assert!(result.contains("&lt;bar&gt;"));
215    }
216  }
217
218  #[test]
219  fn test_hardtab_handling_none() {
220    let options = MarkdownOptions {
221      tab_style: TabStyle::None,
222      highlight_code: false,
223      ..Default::default()
224    };
225    let processor = MarkdownProcessor::new(options);
226
227    let markdown = r#"
228# Test Code
229
230```rust
231fn main() {
232	println!("Hello, world!");
233}
234```
235"#;
236
237    let result = processor.render(markdown);
238    assert!(result.html.contains("\tprintln"));
239  }
240
241  #[test]
242  fn test_hardtab_handling_warn() {
243    let options = MarkdownOptions {
244      tab_style: TabStyle::Warn,
245      highlight_code: false,
246      ..Default::default()
247    };
248    let processor = MarkdownProcessor::new(options);
249
250    let markdown = r#"
251# Test Code
252
253```rust
254fn main() {
255	println!("Hello, world!");
256}
257```
258"#;
259
260    let result = processor.render(markdown);
261    // Should preserve hard tabs but issue warning
262    assert!(result.html.contains("\tprintln"));
263  }
264
265  #[test]
266  fn test_hardtab_handling_normalize() {
267    let options = MarkdownOptions {
268      tab_style: TabStyle::Normalize,
269      highlight_code: false,
270      ..Default::default()
271    };
272    let processor = MarkdownProcessor::new(options);
273
274    let markdown = r#"
275# Test Code
276
277```rust
278fn main() {
279	println!("Hello, world!");
280}
281```
282"#;
283
284    let result = processor.render(markdown);
285    // Should convert hard tabs to 2 spaces
286    assert!(!result.html.contains("\tprintln"));
287    assert!(result.html.contains("  println"));
288  }
289
290  #[test]
291  fn test_hardtab_handling_no_tabs() {
292    let options = MarkdownOptions {
293      tab_style: TabStyle::Warn,
294      highlight_code: false,
295      ..Default::default()
296    };
297    let processor = MarkdownProcessor::new(options);
298
299    let markdown = r#"
300# Test Code
301
302```rust
303fn main() {
304    println!("Hello, world!");
305}
306```
307"#;
308
309    let result = processor.render(markdown);
310    // Should work fine when no tabs are present
311    assert!(result.html.contains("    println"));
312    assert!(!result.html.contains('\t'));
313  }
314
315  #[test]
316  fn test_hardtab_handling_mixed_content() {
317    let options = MarkdownOptions {
318      tab_style: TabStyle::Normalize,
319      highlight_code: false,
320      ..Default::default()
321    };
322    let processor = MarkdownProcessor::new(options);
323
324    let markdown = r#"
325# Test Code
326
327```rust
328fn main() {
329	println!("Hello");  // tab here
330    println!("World");  // spaces here
331}
332```
333"#;
334
335    let result = processor.render(markdown);
336    // Should convert only tabs, preserve spaces
337    assert!(!result.html.contains("\tprintln"));
338    assert!(result.html.contains("  println"));
339    assert!(result.html.contains("    println"));
340  }
341}