Skip to main content

mdbook_diataxis/
lib.rs

1use std::iter;
2use std::path::{Component, Path, PathBuf};
3use std::sync::LazyLock;
4
5use aho_corasick::{AhoCorasick, MatchKind};
6use anyhow::{anyhow, Context, Result};
7use indoc::writedoc;
8use mdbook::book::{Book, Chapter};
9use mdbook::errors::Result as MdbookResult;
10use mdbook::preprocess::{Preprocessor, PreprocessorContext};
11use mdbook::BookItem;
12use toml::value::Table;
13
14#[derive(Default)]
15pub struct DiataxisPreprocessor;
16
17impl DiataxisPreprocessor {
18    pub fn new() -> Self {
19        Self
20    }
21
22    fn preprocess_bookitem(&self, item: &mut BookItem, config: &Config) -> Result<()> {
23        match item {
24            BookItem::Chapter(chapter) => self.preprocess_chapter(chapter, config),
25            BookItem::Separator | BookItem::PartTitle(_) => Ok(()),
26        }
27    }
28
29    fn preprocess_chapter(&self, chapter: &mut Chapter, config: &Config) -> Result<()> {
30        chapter.content = self.preprocess_content(&chapter.content, config, chapter);
31
32        for sub_item in &mut chapter.sub_items {
33            self.preprocess_bookitem(sub_item, config)?;
34        }
35
36        Ok(())
37    }
38
39    fn preprocess_content(&self, text: &str, config: &Config, chapter: &Chapter) -> String {
40        static MATCHER: LazyLock<AhoCorasick> = LazyLock::new(|| {
41            AhoCorasick::builder()
42                .match_kind(MatchKind::LeftmostLongest)
43                .build(Replacement::patterns())
44                .unwrap()
45        });
46
47        let replacement_ctx = ReplacementCtx { config, chapter };
48        let mut ret = String::with_capacity(text.len());
49        MATCHER.replace_all_with(text, &mut ret, |result, _, ret| {
50            let replacement = Replacement::from_pattern_index(result.pattern().as_usize());
51            replacement.write_to(ret, &replacement_ctx);
52            if replacement.is_malformed() {
53                eprintln!(
54                    "Warning: malformed `{{{{#diataxis ...}}}}` expression in {}",
55                    chapter
56                        .source_path
57                        .as_deref()
58                        .expect("internal error: draft chapter has content")
59                        .display(),
60                )
61            }
62            true
63        });
64        ret
65    }
66}
67
68impl Preprocessor for DiataxisPreprocessor {
69    fn name(&self) -> &str {
70        "mdbook-diataxis"
71    }
72
73    fn supports_renderer(&self, renderer: &str) -> bool {
74        renderer == "html"
75    }
76
77    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> MdbookResult<Book> {
78        let config = ctx
79            .config
80            .get_preprocessor("diataxis")
81            .map(Config::new)
82            .transpose()?
83            .unwrap_or_default();
84
85        for section in &mut book.sections {
86            self.preprocess_bookitem(section, &config)?;
87        }
88
89        Ok(book)
90    }
91}
92
93#[derive(Debug, Default)]
94struct Config<'cfg> {
95    tutorials: SectionConfig<'cfg>,
96    how_to_guides: SectionConfig<'cfg>,
97    reference: SectionConfig<'cfg>,
98    explanation: SectionConfig<'cfg>,
99}
100
101impl<'cfg> Config<'cfg> {
102    fn new(raw: &'cfg Table) -> Result<Self> {
103        let section_overrides = |section| -> Result<SectionConfig<'_>> {
104            let overrides = raw
105                .get("compass")
106                .map(|compass_value| {
107                    compass_value
108                        .as_table()
109                        .ok_or_else(|| anyhow!("`compass` field must be a table"))
110                })
111                .transpose()?
112                .and_then(|compass_table| compass_table.get(section))
113                .map(|section_value| {
114                    section_value
115                        .as_table()
116                        .ok_or_else(|| anyhow!("`compass.{section}` field must be a table"))
117                })
118                .transpose()?
119                .map(|section_table| {
120                    SectionConfig::new(section_table)
121                        .with_context(|| anyhow!("cannot parse `compass.{section}` table"))
122                })
123                .transpose()?
124                .unwrap_or_default();
125            Ok(overrides)
126        };
127        let tutorials = section_overrides("tutorials")?;
128        let how_to_guides = section_overrides("how-to-guides")?;
129        let explanation = section_overrides("explanation")?;
130        let reference = section_overrides("reference")?;
131        Ok(Self {
132            tutorials,
133            how_to_guides,
134            explanation,
135            reference,
136        })
137    }
138
139    fn tutorials_title(&self) -> &str {
140        self.tutorials.title_override.unwrap_or("Tutorials")
141    }
142
143    fn tutorials_description(&self) -> &str {
144        self.tutorials
145            .description_override
146            .unwrap_or("Hands-on lessons")
147    }
148
149    fn tutorials_link(&self) -> &Path {
150        self.tutorials
151            .link_override
152            .as_deref()
153            .unwrap_or(Path::new("./tutorials/index.html"))
154    }
155
156    fn how_to_guides_title(&self) -> &str {
157        self.how_to_guides.title_override.unwrap_or("How-to guides")
158    }
159
160    fn how_to_guides_description(&self) -> &str {
161        self.how_to_guides
162            .description_override
163            .unwrap_or("Step-by-step instructions for common tasks")
164    }
165
166    fn how_to_guides_link(&self) -> &Path {
167        self.how_to_guides
168            .link_override
169            .as_deref()
170            .unwrap_or(Path::new("./how-to/index.html"))
171    }
172
173    fn explanation_title(&self) -> &str {
174        self.explanation.title_override.unwrap_or("Explanation")
175    }
176
177    fn explanation_description(&self) -> &str {
178        self.explanation
179            .description_override
180            .unwrap_or("Long-form discussion of key topics")
181    }
182
183    fn explanation_link(&self) -> &Path {
184        self.explanation
185            .link_override
186            .as_deref()
187            .unwrap_or(Path::new("./explanations/index.html"))
188    }
189
190    fn reference_title(&self) -> &str {
191        self.reference.title_override.unwrap_or("Reference")
192    }
193
194    fn reference_description(&self) -> &str {
195        self.reference
196            .description_override
197            .unwrap_or("Technical information")
198    }
199
200    fn reference_link(&self) -> &Path {
201        self.reference
202            .link_override
203            .as_deref()
204            .unwrap_or(Path::new("./reference-materials/index.html"))
205    }
206}
207
208#[derive(Debug, Default)]
209struct SectionConfig<'cfg> {
210    title_override: Option<&'cfg str>,
211    description_override: Option<&'cfg str>,
212    link_override: Option<PathBuf>,
213}
214
215impl<'cfg> SectionConfig<'cfg> {
216    fn new(config_table: &'cfg Table) -> Result<Self> {
217        let title_override = config_table
218            .get("title")
219            .map(|title| {
220                title
221                    .as_str()
222                    .ok_or_else(|| anyhow!("`title` field must be a string"))
223            })
224            .transpose()?;
225        let description_override = config_table
226            .get("description")
227            .map(|desc| {
228                desc.as_str()
229                    .ok_or_else(|| anyhow!("`description` field must be a string"))
230            })
231            .transpose()?;
232        let link_override = config_table
233            .get("link")
234            .map(|file| {
235                file.as_str()
236                    .ok_or_else(|| anyhow!("`link` field must be a string"))
237            })
238            .transpose()?
239            .map(Path::new)
240            .map(|path| {
241                if path
242                    .file_name()
243                    .is_some_and(|file_name| file_name == "README.md")
244                {
245                    return path.with_file_name("index.html");
246                }
247                path.to_owned()
248            })
249            .map(|mut path| {
250                path.set_extension("html");
251                path
252            });
253        Ok(Self {
254            title_override,
255            description_override,
256            link_override,
257        })
258    }
259}
260
261#[derive(Copy, Clone)]
262enum Replacement {
263    Compass,
264    Toc,
265    Malformed,
266}
267
268impl Replacement {
269    const fn patterns() -> [&'static str; 3] {
270        [
271            Self::Compass.pattern(),
272            Self::Toc.pattern(),
273            Self::Malformed.pattern(),
274        ]
275    }
276
277    const fn pattern(&self) -> &'static str {
278        match self {
279            Self::Compass => "{{#diataxis compass}}",
280            Self::Toc => "{{#diataxis table-of-contents}}",
281            Self::Malformed => "{{#diataxis",
282        }
283    }
284
285    fn from_pattern_index(index: usize) -> Self {
286        [Self::Compass, Self::Toc, Self::Malformed][index]
287    }
288
289    fn is_malformed(&self) -> bool {
290        matches!(self, Self::Malformed)
291    }
292
293    fn write_to(&self, buf: &mut String, ctx: &ReplacementCtx) {
294        match self {
295            Self::Compass => self.write_compass_to(buf, ctx),
296            Self::Toc => self.write_toc_to(buf, ctx),
297            Self::Malformed => buf.push_str(self.pattern()),
298        };
299    }
300
301    fn write_compass_to(&self, buf: &mut String, ctx: &ReplacementCtx) {
302        use std::fmt::Write;
303
304        let tutorials_title = ctx.config.tutorials_title();
305        let tutorials_description = ctx.config.tutorials_description();
306        let tutorials_link = ctx.config.tutorials_link().display();
307        let how_to_guide_title = ctx.config.how_to_guides_title();
308        let how_to_guide_description = ctx.config.how_to_guides_description();
309        let how_to_guides_link = ctx.config.how_to_guides_link().display();
310        let reference_title = ctx.config.reference_title();
311        let reference_description = ctx.config.reference_description();
312        let reference_link = ctx.config.reference_link().display();
313        let explanation_title = ctx.config.explanation_title();
314        let explanation_description = ctx.config.explanation_description();
315        let explanation_link = ctx.config.explanation_link().display();
316        writedoc!(
317            buf,
318            r#"
319                <span>
320                    <div class="quote-grid">
321                        <blockquote>
322                            <p>
323                                <div class="diataxis-card-header">
324                                    <a href="{tutorials_link}">{tutorials_title}</a>
325                                </div>
326                                {tutorials_description}
327                            </p>
328                        </blockquote>
329                        <blockquote>
330                            <p>
331                                <div class="diataxis-card-header">
332                                    <a href="{how_to_guides_link}">{how_to_guide_title}</a>
333                                </div>
334                                {how_to_guide_description}
335                            </p>
336                        </blockquote>
337                        <blockquote>
338                            <p>
339                                <div class="diataxis-card-header">
340                                    <a href="{explanation_link}">{explanation_title}</a>
341                                </div>
342                                {explanation_description}
343                            </p>
344                        </blockquote>
345                        <blockquote>
346                            <p>
347                                <div class="diataxis-card-header">
348                                    <a href="{reference_link}">{reference_title}</a>
349                                </div>
350                                {reference_description}
351                            </p>
352                        </blockquote>
353                    </div>
354                </span>
355            "#,
356        )
357        .expect("internal error: cannot to write to string");
358    }
359
360    fn write_toc_to(&self, buf: &mut String, ctx: &ReplacementCtx) {
361        let chapter_path = match &ctx.chapter.source_path {
362            Some(path) => path,
363            _ => return,
364        };
365        ctx.chapter
366            .sub_items
367            .iter()
368            .filter_map(|item| match item {
369                BookItem::Chapter(chapter) => Some(chapter),
370                _ => None,
371            })
372            .map(|child| {
373                let link_path = child
374                    .source_path
375                    .as_deref()
376                    .map(|path| relative_to(chapter_path, path));
377                (&child.name, link_path)
378            })
379            .map(|(child_name, mut link_path)| {
380                if let Some(link_path) = &mut link_path {
381                    if link_path
382                        .file_name()
383                        .is_some_and(|file_name| file_name == "README.md")
384                    {
385                        link_path.set_file_name("index.html")
386                    }
387                }
388                (child_name, link_path)
389            })
390            .for_each(|(child_name, link_path)| {
391                use std::fmt::Write;
392                if let Some(link_path) = link_path {
393                    writeln!(buf, "- [{child_name}]({})", link_path.display())
394                        .expect("internal error: cannot to write to string")
395                } else {
396                    writeln!(buf, "- {child_name}")
397                        .expect("internal error: cannot to write to string")
398                }
399            });
400    }
401}
402
403/// Computes the path of `target` relative to `source`.
404///
405/// `target` must be a sibling of `source` or be in a child directory which is a sibling of
406/// `source`. Symlinks are not supported.
407fn relative_to(source: &Path, target: &Path) -> PathBuf {
408    target
409        .components()
410        .zip(source.components().chain(iter::repeat(Component::RootDir)))
411        .skip_while(|(target_component, source_component)| target_component == source_component)
412        .map(|(target_component, _)| target_component)
413        .collect::<PathBuf>()
414}
415
416struct ReplacementCtx<'ctx> {
417    #[allow(unused)]
418    config: &'ctx Config<'ctx>,
419    #[allow(unused)]
420    chapter: &'ctx Chapter,
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    use googletest::expect_that;
428    use googletest::matchers::{all, contains_substring};
429    use indoc::indoc;
430    use insta::assert_snapshot;
431    use mdbook::preprocess::CmdPreprocessor;
432
433    mod compass {
434        use super::*;
435
436        #[googletest::test]
437        fn default() {
438            let input_json = indoc! {br##"
439                [{
440                    "root": "/path/to/book",
441                    "config": {
442                        "book": {
443                            "authors": ["AUTHOR"],
444                            "language": "en",
445                            "multilingual": false,
446                            "src": "src",
447                            "title": "TITLE"
448                        },
449                        "preprocessor": {
450                            "diataxis": {}
451                        }
452                    },
453                    "renderer": "html",
454                    "mdbook_version": "0.4.21"
455                }, {
456                    "sections": [{
457                        "Chapter": {
458                            "name": "Chapter 1",
459                            "content": "# Chapter 1\n{{#diataxis compass}}",
460                            "number": [1],
461                            "sub_items": [],
462                            "path": "chapter_1.md",
463                            "source_path": "chapter_1.md",
464                            "parent_names": []
465                        }
466                    }],
467                    "__non_exhaustive": null
468                }]
469            "##};
470            let (ctx, book) = CmdPreprocessor::parse_input(&input_json[..]).unwrap();
471            let book = DiataxisPreprocessor::new().run(&ctx, book).unwrap();
472            let chapter = match &book.sections[0] {
473                BookItem::Chapter(chapter) => chapter,
474                _ => panic!("unexpected first item"),
475            };
476            expect_that!(
477                chapter.content,
478                all!(
479                    contains_substring("Tutorials"),
480                    contains_substring("How-to guides"),
481                    contains_substring("Reference"),
482                    contains_substring("Explanation"),
483                )
484            );
485            assert_snapshot!(chapter.content);
486        }
487
488        #[googletest::test]
489        fn configured() {
490            let input_json = indoc! {br##"
491                [{
492                    "root": "/path/to/book",
493                    "config": {
494                        "book": {
495                            "authors": ["AUTHOR"],
496                            "language": "en",
497                            "multilingual": false,
498                            "src": "src",
499                            "title": "TITLE"
500                        },
501                        "preprocessor": {
502                            "diataxis": {
503                                "compass": {
504                                    "tutorials": {
505                                        "title": "custom-tutorials-title",
506                                        "description": "custom-tutorials-description",
507                                        "link": "custom-tutorials/README.md"
508                                    },
509                                    "how-to-guides": {
510                                        "title": "custom-how-to-guides-title",
511                                        "description": "custom-how-to-guides-description",
512                                        "link": "custom-how-to-guides-link.md"
513                                    },
514                                    "reference": {
515                                        "title": "custom-reference-title",
516                                        "description": "custom-reference-description",
517                                        "link": "custom-reference-link.md"
518                                    },
519                                    "explanation": {
520                                        "title": "custom-explanation-title",
521                                        "description": "custom-explanation-description",
522                                        "link": "custom-explanation-link.md"
523                                    }
524                                }
525                            }
526                        }
527                    },
528                    "renderer": "html",
529                    "mdbook_version": "0.4.21"
530                }, {
531                    "sections": [{
532                        "Chapter": {
533                            "name": "Chapter 1",
534                            "content": "# Chapter 1\n{{#diataxis compass}}",
535                            "number": [1],
536                            "sub_items": [],
537                            "path": "chapter_1.md",
538                            "source_path": "chapter_1.md",
539                            "parent_names": []
540                        }
541                    }],
542                    "__non_exhaustive": null
543                }]
544            "##};
545            let (ctx, book) = CmdPreprocessor::parse_input(&input_json[..]).unwrap();
546            let book = DiataxisPreprocessor::new().run(&ctx, book).unwrap();
547            let chapter = match &book.sections[0] {
548                BookItem::Chapter(chapter) => chapter,
549                _ => panic!("unexpected first item"),
550            };
551            expect_that!(
552                chapter.content,
553                all!(
554                    contains_substring("custom-tutorials-title"),
555                    contains_substring("custom-tutorials-description"),
556                    contains_substring(r#"href="custom-tutorials/index.html""#),
557                    contains_substring("custom-how-to-guides-title"),
558                    contains_substring("custom-how-to-guides-description"),
559                    contains_substring(r#"href="custom-how-to-guides-link.html""#),
560                    contains_substring("custom-reference-title"),
561                    contains_substring("custom-reference-description"),
562                    contains_substring(r#"href="custom-reference-link.html""#),
563                    contains_substring("custom-explanation-title"),
564                    contains_substring("custom-explanation-description"),
565                    contains_substring(r#"href="custom-explanation-link.html""#),
566                )
567            );
568            assert_snapshot!(chapter.content);
569        }
570    }
571
572    mod toc {
573        use super::*;
574
575        #[googletest::test]
576        fn default() {
577            let input_json = indoc! {br##"
578                [{
579                    "root": "/path/to/book",
580                    "config": {
581                        "book": {
582                            "authors": ["AUTHOR"],
583                            "language": "en",
584                            "multilingual": false,
585                            "src": "src",
586                            "title": "TITLE"
587                        },
588                        "preprocessor": {
589                            "diataxis": {}
590                        }
591                    },
592                    "renderer": "html",
593                    "mdbook_version": "0.4.21"
594                }, {
595                    "sections": [{
596                        "Chapter": {
597                            "name": "Chapter 1",
598                            "content": "# Chapter 1\n{{#diataxis table-of-contents}}",
599                            "number": [1],
600                            "sub_items": [{
601                                "Chapter": {
602                                    "name": "Non-draft sub-chapter",
603                                    "content": "non-draft sub content",
604                                    "number": [1, 1],
605                                    "sub_items": [],
606                                    "path": "chapter_1/dir/non_draft_sub.md",
607                                    "source_path": "chapter_1/dir/non_draft_sub.md",
608                                    "parent_names": []
609                                }
610                            }, {
611                                "Chapter": {
612                                    "name": "Draft sub-chapter",
613                                    "content": "draft sub content",
614                                    "number": [1, 1],
615                                    "sub_items": [],
616                                    "path": "chapter_1/dir/draft_sub.md",
617                                    "parent_names": []
618                                }
619                            }, {
620                                "Chapter": {
621                                    "name": "README sub-chapter",
622                                    "content": "README sub content",
623                                    "number": [1, 1],
624                                    "sub_items": [],
625                                    "path": "chapter_1/sub_chapter/README.md",
626                                    "source_path": "chapter_1/sub_chapter/README.md",
627                                    "parent_names": []
628                                }
629                            }],
630                            "path": "chapter_1/README.md",
631                            "source_path": "chapter_1/README.md",
632                            "parent_names": []
633                        }
634                    }],
635                    "__non_exhaustive": null
636                }]
637            "##};
638            let (ctx, book) = CmdPreprocessor::parse_input(&input_json[..]).unwrap();
639            let book = DiataxisPreprocessor::new().run(&ctx, book).unwrap();
640            let chapter = match &book.sections[0] {
641                BookItem::Chapter(chapter) => chapter,
642                _ => panic!("unexpected first item"),
643            };
644            expect_that!(
645                chapter.content,
646                all!(
647                    contains_substring("- [Non-draft sub-chapter](dir/non_draft_sub.md)"),
648                    contains_substring("- Draft sub-chapter"),
649                )
650            );
651            assert_snapshot!(chapter.content);
652        }
653    }
654}