diff --git a/benches/benches.rs b/benches/benches.rs index dc52e16..8522948 100644 --- a/benches/benches.rs +++ b/benches/benches.rs @@ -2,6 +2,7 @@ use criterion::BenchmarkId; use criterion::{black_box, criterion_group, criterion_main, Criterion}; use tokio::runtime; +use aurelius::render::MarkdownRenderer; use aurelius::Server; fn from_elem(c: &mut Criterion) { @@ -15,7 +16,8 @@ fn from_elem(c: &mut Criterion) { let addr = "127.0.0.1:0".parse().unwrap(); - let server = runtime.block_on(async { Server::bind(&addr).await.unwrap() }); + let server = + runtime.block_on(async { Server::bind(&addr, MarkdownRenderer::new()).await.unwrap() }); b.to_async(&runtime).iter(|| async { server.send(black_box("Hello, world!")).await.unwrap(); @@ -30,7 +32,8 @@ fn from_elem(c: &mut Criterion) { let addr = "127.0.0.1:0".parse().unwrap(); - let server = runtime.block_on(async { Server::bind(&addr).await.unwrap() }); + let server = + runtime.block_on(async { Server::bind(&addr, MarkdownRenderer::new()).await.unwrap() }); let markdown = "a ".repeat(100_000); diff --git a/src/lib.rs b/src/lib.rs index 35b363f..d256e53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,14 @@ //! [aurelius](https://github.com/euclio/aurelius) is a complete solution for live-previewing -//! markdown as HTML. +//! markdown (and more!) as HTML. //! -//! This crate provides a server that can render and update an HTML preview of markdown without a +//! This crate provides a [`Server`] that can render and update an HTML preview of input without a //! client-side refresh. Upon receiving an HTTP request, the server responds with an HTML page -//! containing a rendering of supplied markdown. Client-side JavaScript then initiates a WebSocket +//! containing a rendering of the input. Client-side JavaScript then initiates a WebSocket //! connection which allows the server to push changes to the client. //! //! This crate was designed to power [vim-markdown-composer], a markdown preview plugin for -//! [Neovim](http://neovim.io), but it may be used to implement similar plugins for any editor. +//! [Neovim](http://neovim.io), but it may be used to implement similar plugins for any editor. It +//! also supports arbitrary renderers through the [`Renderer`] trait. //! See [vim-markdown-composer] for a real-world usage example. //! //! # Example @@ -15,10 +16,11 @@ //! ```no_run //! use std::net::SocketAddr; //! use aurelius::Server; +//! use aurelius::render::MarkdownRenderer; //! //! # tokio_test::block_on(async { //! let addr = "127.0.0.1:1337".parse::()?; -//! let mut server = Server::bind(&addr).await?; +//! let mut server = Server::bind(&addr, MarkdownRenderer::new()).await?; //! //! server.open_browser()?; //! @@ -52,38 +54,44 @@ use std::process::Stdio; use std::sync::{Arc, RwLock}; use axum::{extract::Extension, http::Uri, routing::get, Router}; -use pulldown_cmark::{Options, Parser}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use tokio::sync::oneshot; use tokio::sync::watch::{self, Sender}; use tower_http::trace::TraceLayer; use tracing::log::*; +pub mod render; mod service; -/// Markdown preview server. +use crate::render::Renderer; + +/// Live preview server. /// -/// Listens for HTTP connections and serves a page containing a live markdown preview. The page +/// Listens for HTTP connections and serves a page containing a live rendered preview. The page /// contains JavaScript to open a websocket connection back to the server for rendering updates. +/// +/// The server is asynchronous, and assumes that a `tokio` runtime is in use. #[derive(Debug)] -pub struct Server { +pub struct Server { addr: SocketAddr, config: Arc>, - external_renderer: Option>, + renderer: R, output: RefCell, tx: Sender, _shutdown_tx: oneshot::Sender<()>, } -impl Server { - /// Binds the server to a specified address. +impl Server +where + R: Renderer, +{ + /// Binds the server to a specified address `addr` using the provided `renderer`. /// /// Binding to port 0 will request a port assignment from the OS. Use [`addr()`][Self::addr] /// to determine what port was assigned. /// /// The server must be bound using a Tokio runtime. - pub async fn bind(addr: &SocketAddr) -> io::Result { + pub async fn bind(addr: &SocketAddr, renderer: R) -> io::Result> { let (tx, rx) = watch::channel(String::new()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -111,7 +119,7 @@ impl Server { Ok(Server { addr, config, - external_renderer: None, + renderer, tx, output: RefCell::new(String::new()), _shutdown_tx: shutdown_tx, @@ -123,38 +131,17 @@ impl Server { self.addr } - /// Publish new markdown to be rendered by the server. + /// Publish new input to be rendered by the server. /// /// The new HTML will be sent to all connected websocket clients. - /// - /// # Errors - /// - /// This method forwards errors from an external renderer, if set. Otherwise, the method is - /// infallible. - pub async fn send(&self, markdown: &str) -> io::Result<()> { + pub async fn send(&self, input: &str) -> Result<(), R::Error> { let mut output = self.output.take(); output.clear(); // Heuristic taken from rustdoc - output.reserve(markdown.len() * 3 / 2); - - if let Some(renderer) = &self.external_renderer { - let child = renderer.borrow_mut().spawn()?; + output.reserve(input.len() * 3 / 2); - child.stdin.unwrap().write_all(markdown.as_bytes()).await?; - - child.stdout.unwrap().read_to_string(&mut output).await?; - } else { - let parser = Parser::new_ext( - markdown, - Options::ENABLE_FOOTNOTES - | Options::ENABLE_TABLES - | Options::ENABLE_STRIKETHROUGH - | Options::ENABLE_TASKLISTS, - ); - - pulldown_cmark::html::push_html(&mut output, parser); - }; + self.renderer.render(input, &mut output)?; self.output.replace(self.tx.send_replace(output)); @@ -165,7 +152,7 @@ impl Server { /// /// This can be thought of as the "working directory" of the server. Any HTTP requests with /// non-root paths will be joined to this folder and used to serve files from the filesystem. - /// Typically this is used to serve image links relative to the markdown file. + /// Typically this is used to serve image links relative to the input file. /// /// By default, the server will not serve static files. pub fn set_static_root(&mut self, root: impl Into) { @@ -210,48 +197,6 @@ impl Server { Ok(()) } - /// Set an external program to use for rendering the markdown. - /// - /// By default, aurelius uses [`pulldown_cmark`] to render markdown in-process. - /// `pulldown-cmark` is an extremely fast, [CommonMark]-compliant parser that is sufficient - /// for most use-cases. However, other markdown renderers may provide additional features. - /// - /// The `Command` supplied to this function should expect markdown on stdin and print HTML on - /// stdout. - /// - /// # Example - /// - /// To use [`pandoc`] to render markdown: - /// - /// - /// ```no_run - /// # async fn dox() -> Result<(), Box> { - /// use std::net::SocketAddr; - /// use tokio::process::Command; - /// use aurelius::Server; - /// - /// let addr = "127.0.0.1:1337".parse::()?; - /// let mut server = Server::bind(&addr).await?; - /// - /// let mut pandoc = Command::new("pandoc"); - /// pandoc.args(&["-f", "markdown", "-t", "html"]); - /// - /// server.set_external_renderer(pandoc); - /// # Ok(()) - /// # } - /// ``` - /// - /// [`pulldown_cmark`]: https://github.com/raphlinus/pulldown-cmark - /// [CommonMark]: https://commonmark.org/ - /// [`pandoc`]: https://pandoc.org/ - pub fn set_external_renderer(&mut self, mut command: Command) { - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - self.external_renderer = Some(RefCell::new(command)); - } - /// Opens the user's default browser with the server's URL in the background. /// /// This function uses platform-specific utilities to determine the browser. The following @@ -320,11 +265,12 @@ mod tests { use tokio::net::lookup_host; use tokio::time::{timeout, Duration}; - use super::Server; + use crate::render::MarkdownRenderer; + use crate::Server; - async fn new_server() -> anyhow::Result { + async fn new_server() -> anyhow::Result> { let addr = lookup_host("localhost:0").await?.next().unwrap(); - Ok(Server::bind(&addr).await?) + Ok(Server::bind(&addr, MarkdownRenderer::new()).await?) } async fn assert_websocket_closed( diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..af6bdaf --- /dev/null +++ b/src/render.rs @@ -0,0 +1,33 @@ +//! HTML rendering. + +mod command; +mod markdown; + +pub use command::CommandRenderer; +pub use markdown::MarkdownRenderer; + +/// HTML renderer implementation. +/// +/// Implementors of this trait convert input into HTML. +pub trait Renderer { + /// Potential errors returned by the rendering. If rendering is infallible (for example, + /// markdown can always produce HTML from its input), this type can be set to + /// [`std::convert::Infallible`]. + type Error; + + /// Renders input as HTML. + /// + /// The HTML should be written directly into the `html` buffer. The buffer will be reused + /// between multiple calls to this method, with its capacity already reserved, so this function + /// only needs to write the HTML. + fn render(&self, input: &str, html: &mut String) -> Result<(), Self::Error>; + + /// A hint for how many bytes the output will be. + /// + /// This hint should be cheap to compute and is not required to be accurate. However, accurate + /// hints may improve performance by saving intermediate allocations when reserving capacity + /// for the output buffer. + fn size_hint(&self, input: &str) -> usize { + input.len() + } +} diff --git a/src/render/command.rs b/src/render/command.rs new file mode 100644 index 0000000..880e379 --- /dev/null +++ b/src/render/command.rs @@ -0,0 +1,59 @@ +use std::cell::RefCell; +use std::io::{self, prelude::*}; +use std::process::{Command, Stdio}; + +use super::Renderer; + +/// Renderer that uses an external command to render input. +/// +/// [`MarkdownRenderer`](crate::render::MarkdownRenderer) uses an extremely fast, in-memory parser +/// that is sufficient for most use-cases. However, this renderer may be useful if your markdown +/// requires features unsupported by [`pulldown_cmark`]. +/// +/// # Example +/// +/// Creating an external renderer that uses [pandoc](https://pandoc.org/) to render markdown: +/// +/// ```no_run +/// use std::process::Command; +/// use aurelius::render::CommandRenderer; +/// +/// let mut pandoc = Command::new("pandoc"); +/// pandoc.args(&["-f", "markdown", "-t", "html"]); +/// +/// CommandRenderer::new(pandoc); +/// ``` +#[derive(Debug)] +pub struct CommandRenderer { + command: RefCell, +} + +impl CommandRenderer { + /// Create a new external command renderer that will spawn processes using the given `command`. + /// + /// The provided [`Command`] should expect markdown input on stdin and print HTML on stdout. + pub fn new(mut command: Command) -> CommandRenderer { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + CommandRenderer { + command: RefCell::new(command), + } + } +} + +impl Renderer for CommandRenderer { + type Error = io::Error; + + fn render(&self, input: &str, html: &mut String) -> Result<(), Self::Error> { + let child = self.command.borrow_mut().spawn()?; + + child.stdin.unwrap().write_all(input.as_bytes())?; + + child.stdout.unwrap().read_to_string(html)?; + + Ok(()) + } +} diff --git a/src/render/markdown.rs b/src/render/markdown.rs new file mode 100644 index 0000000..e28e5ce --- /dev/null +++ b/src/render/markdown.rs @@ -0,0 +1,45 @@ +use std::convert::Infallible; + +use pulldown_cmark::{html, Options, Parser}; + +use super::Renderer; + +/// Markdown renderer that uses [`pulldown_cmark`] as the backend. +#[derive(Debug)] +pub struct MarkdownRenderer { + options: Options, +} + +impl MarkdownRenderer { + /// Create a new instance of the renderer. + pub fn new() -> MarkdownRenderer { + MarkdownRenderer { + options: Options::ENABLE_FOOTNOTES + | Options::ENABLE_TABLES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS, + } + } +} + +impl Default for MarkdownRenderer { + fn default() -> Self { + Self::new() + } +} + +impl Renderer for MarkdownRenderer { + type Error = Infallible; + + fn render(&self, markdown: &str, html: &mut String) -> Result<(), Self::Error> { + let parser = Parser::new_ext(markdown, self.options); + + html::push_html(html, parser); + + Ok(()) + } + + fn size_hint(&self, input: &str) -> usize { + input.len() * 3 / 2 + } +} diff --git a/tests/it/main.rs b/tests/it/main.rs index bcea6b7..d543528 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -1,11 +1,12 @@ use tokio::net::lookup_host; +use aurelius::render::MarkdownRenderer; use aurelius::Server; mod files; mod options; -async fn new_server() -> anyhow::Result { +async fn new_server() -> anyhow::Result> { let addr = lookup_host("localhost:0").await?.next().unwrap(); - Ok(Server::bind(&addr).await?) + Ok(Server::bind(&addr, MarkdownRenderer::new()).await?) } diff --git a/tests/it/options.rs b/tests/it/options.rs index 7cd990c..5021058 100644 --- a/tests/it/options.rs +++ b/tests/it/options.rs @@ -93,11 +93,15 @@ async fn highlight_theme() -> Result<(), Box> { #[cfg(not(windows))] #[tokio::test] async fn external_renderer() -> Result<(), Box> { - use tokio::process::Command; + use aurelius::render::CommandRenderer; + use aurelius::Server; + use std::process::Command; - let mut server = new_server().await?; - - server.set_external_renderer(Command::new("cat")); + let addr = tokio::net::lookup_host("localhost:0") + .await? + .next() + .unwrap(); + let server = Server::bind(&addr, CommandRenderer::new(Command::new("cat"))).await?; let (mut websocket, _) = async_tungstenite::tokio::connect_async(format!("ws://{}", server.addr())).await?;