1#![doc = include_str!("../README.md")]
2
3use std::iter::once;
4use std::marker::PhantomData;
5
6use anyhow::anyhow;
7pub use config::{CodeConfig, HeadingConfig, NumberingConfig, NumberingStyle};
8use either::Either;
9use mdbook_preprocessor::book::{Book, BookItem};
10use mdbook_preprocessor::config::Config;
11use mdbook_preprocessor::errors::Error;
12use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
13use pulldown_cmark::{CowStr, Event, Parser, Tag};
14use pulldown_cmark_to_cmark::{State, cmark_resume_with_options};
15
16mod config;
17#[cfg(test)]
18mod tests;
19
20static HIGHLIGHT_JS_LINE_NUMBERS_JS: &str = concat!(
21 "<script defer>\n\
22 window.addEventListener('DOMContentLoaded', function() { ",
23 include_str!("highlightjs/line-numbers-min.js"),
24 " });\n\
25 </script>\n",
26);
27
28static HIGHLIGHT_JS_LINE_NUMBERS_CSS: &str = concat!(
29 "<style>\n",
30 include_str!("highlightjs/line-numbers-min.css"),
31 "\n</style>\n",
32);
33
34static SECTION_NUMBERS_CSS: &str = concat!(
35 "<style>",
36 include_str!("heading/numbering-min.css"),
37 "</style>\n"
38);
39
40static SECTION_NUMBERS_PRINT_HIDE_CSS: &str = concat!(
41 "<style>",
42 include_str!("heading/hide-min.css"),
43 "</style>\n"
44);
45
46pub struct NumberingPreprocessor(PhantomData<()>);
48
49impl NumberingPreprocessor {
50 pub const fn new() -> Self {
52 Self(PhantomData)
53 }
54}
55
56impl Default for NumberingPreprocessor {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl NumberingPreprocessor {
63 fn parser_options() -> pulldown_cmark::Options {
64 use pulldown_cmark::Options;
65 let mut options = Options::empty();
66 options.insert(Options::ENABLE_TABLES);
67 options.insert(Options::ENABLE_FOOTNOTES);
68 options.insert(Options::ENABLE_STRIKETHROUGH);
69 options.insert(Options::ENABLE_TASKLISTS);
70 options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
72 options.insert(Options::ENABLE_MATH);
76 options.insert(Options::ENABLE_GFM);
77 options.insert(Options::ENABLE_DEFINITION_LIST);
78 options.insert(Options::ENABLE_SUPERSCRIPT);
79 options.insert(Options::ENABLE_SUBSCRIPT);
80 options
82 }
83 fn render_book_item(item: &mut BookItem, config: &NumberingConfig, mut cb: impl FnMut(Error)) {
84 let BookItem::Chapter(ch) = item else { return };
85 if ch.is_draft_chapter() {
86 return;
87 }
88 let c = &ch.content;
89
90 let options = Self::parser_options();
91
92 let tokenized = Parser::new_ext(c, options);
93
94 let options = pulldown_cmark_to_cmark::Options::default();
95
96 let mut buf = String::with_capacity(c.len());
97
98 let mut state = State::default();
99
100 if config.heading.enable {
101 if let Some(a) = &ch.number {
102 let name = ch.name.clone();
103 let mut stack = a.clone();
104 let events = tokenized.flat_map(|mut event| match event {
105 Event::Start(Tag::Heading {
106 level,
107 ref mut attrs,
108 ..
109 }) => {
110 let level_depth = match config.heading.numbering_style {
111 NumberingStyle::Consecutive => level as usize,
112 NumberingStyle::Top => level as usize + a.len() - 1,
113 };
114 if level_depth > stack.len() + 1 {
115 cb(anyhow!(
116 "\
117 Heading level {} found, \
118 but only {} levels in numbering \"{}\" for chapter \"{}\".",
119 level,
120 stack.len(),
121 stack,
122 name,
123 ));
124 }
125 if config.heading.numbering_style == NumberingStyle::Consecutive
126 && level_depth < a.len()
127 {
128 cb(anyhow!(
129 "\
130 Heading level {} found, \
131 but numbering \"{}\" for chapter \"{}\" has more levels. \
132 Consider using `numbering-style = \"top\"` in the config, \
133 if you want the top heading to be level 1.",
134 level,
135 stack,
136 name,
137 ));
138 }
139 while level_depth > stack.len() {
140 stack.push(0);
141 }
142 stack.truncate(level_depth);
143 if level_depth > a.len() {
147 stack[level_depth - 1] += 1;
148 }
149 attrs.push((
150 CowStr::from("data-numbering"),
151 Some(CowStr::from(format!("{stack}"))),
152 ));
153 Either::Right(
154 [
155 event,
156 Event::InlineHtml(CowStr::from(format!(
157 "<span class=\"heading numbering\">{stack} </span>"
158 ))),
159 ]
160 .into_iter(),
161 )
162 }
163 _ => Either::Left(once(event)),
164 });
165 state = cmark_resume_with_options(events, &mut buf, Some(state), options.clone())
166 .unwrap();
167 state = cmark_resume_with_options(
168 once(Event::InlineHtml(CowStr::from(SECTION_NUMBERS_CSS))),
169 &mut buf,
170 Some(state),
171 options.clone(),
172 )
173 .unwrap();
174
175 if config.heading.numbering_style == NumberingStyle::Consecutive && a.len() > 1 {
176 state = cmark_resume_with_options(
177 once(Event::InlineHtml(CowStr::from(
178 SECTION_NUMBERS_PRINT_HIDE_CSS,
179 ))),
180 &mut buf,
181 Some(state),
182 options.clone(),
183 )
184 .unwrap();
185 }
186 } else {
187 let events = tokenized.map(|mut event| match event {
188 Event::Start(Tag::Heading { ref mut attrs, .. }) => {
189 attrs.push((CowStr::from("data-numbering"), None));
190 event
191 }
192 _ => event,
193 });
194 state = cmark_resume_with_options(events, &mut buf, Some(state), options.clone())
195 .unwrap();
196 }
197 } else {
198 state = cmark_resume_with_options(tokenized, &mut buf, Some(state), options.clone())
199 .unwrap();
200 };
201
202 if config.code.enable {
203 state = cmark_resume_with_options(
204 [
205 Event::InlineHtml(CowStr::from(HIGHLIGHT_JS_LINE_NUMBERS_JS)),
206 Event::InlineHtml(CowStr::from(HIGHLIGHT_JS_LINE_NUMBERS_CSS)),
207 ]
208 .into_iter(),
209 &mut buf,
210 Some(state),
211 options,
212 )
213 .unwrap();
214 }
215
216 state.finalize(&mut buf).unwrap();
217
218 ch.content = buf;
224 }
225
226 fn get_config(config: &Config, mut cb: impl FnMut(&Error)) -> NumberingConfig {
227 config.get("preprocessor.numbering").map_or_else(
228 |err| {
229 cb(&err);
230 NumberingConfig::default()
231 },
232 |cfg| cfg.unwrap_or_default(),
233 )
234 }
235
236 fn validate_config(config: &NumberingConfig, original: &Config, cb: impl FnMut(Error)) {
237 let _ = config;
238 let _ = original;
239 let _ = cb;
241 }
242}
243
244impl Preprocessor for NumberingPreprocessor {
245 fn name(&self) -> &str {
246 "numbering"
247 }
248
249 fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
250 let config: NumberingConfig = Self::get_config(&ctx.config, |err| {
251 eprintln!("Using default config for mdbook-numbering due to config error: {err}")
252 });
253
254 Self::validate_config(&config, &ctx.config, |err| {
255 eprintln!("mdbook-numbering: {err}");
256 });
257
258 book.for_each_mut(|item| {
265 Self::render_book_item(item, &config, |err| eprintln!("mdbook-numbering: {err}"));
266 });
267 Ok(book)
268 }
269}