markdown-compiler 0.1.0

Strict Markdown content validation, asset resolution, and revision identities for Maincopy
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use super::LogicalContentPath;

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct FieldPath(String);

impl FieldPath {
    pub(crate) fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    fn sort_rank(&self) -> u16 {
        let value = self.as_str();
        match value {
            "$path" => 0,
            "$document" => 1,
            "$frontmatter" => 2,
            "site" => 10,
            "site.title" => 11,
            "site.base_url" => 12,
            "site.description" => 13,
            "site.favicon" => 14,
            "site.image" => 15,
            "author" => 20,
            "author.name" => 21,
            "assets" => 30,
            "assets.allowed_https_origins" => 31,
            "tips.enabled" => 50,
            "id" => 100,
            "title" => 110,
            "slug" => 120,
            "authored_at" => 130,
            "updated_at" => 140,
            "description" => 150,
            "image" => 160,
            "tags" => 170,
            "aliases" => 180,
            "draft" => 190,
            "tips" => 200,
            "published_at" => 220,
            _ if value.starts_with("assets.allowed_https_origins[") => 32,
            _ if value.starts_with("tags[") => 171,
            _ if value.starts_with("aliases[") => 181,
            _ => 1_000,
        }
    }
}

impl fmt::Display for FieldPath {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentValidationCode {
    ContentPlatformUnsupported,
    ContentRootUnavailable,
    PublicationFileMissing,
    ContentNamespaceInvalid,
    ContentEntryUnreadable,
    UnsupportedFilenameEncoding,
    InvalidLogicalContentPath,
    UnexpectedPostEntry,
    ContentSymlinkUnsupported,
    UnsupportedContentEntryKind,
    ContentTextInvalidUtf8,
    AuthoredSvgUnsupported,
    ContentFileTooLarge,
    ContentTreeTooLarge,
    ContentEntryLimitExceeded,
    ContentDepthLimitExceeded,
    ContentPathTooLong,
    DuplicateLogicalAssetPath,
    LogicalPathCaseCollision,
    ContentEntryChanged,
    PublicationTomlInvalid,
    FrontmatterOpeningDelimiterMissing,
    FrontmatterOpeningDelimiterMalformed,
    FrontmatterClosingDelimiterMissing,
    FrontmatterClosingDelimiterMalformed,
    FrontmatterTomlInvalid,
    RequiredFieldMissing,
    InvalidFieldType,
    UnknownField,
    PublishedAtUnsupported,
    TextEmpty,
    TextContainsControl,
    InvalidBaseUrl,
    InvalidPostId,
    InvalidPostSlug,
    InvalidPostTag,
    InvalidPostAlias,
    DatetimeOffsetRequired,
    DatetimeInvalid,
    UpdatedAtBeforeAuthoredAt,
    DuplicateTag,
    AliasMatchesSlug,
    DuplicatePostId,
    DuplicatePostSlug,
    DuplicatePostAlias,
    DuplicatePostRoute,
    DraftDirectoryConflict,
    PostCollectionPathMismatch,
    InternalValidationInvariant,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ValidationLocation {
    pub path: LogicalContentPath,
    pub field: FieldPath,
}

impl ValidationLocation {
    pub(crate) const fn new(path: LogicalContentPath, field: FieldPath) -> Self {
        Self { path, field }
    }
}

#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize)]
#[error("{path}: {field}: {message}")]
pub struct ContentValidationError {
    pub path: LogicalContentPath,
    pub field: FieldPath,
    pub code: ContentValidationCode,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub related: Option<ValidationLocation>,
}

impl ContentValidationError {
    pub(crate) fn new(
        path: LogicalContentPath,
        field: impl Into<String>,
        code: ContentValidationCode,
        message: impl Into<String>,
    ) -> Self {
        Self {
            path,
            field: FieldPath::new(field),
            code,
            message: message.into(),
            related: None,
        }
    }

    pub(crate) fn with_related(mut self, related: ValidationLocation) -> Self {
        self.related = Some(related);
        self
    }

    fn sort_key(
        &self,
    ) -> (
        &str,
        u16,
        &str,
        ContentValidationCode,
        Option<(&str, &str)>,
        &str,
    ) {
        (
            self.path.as_str(),
            self.field.sort_rank(),
            self.field.as_str(),
            self.code,
            self.related
                .as_ref()
                .map(|related| (related.path.as_str(), related.field.as_str())),
            self.message.as_str(),
        )
    }
}

#[derive(Clone, Debug, Eq, Error, PartialEq)]
#[error("content validation failed with {} error(s)", .0.len())]
pub struct ContentValidationErrors(Vec<ContentValidationError>);

impl ContentValidationErrors {
    pub fn errors(&self) -> &[ContentValidationError] {
        &self.0
    }

    pub fn into_errors(self) -> Vec<ContentValidationError> {
        self.0
    }
}

#[derive(Default)]
pub(crate) struct DiagnosticCollector {
    errors: Vec<ContentValidationError>,
}

impl DiagnosticCollector {
    pub(crate) fn push(&mut self, error: ContentValidationError) {
        self.errors.push(error);
    }

    pub(crate) fn len(&self) -> usize {
        self.errors.len()
    }

    pub(crate) fn is_empty(&self) -> bool {
        self.errors.is_empty()
    }

    pub(crate) fn finish(mut self) -> ContentValidationErrors {
        if self.errors.is_empty() {
            self.errors.push(ContentValidationError::new(
                LogicalContentPath::new("<content-validation>"),
                "$document",
                ContentValidationCode::InternalValidationInvariant,
                "an empty diagnostic collection was finalized as an error",
            ));
        }
        self.errors
            .sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
        self.errors.dedup();
        ContentValidationErrors(self.errors)
    }
}