mjml-hamlet-0.1.0.0: src/Text/Mjml/Hamlet/Syntax.hs
-- | The template syntax: Hamlet's, applied to MJML.
--
-- A template is a sequence of lines whose indentation gives the nesting, as in
-- Hamlet. Each line is one of:
--
-- * a tag, @\<mj-button href=#{url} .primary>Pay now@, whose children are
-- the more indented lines below it and whose text may follow it on the
-- same line;
-- * a control line: @$forall@, @$if@ \/ @$elseif@ \/ @$else@, @$maybe@ \/
-- @$nothing@, @$case@ \/ @$of@, @$with@, or a @$#@ comment;
-- * @^{expr}@ alone, embedding nodes or markup built elsewhere;
-- * text, in which @#{expr}@ interpolates an escaped value and @^{expr}@
-- raw markup. A leading backslash is dropped, so @\\ #{a}@ keeps its space.
--
-- Parsing produces a 'Doc' tree with the Haskell fragments still as source
-- text; "Text.Mjml.Hamlet.TH" turns them into code.
module Text.Mjml.Hamlet.Syntax (
Pos (..),
Doc (..),
Attr (..),
Piece (..),
parseTemplate,
) where
import Control.Monad (void)
import Data.Char (isAlphaNum, isSpace)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Void (Void)
import Text.Megaparsec hiding (Pos)
import Text.Megaparsec.Char
-- | A line and column in the template, both from 1.
data Pos = Pos {line :: Int, column :: Int}
deriving stock (Show, Eq, Ord)
data Doc
= DTag Pos Text [Attr] [Doc]
| DText Pos [Piece]
| DEmbed Pos String
| DForall Pos String String [Doc]
-- ^ Pattern, expression, body.
| DIf Pos [(Pos, String, [Doc])] (Maybe [Doc])
| DMaybe Pos String String [Doc] (Maybe [Doc])
| DCase Pos String [(Pos, String, [Doc])]
| DWith Pos String String [Doc]
| DOf Pos String [Doc]
-- ^ Only while parsing: an @$of@ before it joins its @$case@.
deriving stock (Show, Eq)
data Attr
= Attr Pos Text (Maybe [Piece])
-- ^ 'Nothing' for a bare attribute.
| ClassAttr Pos [Piece]
-- ^ @.name@: a class, @css-class@ on MJML elements.
deriving stock (Show, Eq)
data Piece
= PLit Text
| PInterp Pos String
| PEmbed Pos String
deriving stock (Show, Eq)
-- | A line before nesting: its indentation and what it holds.
data Line
= LTag Text [Attr] [Piece]
| LText [Piece]
| LEmbed String
| LForall String String
| LIf String
| LElseIf String
| LElse
| LMaybe String String
| LNothing
| LCase String
| LOf String
| LWith String String
deriving stock (Show)
type Parser = Parsec Void Text
-- | Parse a template. The error is rendered with the template's own line and
-- column numbers; the caller adds where the template sits in its file.
parseTemplate :: Text -> Either (Pos, Text) [Doc]
parseTemplate src = do
ls <- traverse parseLine (numbered src)
nest ls
numbered :: Text -> [(Int, Int, Text)]
numbered src =
[ (n, T.length indent + 1, body)
| (n, raw) <- zip [1 ..] (T.lines src)
, let (indent, body) = T.span (== ' ') (T.dropWhileEnd isSpace raw)
, not (T.null body)
, not ("$#" `T.isPrefixOf` body)
]
parseLine :: (Int, Int, Text) -> Either (Pos, Text) (Pos, Line)
parseLine (n, col, body)
| "\t" `T.isPrefixOf` body = Left (Pos n col, "tabs are not allowed for indentation; use spaces")
| otherwise =
let initial =
State
{ stateInput = body
, stateOffset = 0
, statePosState =
PosState
{ pstateInput = body
, pstateOffset = 0
, pstateSourcePos = SourcePos "" (mkPos n) (mkPos col)
, pstateTabWidth = defaultTabWidth
, pstateLinePrefix = ""
}
, stateParseErrors = []
}
in case snd (runParser' (lineP <* eof) initial) of
Right l -> Right (Pos n col, l)
Left bundle -> case bundleErrors bundle of
e :| _ ->
Left (Pos n (col + errorOffset e), T.strip (T.pack (parseErrorTextPretty e)))
lineP :: Parser Line
lineP =
choice
[ controlP
, tagP
, try (LEmbed <$> (string "^{" *> balanced) <* eof)
, LText <$> piecesP (const False)
]
controlP :: Parser Line
controlP = do
void (char '$')
keyword <- takeWhile1P (Just "a control keyword") isAlphaNum
let rest = T.unpack . T.strip <$> takeRest
binding = do
space1
src <- takeRest
case T.breakOn "<-" src of
(pat, expr)
| not (T.null expr) ->
pure (T.unpack (T.strip pat), T.unpack (T.strip (T.drop 2 expr)))
_ -> fail ("$" <> T.unpack keyword <> " needs a binding: $" <> T.unpack keyword <> " pattern <- expression")
needs what = space1 *> (rest >>= \r -> if null r then fail ("$" <> T.unpack keyword <> " needs " <> what) else pure r)
case keyword of
"forall" -> uncurry LForall <$> binding
"maybe" -> uncurry LMaybe <$> binding
"with" -> uncurry LWith <$> binding
"if" -> LIf <$> needs "a condition"
"elseif" -> LElseIf <$> needs "a condition"
"else" -> LElse <$ eof
"nothing" -> LNothing <$ eof
"case" -> LCase <$> needs "an expression"
"of" -> LOf <$> needs "a pattern"
other -> fail ("unknown control keyword $" <> T.unpack other)
tagP :: Parser Line
tagP = do
void (char '<')
name <- takeWhile1P (Just "a tag name") (\c -> isAlphaNum c || c == '-' || c == ':' || c == '_')
as <- many (try (space1 *> attrP))
space
void (char '>') <?> "'>' closing the tag"
inline <- optional (piecesP (const False))
pure (LTag (T.toLower name) as (maybe [] id inline))
attrP :: Parser Attr
attrP = do
pos <- getPos
choice
[ ClassAttr pos <$> (char '.' *> valueP)
, do
name <- takeWhile1P (Just "an attribute name") (\c -> isAlphaNum c || c `elem` ("-_:" :: String))
value <- optional (char '=' *> valueP)
pure (Attr pos name value)
]
valueP :: Parser [Piece]
valueP =
choice
[ between (char '"') (char '"') (piecesP (== '"'))
, between (char '\'') (char '\'') (piecesP (== '\''))
, piecesP (\c -> isSpace c || c == '>')
]
-- | Literal text and interpolations, up to a character the caller stops at.
piecesP :: (Char -> Bool) -> Parser [Piece]
piecesP stop = do
merge <$> many piece
where
piece =
choice
[ try (PInterp <$> getPos <*> (string "#{" *> balanced))
, try (PEmbed <$> getPos <*> (string "^{" *> balanced))
, PLit . T.singleton <$> satisfy (not . stop)
]
merge = \case
PLit a : PLit b : rest -> merge (PLit (a <> b) : rest)
x : rest -> x : merge rest
[] -> []
-- | The Haskell source inside @{...}@, up to the matching brace.
balanced :: Parser String
balanced = go (0 :: Int)
where
go depth = do
c <- anySingle <?> "a closing '}'"
case c of
'}' | depth == 0 -> pure []
'}' -> (c :) <$> go (depth - 1)
'{' -> (c :) <$> go (depth + 1)
'"' -> do
s <- stringLit
(\r -> '"' : s <> r) <$> go depth
_ -> (c :) <$> go depth
stringLit = do
c <- anySingle
case c of
'"' -> pure "\""
'\\' -> do
e <- anySingle
(\r -> '\\' : e : r) <$> stringLit
_ -> (c :) <$> stringLit
getPos :: Parser Pos
getPos = do
p <- getSourcePos
pure (Pos (unPos (sourceLine p)) (unPos (sourceColumn p)))
-- | Turn indented lines into a tree, pairing each control line with its
-- continuations (@$else@, @$nothing@, @$of@).
nest :: [(Pos, Line)] -> Either (Pos, Text) [Doc]
nest = fmap fst . block 0
where
-- Parse sibling lines whose column is exactly the first one's, returning
-- the docs and the remaining lines.
block :: Int -> [(Pos, Line)] -> Either (Pos, Text) ([Doc], [(Pos, Line)])
block _ [] = Right ([], [])
block minCol ls@((p, _) : _)
| column p <= minCol = Right ([], ls)
| otherwise = siblings (column p) ls
siblings col = \case
(p, l) : rest
| column p == col -> do
(kids, rest') <- block col rest
(doc, rest'') <- assemble col p l kids rest'
(more, rest''') <- siblings col rest''
pure (doc : more, rest''')
| column p > col -> Left (p, "unexpected indentation")
rest -> Right ([], rest)
-- Continuations at the same column belong to the control line above.
continuation col = \case
(p, l) : rest | column p == col -> Just (p, l, rest)
_ -> Nothing
assemble col p l kids rest = case l of
LTag name as inline ->
let inlineDoc = [DText p inline | not (null inline)]
in Right (DTag p name as (inlineDoc <> kids), rest)
LText ps -> do
noChildren p kids
Right (DText p (dropBackslash ps), rest)
LEmbed e -> noChildren p kids >> Right (DEmbed p e, rest)
LForall pat e -> Right (DForall p pat e kids, rest)
LWith pat e -> Right (DWith p pat e kids, rest)
LIf c -> do
(branches, final, rest') <- ifChain col rest
Right (DIf p ((p, c, kids) : branches) final, rest')
LMaybe pat e -> case continuation col rest of
Just (_, LNothing, rest') -> do
(kids', rest'') <- block col rest'
Right (DMaybe p pat e kids (Just kids'), rest'')
_ -> Right (DMaybe p pat e kids Nothing, rest)
LCase e -> do
alts <- traverse asAlternative kids
if null alts then Left (p, "$case needs at least one nested $of") else Right (DCase p e alts, rest)
LElseIf _ -> Left (p, "$elseif without a matching $if")
LElse -> Left (p, "$else without a matching $if")
LNothing -> Left (p, "$nothing without a matching $maybe")
LOf pat -> Right (DOf p pat kids, rest)
ifChain col rest = case continuation col rest of
Just (p', LElseIf c, rest') -> do
(kids, rest'') <- block col rest'
(more, final, rest''') <- ifChain col rest''
Right ((p', c, kids) : more, final, rest''')
Just (_, LElse, rest') -> do
(kids, rest'') <- block col rest'
Right ([], Just kids, rest'')
_ -> Right ([], Nothing, rest)
asAlternative = \case
DOf p pat kids -> Right (p, pat, kids)
d -> Left (docPos d, "only $of lines can be nested under $case")
noChildren p kids = if null kids then Right () else Left (p, "only tags and control lines can have nested lines")
dropBackslash = \case
PLit t : more | Just ('\\', t') <- T.uncons t -> [PLit t' | not (T.null t')] <> more
ps -> ps
docPos :: Doc -> Pos
docPos = \case
DTag p _ _ _ -> p
DText p _ -> p
DEmbed p _ -> p
DForall p _ _ _ -> p
DIf p _ _ -> p
DMaybe p _ _ _ _ -> p
DCase p _ _ -> p
DWith p _ _ _ -> p
DOf p _ _ -> p