+///
+/// ```rust
+/// fn main() {}
+/// ```
+///
+///
+///
+/// ````
+///
+/// With no configuration, or with `output-mode = "default"`, it renders the
+/// following Markdown to be further preprocessed or rendered to HTML:
+///
+/// ````markdown
+///
+/// ````
+///
+/// When `output-mode = "simple"` in the configuration, it instead emits:
+///
+/// ````markdown
+/// Filename: src/main.rs
+///
+/// ```rust
+/// fn main() {}
+/// ```
+///
+/// Listing 1-2: Some *text*, yeah?
+/// ````
+pub struct TrplListing;
+
+impl Preprocessor for TrplListing {
+ fn name(&self) -> &str {
+ "trpl-listing"
+ }
+
+ fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result {
+ let config = ctx
+ .config
+ .get_preprocessor(self.name())
+ .ok_or(Error::NoConfig)?;
+
+ let key = String::from("output-mode");
+ let mode = config
+ .get(&key)
+ .map(|value| match value.as_str() {
+ Some(s) => Mode::try_from(s).map_err(|_| Error::BadValue {
+ key,
+ value: value.to_string(),
+ }),
+ None => Err(Error::BadValue {
+ key,
+ value: value.to_string(),
+ }),
+ })
+ .transpose()?
+ .unwrap_or(Mode::Default);
+
+ let mut errors: Vec = vec![];
+ book.for_each_mut(|item| {
+ if let BookItem::Chapter(ref mut chapter) = item {
+ match rewrite_listing(&chapter.content, mode) {
+ Ok(rewritten) => chapter.content = rewritten,
+ Err(reason) => errors.push(reason),
+ }
+ }
+ });
+
+ if errors.is_empty() {
+ Ok(book)
+ } else {
+ Err(CompositeError(errors.join("\n")).into())
+ }
+ }
+
+ fn supports_renderer(&self, renderer: &str) -> bool {
+ renderer == "html"
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+enum Error {
+ #[error("No config for trpl-listing")]
+ NoConfig,
+
+ #[error("Bad config value '{value}' for key '{key}'")]
+ BadValue { key: String, value: String },
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error("Error(s) rewriting input: {0}")]
+struct CompositeError(String);
+
+#[derive(Debug, Clone, Copy)]
+enum Mode {
+ Default,
+ Simple,
+}
+
+/// Trivial marker struct to indicate an internal error.
+///
+/// The caller has enough info to do what it needs without passing data around.
+struct ParseErr;
+
+impl TryFrom<&str> for Mode {
+ type Error = ParseErr;
+
+ fn try_from(value: &str) -> std::prelude::v1::Result {
+ match value {
+ "default" => Ok(Mode::Default),
+ "simple" => Ok(Mode::Simple),
+ _ => Err(ParseErr),
+ }
+ }
+}
+
+fn rewrite_listing(src: &str, mode: Mode) -> Result {
+ let parser = Parser::new(src);
+
+ struct State<'e> {
+ current_listing: Option,
+ events: Vec, String>>,
+ }
+
+ let final_state = parser.fold(
+ State {
+ current_listing: None,
+ events: vec![],
+ },
+ |mut state, ev| {
+ match ev {
+ Event::Html(tag) => {
+ if tag.starts_with(" {
+ match local.as_str() {
+ "number" => builder
+ .with_number(value.as_str()),
+ "caption" => builder
+ .with_caption(value.as_str()),
+ "file-name" => builder
+ .with_file_name(value.as_str()),
+ _ => builder, // TODO: error on extra attrs?
+ }
+ }
+ _ => builder,
+ }
+ })
+ .build();
+
+ match listing_result {
+ Ok(listing) => {
+ let opening_event = match mode {
+ Mode::Default => {
+ let opening_html =
+ listing.opening_html();
+ Event::Html(opening_html.into())
+ }
+ Mode::Simple => {
+ let opening_text =
+ listing.opening_text();
+ Event::Text(opening_text.into())
+ }
+ };
+
+ state.current_listing = Some(listing);
+ state.events.push(Ok(opening_event));
+ }
+ Err(reason) => state.events.push(Err(reason)),
+ }
+ } else if tag.starts_with("") {
+ let trailing = if !tag.ends_with('>') {
+ tag.replace("", "")
+ } else {
+ String::from("")
+ };
+
+ match state.current_listing {
+ Some(listing) => {
+ let closing_event = match mode {
+ Mode::Default => {
+ let closing_html =
+ listing.closing_html(&trailing);
+ Event::Html(closing_html.into())
+ }
+ Mode::Simple => {
+ let closing_text =
+ listing.closing_text(&trailing);
+ Event::Text(closing_text.into())
+ }
+ };
+
+ state.current_listing = None;
+ state.events.push(Ok(closing_event));
+ }
+ None => state.events.push(Err(String::from(
+ "Closing `` without opening tag.",
+ ))),
+ }
+ } else {
+ state.events.push(Ok(Event::Html(tag)));
+ }
+ }
+ ev => state.events.push(Ok(ev)),
+ };
+ state
+ },
+ );
+
+ if final_state.current_listing.is_some() {
+ return Err("Unclosed listing".into());
+ }
+
+ let (events, errors): (Vec<_>, Vec<_>) =
+ final_state.events.into_iter().partition(|e| e.is_ok());
+
+ if !errors.is_empty() {
+ return Err(errors
+ .into_iter()
+ .map(|e| e.unwrap_err())
+ .collect::>()
+ .join("\n"));
+ }
+
+ let mut buf = String::with_capacity(src.len() * 2);
+ cmark(events.into_iter().map(|ok| ok.unwrap()), &mut buf)
+ .map_err(|e| format!("{e}"))?;
+ Ok(buf)
+}
+
+#[derive(Debug)]
+struct Listing {
+ number: String,
+ caption: String,
+ file_name: Option,
+}
+
+impl Listing {
+ fn opening_html(&self) -> String {
+ let figure = String::from("{trailing}"#,
+ number = self.number,
+ caption = self.caption
+ )
+ }
+
+ fn opening_text(&self) -> String {
+ self.file_name
+ .as_ref()
+ .map(|file_name| format!("\nFilename: {file_name}\n"))
+ .unwrap_or_default()
+ }
+
+ fn closing_text(&self, trailing: &str) -> String {
+ format!(
+ "Listing {number}: {caption}{trailing}",
+ number = self.number,
+ caption = self.caption,
+ )
+ }
+}
+
+struct ListingBuilder<'a> {
+ number: Option<&'a str>,
+ caption: Option<&'a str>,
+ file_name: Option<&'a str>,
+}
+
+impl<'a> ListingBuilder<'a> {
+ fn new() -> ListingBuilder<'a> {
+ ListingBuilder {
+ number: None,
+ caption: None,
+ file_name: None,
+ }
+ }
+
+ fn with_number(mut self, value: &'a str) -> Self {
+ self.number = Some(value);
+ self
+ }
+
+ fn with_caption(mut self, value: &'a str) -> Self {
+ self.caption = Some(value);
+ self
+ }
+
+ fn with_file_name(mut self, value: &'a str) -> Self {
+ self.file_name = Some(value);
+ self
+ }
+
+ fn build(self) -> Result {
+ let number = self
+ .number
+ .ok_or_else(|| String::from("Missing number"))?
+ .to_owned();
+
+ let caption = self
+ .caption
+ .map(|caption_source| {
+ let events = Parser::new(caption_source);
+ let mut buf = String::with_capacity(caption_source.len() * 2);
+ html::push_html(&mut buf, events);
+
+ // This is not particularly principled, but since the only
+ // place it is used is here, for caption source handling, it
+ // is “fine”.
+ buf.replace("
", "").replace("
", "").replace('\n', "")
+ })
+ .ok_or_else(|| String::from("Missing caption"))?
+ .to_owned();
+
+ Ok(Listing {
+ number,
+ caption,
+ file_name: self.file_name.map(String::from),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Note: This inserts an additional backtick around the re-emitted code.
+ /// It is not clear *why*, but that seems to be an artifact of the rendering
+ /// done by the `pulldown_cmark_to_cmark` crate.
+ #[test]
+ fn default_mode_works() {
+ let result = rewrite_listing(
+ r#"
+
+```rust
+fn main() {}
+```
+
+"#,
+ Mode::Default,
+ );
+
+ assert_eq!(
+ &result.unwrap(),
+ r#""#
+ );
+ }
+
+ #[test]
+ fn simple_mode_works() {
+ let result = rewrite_listing(
+ r#"
+
+```rust
+fn main() {}
+```
+
+"#,
+ Mode::Simple,
+ );
+
+ assert_eq!(
+ &result.unwrap(),
+ r#"
+Filename: src/main.rs
+
+````rust
+fn main() {}
+````
+
+Listing 1-2: A write-up which might include inline Markdown like code etc."#
+ );
+ }
+
+ #[test]
+ fn actual_listing() {
+ let result = rewrite_listing(
+ r#"Now open the *main.rs* file you just created and enter the code in Listing 1-1.
+
+
+
+```rust
+fn main() {
+ println!("Hello, world!");
+}
+```
+
+
+
+Save the file and go back to your terminal window"#,
+ Mode::Default,
+ );
+
+ assert!(result.is_ok());
+ assert_eq!(
+ result.unwrap(),
+ r#"Now open the *main.rs* file you just created and enter the code in Listing 1-1.
+
+
+
+Save the file and go back to your terminal window"#
+ );
+ }
+
+ #[test]
+ fn no_filename() {
+ let result = rewrite_listing(
+ r#"This is the opening.
+
+
+
+```rust
+fn main() {}
+```
+
+
+
+This is the closing."#,
+ Mode::Default,
+ );
+
+ assert!(result.is_ok());
+ assert_eq!(
+ result.unwrap(),
+ r#"This is the opening.
+
+
+
+This is the closing."#
+ );
+ }
+
+ /// Check that the config options are correctly handled.
+ ///
+ /// Note: none of these tests particularly exercise the *wiring*. They just
+ /// assume that the config itself is done correctly. This is a small enough
+ /// chunk of code that it easy to verify by hand at present. If it becomes
+ /// more complex in the future, it would be good to revisit and integrate
+ /// the same kinds of tests as the unit tests above here.
+ #[cfg(test)]
+ mod config {
+ use super::*;
+
+ // TODO: what *should* the behavior here be? I *think* it should error,
+ // in that there is a problem if it is invoked without that info.
+ #[test]
+ fn no_config() {
+ let input_json = r##"[
+ {
+ "root": "/path/to/book",
+ "config": {
+ "book": {
+ "authors": ["AUTHOR"],
+ "language": "en",
+ "multilingual": false,
+ "src": "src",
+ "title": "TITLE"
+ },
+ "preprocessor": {}
+ },
+ "renderer": "html",
+ "mdbook_version": "0.4.21"
+ },
+ {
+ "sections": [
+ {
+ "Chapter": {
+ "name": "Chapter 1",
+ "content": "# Chapter 1\n",
+ "number": [1],
+ "sub_items": [],
+ "path": "chapter_1.md",
+ "source_path": "chapter_1.md",
+ "parent_names": []
+ }
+ }
+ ],
+ "__non_exhaustive": null
+ }
+ ]"##;
+ let input_json = input_json.as_bytes();
+ let (ctx, book) =
+ mdbook::preprocess::CmdPreprocessor::parse_input(input_json)
+ .unwrap();
+ let result = TrplListing.run(&ctx, book);
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ assert_eq!(format!("{err}"), "No config for trpl-listing");
+ }
+
+ #[test]
+ fn empty_config() {
+ let input_json = r##"[
+ {
+ "root": "/path/to/book",
+ "config": {
+ "book": {
+ "authors": ["AUTHOR"],
+ "language": "en",
+ "multilingual": false,
+ "src": "src",
+ "title": "TITLE"
+ },
+ "preprocessor": {
+ "trpl-listing": {}
+ }
+ },
+ "renderer": "html",
+ "mdbook_version": "0.4.21"
+ },
+ {
+ "sections": [
+ {
+ "Chapter": {
+ "name": "Chapter 1",
+ "content": "# Chapter 1\n",
+ "number": [1],
+ "sub_items": [],
+ "path": "chapter_1.md",
+ "source_path": "chapter_1.md",
+ "parent_names": []
+ }
+ }
+ ],
+ "__non_exhaustive": null
+ }
+ ]"##;
+ let input_json = input_json.as_bytes();
+ let (ctx, book) =
+ mdbook::preprocess::CmdPreprocessor::parse_input(input_json)
+ .unwrap();
+ let result = TrplListing.run(&ctx, book);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn specify_default() {
+ let input_json = r##"[
+ {
+ "root": "/path/to/book",
+ "config": {
+ "book": {
+ "authors": ["AUTHOR"],
+ "language": "en",
+ "multilingual": false,
+ "src": "src",
+ "title": "TITLE"
+ },
+ "preprocessor": {
+ "trpl-listing": {
+ "output-mode": "default"
+ }
+ }
+ },
+ "renderer": "html",
+ "mdbook_version": "0.4.21"
+ },
+ {
+ "sections": [
+ {
+ "Chapter": {
+ "name": "Chapter 1",
+ "content": "# Chapter 1\n",
+ "number": [1],
+ "sub_items": [],
+ "path": "chapter_1.md",
+ "source_path": "chapter_1.md",
+ "parent_names": []
+ }
+ }
+ ],
+ "__non_exhaustive": null
+ }
+ ]"##;
+ let input_json = input_json.as_bytes();
+ let (ctx, book) =
+ mdbook::preprocess::CmdPreprocessor::parse_input(input_json)
+ .unwrap();
+ let result = TrplListing.run(&ctx, book);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn specify_simple() {
+ let input_json = r##"[
+ {
+ "root": "/path/to/book",
+ "config": {
+ "book": {
+ "authors": ["AUTHOR"],
+ "language": "en",
+ "multilingual": false,
+ "src": "src",
+ "title": "TITLE"
+ },
+ "preprocessor": {
+ "trpl-listing": {
+ "output-mode": "simple"
+ }
+ }
+ },
+ "renderer": "html",
+ "mdbook_version": "0.4.21"
+ },
+ {
+ "sections": [
+ {
+ "Chapter": {
+ "name": "Chapter 1",
+ "content": "# Chapter 1\n",
+ "number": [1],
+ "sub_items": [],
+ "path": "chapter_1.md",
+ "source_path": "chapter_1.md",
+ "parent_names": []
+ }
+ }
+ ],
+ "__non_exhaustive": null
+ }
+ ]"##;
+ let input_json = input_json.as_bytes();
+ let (ctx, book) =
+ mdbook::preprocess::CmdPreprocessor::parse_input(input_json)
+ .unwrap();
+ let result = TrplListing.run(&ctx, book);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn specify_invalid() {
+ let input_json = r##"[
+ {
+ "root": "/path/to/book",
+ "config": {
+ "book": {
+ "authors": ["AUTHOR"],
+ "language": "en",
+ "multilingual": false,
+ "src": "src",
+ "title": "TITLE"
+ },
+ "preprocessor": {
+ "trpl-listing": {
+ "output-mode": "nonsense"
+ }
+ }
+ },
+ "renderer": "html",
+ "mdbook_version": "0.4.21"
+ },
+ {
+ "sections": [
+ {
+ "Chapter": {
+ "name": "Chapter 1",
+ "content": "# Chapter 1\n",
+ "number": [1],
+ "sub_items": [],
+ "path": "chapter_1.md",
+ "source_path": "chapter_1.md",
+ "parent_names": []
+ }
+ }
+ ],
+ "__non_exhaustive": null
+ }
+ ]"##;
+ let input_json = input_json.as_bytes();
+ let (ctx, book) =
+ mdbook::preprocess::CmdPreprocessor::parse_input(input_json)
+ .unwrap();
+ let result = TrplListing.run(&ctx, book);
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ assert_eq!(
+ format!("{err}"),
+ "Bad config value '\"nonsense\"' for key 'output-mode'"
+ );
+ }
+ }
+}
diff --git a/rustbook-en/packages/mdbook-trpl-listing/src/main.rs b/rustbook-en/packages/mdbook-trpl-listing/src/main.rs
new file mode 100644
index 00000000..3792b46f
--- /dev/null
+++ b/rustbook-en/packages/mdbook-trpl-listing/src/main.rs
@@ -0,0 +1,37 @@
+use std::io;
+
+use clap::{self, Parser, Subcommand};
+use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
+
+use mdbook_trpl_listing::TrplListing;
+
+fn main() -> Result<(), String> {
+ let cli = Cli::parse();
+ if let Some(Command::Supports { renderer }) = cli.command {
+ return if TrplListing.supports_renderer(&renderer) {
+ Ok(())
+ } else {
+ Err(format!("Renderer '{renderer}' is unsupported"))
+ };
+ }
+
+ let (ctx, book) = CmdPreprocessor::parse_input(io::stdin()).map_err(|e| format!("{e}"))?;
+ let processed = TrplListing.run(&ctx, book).map_err(|e| format!("{e}"))?;
+ serde_json::to_writer(io::stdout(), &processed).map_err(|e| format!("{e}"))
+}
+
+/// A simple preprocessor for semantic markup for code listings in _The Rust
+/// Programming Language_.
+#[derive(Parser, Debug)]
+struct Cli {
+ #[command(subcommand)]
+ command: Option,
+}
+
+#[derive(Subcommand, Debug)]
+enum Command {
+ /// Is the renderer supported?
+ ///
+ /// All renderers are supported! This is the contract for mdBook.
+ Supports { renderer: String },
+}
diff --git a/rustbook-en/packages/mdbook-trpl-note/Cargo.toml b/rustbook-en/packages/mdbook-trpl-note/Cargo.toml
index 5834b966..d2f80f9b 100644
--- a/rustbook-en/packages/mdbook-trpl-note/Cargo.toml
+++ b/rustbook-en/packages/mdbook-trpl-note/Cargo.toml
@@ -6,11 +6,11 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
-clap = { version = "4.5.4", features = ["derive"] }
+clap = { workspace = true }
mdbook = { workspace = true }
-pulldown-cmark = { version = "0.10.3", features = ["simd"] }
-pulldown-cmark-to-cmark = "13.0.0"
-serde_json = "1.0.116"
+pulldown-cmark = { workspace = true }
+pulldown-cmark-to-cmark = { workspace = true }
+serde_json = { workspace = true }
[dev-dependencies]
-assert_cmd = "2.0.14"
+assert_cmd = { workspace = true }
diff --git a/rustbook-en/packages/mdbook-trpl-note/src/lib.rs b/rustbook-en/packages/mdbook-trpl-note/src/lib.rs
index 7a671be1..774dc82b 100644
--- a/rustbook-en/packages/mdbook-trpl-note/src/lib.rs
+++ b/rustbook-en/packages/mdbook-trpl-note/src/lib.rs
@@ -34,7 +34,11 @@ impl Preprocessor for TrplNote {
"simple-note-preprocessor"
}
- fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result {
+ fn run(
+ &self,
+ _ctx: &PreprocessorContext,
+ mut book: Book,
+ ) -> Result {
book.for_each_mut(|item| {
if let BookItem::Chapter(ref mut chapter) = item {
chapter.content = rewrite(&chapter.content);
@@ -75,7 +79,9 @@ pub fn rewrite(text: &str) -> String {
events.extend([
SoftBreak,
SoftBreak,
- Html(r#""#.into()),
+ Html(
+ r#""#.into(),
+ ),
SoftBreak,
SoftBreak,
Start(Tag::Paragraph),
@@ -89,7 +95,10 @@ pub fn rewrite(text: &str) -> String {
}
}
- (StartingBlockquote(_blockquote_events), heading @ Start(Tag::Heading { .. })) => {
+ (
+ StartingBlockquote(_blockquote_events),
+ heading @ Start(Tag::Heading { .. }),
+ ) => {
events.extend([
SoftBreak,
SoftBreak,
@@ -101,14 +110,18 @@ pub fn rewrite(text: &str) -> String {
state = InNote;
}
- (StartingBlockquote(ref mut events), Start(Tag::Paragraph)) => {
- events.push(Start(Tag::Paragraph));
+ (StartingBlockquote(ref mut events), Start(tag)) => {
+ events.push(Start(tag));
}
(InNote, End(TagEnd::BlockQuote)) => {
// As with the start of the block HTML, the closing HTML must be
// separated from the Markdown text by two newlines.
- events.extend([SoftBreak, SoftBreak, Html("".into())]);
+ events.extend([
+ SoftBreak,
+ SoftBreak,
+ Html("".into()),
+ ]);
state = Default;
}
@@ -258,7 +271,8 @@ mod tests {
#[test]
fn h1_then_blockquote() {
- let text = "> # Header\n > And then some note content.\n\n> This is quoted.";
+ let text =
+ "> # Header\n > And then some note content.\n\n> This is quoted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
@@ -268,7 +282,8 @@ mod tests {
#[test]
fn blockquote_then_h1_note() {
- let text = "> This is quoted.\n\n> # Header\n > And then some note content.";
+ let text =
+ "> This is quoted.\n\n> # Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
@@ -276,6 +291,16 @@ mod tests {
);
}
+ #[test]
+ fn blockquote_with_strong() {
+ let text = "> **Bold text in a paragraph.**";
+ let processed = rewrite(text);
+ assert_eq!(
+ render_markdown(&processed),
+ "
\n
Bold text in a paragraph.
\n
\n"
+ );
+ }
+
fn render_markdown(text: &str) -> String {
let parser = Parser::new(text);
let mut buf = String::new();
diff --git a/rustbook-en/src/ch01-02-hello-world.md b/rustbook-en/src/ch01-02-hello-world.md
index f2914637..f0b97e49 100644
--- a/rustbook-en/src/ch01-02-hello-world.md
+++ b/rustbook-en/src/ch01-02-hello-world.md
@@ -49,7 +49,7 @@ convention is to use an underscore to separate them. For example, use
Now open the *main.rs* file you just created and enter the code in Listing 1-1.
-Filename: main.rs
+
```rust
fn main() {
@@ -57,7 +57,7 @@ fn main() {
}
```
-Listing 1-1: A program that prints `Hello, world!`
+
Save the file and go back to your terminal window in the
*~/projects/hello_world* directory. On Linux or macOS, enter the following
diff --git a/rustbook-en/src/ch01-03-hello-cargo.md b/rustbook-en/src/ch01-03-hello-cargo.md
index 42cd0889..5750e4f0 100644
--- a/rustbook-en/src/ch01-03-hello-cargo.md
+++ b/rustbook-en/src/ch01-03-hello-cargo.md
@@ -58,7 +58,7 @@ repository; you can override this behavior by using `cargo new --vcs=git`.
Open *Cargo.toml* in your text editor of choice. It should look similar to the
code in Listing 1-2.
-Filename: Cargo.toml
+
```toml
[package]
@@ -71,8 +71,7 @@ edition = "2021"
[dependencies]
```
-Listing 1-2: Contents of *Cargo.toml* generated by `cargo
-new`
+
This file is in the [*TOML*][toml] (*Tom’s Obvious, Minimal
Language*) format, which is Cargo’s configuration format.
diff --git a/rustbook-en/src/ch05-02-example-structs.md b/rustbook-en/src/ch05-02-example-structs.md
index b2416b74..1e7c9f7e 100644
--- a/rustbook-en/src/ch05-02-example-structs.md
+++ b/rustbook-en/src/ch05-02-example-structs.md
@@ -141,7 +141,7 @@ If we continue reading the errors, we’ll find this helpful note:
```
Let’s try it! The `println!` macro call will now look like `println!("rect1 is
-{:?}", rect1);`. Putting the specifier `:?` inside the curly brackets tells
+{rect1:?}");`. Putting the specifier `:?` inside the curly brackets tells
`println!` we want to use an output format called `Debug`. The `Debug` trait
enables us to print our struct in a way that is useful for developers so we can
see its value while we’re debugging our code.
diff --git a/rustbook-en/tests/integration/main.rs b/rustbook-en/tests/integration/main.rs
new file mode 100644
index 00000000..6944fae6
--- /dev/null
+++ b/rustbook-en/tests/integration/main.rs
@@ -0,0 +1,22 @@
+use assert_cmd::Command;
+
+#[test]
+fn supports_html_renderer() {
+ let cmd = Command::cargo_bin(env!("CARGO_PKG_NAME"))
+ .unwrap()
+ .args(["supports", "html"])
+ .ok();
+ assert!(cmd.is_ok());
+}
+
+#[test]
+fn errors_for_other_renderers() {
+ let cmd = Command::cargo_bin(env!("CARGO_PKG_NAME"))
+ .unwrap()
+ .args(["supports", "total-nonsense"])
+ .ok();
+ assert!(cmd.is_err());
+}
+
+// It would be nice to add an actual fixture for an mdbook, but doing *that* is
+// going to be a bit of a pain, and what I have should cover it for now.
diff --git a/rustbook-en/theme/listing.css b/rustbook-en/theme/listing.css
new file mode 100644
index 00000000..9b5929c6
--- /dev/null
+++ b/rustbook-en/theme/listing.css
@@ -0,0 +1,3 @@
+figure.listing {
+ margin: 0;
+}