mdbook_termlink/
lib.rs

1//! # mdbook-termlink
2//!
3//! An mdBook preprocessor that automatically links glossary terms throughout documentation.
4//!
5//! ## Features
6//!
7//! - Parses glossary terms from definition list markdown
8//! - Auto-links first occurrence of each term per page
9//! - Configurable via `book.toml`
10//! - Skips code blocks, inline code, existing links, and headings
11//! - Supports case-insensitive matching
12//! - Custom CSS class for styled links
13//!
14//! ## Usage
15//!
16//! Add to your `book.toml`:
17//!
18//! ```toml
19//! [preprocessor.termlink]
20//! glossary-path = "reference/glossary.md"
21//! link-first-only = true
22//! css-class = "glossary-term"
23//! case-sensitive = false
24//! ```
25//!
26//! ## Glossary Format
27//!
28//! Use standard markdown definition lists:
29//!
30//! ```markdown
31//! API (Application Programming Interface)
32//! : A set of protocols for building software.
33//!
34//! REST
35//! : Representational State Transfer.
36//! ```
37
38pub mod config;
39mod glossary;
40mod linker;
41
42pub use config::Config;
43pub use glossary::Term;
44
45use std::collections::HashSet;
46
47use anyhow::{Context, Result, bail};
48use mdbook_preprocessor::book::{Book, BookItem};
49use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
50
51/// mdBook preprocessor that auto-links glossary terms throughout documentation.
52#[derive(Debug)]
53pub struct TermlinkPreprocessor {
54    config: Config,
55}
56
57impl TermlinkPreprocessor {
58    /// Creates a new preprocessor instance from the given context.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the configuration in `book.toml` is invalid.
63    pub fn new(ctx: &PreprocessorContext) -> Result<Self> {
64        let config = Config::from_context(ctx)?;
65        Ok(Self { config })
66    }
67}
68
69impl Preprocessor for TermlinkPreprocessor {
70    fn name(&self) -> &'static str {
71        "termlink"
72    }
73
74    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
75        // 1. Extract terms from glossary
76        let terms = glossary::extract_terms(&book, &self.config)
77            .context("Failed to extract glossary terms")?;
78
79        if terms.is_empty() {
80            log::warn!(
81                "No glossary terms found in {}",
82                self.config.glossary_path().display()
83            );
84            return Ok(book);
85        }
86
87        log::info!("Found {} glossary terms", terms.len());
88
89        // 2. Validate alias conflicts (before applying aliases)
90        let term_names: HashSet<String> = terms.iter().map(|t| t.name().to_lowercase()).collect();
91
92        for (term_name, aliases) in self.config.all_aliases() {
93            for alias in aliases {
94                let alias_lower = alias.to_lowercase();
95                // Check if alias conflicts with a different term's name
96                if term_names.contains(&alias_lower) && alias_lower != term_name.to_lowercase() {
97                    bail!("Alias '{alias}' for term '{term_name}' conflicts with existing term");
98                }
99            }
100        }
101
102        // 3. Apply aliases from config to terms
103        let terms: Vec<Term> = terms
104            .into_iter()
105            .map(|term| {
106                if let Some(aliases) = self.config.aliases(term.name()) {
107                    term.with_aliases(aliases.clone())
108                } else {
109                    term
110                }
111            })
112            .collect();
113
114        // 4. Calculate glossary HTML path for linking
115        let glossary_html_path = glossary::get_glossary_html_path(self.config.glossary_path());
116
117        // 5. Process each chapter
118        book.for_each_mut(|item| {
119            if let BookItem::Chapter(chapter) = item {
120                // Skip draft chapters and the glossary itself
121                let Some(chapter_path) = chapter.path.as_ref() else {
122                    return;
123                };
124
125                if self.config.is_glossary_path(chapter_path) {
126                    log::debug!("Skipping glossary file: {}", chapter_path.display());
127                    return;
128                }
129
130                // Check exclude-pages
131                if self.config.should_exclude(chapter_path) {
132                    log::debug!("Skipping excluded page: {}", chapter_path.display());
133                    return;
134                }
135
136                // Calculate relative path from chapter to glossary
137                let relative_glossary =
138                    linker::calculate_relative_path(chapter_path, &glossary_html_path);
139
140                // Add term links
141                match linker::add_term_links(
142                    &chapter.content,
143                    &terms,
144                    &relative_glossary,
145                    &self.config,
146                ) {
147                    Ok(new_content) => {
148                        chapter.content = new_content;
149                    }
150                    Err(e) => {
151                        log::error!("Failed to process chapter {}: {e}", chapter_path.display());
152                    }
153                }
154            }
155        });
156
157        Ok(book)
158    }
159}