Skip to main content

mdbook_repo_links/
main.rs

1//! `mdbook-repo-links`: an mdBook preprocessor for a book whose chapters
2//! include documents from elsewhere in the repository.
3//!
4//! The book mirrors the repository's layout — `docs/src/installer/DESIGN.md`
5//! includes `installer/DESIGN.md` — so a relative link written for the
6//! original resolves the same way in the book. What the mirror cannot hold
7//! are the targets that are not chapters: source files, scripts, the licence.
8//! In the book those would be dead links; this points each one at the file
9//! on GitHub instead, and leaves every other link as it is.
10//!
11//! Configured in `book.toml`:
12//!
13//! ```toml
14//! [preprocessor.repo-links]
15//! command = "cargo run --quiet --locked -p mdbook-repo-links --"
16//! repository = "https://github.com/<owner>/<repo>"
17//! branch = "master"     # default
18//! repo-root = ".."      # from the book's root; default
19//! ```
20
21mod links;
22mod resolver;
23
24fn main() -> std::process::ExitCode {
25    // mdBook asks first whether a renderer is supported; a plain link works in all of them.
26    if std::env::args().nth(1).as_deref() == Some("supports") {
27        return std::process::ExitCode::SUCCESS;
28    }
29    match run() {
30        Ok(()) => std::process::ExitCode::SUCCESS,
31        Err(message) => {
32            eprintln!("mdbook-repo-links: {message}");
33            std::process::ExitCode::FAILURE
34        }
35    }
36}
37
38/// Reads `[context, book]` from stdin and writes the book back to stdout.
39fn run() -> Result<(), String> {
40    let input: serde_json::Value = serde_json::from_reader(std::io::stdin().lock())
41        .map_err(|e| format!("cannot read mdBook's input: {e}"))?;
42    let serde_json::Value::Array(mut parts) = input else {
43        return Err("mdBook's input is not [context, book]".into());
44    };
45    if parts.len() != 2 {
46        return Err("mdBook's input is not [context, book]".into());
47    }
48    let mut book = parts.pop().unwrap_or_default();
49    let context = parts.pop().unwrap_or_default();
50    let config = resolver::Config::from_context(&context)?;
51    rewrite_book(&mut book, &config);
52    serde_json::to_writer(std::io::stdout().lock(), &book)
53        .map_err(|e| format!("cannot write the book back: {e}"))
54}
55
56/// Every chapter, wherever it sits: the walk is over the JSON itself, so a
57/// change in how mdBook nests sections between versions does not matter.
58fn rewrite_book(value: &mut serde_json::Value, config: &resolver::Config) {
59    match value {
60        serde_json::Value::Object(map) => {
61            if let Some(serde_json::Value::Object(chapter)) = map.get_mut("Chapter") {
62                rewrite_chapter(chapter, config);
63            }
64            for child in map.values_mut() {
65                rewrite_book(child, config);
66            }
67        }
68        serde_json::Value::Array(items) => {
69            for item in items {
70                rewrite_book(item, config);
71            }
72        }
73        _ => {}
74    }
75}
76
77fn rewrite_chapter(
78    chapter: &mut serde_json::Map<String, serde_json::Value>,
79    config: &resolver::Config,
80) {
81    // A draft chapter has no file, and nothing to rewrite.
82    let Some(source) = chapter
83        .get("source_path")
84        .and_then(serde_json::Value::as_str)
85    else {
86        return;
87    };
88    let resolver = resolver::Resolver::for_chapter(config, std::path::Path::new(source));
89    if let Some(serde_json::Value::String(content)) = chapter.get_mut("content") {
90        *content = links::rewrite_links(content, &|target| resolver.resolve(target));
91    }
92}