Skip to content

Commit

Permalink
factor rendering into trait
Browse files Browse the repository at this point in the history
Fixes #21.
  • Loading branch information
euclio committed Jul 5, 2022
1 parent bcfafde commit 31587a5
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 94 deletions.
7 changes: 5 additions & 2 deletions benches/benches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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);

Expand Down
118 changes: 32 additions & 86 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
//! [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
//!
//! ```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::<SocketAddr>()?;
//! let mut server = Server::bind(&addr).await?;
//! let mut server = Server::bind(&addr, MarkdownRenderer::new()).await?;
//!
//! server.open_browser()?;
//!
Expand Down Expand Up @@ -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<R> {
addr: SocketAddr,
config: Arc<RwLock<Config>>,
external_renderer: Option<RefCell<Command>>,
renderer: R,
output: RefCell<String>,
tx: Sender<String>,
_shutdown_tx: oneshot::Sender<()>,
}

impl Server {
/// Binds the server to a specified address.
impl<R> Server<R>
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<Self> {
pub async fn bind(addr: &SocketAddr, renderer: R) -> io::Result<Server<R>> {
let (tx, rx) = watch::channel(String::new());
let (shutdown_tx, shutdown_rx) = oneshot::channel();

Expand Down Expand Up @@ -111,7 +119,7 @@ impl Server {
Ok(Server {
addr,
config,
external_renderer: None,
renderer,
tx,
output: RefCell::new(String::new()),
_shutdown_tx: shutdown_tx,
Expand All @@ -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));

Expand All @@ -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<PathBuf>) {
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
/// use std::net::SocketAddr;
/// use tokio::process::Command;
/// use aurelius::Server;
///
/// let addr = "127.0.0.1:1337".parse::<SocketAddr>()?;
/// 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
Expand Down Expand Up @@ -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<Server> {
async fn new_server() -> anyhow::Result<Server<MarkdownRenderer>> {
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<S: AsyncRead + AsyncWrite + Unpin>(
Expand Down
33 changes: 33 additions & 0 deletions src/render.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
59 changes: 59 additions & 0 deletions src/render/command.rs
Original file line number Diff line number Diff line change
@@ -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<Command>,
}

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(())
}
}
45 changes: 45 additions & 0 deletions src/render/markdown.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 3 additions & 2 deletions tests/it/main.rs
Original file line number Diff line number Diff line change
@@ -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<Server> {
async fn new_server() -> anyhow::Result<Server<MarkdownRenderer>> {
let addr = lookup_host("localhost:0").await?.next().unwrap();
Ok(Server::bind(&addr).await?)
Ok(Server::bind(&addr, MarkdownRenderer::new()).await?)
}
Loading

0 comments on commit 31587a5

Please sign in to comment.