perfectstar2k 0.4.0

A modern TUI homage to WordStar & WordPerfect for DOS — a real writing tool built on WordStar's touch-typist command language and long-hand-page metaphor.
use std::io;

use super::{Block, CompiledDoc, Exporter, TextRun, escape_xml};

pub struct HtmlExporter;
pub struct PlainTextExporter;

impl Exporter for HtmlExporter {
    fn render(&self, doc: &CompiledDoc) -> io::Result<Vec<u8>> {
        let mut out = String::from(
            "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>PerfectStar Export</title>\n</head>\n<body>\n",
        );
        for block in &doc.blocks {
            match block {
                Block::Heading { level, runs } => {
                    out.push_str(&format!("<h{level}>"));
                    render_runs(&mut out, runs);
                    out.push_str(&format!("</h{level}>\n"));
                }
                Block::Paragraph(runs) => {
                    out.push_str("<p>");
                    render_runs(&mut out, runs);
                    out.push_str("</p>\n");
                }
                Block::PageBreak => out.push_str("<hr class=\"page-break\">\n"),
                Block::Separator => out.push_str("<hr class=\"separator\">\n"),
            }
        }
        out.push_str("</body>\n</html>\n");
        Ok(out.into_bytes())
    }
}

impl Exporter for PlainTextExporter {
    fn render(&self, doc: &CompiledDoc) -> io::Result<Vec<u8>> {
        Ok(doc.plain_text().as_bytes().to_vec())
    }
}

fn render_runs(out: &mut String, runs: &[TextRun]) {
    for run in runs {
        if run.bold {
            out.push_str("<strong>");
        }
        if run.italic {
            out.push_str("<em>");
        }
        if run.code {
            out.push_str("<code>");
        }
        out.push_str(&escape_xml(&run.text));
        if run.code {
            out.push_str("</code>");
        }
        if run.italic {
            out.push_str("</em>");
        }
        if run.bold {
            out.push_str("</strong>");
        }
    }
}

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

    #[test]
    fn html_matches_golden() {
        let doc = CompiledDoc::from_text("# One\nA **bold** & *fine* -- line.\n.. note\n");
        let actual = String::from_utf8(HtmlExporter.render(&doc).unwrap()).unwrap();
        assert_eq!(
            actual,
            include_str!("../../tests/fixtures/export/book.html.golden")
        );
    }

    #[test]
    fn plain_text_retains_clean_export_behavior() {
        let doc = CompiledDoc::from_text("A **bold** line.\n.. note\n");
        assert_eq!(
            PlainTextExporter.render(&doc).unwrap(),
            include_bytes!("../../tests/fixtures/export/book.txt.golden")
        );
    }
}