mdbook_numbering/
lib.rs

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
46/// mdbook preprocessor for adding numbering to headings and code blocks.
47pub struct NumberingPreprocessor(PhantomData<()>);
48
49impl NumberingPreprocessor {
50    /// Create a new `NumberingPreprocessor`.
51    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_SMART_PUNCTUATION);
71        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
72        // options.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
73        // options.insert(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS);
74        // options.insert(Options::ENABLE_OLD_FOOTNOTES);
75        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.insert(Options::ENABLE_WIKILINKS);
81        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                        // while level_depth < stack.len() {
144                        //     stack.pop();
145                        // }
146                        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        // eprintln!("--- Chapter '{}' Processed ---", ch.name);
219        // eprintln!("vvv Original Below\n{c:?}\n^^^ Original Above");
220        // eprintln!("vvv Processed Below\n{buf:?}\n^^^ Processed Above");
221        // eprintln!("-------------------------------");
222
223        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        // Add validation logic here if needed in the future.
240        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        // eprintln!("mdbook-numbering: Using config: {config:#?}");
259        // eprintln!("mdbook-numbering: Processing book...");
260        // eprintln!("-----------------------------------");
261        // eprintln!("Book before processing:\n{book:#?}");
262        // eprintln!("-----------------------------------");
263
264        book.for_each_mut(|item| {
265            Self::render_book_item(item, &config, |err| eprintln!("mdbook-numbering: {err}"));
266        });
267        Ok(book)
268    }
269}