1#[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 #[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 #[must_use]
127 pub const fn options(&self) -> &MarkdownOptions {
128 &self.options
129 }
130
131 #[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 #[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 #[must_use]
151 pub const fn manpage_urls(&self) -> Option<&FxHashMap<String, String>> {
152 self.manpage_urls.as_ref()
153 }
154
155 #[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(); }
173 String::from_utf8(buf).unwrap_or_else(|_| html.to_string())
174 }
175
176 fn handle_hardtabs(&self, code: &str) -> String {
178 use super::types::TabStyle;
179
180 if !code.contains('\t') {
182 return code.to_string();
183 }
184
185 match self.options.tab_style {
186 TabStyle::None => code.to_string(),
188
189 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 TabStyle::Normalize => {
202 log::debug!("Replacing hard tabs with spaces");
203 code.replace('\t', " ")
204 },
205 }
206 }
207
208 fn process_hardtabs(&self, markdown: &str) -> String {
210 use super::types::TabStyle;
211 use crate::utils::codeblock::FenceTracker;
212
213 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 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 if lines.peek().is_some() {
236 result.push('\n');
237 }
238 }
239
240 result
241 }
242
243 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 #[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 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 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 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 processed = super::extensions::process_myst_autolinks(&processed);
332
333 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 #[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 #[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 #[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 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 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 Self::process_header_anchors_html(&html_output)
519 }
520
521 fn process_header_anchors_html(html: &str) -> String {
525 let result = HEADER_ANCHOR_RE
527 .replace_all(html, |caps: ®ex::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 HEADER_NO_ID_RE
538 .replace_all(&result, |caps: ®ex::Captures| {
539 let level = &caps[1];
540 let content = &caps[2];
541 let text_only = HTML_TAG_RE.replace_all(content, "");
543 let id = utils::slugify(&text_only);
544 if id.is_empty() {
545 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 fn comrak_options(&self) -> Options<'_> {
556 let mut options = Options::default();
557 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 options.render.r#unsafe = true;
617
618 options.extension.header_id_prefix = None;
620 options.extension.description_lists = true;
621 options
622 }
623
624 #[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 #[cfg(not(feature = "nixpkgs"))]
636 fn process_manpage_references_html(&self, html: &str) -> String {
637 html.to_string()
638 }
639
640 fn kuchiki_postprocess(&self, html: &str) -> String {
642 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 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 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 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 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 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 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 let has_code = !safe_select(&li_element, "code, pre").is_empty();
794 if has_code {
795 continue; }
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 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 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 let has_code = !safe_select(&p_element, "code, pre").is_empty();
857 if has_code {
858 continue; }
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 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 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 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 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 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 let anchor_start = i;
958 i += 4; 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 let anchor_end = i + 1;
977
978 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 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 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 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 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 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 if text_content.trim() == "{{ANCHOR}}" {
1062 for child in link_element.children() {
1063 child.detach();
1064 }
1065 }
1066 let display_text = humanize_anchor(&href_value);
1068 link_element.append(kuchikikiki::NodeRef::new_text(display_text));
1069 }
1070 }
1071 }
1072 }
1073
1074 fn process_option_anchor_links(document: &kuchikikiki::NodeRef) {
1076 let mut to_modify = Vec::new();
1077
1078 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(); 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 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 for child in link_element.children() {
1116 child.detach();
1117 }
1118
1119 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 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 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 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
1174fn 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
1194fn 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#[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 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
1320pub 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#[must_use]
1361pub(crate) fn slugify_heading(text: &str) -> String {
1362 utils::slugify(&html_escape::encode_text(text))
1363}
1364
1365pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1386pub enum ProcessorFeature {
1387 Gfm,
1389 Nixpkgs,
1391 SyntaxHighlighting,
1393 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
1423fn 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}