kladd 0.2.0

A weirdly personal markup language
Documentation
use htmlize::{escape_attribute, escape_text};

use crate::{
    ast::{
        AstNode, AttributeKind, AttributeValue, Attributes, CodeNode, Document, HeadingNode,
        LinkNode, NamedNode, NodeKind, NodeTag, Quote, QuotedNode,
    },
    error::{HtmlRenderError, KladdError},
};

#[derive(Debug, Clone, Copy)]
pub struct HtmlOptions {
    /// Render newlines as actual newlines in the HTML
    preserve_newlines: bool,
    /// Convert things like `...` into `…`
    smart_punctuation: bool,
}

impl Default for HtmlOptions {
    fn default() -> Self {
        Self {
            preserve_newlines: false,
            smart_punctuation: true,
        }
    }
}

pub fn to_html(doc: &Document) -> Result<String, KladdError> {
    Ok(inner_to_html(doc, None)?)
}

pub fn to_html_with_options(doc: &Document, options: HtmlOptions) -> Result<String, KladdError> {
    Ok(inner_to_html(doc, Some(options))?)
}

fn inner_to_html(
    Document { body, .. }: &Document,
    options: Option<HtmlOptions>,
) -> Result<String, HtmlRenderError> {
    let mut buf = String::new();

    for node in body {
        htmlify_node(node, options, &mut buf)?;
    }

    Ok(buf)
}

fn level_to_heading(level: u8) -> &'static str {
    match level {
        1 => "h1",
        2 => "h2",
        3 => "h3",
        4 => "h4",
        5 => "h5",
        _ => "h6",
    }
}

fn write_attribute((kind, value): (&AttributeKind, &AttributeValue), buf: &mut String) {
    buf.push(' ');

    match kind {
        AttributeKind::Class => buf.push_str("class"),
        AttributeKind::Id => buf.push_str("id"),
        AttributeKind::Href => buf.push_str("href"),
        AttributeKind::Attr(v) => match v.as_str() {
            "alt" | "background" | "checked" | "dir" | "disabled" | "hidden" | "style"
            | "title" => buf.push_str(v),
            _ => {
                buf.push_str("data-");
                buf.push_str(v);
            }
        },
    }

    match value {
        AttributeValue::String(v) => {
            buf.push('=');
            buf.push('"');
            buf.push_str(&escape_attribute::<&str>(v));
            buf.push('"');
        }
        AttributeValue::Boolean => todo!(),
    }
}

fn write_attributes(attrs: &Attributes, buf: &mut String) {
    for attr in attrs {
        write_attribute(attr, buf);
    }
}

type HtmlResult = std::result::Result<(), HtmlRenderError>;

fn htmlify_node(node: &AstNode, options: Option<HtmlOptions>, buf: &mut String) -> HtmlResult {
    use NodeKind::*;
    use NodeTag::*;

    match (&node.kind, node.tag) {
        (Heading(HeadingNode { level }), Start) => {
            buf.push('<');
            buf.push_str(level_to_heading(*level));

            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');
        }
        (Heading(HeadingNode { level }), End) => {
            buf.push_str("</");
            buf.push_str(level_to_heading(*level));
            buf.push('>');
            buf.push('\n');
        }
        (Paragraph, Start) => buf.push_str("<p>"),
        (Paragraph, End) => buf.push_str("</p>"),
        (Block, _) => {}
        (Section, Start) => {
            buf.push_str("<section");

            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');
            buf.push('\n');
        }
        (Section, End) => {
            buf.push_str("</section>");
            buf.push('\n');
        }
        (NamedBlock(NamedNode { name }), Start) => {
            buf.push_str("<div");

            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }

            let mut class_seen = false;
            if let Some(attrs) = &node.attributes.inner() {
                for (kind, value) in attrs.iter() {
                    if *kind == AttributeKind::Class {
                        class_seen = true;
                        write_attribute(
                            (kind, &value.concat(AttributeValue::String(name.to_owned()))),
                            buf,
                        );
                    }

                    write_attribute((kind, value), buf);
                }
            }

            if !class_seen {
                write_attribute(
                    (
                        &AttributeKind::Class,
                        &AttributeValue::String(name.to_owned()),
                    ),
                    buf,
                );
            }

            buf.push('>');
            buf.push('\n');
        }
        (NamedBlock(_), End) => {
            buf.push_str("</div>");
            buf.push('\n');
        }
        (CodeBlock(CodeNode { language, body }), _) => {
            buf.push_str("<pre><code");

            if let Some(lang) = language {
                buf.push_str(" data-lang=");
                buf.push('"');
                buf.push_str(lang);
                buf.push('"');
            }

            if let Some(attrs) = &node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');
            buf.push('\n');

            buf.push_str(body);

            buf.push_str("</pre></code>");
            buf.push('\n');
        }
        (Text(str), _) => {
            if options.is_some_and(|o| o.smart_punctuation) {
                buf.push_str(
                    &escape_text(str)
                        .replace("...", "&hellip;")
                        .replace("---", "&mdash;")
                        .replace("--", "&ndash;"),
                );
            } else {
                buf.push_str(&escape_text(str));
            }
        }
        (Strong, Start) => buf.push_str("<strong>"),
        (Strong, End) => buf.push_str("</strong>"),
        (Italic, Start) => buf.push_str("<em>"),
        (Italic, End) => buf.push_str("</em>"),
        (Underline, Start) => buf.push_str(r#"<span class="underline">"#),
        (Underline, End) => buf.push_str("</span>"),
        (Highlight, Start) => buf.push_str("<mark>"),
        (Highlight, End) => buf.push_str("</mark>"),
        (Strikethrough, Start) => buf.push_str("<s>"),
        (Strikethrough, End) => buf.push_str("</s>"),
        (Superscript, Start) => buf.push_str("<sup>"),
        (Superscript, End) => buf.push_str("</sup>"),
        (Subscript, Start) => buf.push_str("<sub>"),
        (Subscript, End) => buf.push_str("</sub>"),
        (Naked, Start) => {
            buf.push_str("<span");

            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');
        }
        (Naked, End) => buf.push_str("</span>"),
        (Quoted(QuotedNode { quote }), _) => {
            match (quote, options.is_some_and(|o| o.smart_punctuation)) {
                (Quote::Single, true) => buf.push(''),
                (Quote::Double, true) => buf.push(''),
                (Quote::Single, false) => buf.push('\''),
                (Quote::Double, false) => buf.push('"'),
            }
        }
        (Code(CodeNode { language, body }), _) => {
            buf.push_str("<code");

            if let Some(lang) = language {
                buf.push_str(" data-lang=");
                buf.push('"');
                buf.push_str(lang);
                buf.push('"');
            }

            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');

            buf.push_str(body);
            buf.push_str("</code>");
        }
        (Link(LinkNode { href }), Start) => {
            buf.push_str("<a");

            write_attribute(
                (
                    &AttributeKind::Href,
                    &AttributeValue::String(href.to_owned()),
                ),
                buf,
            );
            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');
        }
        (Link(_), End) => buf.push_str("</a>"),
        (Custom(_), Start) => {
            buf.push_str("<span");

            if let Some(attrs) = node.attributes.inner() {
                write_attributes(attrs, buf);
            }
            buf.push('>');
        }
        (Custom(_), End) => buf.push_str("</span>"),
        (Softbreak, _) => {
            if options.is_some_and(|o| o.preserve_newlines) {
                buf.push('\n')
            } else {
                buf.push(' ');
            }
        }
    }

    Ok(())
}