mjml-0.1.0.0: src/Text/Mjml/Parser.hs
-- | MJML's XML syntax, read the way @mjml-parser-xml@ reads it.
--
-- That parser is htmlparser2 in HTML mode with entity decoding off, so this is
-- not an XML parser: tag names are lower-cased, attribute values are kept
-- verbatim, and the body of an /ending tag/ (@mj-text@, @mj-button@, …) is not
-- parsed at all but kept as the raw source between its tags.
module Text.Mjml.Parser (
parseMjml,
ParseError (..),
) where
import Data.Char (isAlpha, isSpace, toLower)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Unsafe (lengthWord8, takeWord8)
import Text.Mjml.Node (Node (..), comment)
newtype ParseError = ParseError Text
deriving stock (Show, Eq)
data Token
= TOpen Text [(Text, Text)] Bool
-- ^ Name, attributes, self-closing.
| TClose Text
| TText Text
| TComment Text
| TOther
deriving stock (Show, Eq)
-- | Parse a document. The set names the ending tags, whose content is kept as
-- raw HTML instead of being parsed into children.
parseMjml :: Set Text -> Text -> Either ParseError Node
parseMjml endingTags source =
case go source [] Nothing of
Just root -> Right root
Nothing -> Left (ParseError "Parsing failed. Check your mjml.")
where
-- The stack holds open elements with their children reversed.
go :: Text -> [Node] -> Maybe Node -> Maybe Node
go input stack root
| T.null input = finish stack root
| otherwise =
let (tok, rest) = nextToken input
in case tok of
TOpen name attrs selfClosing
| name == "mj-include" -> go rest stack root
| name `Set.member` endingTags ->
let (raw, rest') =
if selfClosing then ("", rest) else scanRaw endingTags name rest
node = Node name attrs [] (T.strip raw)
in attach node rest' stack root
| selfClosing -> attach (Node name attrs [] "") rest stack root
| otherwise -> go rest (Node name attrs [] "" : stack) root
TClose name
| any ((== name) . tagName) stack ->
let (popped, stack') = closeTo name stack
in case stack' of
[] -> go rest [] (root `orElse` Just popped)
_ -> attachClosed popped rest stack' root
| otherwise -> go rest stack root
TText t -> case stack of
cur : more
| not (T.null (T.strip t)) ->
go rest (cur {content = T.strip (content cur <> T.strip t)} : more) root
_ -> go rest stack root
TComment body -> case stack of
cur : more -> go rest (cur {children = comment body : children cur} : more) root
[] -> go rest stack root
TOther -> go rest stack root
attach node rest stack root = case stack of
cur : more -> go rest (cur {children = node : children cur} : more) root
[] -> go rest [] (root `orElse` Just node)
attachClosed node rest stack root = case stack of
cur : more -> go rest (cur {children = node : children cur} : more) root
[] -> go rest [] (root `orElse` Just node)
-- Pop up to and including the named element, folding each popped
-- element into its parent.
closeTo name = \case
top : more
| tagName top == name -> (finalize top, more)
| otherwise -> case more of
parent : more' -> closeTo name (parent {children = finalize top : children parent} : more')
[] -> (finalize top, [])
[] -> (Node name [] [] "", [])
finish stack root = case stack of
[] -> root
[top] -> root `orElse` Just (finalize top)
top : parent : more -> finish (parent {children = finalize top : children parent} : more) root
finalize node = node {children = reverse (children node)}
orElse (Just a) _ = Just a
orElse Nothing b = b
-- | Read the raw content of an ending tag up to its matching close tag,
-- returning the content and the input after the close tag.
scanRaw :: Set Text -> Text -> Text -> (Text, Text)
scanRaw endingTags _name input = loop (1 :: Int) input
where
-- The content is the input up to where the closing tag starts; measuring
-- in UTF-8 bytes keeps that constant time per token.
upTo rest = takeWord8 (lengthWord8 input - lengthWord8 rest) input
loop depth rest
| T.null rest = (input, "")
| otherwise =
let (tok, rest') = nextToken rest
in case tok of
TOpen n _ False
| n `Set.member` endingTags -> loop (depth + 1) rest'
TClose n
| n `Set.member` endingTags ->
if depth == 1
then (upTo rest, rest')
else loop (depth - 1) rest'
_ -> loop depth rest'
nextToken :: Text -> (Token, Text)
nextToken input = case T.uncons input of
Just ('<', after)
| "!--" `T.isPrefixOf` after ->
let (body, rest) = T.breakOn "-->" (T.drop 3 after)
in (TComment body, T.drop 3 rest)
| "![CDATA[" `T.isPrefixOf` after ->
let (body, rest) = T.breakOn "]]>" (T.drop 8 after)
in (TText body, T.drop 3 rest)
| Just (c, _) <- T.uncons after, c == '!' || c == '?' ->
let (_, rest) = T.breakOn ">" after in (TOther, T.drop 1 rest)
| Just ('/', afterSlash) <- T.uncons after
, Just (c, _) <- T.uncons afterSlash
, isAlpha c ->
let (name, rest) = T.break (\x -> x == '>' || isSpace x) afterSlash
(_, rest') = T.breakOn ">" rest
in (TClose (T.toLower name), T.drop 1 rest')
| Just (c, _) <- T.uncons after, isAlpha c -> openTag after
_ ->
let (t, rest) = T.break (== '<') input
in if T.null t
then (TText (T.take 1 input), T.drop 1 input)
else (TText t, rest)
openTag :: Text -> (Token, Text)
openTag after =
let (name, rest) = T.break (\x -> x == '>' || x == '/' || isSpace x) after
in attrs (T.map toLower name) [] rest
where
attrs name acc s =
let s' = T.dropWhile isSpace s
in case T.uncons s' of
Nothing -> (TOpen name (reverse acc) False, "")
Just ('>', r) -> (TOpen name (reverse acc) False, r)
Just ('/', r)
| Just ('>', r') <- T.uncons (T.dropWhile isSpace r) -> (TOpen name (reverse acc) True, r')
| otherwise -> attrs name acc r
_ ->
let (key, r) = T.break (\x -> x == '=' || x == '>' || x == '/' || isSpace x) s'
r1 = T.dropWhile isSpace r
in case T.uncons r1 of
Just ('=', r2) ->
let r3 = T.dropWhile isSpace r2
in case T.uncons r3 of
Just (q, r4)
| q == '"' || q == '\'' ->
let (v, r5) = T.break (== q) r4
in attrs name ((key, v) : acc) (T.drop 1 r5)
_ ->
let (v, r5) = T.break (\x -> x == '>' || isSpace x) r3
in attrs name ((key, v) : acc) r5
_
| T.null key -> attrs name acc (T.drop 1 r1)
| otherwise -> attrs name ((key, "") : acc) r1