katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use crate::export_html_ops::ExportHtmlOps;
use crate::export_html_payload::HtmlExportPayloadFactory;
use crate::export_legacy_note_payload::LegacyNoteHtmlWriter;
use crate::forge::BuildGraph;
use crate::theme::KdvThemeSnapshot;
use katana_markdown_model::{DescriptionItem, KmmNode};

mod nested_blockquote;
use nested_blockquote::NestedBlockquoteHtml;

pub(crate) struct BlockHtmlWriter;

impl BlockHtmlWriter {
    pub(crate) fn append_blockquote(
        html: &mut String,
        graph: &BuildGraph,
        theme: &KdvThemeSnapshot,
        node: &KmmNode,
    ) {
        if NestedBlockquoteHtml::is_nested(&node.source.raw.text) {
            NestedBlockquoteHtml::append(html, theme, &node.source.raw.text);
            return;
        }
        html.push_str("<blockquote data-kdv-blockquote=\"quote\">");
        if node.children.is_empty() {
            html.push_str(&ExportHtmlOps::render_text(&node.source.raw.text));
        } else {
            Self::append_children(html, graph, theme, node);
        }
        html.push_str("</blockquote>\n");
    }

    pub(crate) fn append_alert(
        html: &mut String,
        graph: &BuildGraph,
        theme: &KdvThemeSnapshot,
        node: &KmmNode,
        label: &str,
    ) {
        if !Self::is_gfm_alert(&node.source.raw.text) {
            LegacyNoteHtmlWriter::append(html, &node.source.raw.text, theme);
            return;
        }
        html.push_str(&format!(
            "<aside data-github-alert=\"{}\" data-kdv-blockquote=\"alert\">",
            ExportHtmlOps::escape_html(label)
        ));
        Self::append_alert_title(html, label);
        if node.children.is_empty() {
            html.push_str(&format!(
                "<p>{}</p>",
                ExportHtmlOps::render_text(&ExportHtmlOps::alert_body(&node.source.raw.text))
            ));
        } else {
            Self::append_alert_children(html, graph, theme, node, label);
        }
        html.push_str("</aside>\n");
    }

    pub(crate) fn append_description_list(html: &mut String, items: &[DescriptionItem]) {
        html.push_str("<dl>\n");
        for item in items {
            html.push_str(&format!(
                "<dt>{}</dt><dd>{}</dd>\n",
                ExportHtmlOps::render_text(&item.term),
                ExportHtmlOps::render_text(&item.description)
            ));
        }
        html.push_str("</dl>\n");
    }

    pub(crate) fn append_raw_block(html: &mut String, reason: &str, text: &str) {
        html.push_str(&format!(
            "<pre data-kdv-raw-reason=\"{}\">{}</pre>\n",
            ExportHtmlOps::escape_html(reason),
            ExportHtmlOps::escape_html(text)
        ));
    }

    fn append_children(
        html: &mut String,
        graph: &BuildGraph,
        theme: &KdvThemeSnapshot,
        node: &KmmNode,
    ) {
        for child in &node.children {
            HtmlExportPayloadFactory::append_node(html, graph, theme, child);
        }
    }

    fn append_alert_children(
        html: &mut String,
        graph: &BuildGraph,
        theme: &KdvThemeSnapshot,
        node: &KmmNode,
        label: &str,
    ) {
        let mut children = node.children.iter();
        if let Some(first) = children.next()
            && !Self::is_alert_title(first, label)
        {
            HtmlExportPayloadFactory::append_node(html, graph, theme, first);
        }
        for child in children {
            HtmlExportPayloadFactory::append_node(html, graph, theme, child);
        }
    }

    fn append_alert_title(html: &mut String, label: &str) {
        let title = Self::alert_title(label);
        html.push_str(&format!(
            "<p data-kdv-alert-title=\"{}\"><span data-kdv-alert-icon=\"{}\" aria-hidden=\"true\">{}</span><strong>{}</strong></p>",
            ExportHtmlOps::escape_html(label),
            ExportHtmlOps::escape_html(label),
            Self::alert_icon_svg(label),
            title
        ));
    }

    fn is_gfm_alert(raw: &str) -> bool {
        raw.lines()
            .next()
            .map(|line| {
                line.trim_start()
                    .strip_prefix('>')
                    .unwrap_or(line)
                    .trim_start()
                    .starts_with("[!")
            })
            .unwrap_or(false)
    }

    fn is_alert_title(node: &KmmNode, label: &str) -> bool {
        let expected = Self::alert_title(label);
        node.children.iter().any(|child| {
            matches!(&child.kind, katana_markdown_model::KmmNodeKind::Strong(span) if span.text == expected)
        })
    }

    fn alert_title(label: &str) -> &'static str {
        match label {
            "NOTE" => "Note",
            "TIP" => "Tip",
            "IMPORTANT" => "Important",
            "WARNING" => "Warning",
            "CAUTION" => "Caution",
            _ => "Note",
        }
    }

    fn alert_icon_svg(label: &str) -> &'static str {
        match label {
            "NOTE" => {
                r#"<svg data-kdv-alert-icon-svg="NOTE" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"></circle><path d="M8 7v4"></path><path d="M8 5h.01"></path></svg>"#
            }
            "TIP" => {
                r#"<svg data-kdv-alert-icon-svg="TIP" viewBox="0 0 16 16"><path d="M6 13h4"></path><path d="M6.5 10.5h3"></path><path d="M5 6a3 3 0 1 1 6 0c0 1.1-.55 1.85-1.15 2.45-.45.45-.85.95-.85 1.55H7c0-.6-.4-1.1-.85-1.55C5.55 7.85 5 7.1 5 6z"></path></svg>"#
            }
            "IMPORTANT" => {
                r#"<svg data-kdv-alert-icon-svg="IMPORTANT" viewBox="0 0 16 16"><path d="M3 3.5h10v8H8l-3 2v-2H3z"></path><path d="M8 5.5v3"></path><path d="M8 10.5h.01"></path></svg>"#
            }
            "WARNING" => {
                r#"<svg data-kdv-alert-icon-svg="WARNING" viewBox="0 0 16 16"><path d="M8 2.5 14 13H2z"></path><path d="M8 6v3"></path><path d="M8 11h.01"></path></svg>"#
            }
            "CAUTION" => {
                r#"<svg data-kdv-alert-icon-svg="CAUTION" viewBox="0 0 16 16"><path d="M5 2h6l3 3v6l-3 3H5l-3-3V5z"></path><path d="M8 5v4"></path><path d="M8 11h.01"></path></svg>"#
            }
            _ => {
                r#"<svg data-kdv-alert-icon-svg="NOTE" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"></circle><path d="M8 7v4"></path><path d="M8 5h.01"></path></svg>"#
            }
        }
    }
}

#[cfg(test)]
#[path = "export_block_payload_tests.rs"]
mod tests;