infra: share infrastructure for mdbook preprocessors

- Create an `mdbook_trpl` library package which hosts shared concerns
  for the packages, e.g. error and config handling.
- Move `mdbook-trpl-note` and `mdbook-trpl-listing` into the new shared
  package, with binaries at `src/bin/(note|listing)/main.rs` and the
  existing libraries at `src/note/mod.rs` and `src/listing/mod.rs` with
  their associated tests.
- Extract their actual shared pieces into the crate root.
- Update `tools/nostarch.sh` to build all the bins in `mdbook_trpl` at
  one time.

At the moment, this doesn't do a lot except trim down the number of
packages in the repository, but it sets things up nicely to support more
preprocessors (which I am going to add shortly).
This commit is contained in:
Chris Krycho
2024-11-08 09:23:13 -07:00
parent 397d181acc
commit 9e2673a008
24 changed files with 1363 additions and 2025 deletions

View File

@@ -52,12 +52,8 @@ jobs:
- name: Run `tools` package tests
run: |
cargo test
- name: Run `mdbook-trpl-note` package tests
working-directory: packages/mdbook-trpl-note
run: |
cargo test
- name: Run `mdbook-trpl-listing` package tests
working-directory: packages/mdbook-trpl-listing
- name: Run `mdbook-trpl` package tests
working-directory: packages/mdbook-trpl
run: |
cargo test
lint:
@@ -77,10 +73,8 @@ jobs:
mkdir bin
curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.21/mdbook-v0.4.21-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=bin
echo "$(pwd)/bin" >> "${GITHUB_PATH}"
- name: Install mdbook-trpl-note
run: cargo install --path packages/mdbook-trpl-note
- name: Install mdbook-trpl-listing
run: cargo install --path packages/mdbook-trpl-listing
- name: Install mdbook-trpl binaries
run: cargo install --path packages/mdbook-trpl
- name: Install aspell
run: sudo apt-get install aspell
- name: Install shellcheck

View File

@@ -39,8 +39,7 @@ look right, but you _will_ still be able to build the book. To use the plugins,
you should run:
```bash
$ cargo install --locked --path packages/mdbook-trpl-listing
$ cargo install --locked --path packages/mdbook-trpl-note
$ cargo install --locked --path packages/mdbook_trpl
```
## Building

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
[package]
name = "mdbook-trpl-note"
version = "1.0.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4", features = ["derive"] }
mdbook = { version = "0.4", default-features = false } # only need the library
pulldown-cmark = { version = "0.10", features = ["simd"] }
pulldown-cmark-to-cmark = "13"
serde_json = "1"
[dev-dependencies]
assert_cmd = "2"
# This package is used as a path dependency in `rust-lang/rust`, not published
# to crates.io, so it cannot be part of the `rust-lang/book` workspace, because
# path dependencies do not get built as a crate within the hosting workspace.
[workspace]

View File

