mirror of
https://github.com/rust-lang/book.git
synced 2026-07-16 15:18:30 -04:00
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:
14
.github/workflows/main.yml
vendored
14
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1087
packages/mdbook-trpl-note/Cargo.lock
generated
1087
packages/mdbook-trpl-note/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
13
packages/mdbook-trpl/README.md
Normal file
13
packages/mdbook-trpl/README.md
Normal 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/
|
||||
42
packages/mdbook-trpl/src/bin/figure.rs
Normal file
42
packages/mdbook-trpl/src/bin/figure.rs
Normal 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 },
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
71
packages/mdbook-trpl/src/config/mod.rs
Normal file
71
packages/mdbook-trpl/src/config/mod.rs
Normal 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;
|
||||
@@ -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'"
|
||||
);
|
||||
}
|
||||
252
packages/mdbook-trpl/src/figure/mod.rs
Normal file
252
packages/mdbook-trpl/src/figure/mod.rs
Normal 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 figure’s 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;
|
||||
60
packages/mdbook-trpl/src/figure/tests.rs
Normal file
60
packages/mdbook-trpl/src/figure/tests.rs
Normal 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>`");
|
||||
}
|
||||
35
packages/mdbook-trpl/src/lib.rs
Normal file
35
packages/mdbook-trpl/src/lib.rs
Normal 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)
|
||||
}
|
||||
@@ -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),
|
||||
@@ -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;
|
||||
145
packages/mdbook-trpl/src/note/mod.rs
Normal file
145
packages/mdbook-trpl/src/note/mod.rs
Normal 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;
|
||||
195
packages/mdbook-trpl/src/note/tests.rs
Normal file
195
packages/mdbook-trpl/src/note/tests.rs
Normal 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
|
||||
}
|
||||
20
packages/mdbook-trpl/tests/integration/main.rs
Normal file
20
packages/mdbook-trpl/tests/integration/main.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user