packages feed

mjml-0.1.0.0: src/Text/Mjml/Schema.hs

-- | What MJML's validator knows about a document: which elements may contain
-- which, which attributes each element takes, and what values those accept.
--
-- The renderer itself never rejects a document, as MJML does not; this module
-- is for tools that want to, such as a compile-time template checker.
module Text.Mjml.Schema (
    allowedChildren,
    allowedAttributesOf,
    globalAttributes,
    checkAttribute,
    checkChild,
    knownTag,
) where

import Data.Char (isDigit, toLower)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T

import Text.Mjml.Render (Component (..), Registry)


-- | MJML's @dependencies@: the elements each element may contain. 'Nothing'
-- for an element that accepts anything (@mj-attributes@, whose children are
-- tag names).
allowedChildren :: Map Text (Maybe [Text])
allowedChildren =
    Map.fromList
        [ ("mjml", Just ["mj-body", "mj-head", "mj-raw"])
        , ("mj-accordion", Just ["mj-accordion-element", "mj-raw"])
        , ("mj-accordion-element", Just ["mj-accordion-title", "mj-accordion-text", "mj-raw"])
        , ("mj-accordion-title", Just [])
        , ("mj-accordion-text", Just [])
        , ("mj-attributes", Nothing)
        , ("mj-body", Just ["mj-raw", "mj-section", "mj-wrapper", "mj-hero"])
        , ("mj-button", Just [])
        , ("mj-carousel", Just ["mj-carousel-image"])
        , ("mj-carousel-image", Just [])
        , ("mj-column", Just ["mj-accordion", "mj-button", "mj-carousel", "mj-divider", "mj-image", "mj-raw", "mj-social", "mj-spacer", "mj-table", "mj-text", "mj-navbar"])
        , ("mj-html-attribute", Just [])
        , ("mj-html-attributes", Just ["mj-selector"])
        , ("mj-divider", Just [])
        , ("mj-group", Just ["mj-column", "mj-raw"])
        , ("mj-head", Just ["mj-attributes", "mj-breakpoint", "mj-html-attributes", "mj-font", "mj-preview", "mj-style", "mj-title", "mj-raw"])
        , ("mj-hero", Just ["mj-accordion", "mj-button", "mj-carousel", "mj-divider", "mj-image", "mj-social", "mj-spacer", "mj-table", "mj-text", "mj-navbar", "mj-raw"])
        , ("mj-image", Just [])
        , ("mj-navbar", Just ["mj-navbar-link", "mj-raw"])
        , ("mj-raw", Just [])
        , ("mj-section", Just ["mj-column", "mj-group", "mj-raw"])
        , ("mj-selector", Just ["mj-html-attribute"])
        , ("mj-social", Just ["mj-social-element", "mj-raw"])
        , ("mj-social-element", Just [])
        , ("mj-spacer", Just [])
        , ("mj-table", Just [])
        , ("mj-text", Just [])
        , ("mj-wrapper", Just ["mj-hero", "mj-raw", "mj-section"])
        ]


-- | Attributes every element takes.
globalAttributes :: [Text]
globalAttributes = ["mj-class", "css-class"]


-- | The head elements are handled outside the component registry.
headAttributes :: Map Text [(Text, Text)]
headAttributes =
    Map.fromList
        [ ("mjml", [("lang", "string"), ("dir", "string"), ("owa", "string")])
        , ("mj-head", [])
        , ("mj-attributes", [])
        , ("mj-breakpoint", [("width", "unit(px)")])
        , ("mj-font", [("name", "string"), ("href", "string")])
        , ("mj-preview", [])
        , ("mj-style", [("inline", "string")])
        , ("mj-title", [])
        , ("mj-html-attributes", [])
        , ("mj-selector", [("path", "string")])
        , ("mj-html-attribute", [("name", "string")])
        ]


knownTag :: Registry -> Text -> Bool
knownTag reg t = Map.member t reg || Map.member t headAttributes


-- | The declared attributes of an element and their types, or 'Nothing' for
-- an unknown element.
allowedAttributesOf :: Registry -> Text -> Maybe [(Text, Text)]
allowedAttributesOf reg t = case Map.lookup t reg of
    Just c -> Just (allowedAttributes c)
    Nothing -> Map.lookup t headAttributes