@@ -1,346 +0,0 @@
use mdbook::{
book::Book,
errors::Result,
preprocess::{Preprocessor, PreprocessorContext},
utils::new_cmark_parser,
BookItem,
};
use pulldown_cmark::{
Event::{self, *},
Tag, TagEnd,
};
use pulldown_cmark_to_cmark::cmark;
/// A simple preprocessor for semantic notes in _The Rust Programming Language_.
///
/// Takes in Markdown like this:
///
/// ```markdown
/// > Note: This is a note.
/// ```
///
/// Spits out Markdown like this:
///
/// ```markdown
/// <section class="note" aria-role="note">
///
/// This is a note.
///
/// </section>
/// ```
pub struct TrplNote;
impl Preprocessor for TrplNote {
fn name(&self) -> &str {
"simple-note-preprocessor"
}
fn run(
&self,
_ctx: &PreprocessorContext,
mut book: Book,
) -> Result<mdbook::book::Book> {
book.for_each_mut(|item| {
if let BookItem::Chapter(ref mut chapter) = item {
chapter.content = rewrite(&chapter.content);
}
});
Ok(book)
}
fn supports_renderer(&self, renderer: &str) -> bool {
renderer == "html" || renderer == "markdown" || renderer == "test"
}
}
pub fn rewrite(text: &str) -> String {
let parser = new_cmark_parser(text, true);
let mut events = Vec::new();
let mut state = Default;
for event in parser {
match (&mut state, event) {
(Default, Start(Tag::BlockQuote)) => {
state = StartingBlockquote(vec![Start(Tag::BlockQuote)]);
}
(StartingBlockquote(blockquote_events), Text(content)) => {
if content.starts_with("Note: ") {
// This needs the "extra" `SoftBreak`s so that when the final rendering pass
// happens, it does not end up treating the internal content as inline *or*
// treating the HTML tags as inline tags:
//
// - Content inside HTML blocks is only rendered as Markdown when it is
// separated from the block HTML elements: otherwise it gets treated as inline
// HTML and *not* rendered.
// - Along the same lines, an HTML tag that happens to be directly adjacent to
// the end of a previous Markdown block will end up being rendered as part of
// that block.
events.extend([
SoftBreak,
SoftBreak,
Html(
r#"<section class="note" aria-role="note">"#.into(),
),
SoftBreak,
SoftBreak,
Start(Tag::Paragraph),
Text(content),
]);
state = InNote;
} else {
events.append(blockquote_events);
events.push(Text(content));
state = Default;
}
}
(
StartingBlockquote(_blockquote_events),
heading @ Start(Tag::Heading { .. }),
) => {
events.extend([
SoftBreak,
SoftBreak,
Html(r#"<section class="note" aria-role="note">"#.into()),
SoftBreak,
SoftBreak,
heading,
]);
state = InNote;
}
(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("</section>".into()),
]);
state = Default;
}
(_, event) => {
events.push(event);
}
}
}
let mut buf = String::new();
cmark(events.into_iter(), &mut buf).unwrap();
buf
}
use State::*;
#[derive(Debug)]
enum State<'e> {
Default,
StartingBlockquote(Vec<Event<'e>>),
InNote,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_note() {
let text = "Hello, world.\n\nThis is some text.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<p>Hello, world.</p>\n<p>This is some text.</p>\n"
);
}
#[test]
fn with_note() {
let text = "> Note: This is some text.\n> It keeps going.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<p>Note: This is some text.\nIt keeps going.</p>\n</section>"
);
}
#[test]
fn regular_blockquote() {
let text = "> This is some text.\n> It keeps going.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>This is some text.\nIt keeps going.</p>\n</blockquote>\n"
);
}
#[test]
fn combined() {
let text = "> Note: This is some text.\n> It keeps going.\n\nThis is regular text.\n\n> This is a blockquote.\n";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<p>Note: This is some text.\nIt keeps going.</p>\n</section>\n<p>This is regular text.</p>\n<blockquote>\n<p>This is a blockquote.</p>\n</blockquote>\n"
);
}
#[test]
fn blockquote_then_note() {
let text = "> This is quoted.\n\n> Note: This is noted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n<section class=\"note\" aria-role=\"note\">\n<p>Note: This is noted.</p>\n</section>"
);
}
#[test]
fn note_then_blockquote() {
let text = "> Note: This is noted.\n\n> This is quoted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<p>Note: This is noted.</p>\n</section>\n<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n"
);
}
#[test]
fn with_h1_note() {
let text = "> # Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h1>Header</h1>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h2_note() {
let text = "> ## Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h2>Header</h2>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h3_note() {
let text = "> ### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h3>Header</h3>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h4_note() {
let text = "> #### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h4>Header</h4>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h5_note() {
let text = "> ##### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h5>Header</h5>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h6_note() {
let text = "> ###### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h6>Header</h6>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn h1_then_blockquote() {
let text =
"> # Header\n > And then some note content.\n\n> This is quoted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h1>Header</h1>\n<p>And then some note content.</p>\n</section>\n<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n"
);
}
#[test]
fn blockquote_then_h1_note() {
let text =
"> This is quoted.\n\n> # Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n<section class=\"note\" aria-role=\"note\">\n<h1>Header</h1>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn blockquote_with_strong() {
let text = "> **Bold text in a paragraph.**";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p><strong>Bold text in a paragraph.</strong></p>\n</blockquote>\n"
);
}
#[test]
fn normal_table() {
let text = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Text 123 | More 456 |";
let processed = rewrite(text);
assert_eq!(
processed,
"|Header 1|Header 2|\n|--------|--------|\n|Text 123|More 456|",
"It strips some whitespace but otherwise leaves the table intact."
);
}
#[test]
fn table_in_note() {
let text = "> Note: table stuff.\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Text 123 | More 456 |";
let processed = rewrite(text);
assert_eq!(
processed,
"\n\n<section class=\"note\" aria-role=\"note\">\n\nNote: table stuff.\n\n</section>\n\n|Header 1|Header 2|\n|--------|--------|\n|Text 123|More 456|",
"It adds the note markup but leaves the table untouched, to be rendered as Markdown."
);
}
#[test]
fn table_in_quote() {
let text = "> A table.\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Text 123 | More 456 |";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>A table.</p>\n</blockquote>\n<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead><tbody>\n<tr><td>Text 123</td><td>More 456</td></tr>\n</tbody></table>\n",
"It renders blockquotes with nested tables as expected."
);
}
fn render_markdown(text: &str) -> String {
let parser = new_cmark_parser(text, true);
let mut buf = String::new();
pulldown_cmark::html::push_html(&mut buf, parser);
buf
}
}

View File

@@ -1,22 +0,0 @@
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.

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,27 @@
[package]
name = "mdbook-trpl-listing"
name = "mdbook-trpl"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "mdbook-trpl-note"
path = "src/bin/note.rs"
[[bin]]
name = "mdbook-trpl-listing"
path = "src/bin/listing.rs"
[[bin]]
name = "mdbook-trpl-figure"
path = "src/bin/figure.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
html_parser = "0.7.0"
mdbook = { version = "0.4", default-features = false } # only need the library
pulldown-cmark = { version = "0.10", features = ["simd"] }
pulldown-cmark-to-cmark = "13"
pulldown-cmark = { version = "0.12", features = ["simd"] }
pulldown-cmark-to-cmark = "19"
serde_json = "1"
thiserror = "1.0.60"
toml = "0.8.12"

View File

@@ -0,0 +1,13 @@
# mdbook_trpl
A shared package for [mdbook][mdbook] [preprocessors][pre] used in [_The Rust
Programming Language_][trpl].
Supplies the following preprocessor binaries:
- [mdbook-trpl-note](./src/bin/note)
- [mdbook-trpl-listing](./src/bin/listing)
[mdbook]: https://crates.io/crates/mdbook
[pre]: https://rust-lang.github.io/mdBook/format/configuration/preprocessors.html
[trpl]: https://doc.rust-lang.org/book/

View File

