☰
mdBook 二次开发指南:构建流水线、库模式集成与 Preprocessor/Backend 插件机制
2026/10/1 16:51:57 网站建设 项目流程
  • 开发工具
  • 文档

【免费下载链接】mdBook

Create book from markdown files. Like Gitbook but implemented in Rust

项目地址:https://gitcode.com/gh_mirrors/md/mdBook
点击查看免费下载

mdBook 虽然主要作为命令行工具使用,但它的可扩展性并不止于mdbook build这一层:你可以把底层 crate 作为库直接嵌入自己的项目来编程驱动书的构建,也可以通过Preprocessor(预处理插件)与Backend(渲染后端)两大机制深度定制构建过程。本文以仓库中的 开发者指南 为骨架,结合mdbook-driver、mdbook-preprocessor、mdbook-renderer等 crate 的源码与仓库内现成示例,带你完整掌握 mdBook 的构建原理、库模式用法以及自定义插件的开发全流程。

理解 mdBook 的构建流水线

mdbook二进制本质上只是底层 mdBook 各 crate 的一个包装壳,把它们的功能以命令行程序的形式暴露出来。因此,理解"一本书从源码到成品"经历了哪些步骤,是二次开发的基础。

根据 开发者指南 与 MDBook 的实现,一次渲染大致分为两大阶段:

  1. 加载书(Load the book)
    • 解析book.toml;如果文件不存在,则回退到默认的Config。对应源码中MDBook::load()的Config::from_disk()/Config::default()逻辑,之后还会调用config.update_from_env()让环境变量覆盖配置(见 crates/mdbook-driver/src/mdbook.rs#L48-L68);
    • 把书的所有章节加载进内存(load_book,根据SUMMARY.md生成Book结构);
    • 发现本次构建应该使用哪些 preprocessor / backend(determine_renderers、determine_preprocessors)。
  2. 针对每一个 backend:
    1. 运行所有 preprocessor,逐个对书进行修改;
    2. 调用该 backend,让它渲染处理后的结果。

其中第 2 步在源码中有清晰对应:build()遍历所有 renderer 并调用execute_build_process()(crates/mdbook-driver/src/mdbook.rs#L161-L169);execute_build_process()先调用preprocess_book()按序执行 preprocessor,再用处理后的书构造RenderContext交给 renderer(crates/mdbook-driver/src/mdbook.rs#L188-L209)。

关键点:preprocessor 是在"书加载完成之后、渲染之前"运行的,它只能改动内存中的Book对象;而 backend 拿到的是 preprocessor 处理完毕的最终结果。两条扩展路径因此在流水线中的位置完全不同。

开发者与构建过程打交道的方式主要有两种,即本指南后续两大部分:Preprocessors 与 Alternative Backends。

以库模式使用 mdBook:mdbook-drivercrate

如果你不想依赖 CLI,而是希望在程序里直接驱动 mdBook,可以使用mdbook-drivercrate。官方文档列举的典型动机包括:

  • 把 mdBook 集成进当前项目;
  • 扩展 mdBook 的能力;
  • 在构建书之前做一些处理或测试;
  • 访问公开 API 以辅助编写新的 Renderer。

入口类型是MDBook,它的职责在 crates/mdbook-driver/src/mdbook.rs 中一目了然:持有书的根目录root、配置config、内存中的书book,以及按名字索引的renderers和preprocessors两个插件表。

加载一本已有的书并构建

use mdbook_driver::MDBook; let root_dir = "/path/to/book/root"; let mut md = MDBook::load(root_dir) .expect("Unable to load the book"); md.build().expect("Building failed");

MDBook::load()会自动探测根目录下的book.toml,不存在则使用默认配置。如果你需要更精细的控制,还有load_with_config()与load_with_config_and_summary()两个变体可用(分别允许你传入自定义Config,以及自定义的Summary)。

从零初始化一本书

使用MDBook::init()可以获得一个BookBuilder,用于创建新书及其目录骨架:

use mdbook_driver::MDBook; use mdbook_driver::config::Config; let root_dir = "/path/to/book/root"; // 创建一个默认配置并改动几项 let mut cfg = Config::default(); cfg.book.title = Some("My Book".to_string()); cfg.book.authors.push("Michael-F-Bryan".to_string()); MDBook::init(root_dir) .create_gitignore(true) .with_config(cfg) .build() .expect("Book generation failed");

BookBuilder会生成src/SUMMARY.md、src/chapter_1.md等样板文件,让你快速起步。

编程方式注册插件

在库模式下,你不需要通过book.toml声明插件,而是直接注入 Rust 对象:

  • with_renderer(renderer):注册一个实现Renderertrait 的类型;
  • with_preprocessor(preprocessor):注册一个实现Preprocessortrait 的类型。

这也解释了为什么 mdbook-preprocessor 的文档 会把Preprocessortrait 与MDBook::with_preprocessor关联起来:库模式与配置文件模式最终通向同一套 trait 抽象。

工作区中的 crate 分工

从仓库的 crates 目录 可以看出 mdBook 的模块化程度,二次开发时可按需取用:

crate职责
mdbook-driver高层入口,MDBook类型的所在处
mdbook-core各 crate 共享的核心类型(Book、Config等),会被其他 crate 按需 re-export
mdbook-preprocessor实现 preprocessor 的辅助库
mdbook-renderer实现 backend 的辅助库
mdbook-markdownMarkdown 渲染器
mdbook-summarySUMMARY.md解析器
mdbook-htmlHTML 渲染器

钩子一:Preprocessor(预处理插件)

一个preprocessor就是一段在书加载完成之后、渲染之前运行的代码,它允许你对书进行更新和修改。典型用例包括:

  • 实现类似\{{#include /path/to/file.md}}的自定义 helper;
  • 把 LaTeX 风格表达式(如$$ \frac{1}{3} $$)替换为对应的 MathJax 形式。

配置文件层面的用法参见 配置 Preprocessor,下文聚焦其背后的运行机制与实现方式。

插件的发现机制:[preprocessor.foo]表

mdBook 发现第三方插件的机制相当简单:在book.toml里新增一张表(例如[preprocessor.foo]对应名为foo的 preprocessor),然后 mdBook 会在构建过程中尝试调用名为mdbook-foo的程序。

在 crates/mdbook-driver/src/lib.rs#L82-L104 的compose_command()中可以确认细节:默认命令字符串来自mdbook-foo这种约定;如果你给[preprocessor.foo]配置了command字段,则会用command覆盖;命令会被shlex拆成可执行文件与参数;相对路径会被解释为相对于书根目录,单段路径则在PATH中查找。

两次调用协议:supports与数据交换

一旦 preprocessor 被定义、构建开始,mdBook 会执行preprocessor.foo.command两次:

  1. 第一次调用用来探测该 preprocessor 是否支持给定的 renderer。mdBook 传入两个参数:第一个是字符串supports,第二个是 renderer 的名字。preprocessor 若支持该 renderer 就以退出码0退出,否则返回非零退出码。
  2. 如果支持该 renderer,mdBook 第二次运行它,并把 JSON 数据写入其 stdin。该 JSON 是一个[context, book]数组:context是序列化后的PreprocessorContext对象,book是包含全书内容的Book对象。

preprocessor 需要把自己修改后的Book对象以 JSON 格式写到 stdout 返回给 mdBook。

这一协议在 examples/nop-preprocessor.rs 中有完整实现:handle_supports()根据pre.supports_renderer(renderer)的结果调用process::exit(0)或process::exit(1);handle_preprocessing()则通过mdbook_preprocessor::parse_input(io::stdin())读取(ctx, book),调用pre.run(&ctx, book)后用serde_json::to_writer写回 stdout。

PreprocessorContext的结构在 crates/mdbook-preprocessor/src/lib.rs#L47-L66 中定义,包含:书的根目录root、书配置config、当前使用的 renderer 名renderer、调用方 mdBook 版本mdbook_version,以及一个内部使用的chapter_titles映射。

从零实现:Preprocessortrait

最简单的方式是:在lib.rs里实现Preprocessortrait,再写一个外壳二进制把输入翻译成正确的 trait 方法调用。trait 定义见 crates/mdbook-preprocessor/src/lib.rs#L30-L45:

pub trait Preprocessor { /// 获取 Preprocessor 的名字。 fn name(&self) -> &str; /// 运行该 Preprocessor,允许它在书交给 renderer 之前修改书。 fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book>; /// 提示 MDBook 该 preprocessor 是否与某个 renderer 兼容。 /// 默认永远返回 true。 fn supports_renderer(&self, _renderer: &str) -> Result<bool> { Ok(true) } }

仓库中现成的 no-op 示例 可以直接改造成你自己的 preprocessor。它的main()用 clap 声明了supports子命令,然后分发到两个处理函数;nop_lib模块里是真正实现Preprocessor的地方:

impl Preprocessor for Nop { fn name(&self) -> &str { "nop-preprocessor" } fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> { // 测试场景:通过配置项让 preprocessor 故意报错 match ctx .config .get::<bool>("preprocessor.nop-preprocessor.blow-up") { Ok(Some(true)) => anyhow::bail!("Boom!!1!"), Ok(_) => {} Err(e) => anyhow::bail!("expect bool for blow-up: {e}"), } // 我们确实是个什么都不做的 preprocessor Ok(book) } fn supports_renderer(&self, renderer: &str) -> Result<bool> { Ok(renderer != "not-supported") } }

注意run()里的细节:preprocessor 可以通过ctx.config.get::<bool>(...)读取book.toml中自己专属表里的配置项——这正是[preprocessor.nop-preprocessor.blow-up]这类自定义键值对的读取方式。该示例还附带了一个单元测试(nop_preprocessor_run),用一段硬编码的 JSON 数组直接喂给parse_input(),验证"什么也不做"的 preprocessor 输出应与输入完全一致——这是理解协议数据格式的最佳参考资料。

实现提示:用库基础设施简化工作

引入mdbook-preprocessor作为库依赖后,你可以直接使用其现成的基础设施:

  • 用parse_input()反序列化 stdin 上的 JSON:serde_json::from_reader(reader)一行即可得到(PreprocessorContext, Book);
  • 通过Book::for_each_mut()原地修改每个章节(章节可以递归遍历,也可以用这个便捷方法);
  • 修改完成后用serde_json把书写回 stdout。

chapter.content本质上只是一段恰好是 Markdown 的字符串。虽然用正则或手动查找替换也能改,但更稳妥的做法是先把它解析成结构化事件。mdBook 用pulldown-cmark解析 Markdown,该能力通过mdbook-markdowncrate 暴露;而pulldown-cmark-to-cmarkcrate 可以把事件流翻译回 Markdown 文本。

移除强调的示例 演示了如何"在不破坏文档的前提下"删除所有强调标记——这正是上面思路的完整落地:

fn remove_emphasis(num_removed_items: &mut usize, chapter: &mut Chapter) -> Result<String> { let mut buf = String::with_capacity(chapter.content.len()); let events = Parser::new(&chapter.content).filter(|e| match e { Event::Start(Tag::Emphasis) | Event::Start(Tag::Strong) => { *num_removed_items += 1; false } Event::End(TagEnd::Emphasis) | Event::End(TagEnd::Strong) => false, _ => true, }); Ok(pulldown_cmark_to_cmark::cmark(events, &mut buf).map(|_| buf)?) }

它的run()用book.for_each_chapter_mut(...)遍历所有章节,逐个替换ch.content,并统计移除数量。该示例的完整工程位于 examples/remove-emphasis/mdbook-remove-emphasis,配套的测试用例在 examples/remove-emphasis/test.rs。

用其他语言实现 preprocessor

由于 mdBook 与 preprocessor 之间只通过 stdin/stdout 交换 JSON、用退出码表达结果,完全可以用 Rust 之外的语言实现。下面的 Python 脚本修改第一章的内容,配置中preprocessor.foo.command直接指向这个脚本:

import json import sys if __name__ == '__main__': if len(sys.argv) > 1: # we check if we received any argument if sys.argv[1] == "supports": # then we are good to return an exit status code of 0, since the other argument will just be the renderer's name sys.exit(0) # load both the context and the book representations from stdin context, book = json.load(sys.stdin) # and now, we can just modify the content of the first chapter book['items'][0]['Chapter']['content'] = '# Hello' # we are done with the book's modification, we can just print it to stdout, print(json.dumps(book))

钩子二:Alternative Backend(自定义渲染后端)

一个backend就是 mdBook 在渲染过程中调用的一个程序。mdBook 通过 stdin 向它传入书的 JSON 表示和配置信息;backend 收到后可以自由决定做什么——分析、导出其他格式、生成统计报告皆可。

配置层面的用法参见 配置 Renderer。下面以仓库 mdbook-wordcount 示例 为线索,走完一个完整后端的生命周期。

设置项目

创建新二进制项目并加入mdbook-renderer依赖:

$ cargo new --bin mdbook-wordcount $ cd mdbook-wordcount $ cargo add mdbook-renderer

当mdbook-wordcount被调用时,mdBook 会通过其 stdin 发送一个 JSON 版本的RenderContext。RenderContext::from_json()构造器(见 crates/mdbook-renderer/src/lib.rs#L85-L88)可以直接加载它。以下是后端加载一本书所需的全部样板代码:

// src/main.rs use std::io; use mdbook_renderer::RenderContext; fn main() { let mut stdin = io::stdin(); let ctx = RenderContext::from_json(&mut stdin).unwrap(); }

RenderContext的字段定义在 crates/mdbook-renderer/src/lib.rs#L37-L61:

  • version:来自调用方 mdBook 的Cargo.toml中对应字段,后端可用它判断自身与 mdBook 版本的兼容性(官方建议用semvercrate 检查该字段,不兼容时发出警告);
  • root:书的根目录;
  • book:加载后的书对象;
  • config:加载后的配置;
  • destination:后端必须把构建产物放到这里的目录;
  • chapter_titles:内部使用的章节标题映射。

遍历书并统计字数

RenderContext内含book字段,而Book有Book::iter()方法可以遍历书中的所有条目,因此遍历章节非常直接:

fn main() { let mut stdin = io::stdin(); let ctx = RenderContext::from_json(&mut stdin).unwrap(); for item in ctx.book.iter() { if let BookItem::Chapter(ref ch) = *item { let num_words = count_words(ch); println!("{}: {}", ch.name, num_words); } } } fn count_words(ch: &Chapter) -> usize { ch.content.split_whitespace().count() }

启用后端

先安装程序:

$ cargo install --path .

然后进入目标书目录,编辑其book.toml,加入[output.wordcount]表:

[book] title = "mdBook Documentation" description = "Create book from markdown files. Like Gitbook but implemented in Rust" authors = ["Mathieu David", "Michael-F-Bryan"] + [output.html] + [output.wordcount]

mdBook 加载书时会扫描book.toml中所有output.*表来决定启用哪些后端;如果没有提供任何[output.*]表,则回退到默认的 HTML 渲染器。反过来,只要你自定义了后端,就必须显式保留[output.html]表(哪怕它是空的),否则 HTML 后端不会运行。

构建时的输出大致如下:

$ mdbook build ... 2018-01-16 07:31:15 [INFO] (mdbook::renderer): Invoking the "mdbook-wordcount" renderer mdBook: 126 Command Line Tool: 224 init: 283 ...

这里之所以不用写全名/全路径,是因为 mdBook 会按约定推断程序名:名为foo的后端,其可执行文件通常叫mdbook-foo,对应book.toml里的[output.foo]条目。如果命令需要命令行参数或是解释型脚本,可以用command字段显式指定:

[book] title = "mdBook Documentation" description = "Create book from markdown files. Like Gitbook but implemented in Rust" authors = ["Mathieu David", "Michael-F-Bryan"] [output.html] [output.wordcount] + command = "python /path/to/wordcount.py"

在 crates/mdbook-driver/src/builtin_renderers/mod.rs#L32-L87 的CmdRenderer::render()中可以读到完整的调用实现:它先创建destination目录,把RenderContext序列化写入子进程 stdin,然后等待子进程结束,退出码非零即判定渲染失败。

另外注意输出目录布局:只有一个 backend 时,产物直接放在book目录(可用build.build-dir覆盖);有多个 backend 时,每个 backend 各自占用book下的一个子目录(例如上面的配置会生成book/html和book/wordcount)。

读取后端专属配置

Config大体上可当作一个嵌套的 hashmap:可以用get()访问内容,或用get_deserialized()便捷地取出某个值并自动反序列化为任意类型T。要为后端实现配置,先添加 serde 依赖:

$ cargo add serde serde_derive

然后定义可序列化的配置结构体:

use serde_derive::{Serialize, Deserialize}; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case")] pub struct WordcountConfig { pub ignores: Vec<String>, }

在main()里反序列化配置,并跳过被忽略的章节:

let cfg: WordcountConfig = ctx.config .get_deserialized("output.wordcount") .unwrap_or_default(); for item in ctx.book.iter() { if let BookItem::Chapter(ref ch) = *item { if cfg.ignores.contains(&ch.name) { continue; } let num_words = count_words(ch); println!("{}: {}", ch.name, num_words); } }

对应的book.toml配置形如:

[output.wordcount] ignores = ["Example Chapter"]

输出产物与失败信号

mdBook 通过RenderContext的destination字段告诉后端产物应该放哪里。没有保证该目录已存在或为空——为了允许后端缓存上次运行的结果,mdBook 可能保留目录中的旧内容——所以最好用fs::create_dir_all()先创建它:

let _ = fs::create_dir_all(&ctx.destination); let mut f = File::create(ctx.destination.join("wordcounts.txt")).unwrap(); for item in ctx.book.iter() { if let BookItem::Chapter(ref ch) = *item { ... let num_words = count_words(ch); println!("{}: {}", ch.name, num_words); writeln!(f, "{}: {}", ch.name, num_words).unwrap(); } }

处理书的过程中随时可能出错,而mdBook 会把非零退出码解读为渲染失败。例如,若要强制每个章节的字数为偶数,遇到奇数就报错退出:

use std::process; if cfg.deny_odds && num_words % 2 == 1 { eprintln!("{} has an odd number of words!", ch.name); process::exit(1); }

此时重新安装并构建,你会看到:

$ cargo install --path . --force $ mdbook build /path/to/book ... 2018-01-16 21:21:39 [INFO] (mdbook::renderer): Invoking the "wordcount" renderer mdBook: 126 Command Line Tool: 224 init: 283 init has an odd number of words! 2018-01-16 21:21:39 [ERROR] (mdbook::renderer): Renderer exited with non-zero return code. 2018-01-16 21:21:39 [ERROR] (mdbook::utils): Error: Rendering failed 2018-01-16 21:21:39 [ERROR] (mdbook::utils): Caused By: The "mdbook-wordcount" renderer failed

两点实践建议:

  • 插件子进程的输出会立即透传给用户,因此插件应遵循"沉默原则"(rule of silence):只在必要时输出,例如生成出错或警告时;
  • 所有环境变量都会透传给后端,因此你可以照常用MDBOOK_LOG控制日志详细程度。

配置层面的整合:插件表的完整用法

无论 preprocessor 还是 backend,最终都要通过book.toml与构建管线接通。下面是两类插件配置表的完整速查(详见 配置 Preprocessor 与 配置 Renderer)。

Preprocessor 表[preprocessor.<name>]:

[preprocessor.example] # 该表可以包含插件专属的任意键值对 some-extra-feature = true # 只对特定 renderer 生效 renderers = ["html"] # 覆盖默认的可执行文件名(默认是 mdbook-example)或附加参数 command = "python random.py" # 插件未安装时把报错降级为警告 optional = true # 控制执行顺序:在 links 之后运行 after = ["links"] # 或:要求 links 在本插件之前运行 # [preprocessor.links] # before = ["linenos"]

关于顺序需要说明:同优先级(通过before/after指定)的 preprocessor 按名字排序;循环依赖会被检测出来并报错。内置的links(展开{{#playground}}、{{#include}}、{{#rustdoc_include}}helper)和index(把README.md统一转成index.md/index.html)preprocessor 默认开启,可通过build.use-default-preprocessors关闭。

Backend 表[output.<name>]:

[output.wordcount] # 该表可以包含后端专属的任意键值对 ignores = ["Example Chapter"] # 覆盖默认的可执行文件名(默认是 mdbook-wordcount)或附加参数 command = "python random.py" # 后端未安装时把报错降级为警告 optional = true

内置后端包括html(默认,仅当没有任何[output]表时启用)和markdown(运行完 preprocessor 后输出 Markdown,常用于调试 preprocessor,尤其是配合mdbook test观察传给 rustdoc 的 Markdown;用空表[output.markdown]即可启用,目前没有其他配置项)。

从哪里继续深入

  • 完整后端示例源码:guide/src/for_developers/mdbook-wordcount/src/main.rs,以及其对应的书目录配置;
  • Preprocessor 协议与 trait 定义:crates/mdbook-preprocessor/src/lib.rs;
  • Renderer trait 与RenderContext定义:crates/mdbook-renderer/src/lib.rs;
  • 构建流程核心实现:crates/mdbook-driver/src/mdbook.rs;
  • 命令行插件的命令组装与错误处理:crates/mdbook-driver/src/lib.rs、crates/mdbook-driver/src/builtin_renderers/mod.rs;
  • 内置linkspreprocessor 的完整实现(含大量协议解析单元测试):crates/mdbook-driver/src/builtin_preprocessors/links.rs;
  • 可直接改造的 no-op 示例:examples/nop-preprocessor.rs,以及更复杂的 Markdown 解析示例:examples/remove-emphasis/mdbook-remove-emphasis/src/main.rs。

掌握了"加载书 → 逐 backend 运行 preprocessor 再调用 renderer"这条主线,再加上 stdin/stdout 的 JSON 协议与book.toml表配置,你就能在 mdBook 之上自由搭建自己的预处理工具链与消费端了。

  • 开发工具
  • 文档

【免费下载链接】mdBook

Create book from markdown files. Like Gitbook but implemented in Rust

项目地址:https://gitcode.com/gh_mirrors/md/mdBook
点击查看免费下载

相关推荐

上一篇:Neovim代码质量检查终极指南:如何使用nvim-lint提升开发效率
下一篇:Umi-OCR双层PDF转换:离线免费,5 步让扫描件变可搜索文档

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询