dmos 0.7.0

Djot HTML renderer with advanced features
Documentation
//! A [djot](https://djot.net) renderer with advanced features.
//!
//! DMOS builds on the [jotdown] djot parser and its default
//! HTML renderer, but adds the following features:
//!
//! * Syntax highlighting (if enabled, see [feature flags](#feature-flags))
//! * Table of contents generation with [various options][TocOpts]
//! * Adding [anchors][RenderOpts::anchor] to section headings
//! * Convenient extraction of document [title](title())
//! * Extraction of document [metadata](meta()) from specific attributes
//!
//! The main entry points are the module's [`render()`], [`title()`], [`toc()`],
//! and [`meta()`] functions. See [`RenderOpts`] and [`TocOpts`] for options
//! controlling the output. For more low-level customizations, the individual
//! renderers could also be used, but make sure to use the re-exported
//! [`jotdown`] module when working with them.
//!
//! # Feature flags
//!
//! - `emojis` (default): support for emojis via djot symbols
//! - `syntect` (default): syntax highlighting for code block based on
//!   [syntect]
//! - `inkjet`: syntax highlighting based on [inkjet]
//!
//! [jotdown]: https://crates.io/crates/jotdown
//! [syntect]: https://crates.io/crates/syntect
//! [inkjet]: https://crates.io/crates/inkjet

mod dmos;
mod meta;
mod opts;
mod title;
mod toc;

use std::fmt;
use std::io;

use jotdown::Parser;
use jotdown::Render;

pub use jotdown;

pub use dmos::DocumentRenderer;
pub use meta::MetaRenderer;
pub use opts::MetaFormat;
pub use opts::MetaOpts;
pub use opts::RenderOpts;
pub use opts::TocOpts;
pub use title::TitleRenderer;
pub use toc::TocRenderer;

