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}