-- | Check one attribute of an element: 'Nothing' when it is fine, otherwise
-- the reason, worded as MJML's validator words it.
checkAttribute :: Registry -> Text -> Text -> Maybe Text -> Maybe Text
checkAttribute reg tag attrName value = case allowedAttributesOf reg tag of
    Nothing -> Nothing
    Just allowed
        | attrName `elem` globalAttributes -> Nothing
        | otherwise -> case lookup attrName allowed of
            Nothing ->
                Just $
                    "Attribute "
                        <> attrName
                        <> " is illegal on <"
                        <> tag
                        <> ">; it takes "
                        <> T.intercalate ", " (map fst allowed <> globalAttributes)
            Just ty -> value >>= \v -> fmap (\m -> "Attribute " <> attrName <> " " <> m) (checkType ty v)


-- | Check that a child may appear inside a parent.
checkChild :: Registry -> Text -> Text -> Maybe Text
checkChild reg parent child
    | not (knownTag reg child) = Nothing
    | otherwise = case Map.lookup parent allowedChildren of
        Just (Just kids)
            | child `notElem` kids ->
                Just $
                    child
                        <> " cannot be used inside "
                        <> parent
                        <> ", only inside: "
                        <> T.intercalate ", " [p | (p, Just ks) <- Map.toList allowedChildren, child `elem` ks]
        _ -> Nothing


-- | MJML's attribute types: @color@, @unit(...)@, @enum(...)@, @boolean@,
-- @integer@ and @string@.
checkType :: Text -> Text -> Maybe Text
checkType ty value
    | "color" `T.isPrefixOf` ty =
        if validColor then Nothing else Just ("has invalid value: " <> value <> " for type Color")
    | "unit" `T.isPrefixOf` ty =
        if validUnit then Nothing else Just ("has invalid value: " <> value <> " for type Unit, only accepts (" <> T.intercalate ", " units <> ") units and " <> T.intercalate " to " counts <> " value(s)")
    | "enum" `T.isPrefixOf` ty =
        if value `elem` params then Nothing else Just ("has invalid value: " <> value <> " for type Enum, only accepts " <> T.intercalate ", " params)
    | "boolean" `T.isPrefixOf` ty =
        if value `elem` ["true", "false"] then Nothing else Just ("has invalid value: " <> value <> " for type Boolean")
    | "integer" `T.isPrefixOf` ty =
        if T.any isDigit value then Nothing else Just ("has invalid value: " <> value <> " for type Integer")
    | otherwise = Nothing
  where
    params = T.splitOn "," (T.takeWhile (/= ')') (T.drop 1 (T.dropWhile (/= '(') ty)))
    units = params
    counts = case T.breakOn "{" ty of
        (_, rest) | not (T.null rest) -> T.splitOn "," (T.takeWhile (/= '}') (T.drop 1 rest))
        _ -> ["1"]
    allowNegative = "unitWithNegative" `T.isPrefixOf` ty
    allowAuto = "auto" `elem` units
    realUnits = filter (/= "auto") units

    validUnit =
        let items = T.splitOn " " (T.dropWhileEnd (== ' ') value)
            n = length items
            (lo, hi) = case map (read . T.unpack) counts :: [Int] of
                [a] -> (a, a)
                [a, b] -> (a, b)
                _ -> (1, 1)
         in n >= lo && n <= hi && all validItem items
    validItem item
        | item == "0" = True
        | allowAuto && item == "auto" = True
        | otherwise =
            let numeric c = isDigit c || c == ',' || c == '.' || (allowNegative && c == '-')
                number = T.takeWhile numeric item
                suffix = T.drop (T.length number) item
             in not (T.null number) && suffix `elem` realUnits

    lowered = T.map toLower value
    validColor =
        ("rgba(" `T.isInfixOf` lowered)
            || ("rgb(" `T.isInfixOf` lowered)
            || hexColor
            || lowered `elem` namedColors
    hexColor = case T.uncons lowered of
        Just ('#', h) -> (T.length h == 3 || T.length h == 6) && T.all (`elem` ("0123456789abcdef" :: String)) h
        _ -> False


namedColors :: [Text]
namedColors =
    T.words
        "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown \
        \burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan \
        \darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid \
        \darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet \
        \deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro \
        \ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo inherit ivory \
        \khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow \
        \lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray \
        \lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine \
        \mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise \
        \mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab \
        \orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru \
        \pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown \
        \seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan \
        \teal thistle tomato transparent turquoise violet wheat white whitesmoke yellow yellowgreen"