@@ -0,0 +1,42 @@
use std::io;
use clap::{self, Parser, Subcommand};
use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
use mdbook_trpl::Figure;
fn main() -> Result<(), String> {
match Cli::parse().command {
Some(Command::Supports { renderer }) => {
if Figure.supports_renderer(&renderer) {
Ok(())
} else {
Err(format!("Renderer '{renderer}' is unsupported"))
}
}
None => {
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())
.map_err(|e| format!("{e}"))?;
let processed =
Figure.run(&ctx, book).map_err(|e| format!("{e}"))?;
serde_json::to_writer(io::stdout(), &processed)
.map_err(|e| format!("{e}"))
}
}
}
/// A simple preprocessor for handling figures with images in _The Rust
/// Programming Language_ book.
#[derive(Parser, Debug)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Is the renderer supported?
///
/// Supported renderers are `'html'`, `'markdown'`, and `'test'`.
Supports { renderer: String },
}

View File

@@ -3,20 +3,21 @@ use std::io;
use clap::{self, Parser, Subcommand};
use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
use mdbook_trpl_listing::TrplListing;
use mdbook_trpl::Listing;
fn main() -> Result<(), String> {
let cli = Cli::parse();
if let Some(Command::Supports { renderer }) = cli.command {
return if TrplListing.supports_renderer(&renderer) {
return if Listing.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}"))?;
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())
.map_err(|e| format!("{e}"))?;
let processed = Listing.run(&ctx, book).map_err(|e| format!("{e}"))?;
serde_json::to_writer(io::stdout(), &processed).map_err(|e| format!("{e}"))
}

View File

@@ -3,11 +3,11 @@ use std::io;
use clap::{self, Parser, Subcommand};
use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
use mdbook_trpl_note::TrplNote;
use mdbook_trpl::Note;
fn main() -> Result<(), String> {
let cli = Cli::parse();
let simple_note = TrplNote;
let simple_note = Note;
if let Some(Command::Supports { renderer }) = cli.command {
return if simple_note.supports_renderer(&renderer) {
Ok(())
@@ -16,8 +16,8 @@ fn main() -> Result<(), String> {
};
}
let (ctx, book) =
CmdPreprocessor::parse_input(io::stdin()).map_err(|e| format!("blah: {e}"))?;
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())
.map_err(|e| format!("blah: {e}"))?;
let processed = simple_note.run(&ctx, book).map_err(|e| format!("{e}"))?;
serde_json::to_writer(io::stdout(), &processed).map_err(|e| format!("{e}"))
}

View File

