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
403fn 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}