katana-document-viewer 0.1.0

UI-independent document artifact, render evaluation, and export foundation for KatanA.
Documentation
use crate::artifact::{Artifact, ArtifactBytes, ArtifactFactory, ArtifactFormat};
use crate::document::DocumentSnapshot;
use crate::export_payload::ExportPayloadFactory;
use crate::theme::KdvThemeSnapshot;
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MarkdownEvaluationTarget {
    CommonMark,
    Gfm,
    Math,
    GitHubAlert,
    KatanaCompatibility,
    ExternalRendering,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransformStep {
    EvaluateMarkdown,
    RenderDiagrams,
    BuildArtifactManifest,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildProfile {
    pub evaluation_targets: Vec<MarkdownEvaluationTarget>,
    pub transform_steps: Vec<TransformStep>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildRequest {
    pub snapshot: DocumentSnapshot,
    pub profile: BuildProfile,
    pub theme: KdvThemeSnapshot,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildGraph {
    pub snapshot: DocumentSnapshot,
    pub profile: BuildProfile,
    pub theme: KdvThemeSnapshot,
    pub diagnostics: ForgeDiagnostics,
    pub rendered_diagrams: Vec<RenderedDiagram>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenderedDiagram {
    pub node_id: String,
    pub kind: String,
    pub svg: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExportFormat {
    Html,
    Pdf,
    Png,
    Jpeg,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExportRequest {
    pub graph: BuildGraph,
    pub format: ExportFormat,
    pub theme: KdvThemeSnapshot,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExportOutput {
    pub artifact: Artifact,
    pub diagnostics: ForgeDiagnostics,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForgeDiagnostics {
    pub messages: Vec<String>,
}

#[derive(Debug, Error)]
pub enum ForgeError {
    #[error("backend failed: {0}")]
    Backend(String),
    #[error("export failed: {0}")]
    Export(String),
    #[error("export produced empty artifact bytes for {0:?}")]
    EmptyExportArtifact(ExportFormat),
}

pub trait ForgeBackend {
    fn build(&self, request: &BuildRequest) -> Result<BuildGraph, ForgeError>;
    fn export(&self, request: &ExportRequest) -> Result<ExportOutput, ForgeError>;
}

pub struct ForgePipeline<B> {
    backend: B,
}

impl BuildProfile {
    pub fn markdown_export() -> Self {
        Self {
            evaluation_targets: vec![
                MarkdownEvaluationTarget::CommonMark,
                MarkdownEvaluationTarget::Gfm,
                MarkdownEvaluationTarget::Math,
                MarkdownEvaluationTarget::GitHubAlert,
                MarkdownEvaluationTarget::KatanaCompatibility,
                MarkdownEvaluationTarget::ExternalRendering,
            ],
            transform_steps: vec![
                TransformStep::EvaluateMarkdown,
                TransformStep::RenderDiagrams,
                TransformStep::BuildArtifactManifest,
            ],
        }
    }
}

impl BuildGraph {
    pub fn from_request(request: &BuildRequest) -> Self {
        Self {
            snapshot: request.snapshot.clone(),
            profile: request.profile.clone(),
            theme: request.theme.clone(),
            diagnostics: ForgeDiagnostics {
                messages: Vec::new(),
            },
            rendered_diagrams: Vec::new(),
        }
    }

    pub fn with_rendered_diagrams(mut self, rendered_diagrams: Vec<RenderedDiagram>) -> Self {
        self.rendered_diagrams = rendered_diagrams;
        self
    }
}

impl ExportFormat {
    pub fn artifact_format(self) -> ArtifactFormat {
        match self {
            Self::Html => ArtifactFormat::Html,
            Self::Pdf => ArtifactFormat::Pdf,
            Self::Png => ArtifactFormat::Png,
            Self::Jpeg => ArtifactFormat::Jpeg,
        }
    }
}

impl<B: ForgeBackend> ForgePipeline<B> {
    pub fn new(backend: B) -> Self {
        Self { backend }
    }

    pub fn build(&self, request: &BuildRequest) -> Result<BuildGraph, ForgeError> {
        self.backend.build(request)
    }

    pub fn export(&self, request: &ExportRequest) -> Result<ExportOutput, ForgeError> {
        let output = self.backend.export(request)?;
        if output.artifact.bytes.bytes.is_empty() {
            return Err(ForgeError::EmptyExportArtifact(request.format));
        }
        Ok(output)
    }
}

pub struct ManifestOnlyBackend;

impl ForgeBackend for ManifestOnlyBackend {
    fn build(&self, request: &BuildRequest) -> Result<BuildGraph, ForgeError> {
        Ok(BuildGraph::from_request(request))
    }

    fn export(&self, request: &ExportRequest) -> Result<ExportOutput, ForgeError> {
        let snapshot = &request.graph.snapshot;
        let bytes = ExportPayloadFactory::create(&request.graph, request.format, &request.theme)?;
        let artifact = ArtifactFactory::export(
            request.format.artifact_format(),
            snapshot.id.clone(),
            snapshot.revision.clone(),
            ArtifactBytes { bytes },
        );
        Ok(ExportOutput {
            artifact,
            diagnostics: request.graph.diagnostics.clone(),
        })
    }
}

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