@@ -0,0 +1,71 @@
//! Get any `preprocessor.trpl-*` config.
use mdbook::preprocess::PreprocessorContext;
#[derive(Debug, Clone, Copy)]
pub enum Mode {
Default,
Simple,
}
impl Mode {
pub fn from_context(
ctx: &PreprocessorContext,
preprocessor_name: &str,
) -> Result<Mode, Error> {
let config = ctx
.config
.get_preprocessor(preprocessor_name)
.ok_or_else(|| Error::NoConfig(preprocessor_name.into()))?;
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);
Ok(mode)
}
}
/// Trivial marker struct to indicate an internal error.
///
/// The caller has enough info to do what it needs without passing data around.
pub struct ParseErr;
impl TryFrom<&str> for Mode {
type Error = ParseErr;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"default" => Ok(Mode::Default),
"simple" => Ok(Mode::Simple),
_ => Err(ParseErr),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Mdbook(#[from] mdbook::errors::Error),
#[error("No config for '{0}'")]
NoConfig(String),
#[error("Bad config value '{value}' for key '{key}'")]
BadValue { key: String, value: String },
}
#[cfg(test)]
mod tests;

View File

@@ -6,96 +6,118 @@
//! more complex in the future, it would be good to revisit and integrate
//! the same kinds of tests as the unit tests above here.
use super::*;
use mdbook::{
book::Book,
errors::Result,
preprocess::{Preprocessor, PreprocessorContext},
BookItem,
};
use crate::config::Mode;
/// Dummy preprocessor for testing purposes to exercise config.
struct TestPreprocessor;
impl Preprocessor for TestPreprocessor {
fn name(&self) -> &str {
"test-preprocessor"
}
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
let mode = Mode::from_context(ctx, self.name())?;
book.push_item(BookItem::PartTitle(format!("{mode:?}")));
Ok(book)
}
}
// 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"
{
"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": [
{
"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
"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);
let result = TestPreprocessor.run(&ctx, book);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(format!("{err}"), "No config for trpl-listing");
assert_eq!(format!("{err}"), "No config for 'test-preprocessor'");
}
#[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"
{
"root": "/path/to/book",
"config": {
"book": {
"authors": ["AUTHOR"],
"language": "en",
"multilingual": false,
"src": "src",
"title": "TITLE"
},
{
"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
"preprocessor": {
"test-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_ok());
let book = TestPreprocessor.run(&ctx, book).unwrap();
assert!(book.iter().any(
|item| matches!(item, BookItem::PartTitle(title) if title == &format!("{:?}", Mode::Default))
))
}
#[test]
@@ -112,7 +134,7 @@ fn specify_default() {
"title": "TITLE"
},
"preprocessor": {
"trpl-listing": {
"test-preprocessor": {
"output-mode": "default"
}
}
@@ -140,8 +162,10 @@ fn specify_default() {
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());
let book = TestPreprocessor.run(&ctx, book).unwrap();
assert!(book.iter().any(
|item| matches!(item, BookItem::PartTitle(title) if title == &format!("{:?}", Mode::Default))
));
}
#[test]
@@ -158,7 +182,7 @@ fn specify_simple() {
"title": "TITLE"
},
"preprocessor": {
"trpl-listing": {
"test-preprocessor": {
"output-mode": "simple"
}
}
@@ -186,8 +210,10 @@ fn specify_simple() {
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());
let book = TestPreprocessor.run(&ctx, book).unwrap();
assert!(book.iter().any(
|item| matches!(item, BookItem::PartTitle(title) if title == &format!("{:?}", Mode::Simple))
))
}
#[test]
@@ -204,7 +230,7 @@ fn specify_invalid() {
"title": "TITLE"
},
"preprocessor": {
"trpl-listing": {
"test-preprocessor": {
"output-mode": "nonsense"
}
}
@@ -232,11 +258,9 @@ fn specify_invalid() {
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();
let result = TestPreprocessor.run(&ctx, book).unwrap_err();
assert_eq!(
format!("{err}"),
format!("{result}"),
"Bad config value '\"nonsense\"' for key 'output-mode'"
);
}

View File

@@ -0,0 +1,252 @@
use anyhow::{anyhow, Result};
use html_parser::{Dom, Node};
use mdbook::{book::Book, preprocess::Preprocessor, BookItem};
use pulldown_cmark::Event;
use pulldown_cmark_to_cmark::cmark;
use crate::config::Mode;
/// A simple preprocessor to rewrite `<figure>`s with `<img>`s.
///
/// This is a no-op by default; it only operates on the book chapters when the
/// `[preprocessor.trpl-figure]` has `output-mode = "simple"`.
///
/// Takes in Markdown containing like this:
///
/// ```markdown
/// <figure>
///
/// <img src="http://www.example.com/some-cool-image.jpg">
///
/// <figcaption>Figure 1-2: A description of the image</figcaption>
///
/// </figure>
/// ```
///
/// Spits out Markdown like this:
///
/// ```markdown
///
/// <img src="http://www.example.com/some-cool-image.jpg">
///
/// Figure 1-2: A description of the image
///
/// ```
pub struct TrplFigure;
impl TrplFigure {
pub fn supports_renderer(&self, renderer: &str) -> bool {
renderer == "html" || renderer == "markdown" || renderer == "test"
}
}
impl Preprocessor for TrplFigure {
fn name(&self) -> &str {
"trpl-figure"
}
fn run(
&self,
ctx: &mdbook::preprocess::PreprocessorContext,
mut book: Book,
) -> Result<Book> {
// The `<figure>`-based output is only replaced in the `Simple` mode.
let Mode::Simple = Mode::from_context(ctx, self.name())? else {
return Ok(book);
};
let mut errors = vec![];
book.for_each_mut(|item| {
if let BookItem::Chapter(ref mut chapter) = item {
match rewrite_figure(&chapter.content) {
Ok(rewritten) => chapter.content = rewritten,
Err(reason) => errors.push(reason),
}
}
});
if errors.is_empty() {
Ok(book)
} else {
Err(CompositeError(errors).into())
}
}
}
#[derive(Debug, thiserror::Error)]
struct CompositeError(Vec<anyhow::Error>);
impl std::fmt::Display for CompositeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Error(s) rewriting input: {}",
self.0.iter().map(|e| format!("{e:?}")).collect::<String>()
)
}
}
const OPEN_FIGURE: &'static str = "<figure>";
const CLOSE_FIGURE: &'static str = "</figure>";
const OPEN_CAPTION: &'static str = "<figcaption>";
const CLOSE_CAPTION: &'static str = "</figcaption>";
fn rewrite_figure(text: &str) -> Result<String> {
let final_state = crate::parser(text).try_fold(
State {
current: None,
events: Vec::new(),
},
|mut state, event| {
match (event, &mut state.current) {
// -- Open figure
(Event::Html(tag), None) if tag.starts_with(OPEN_FIGURE) => {
let mut figure = Figure::new();
figure.events.push(Event::Text("\n".into()));
state.current.replace(figure);
}
(Event::Html(tag), Some(_)) if tag.starts_with(OPEN_FIGURE) => {
return Err(anyhow!(
"Opening `<figure>` when already in a `<figure>`"
))
}
// -- Close figure
(Event::Html(tag), Some(figure))
if tag.starts_with(CLOSE_FIGURE) =>
{
if figure.in_caption {
return Err(anyhow!("Unclosed `<figcaption>`"));
}
state.events.append(&mut figure.events);
state.events.push(Event::Text("\n".into()));
let _ = state.current.take();
}
(Event::Html(tag), None) if tag.trim() == CLOSE_FIGURE => {
return Err(anyhow!(bad_close(CLOSE_FIGURE, OPEN_CAPTION)));
}
// -- Start captions
// We do not allow nested captions, but if we have not yet
// started a caption, it is legal to start one, and we
// intentionally ignore that event entirely other than tracking
// that we have started a caption. We will push the body of the
// caption into the figures events when we hit them.
//
// Note: this does not support `<figcaption class="...">`.
(Event::Html(tag), Some(fig))
if tag.starts_with(OPEN_CAPTION) =>
{
if fig.in_caption {
return Err(anyhow!(bad_open(OPEN_CAPTION)));
} else {
if tag.trim().ends_with(CLOSE_CAPTION) {
let text = Dom::parse(tag.as_ref())?
.children
.into_iter()
.filter_map(text_of)
.collect::<String>();
if text.is_empty() {
return Err(anyhow!(
"Missing caption in `<figcaption>`"
));
}
fig.events.push(Event::Text(text.into()));
} else {
fig.events.push(Event::Text("\n".into()));
fig.in_caption = true;
}
}
}
(Event::Html(tag), None) if tag.starts_with(OPEN_CAPTION) => {
return Err(anyhow!(bad_open(OPEN_CAPTION)))
}
// -- Close captions
(Event::Html(tag), Some(fig))
if tag.trim() == CLOSE_CAPTION =>
{
if fig.in_caption {
fig.events.push(Event::Text("\n".into()));
fig.in_caption = false;
} else {
return Err(anyhow!(bad_close(
CLOSE_CAPTION,
OPEN_CAPTION
)));
}
}
(Event::Html(tag), None) if tag.trim() == CLOSE_CAPTION => {
return Err(anyhow!(bad_close(CLOSE_CAPTION, OPEN_FIGURE)));
}
// Otherwise, if in the body of a figure, push whatever other
// events without modification into the figure state.
(ev, Some(ref mut figure)) => figure.events.push(ev),
// And if not in a figure, no modifications whatsoever.
(ev, None) => state.events.push(ev),
}
Ok(state)
},
)?;
if final_state.current.is_some() {
return Err(anyhow!("Unclosed `<figure>`"));
}
let mut rewritten = String::new();
cmark(final_state.events.into_iter(), &mut rewritten)?;
Ok(rewritten)
}
fn text_of(node: Node) -> Option<String> {
match node {
Node::Text(text) => Some(text),
Node::Element(element) => {
Some(element.children.into_iter().filter_map(text_of).collect())
}
Node::Comment(_) => None,
}
}
fn bad_open(tag: &str) -> String {
format!("Opening `<{tag}>` while not in a `<figure>`.")
}
fn bad_close(close: &str, required_open: &str) -> String {
format!("Closing `<{close}>` while not in a `<{required_open}>`.")
}
#[derive(Debug)]
struct State<'e> {
current: Option<Figure<'e>>,
events: Vec<Event<'e>>,
}
#[derive(Debug)]
struct Figure<'e> {
events: Vec<Event<'e>>,
in_caption: bool,
}
impl<'e> Figure<'e> {
fn new() -> Figure<'e> {
Figure {
events: vec![],
in_caption: false,
}
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,60 @@
use super::*;
#[test]
fn text_without_figures_is_ignored() {
let actual = rewrite_figure("This is some basic text.").unwrap();
assert_eq!(actual, "This is some basic text.");
}
#[test]
fn text_with_figure_replaces_it_with_simple_text() {
let actual = rewrite_figure(
r#"<figure>
<img src="http://www.example.com/some-image.jpg">
<figcaption>Figure 12-34: Look at this cool picture!</figcaption>
</figure>"#,
)
.unwrap();
let expected = r#"
<img src="http://www.example.com/some-image.jpg">
Figure 12-34: Look at this cool picture!
"#;
assert_eq!(actual, expected);
}
#[test]
fn unclosed_figure() {
let result = rewrite_figure("<figure>");
let actual = format!("{:?}", result.unwrap_err());
assert_eq!(actual, "Unclosed `<figure>`");
}
#[test]
fn empty_caption() {
let result = rewrite_figure(
"<figure>
<figcaption></figcaption>
</figure>",
);
let actual = format!("{:?}", result.unwrap_err());
assert_eq!(actual, "Missing caption in `<figcaption>`");
}
#[test]
fn unclosed_caption() {
let result = rewrite_figure(
"<figure>
<figcaption>
</figure>",
);
let actual = format!("{:?}", result.unwrap_err());
assert_eq!(actual, "Unclosed `<figcaption>`");
}

View File

@@ -0,0 +1,35 @@
mod config;
mod figure;
mod listing;
mod note;
pub use config::Mode;
pub use figure::TrplFigure as Figure;
pub use listing::TrplListing as Listing;
pub use note::TrplNote as Note;
use pulldown_cmark::{Options, Parser};
/// Convenience function to get a parser matching `mdbook::new_cmark_parser`.
///
/// This is implemented separately so we are decoupled from mdbook's dependency
/// versions and can update at will (albeit with care to stay aligned with what
/// mdbook does!) to later versions of `pulldown-cmark` and related tools.
///
/// Notes:
///
/// - `mdbook::new_cmark_parser` has an additional parameter which allows smart
/// punctuation to be enabled or disabled; we always enable it.
/// - We do not use footnotes in the text at present, but this goes out of its
/// way to match this up to the old footnotes behavior just to make sure the
/// parsing etc. is all the same.
pub fn parser(text: &str) -> Parser<'_> {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_OLD_FOOTNOTES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
Parser::new_ext(text, opts)
}

View File

@@ -3,12 +3,13 @@ use mdbook::{
book::Book,
errors::Result,
preprocess::{Preprocessor, PreprocessorContext},
utils::new_cmark_parser,
BookItem,
};
use pulldown_cmark::{html, Event};
use pulldown_cmark_to_cmark::cmark;
use crate::config::Mode;
/// A preprocessor for rendering listings more elegantly.
///
/// Given input like this:
@@ -59,28 +60,9 @@ impl Preprocessor for TrplListing {
}
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
let config = ctx
.config
.get_preprocessor(self.name())
.ok_or(Error::NoConfig)?;
let mode = Mode::from_context(ctx, self.name())?;
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<String> = vec![];
let mut errors = vec![];
book.for_each_mut(|item| {
if let BookItem::Chapter(ref mut chapter) = item {
match rewrite_listing(&chapter.content, mode) {
@@ -102,153 +84,114 @@ impl Preprocessor for TrplListing {
}
}
#[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,
}
fn rewrite_listing(src: &str, mode: Mode) -> Result<String, String> {
match mode {
Mode::Default => {
let final_state = crate::parser(src).try_fold(
RewriteState {
current: None,
events: vec![],
},
|mut state, ev| {
match ev {
Event::Html(tag) => {
if tag.starts_with("<Listing") {
state.open_listing(tag, mode)?;
} else if tag.starts_with("</Listing>") {
state.close_listing(tag);
} else {
state.events.push(Ok(Event::Html(tag)));
}
}
ev => state.events.push(Ok(ev)),
};
Ok::<RewriteState<'_>, String>(state)
},
)?;
/// Trivial marker struct to indicate an internal error.
///
/// The caller has enough info to do what it needs without passing data around.
struct ParseErr;
if final_state.current.is_some() {
return Err("Unclosed listing".into());
}
impl TryFrom<&str> for Mode {
type Error = ParseErr;
let (events, errors): (Vec<_>, Vec<_>) =
final_state.events.into_iter().partition(|e| e.is_ok());
fn try_from(value: &str) -> std::prelude::v1::Result<Self, Self::Error> {
match value {
"default" => Ok(Mode::Default),
"simple" => Ok(Mode::Simple),
_ => Err(ParseErr),
if !errors.is_empty() {
return Err(errors
.into_iter()
.map(|e| e.unwrap_err())
.collect::<Vec<String>>()
.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)
}
Mode::Simple => {
// The output text should be very slightly *shorter* than the input,
// so we know this is a reasonable size for the buffer.
let mut rewritten = String::with_capacity(src.len());
let mut current_closing = None;
for line in src.lines() {
if line.starts_with("<Listing") && (line.ends_with(">")) {
let listing =
ListingBuilder::from_tag(&line)?.build(Mode::Simple);
rewritten.push_str(&listing.opening_text());
current_closing = Some(listing.closing_text("\n"));
} else if line == "</Listing>" {
let closing =
current_closing.as_ref().ok_or_else(|| {
String::from(
"Closing `</Listing>` without opening tag.",
)
})?;
rewritten.push_str(closing);
} else {
rewritten.push_str(line);
rewritten.push('\n');
}
}
// Since we always push a `'\n'` onto the end of the new string and
// `.lines()` does not tell us whether there *was* such a character,
// this makes the output match the input, and thus avoids adding new
// newlines after conversion.
if !src.ends_with('\n') {
rewritten.pop();
}
Ok(rewritten)
}
}
}
fn rewrite_listing(src: &str, mode: Mode) -> Result<String, String> {
let final_state = new_cmark_parser(src, true).try_fold(
ListingState {
current: None,
events: vec![],
},
|mut state, ev| {
match ev {
Event::Html(tag) => {
if tag.starts_with("<Listing") {
state.open_listing(tag, mode)?;
} else if tag.starts_with("</Listing>") {
state.close_listing(tag, mode);
} else {
state.events.push(Ok(Event::Html(tag)));
}
}
ev => state.events.push(Ok(ev)),
};
Ok::<ListingState<'_>, String>(state)
},
)?;
if final_state.current.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::<Vec<String>>()
.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)
}
struct ListingState<'e> {
struct RewriteState<'e> {
current: Option<Listing>,
events: Vec<Result<Event<'e>, String>>,
}
impl<'e> ListingState<'e> {
impl<'e> RewriteState<'e> {
fn open_listing(
&mut self,
tag: pulldown_cmark::CowStr<'_>,
mode: Mode,
) -> Result<(), String> {
// We do not *keep* the version constructed here, just temporarily
// construct it so the HTML parser, which expects properly closed tags
// to parse it as a *tag* rather than a *weird text node*, will accept
// it and provide a useful view of it.
let to_parse = tag.to_owned().to_string() + "</Listing>";
let listing = Dom::parse(&to_parse)
.map_err(|e| e.to_string())?
.children
.into_iter()
.filter_map(|node| match node {
html_parser::Node::Element(element) => Some(element.attributes),
html_parser::Node::Text(_) | html_parser::Node::Comment(_) => {
None
}
})
.flatten()
.try_fold(ListingBuilder::new(), |builder, (key, maybe_value)| {
match (key.as_str(), maybe_value) {
("number", Some(value)) => Ok(builder.with_number(value)),
("caption", Some(value)) => Ok(builder.with_caption(value)),
("file-name", Some(value)) => {
Ok(builder.with_file_name(value))
}
(attr @ "file-name", None)
| (attr @ "caption", None)
| (attr @ "number", None) => {
Err(format!("Missing value for attribute: '{attr}'"))
}
(attr, _) => {
Err(format!("Unsupported attribute name: '{attr}'"))
}
}
})?
.build();
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())
}
};
let listing = ListingBuilder::from_tag(&tag)?.build(mode);
let opening_event = Event::Html(listing.opening_html().into());
self.current = Some(listing);
self.events.push(Ok(opening_event));
Ok(())
}
fn close_listing(&mut self, tag: pulldown_cmark::CowStr<'_>, mode: Mode) {
fn close_listing(&mut self, tag: pulldown_cmark::CowStr<'_>) {
let trailing = if !tag.ends_with('>') {
tag.replace("</Listing>", "")
} else {
@@ -257,16 +200,8 @@ impl<'e> ListingState<'e> {
match &self.current {
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())
}
};
let closing_event =
Event::Html(listing.closing_html(&trailing).into());
self.current = None;
self.events.push(Ok(closing_event));
@@ -320,7 +255,7 @@ impl Listing {
fn opening_text(&self) -> String {
self.file_name
.as_ref()
.map(|file_name| format!("\nFilename: {file_name}\n"))
.map(|file_name| format!("Filename: {file_name}\n"))
.unwrap_or_default()
}
@@ -336,6 +271,9 @@ impl Listing {
}
}
/// Note: Although this has the same structure as [`Listing`], it does not have
/// the same *semantics*. In particular, this has the *source* for the `caption`
/// while `Listing` has the *rendered* version.
struct ListingBuilder {
number: Option<String>,
caption: Option<String>,
@@ -343,12 +281,46 @@ struct ListingBuilder {
}
impl ListingBuilder {
fn new() -> ListingBuilder {
ListingBuilder {
number: None,
caption: None,
file_name: None,
}
fn from_tag(tag: &str) -> Result<ListingBuilder, String> {
let to_parse = format!("{tag}</Listing>");
Dom::parse(&to_parse)
.map_err(|e| e.to_string())?
.children
.into_iter()
.filter_map(|node| match node {
html_parser::Node::Element(element) => Some(element.attributes),
html_parser::Node::Text(_) | html_parser::Node::Comment(_) => {
None
}
})
.flatten()
.try_fold(
ListingBuilder {
number: None,
caption: None,
file_name: None,
},
|builder, (key, maybe_value)| match (key.as_str(), maybe_value)
{
("number", Some(value)) => Ok(builder.with_number(value)),
("caption", Some(value)) => Ok(builder.with_caption(value)),
("file-name", Some(value)) => {
Ok(builder.with_file_name(value))
}
(attr @ "file-name", None)
| (attr @ "caption", None)
| (attr @ "number", None) => {
Err(format!("Missing value for attribute: '{attr}'"))
}
(attr, _) => {
Err(format!("Unsupported attribute name: '{attr}'"))
}
},
)
}
fn with_number(mut self, value: String) -> Self {
@@ -366,17 +338,20 @@ impl ListingBuilder {
self
}
fn build(self) -> Listing {
let caption = self.caption.map(|caption_source| {
let events = new_cmark_parser(&caption_source, true);
let mut buf = String::with_capacity(caption_source.len() * 2);
html::push_html(&mut buf, events);
fn build(self, mode: Mode) -> Listing {
let caption = match mode {
Mode::Default => self.caption.map(|caption_source| {
let events = crate::parser(&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("<p>", "").replace("</p>", "").replace('\n', "")
});
// This is not particularly principled, but since the only
// place it is used is here, for caption source handling, it
// is “fine”.
buf.replace("<p>", "").replace("</p>", "").replace('\n', "")
}),
Mode::Simple => self.caption,
};
Listing {
number: self.number.map(String::from),

View File

@@ -33,26 +33,33 @@ fn main() {}
#[test]
fn simple_mode_works() {
let result = rewrite_listing(
r#"<Listing number="1-2" caption="A write-up which *might* include inline Markdown like `code` etc." file-name="src/main.rs">
r#"Leading text.
<Listing number="1-2" caption="A write-up which *might* include inline Markdown like `code` etc." file-name="src/main.rs">
```rust
fn main() {}
```
</Listing>"#,
</Listing>
Trailing text."#,
Mode::Simple,
);
assert_eq!(
&result.unwrap(),
r#"
r#"Leading text.
Filename: src/main.rs
````rust
```rust
fn main() {}
````
```
Listing 1-2: A write-up which <em>might</em> include inline Markdown like <code>code</code> etc."#
Listing 1-2: A write-up which *might* include inline Markdown like `code` etc.
Trailing text."#
);
}
@@ -287,9 +294,3 @@ fn main() {}
)
}
}
#[test]
fn missing_value() {}
#[cfg(test)]
mod config;

View File

@@ -0,0 +1,145 @@
use mdbook::{
book::Book,
errors::Result,
preprocess::{Preprocessor, PreprocessorContext},
BookItem,
};
use pulldown_cmark::{
Event::{self, *},
Tag, TagEnd,
};
use pulldown_cmark_to_cmark::cmark;
/// A simple preprocessor for semantic notes in _The Rust Programming Language_.
///
/// Takes in Markdown like this:
///
/// ```markdown
/// > Note: This is a note.
/// ```
///
/// Spits out Markdown like this:
///
/// ```markdown
/// <section class="note" aria-role="note">
///
/// This is a note.
///
/// </section>
/// ```
pub struct TrplNote;
impl Preprocessor for TrplNote {
fn name(&self) -> &str {
"simple-note-preprocessor"
}
fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
book.for_each_mut(|item| {
if let BookItem::Chapter(ref mut chapter) = item {
chapter.content = rewrite(&chapter.content);
}
});
Ok(book)
}
fn supports_renderer(&self, renderer: &str) -> bool {
renderer == "html" || renderer == "markdown" || renderer == "test"
}
}
pub fn rewrite(text: &str) -> String {
let parser = crate::parser(text);
let mut events = Vec::new();
let mut state = Default;
for event in parser {
match (&mut state, event) {
(Default, Start(Tag::BlockQuote(_))) => {
state = StartingBlockquote(vec![Start(Tag::BlockQuote(None))]);
}
(StartingBlockquote(blockquote_events), Text(content)) => {
if content.starts_with("Note: ") {
// This needs the "extra" `SoftBreak`s so that when the final rendering pass
// happens, it does not end up treating the internal content as inline *or*
// treating the HTML tags as inline tags:
//
// - Content inside HTML blocks is only rendered as Markdown when it is
// separated from the block HTML elements: otherwise it gets treated as inline
// HTML and *not* rendered.
// - Along the same lines, an HTML tag that happens to be directly adjacent to
// the end of a previous Markdown block will end up being rendered as part of
// that block.
events.extend([
SoftBreak,
SoftBreak,
Html(
r#"<section class="note" aria-role="note">"#.into(),
),
SoftBreak,
SoftBreak,
Start(Tag::Paragraph),
Text(content),
]);
state = InNote;
} else {
events.append(blockquote_events);
events.push(Text(content));
state = Default;
}
}
(
StartingBlockquote(_blockquote_events),
heading @ Start(Tag::Heading { .. }),
) => {
events.extend([
SoftBreak,
SoftBreak,
Html(r#"<section class="note" aria-role="note">"#.into()),
SoftBreak,
SoftBreak,
heading,
]);
state = InNote;
}
(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("</section>".into()),
]);
state = Default;
}
(_, event) => {
events.push(event);
}
}
}
let mut buf = String::new();
cmark(events.into_iter(), &mut buf).unwrap();
buf
}
use State::*;
#[derive(Debug)]
enum State<'e> {
Default,
StartingBlockquote(Vec<Event<'e>>),
InNote,
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,195 @@
use super::*;
#[test]
fn no_note() {
let text = "Hello, world.\n\nThis is some text.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<p>Hello, world.</p>\n<p>This is some text.</p>\n"
);
}
#[test]
fn with_note() {
let text = "> Note: This is some text.\n> It keeps going.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<p>Note: This is some text.\nIt keeps going.</p>\n</section>"
);
}
#[test]
fn regular_blockquote() {
let text = "> This is some text.\n> It keeps going.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>This is some text.\nIt keeps going.</p>\n</blockquote>\n"
);
}
#[test]
fn combined() {
let text = "> Note: This is some text.\n> It keeps going.\n\nThis is regular text.\n\n> This is a blockquote.\n";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<p>Note: This is some text.\nIt keeps going.</p>\n</section>\n<p>This is regular text.</p>\n<blockquote>\n<p>This is a blockquote.</p>\n</blockquote>\n"
);
}
#[test]
fn blockquote_then_note() {
let text = "> This is quoted.\n\n> Note: This is noted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n<section class=\"note\" aria-role=\"note\">\n<p>Note: This is noted.</p>\n</section>"
);
}
#[test]
fn note_then_blockquote() {
let text = "> Note: This is noted.\n\n> This is quoted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<p>Note: This is noted.</p>\n</section>\n<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n"
);
}
#[test]
fn with_h1_note() {
let text = "> # Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h1>Header</h1>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h2_note() {
let text = "> ## Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h2>Header</h2>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h3_note() {
let text = "> ### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h3>Header</h3>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h4_note() {
let text = "> #### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h4>Header</h4>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h5_note() {
let text = "> ##### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h5>Header</h5>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn with_h6_note() {
let text = "> ###### Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h6>Header</h6>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn h1_then_blockquote() {
let text =
"> # Header\n > And then some note content.\n\n> This is quoted.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<section class=\"note\" aria-role=\"note\">\n<h1>Header</h1>\n<p>And then some note content.</p>\n</section>\n<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n"
);
}
#[test]
fn blockquote_then_h1_note() {
let text =
"> This is quoted.\n\n> # Header\n > And then some note content.";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>This is quoted.</p>\n</blockquote>\n<section class=\"note\" aria-role=\"note\">\n<h1>Header</h1>\n<p>And then some note content.</p>\n</section>"
);
}
#[test]
fn blockquote_with_strong() {
let text = "> **Bold text in a paragraph.**";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p><strong>Bold text in a paragraph.</strong></p>\n</blockquote>\n"
);
}
#[test]
fn normal_table() {
let text = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Text 123 | More 456 |";
let processed = rewrite(text);
assert_eq!(
processed,
"|Header 1|Header 2|\n|--------|--------|\n|Text 123|More 456|",
"It strips some whitespace but otherwise leaves the table intact."
);
}
#[test]
fn table_in_note() {
let text = "> Note: table stuff.\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Text 123 | More 456 |";
let processed = rewrite(text);
assert_eq!(
processed,
"\n\n<section class=\"note\" aria-role=\"note\">\n\nNote: table stuff.\n\n</section>\n\n|Header 1|Header 2|\n|--------|--------|\n|Text 123|More 456|",
"It adds the note markup but leaves the table untouched, to be rendered as Markdown."
);
}
#[test]
fn table_in_quote() {
let text = "> A table.\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Text 123 | More 456 |";
let processed = rewrite(text);
assert_eq!(
render_markdown(&processed),
"<blockquote>\n<p>A table.</p>\n</blockquote>\n<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead><tbody>\n<tr><td>Text 123</td><td>More 456</td></tr>\n</tbody></table>\n",
"It renders blockquotes with nested tables as expected."
);
}
fn render_markdown(text: &str) -> String {
let parser = crate::parser(text);
let mut buf = String::new();
pulldown_cmark::html::push_html(&mut buf, parser);
buf
}

View File

@@ -0,0 +1,20 @@
mod note {
use assert_cmd::Command;
#[test]
fn supports_html_renderer() {
let cmd = Command::cargo_bin("mdbook-trpl-note")
.unwrap()
.args(["supports", "html"])
.ok();
assert!(cmd.is_ok());
}
#[test]
fn errors_for_other_renderers() {
let cmd = Command::cargo_bin("mdbook-trpl-note")
.unwrap()
.args(["supports", "total-nonsense"])
.ok();
assert!(cmd.is_err());
}
}

View File

@@ -4,13 +4,7 @@ set -eu
cargo build --release
cd packages/mdbook-trpl-listing
cargo install --locked --path .
cd ../mdbook-trpl-note
cargo install --locked --path .
cd ../..
cargo install --locked --path ./packages/mdbook_trpl
mkdir -p tmp
rm -rf tmp/*.md