#[derive(thiserror::Error, Debug)]
pub enum Error {
    /// The document title could not be determined (see [`title()`]).
    #[error("no title found")]
    TitleNotFound,
    #[error("error rendering output")]
    Fmt(#[from] fmt::Error),
    /// An error occured during rendering of the table of contents.
    #[error("error rendering table of contents")]
    Toc(#[source] fmt::Error),
    /// An error occured during rendering of the document.
    #[error("error rendering document: {0}")]
    Doc(#[source] std::io::Error),
}

/// Renders a representation of metadata, based on specific djot attributes.
///
/// Djot itself does not define a mechanism for document metadata, but DMOS
/// considers any djot attribute attached to headings whose key starts with
/// `dmos:` to be DMOS-specific metadata. While these attributes _can_ occur
/// anywhere in a document, by convention they should be attached to the first
/// heading.
///
/// The metadata can be rendered either as JSON (see example below), or as
/// shell variables in a syntax that can be written to a file which in turn can
/// be sourced by any POSIX-compliant shell.
///
/// # Example
///
/// Given the following djot snippet:
///
/// ```md
/// {dmos:title="My title"}
/// {dmos:author="Conrad Hoffmann"}
/// # My title, but maybe longer
/// ```
///
/// DMOS would consider these attributes metadata, meaning they would get
/// removed during regular rendering of the document. Rendering this snippet's
/// metadata as JSON would yield the following result:
///
/// ```json
/// {
///   "dmos_title": "My title",
///   "dmos_author": "Conrad Hoffmann"
/// }
/// ```
///
/// **NOTE:** `dmos:title` is a special metadata attribute, as its value
/// also override DMOS's title extraction.
pub fn meta<W>(input: &str, out: &mut W, opts: MetaOpts) -> Result<(), Error>
where
    W: io::Write,
{
    let events = Parser::new(input);
    MetaRenderer::new(opts)
        .write_events(events, out)
        .map_err(Error::Doc)?;
    Ok(())
}

/// Returns the plain-text title of the document.
///
/// The title is assumed to be the first top-level heading, stripped from any
/// formatting. This can be useful, for example to write the title of a document
/// to an HTML `<title>` element.
///
/// The document title can also be overridden by setting specific
/// [metadata](meta()) attributes.
pub fn title(input: &str) -> Result<String, Error> {
    let mut title = String::new();
    let events = Parser::new(input);
    TitleRenderer::default()
        .push_events(events, &mut title)
        .map_err(|_| Error::TitleNotFound)?;
    Ok(title.trim_end().to_string())
}

/// Renders a table of contents for the document as HTML.
///
/// See [`TocOpts`] for controlling various aspects of the rendering.
pub fn toc(input: &str, opts: TocOpts) -> Result<String, Error> {
    let mut toc = String::new();
    let events = Parser::new(input);
    TocRenderer::new(opts)
        .push_events(events, &mut toc)
        .map_err(Error::Toc)?;
    Ok(toc)
}

/// Renders a djot document to HTML.
///
/// Syntax highlighting will be attempted for all fenced code blocks with a
/// language specifier. See [`RenderOpts`] for controlling other aspects of the
/// rendering.
pub fn render<W>(input: &str, out: &mut W, opts: RenderOpts) -> Result<(), Error>
where
    W: io::Write,
{
    let events = Parser::new(input);
    let toc_skip_title = opts.toc.skip_title;
    let toc = if opts.toc.render {
        let mut toc = String::new();
        TocRenderer::new(opts.toc)
            .push_events(events.clone(), &mut toc)
            .map_err(Error::Toc)?;
        Some(toc)
    } else {
        None
    };

    DocumentRenderer::new(
        opts.anchor,
        opts.no_emojis,
        opts.symbols,
        opts.skip_title,
        toc,
        toc_skip_title,
    )
    .write_events(events, out)
    .map_err(Error::Doc)?;
    Ok(())
}

#[cfg(test)]
mod test {
    use super::*;
    use indoc::indoc;

    const TEST_DOC_SIMPLE: &str = indoc! {r#"
        # Title heading

        First content.

        ## Section heading

        Lorem ipsum.
    "#};

    const TEST_DOC_ATTRIBUTES: &str = indoc! {r#"
        {dmos:title="Document title"}
        {dmos:author="Conrad Hoffmann"}
        # Title heading

        First content.

        ## Section heading

        Lorem ipsum.
    "#};

    #[test]
    fn render_simple_default() {
        let want = indoc! {r#"
            <section id="Title-heading">
            <h1>Title heading</h1>
            <p>First content.</p>
            <section id="Section-heading">
            <h2>Section heading</h2>
            <p>Lorem ipsum.</p>
            </section>
            </section>
        "#};
        let mut out: Vec<u8> = Vec::new();
        render(&TEST_DOC_SIMPLE, &mut out, RenderOpts::default()).unwrap();
        let have = String::from_utf8(out).unwrap();
        assert_eq!(have.as_str(), want);
    }

    #[test]
    fn render_simple_toc_default() {
        let want = indoc! {r##"
            <section class="toc"><h3>Table of Contents</h3>
            <ol>
            <li><a href="#Title-heading">Title heading</a>
            <ul>
            <li><a href="#Section-heading">Section heading</a>
            </ul>
            </li>
            </ol>
            </section>
            <section id="Title-heading">
            <h1>Title heading</h1>
            <p>First content.</p>
            <section id="Section-heading">
            <h2>Section heading</h2>
            <p>Lorem ipsum.</p>
            </section>
            </section>
        "##};
        let opts = RenderOpts {
            toc: TocOpts {
                render: true,
                ..Default::default()
            },
            ..Default::default()
        };
        let mut out: Vec<u8> = Vec::new();
        render(&TEST_DOC_SIMPLE, &mut out, opts).unwrap();
        let have = String::from_utf8(out).unwrap();
        assert_eq!(have.as_str(), want);
    }

    #[test]
    fn render_simple_toc_skip_title() {
        let want = indoc! {r##"
            <section id="Title-heading">
            <h1>Title heading</h1>
            <section class="toc"><h3>Table of Contents</h3>
            <ol>
            <li><a href="#Section-heading">Section heading</a></li>
            </ol>
            </section>
            <p>First content.</p>
            <section id="Section-heading">
            <h2>Section heading</h2>
            <p>Lorem ipsum.</p>
            </section>
            </section>
        "##};
        let opts = RenderOpts {
            toc: TocOpts {
                render: true,
                skip_title: true,
                ..Default::default()
            },
            ..Default::default()
        };
        let mut out: Vec<u8> = Vec::new();
        render(&TEST_DOC_SIMPLE, &mut out, opts).unwrap();
        let have = String::from_utf8(out).unwrap();
        assert_eq!(have.as_str(), want);
    }

    #[test]
    fn meta_simple() {
        // Not pretty, but hey
        let want = indoc! {"
        {

        }
        "};
        let mut out: Vec<u8> = Vec::new();
        meta(
            &TEST_DOC_SIMPLE,
            &mut out,
            MetaOpts {
                format: MetaFormat::Json,
            },
        )
        .unwrap();
        let have = String::from_utf8(out).unwrap();
        assert_eq!(have.as_str(), want);
    }

    #[test]
    fn meta_attributes() {
        let want = indoc! {r#"
            {
            "dmos_title": "Document title",
            "dmos_author": "Conrad Hoffmann"
            }
        "#};
        let mut out: Vec<u8> = Vec::new();
        meta(
            &TEST_DOC_ATTRIBUTES,
            &mut out,
            MetaOpts {
                format: MetaFormat::Json,
            },
        )
        .unwrap();
        let have = String::from_utf8(out).unwrap();
        assert_eq!(have.as_str(), want);
    }

    #[test]
    fn title_simple() {
        let title = title(&TEST_DOC_SIMPLE).unwrap();
        assert_eq!(title.as_str(), "Title heading");
    }
}