mjml-hamlet-0.1.0.0: src/Text/Mjml/Hamlet/TH.hs
{-# LANGUAGE TemplateHaskell #-}
-- | Code generation: a checked 'Doc' tree becomes an expression of type
-- @['Node']@.
--
-- Interpolated values are escaped as they go into the tree, because MJML
-- copies attribute values and ending-tag content into its output verbatim.
module Text.Mjml.Hamlet.TH (
compileTemplate,
Source (..),
) where
import Data.Char (isAlphaNum, isLower, isUpper)
import Data.Maybe (catMaybes)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Void (Void)
import Language.Haskell.TH
import Text.Megaparsec qualified as M
import Text.Megaparsec.Char qualified as M
import Text.Parsec qualified as P
import Text.Shakespeare.Base (derefToExp, parseDeref)
import Text.Mjml (Registry)
import Text.Mjml.Hamlet.Check (checkTemplate)
import Text.Mjml.Hamlet.Runtime (ToNodes (..), ToRawHtml (..), ToUrl (..), element, escape, guardMjml, guardStyle, urlAttributes, urlPart)
import Text.Mjml.Hamlet.Syntax
-- | Where the template text came from, for error messages.
data Source = Source
{ file :: FilePath
, startLine :: Int
, firstLineColumn :: Int
-- ^ The column, in the file, of the template's first character.
}
compileTemplate :: Registry -> Set Text -> Source -> Text -> Q Exp
compileTemplate reg ending src body = case parseTemplate body of
Left (p, why) -> failAt src p why
Right docs -> case checkTemplate reg ending docs of
(p, why) : _ -> failAt src p why
[] -> structure src ending docs
failAt :: Source -> Pos -> Text -> Q a
failAt src (Pos l c) why =
let absLine = startLine src + l - 1
absCol = if l == 1 then firstLineColumn src + c - 1 else c
in fail (file src <> ":" <> show absLine <> ":" <> show absCol <> ": " <> T.unpack why)
-- | MJML elements, as @['Node']@.
structure :: Source -> Set Text -> [Doc] -> Q Exp
structure src ending docs = [|concat $(listE (map one docs))|]
where
one = \case
DTag _ name as kids
| name `Set.member` ending ->
[|[element $(textE name) $(attrsE src name True as) [] $(html src kids)]|]
| name `elem` ["mj-attributes", "mj-class"] ->
[|[element $(textE name) $(attrsE src name True as) $(structureRaw kids) ""]|]
| otherwise ->
[|[element $(textE name) $(attrsE src name True as) $(structure src ending kids) ""]|]
DText p _ -> failAt src p "text can only appear inside an ending tag"
DEmbed p e -> [|toNodes $(expr src p e)|]
d -> control src (structure src ending) d [|[]|]
-- @mj-attributes@ children name the elements they set defaults for, so
-- they are any tag at all.
structureRaw kids =
listE
[ [|element $(textE name) $(attrsE src name True as) [] ""|]
| DTag _ name as _ <- kids
]
-- | HTML inside an ending tag, as 'Text'. Consecutive text lines are joined
-- with a space.
html :: Source -> [Doc] -> Q Exp
html src docs = [|T.concat $(listE (go docs))|]
where
go = \case
DText _ a : rest@(DText _ _ : _) -> pieces src a : textE " " : go rest
d : rest -> one d : go rest
[] -> []
one = \case
DTag _ name as kids
| name `elem` voidElements -> [|"<" <> $(textE name) <> $(htmlAttrs name as) <> ">"|]
| otherwise ->
[|"<" <> $(textE name) <> $(htmlAttrs name as) <> ">" <> $(html src kids) <> "</" <> $(textE name) <> ">"|]
DText _ ps -> pieces src ps
DEmbed p e -> [|toRawHtml $(expr src p e)|]
d -> control src (html src) d [|""|]
htmlAttrs name as =
[|
T.concat
( map
(\(k, v) -> " " <> k <> (if T.null v then "" else "=\"" <> v <> "\""))
$(attrsE src name False as)
)
|]
voidElements :: [Text]
voidElements = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]
-- | Control lines, for either kind of content. @body@ compiles nested lines and
-- @empty@ is the content of a branch that renders nothing.
control :: Source -> ([Doc] -> Q Exp) -> Doc -> Q Exp -> Q Exp
control src body d empty = case d of
DForall p pat e kids -> do
pt <- patt src p pat
[|foldMap $(lamE [pure pt] (body kids)) $(expr src p e)|]
DWith p pat e kids -> do
pt <- patt src p pat
caseE (expr src p e) [match (pure pt) (normalB (body kids)) []]
DIf _ branches final ->
foldr
(\(p, c, kids) rest -> condE (expr src p c) (body kids) rest)
(maybe empty body final)
branches
DMaybe p pat e kids nothing -> do
pt <- patt src p pat
caseE
(expr src p e)
[ match (conP 'Just [pure pt]) (normalB (body kids)) []
, match (conP 'Nothing []) (normalB (maybe empty body nothing)) []
]
DCase p e alts ->
caseE
(expr src p e)
[ do
pt <- patt src p' pat
match (pure pt) (normalB (body kids)) []
| (p', pat, kids) <- alts
]
DTag p _ _ _ -> failAt src p "unexpected tag"
DText p _ -> failAt src p "unexpected text"
DEmbed p _ -> failAt src p "unexpected embed"
DOf p _ _ -> failAt src p "$of without a matching $case"
-- | Attributes as @[(Text, Text)]@. Classes written with @.name@ are joined
-- into @css-class@ on MJML elements and @class@ on HTML ones.
--
-- A literal value is used as written: the checker has already seen it. An
-- interpolated one goes through the policy in "Text.Mjml.Hamlet.Runtime".
attrsE :: Source -> Text -> Bool -> [Attr] -> Q Exp
attrsE src tag isMjml as =
let classKey = if isMjml then "css-class" else "class"
plain = [(name, maybe [] id value) | Attr _ name value <- as, name /= classKey]
explicitClass = [maybe [] id value | Attr _ name value <- as, name == classKey]
shorthand = [ps | ClassAttr _ ps <- as]
classes = explicitClass <> shorthand
classAttr =
[ [|Just ($(textE classKey), T.unwords (filter (not . T.null) $(listE (map (pieces src) classes))))|]
| not (null classes)
]
one (name, ps)
| not (any interpolated ps) = [|Just ($(textE name), $(pieces src ps))|]
| name `elem` urlAttributes = case ps of
[PInterp p e] -> [|Just ($(textE name), toUrl $(expr src p e))|]
_ -> [|Just ($(textE name), $(urlPieces ps))|]
| isMjml = [|guardMjml $(textE tag) $(textE name) $(pieces src ps)|]
| name == "style" = [|guardStyle $(pieces src ps)|]
| otherwise = [|Just ($(textE name), $(pieces src ps))|]
urlPieces ps =
[|
T.concat
$( listE
[ case piece of
PInterp p e -> [|urlPart $(expr src p e)|]
_ -> pieces src [piece]
| piece <- ps
]
)
|]
interpolated = \case
PLit _ -> False
_ -> True
in [|catMaybes $(listE (map one plain <> classAttr))|]
-- | Text with interpolations, as 'Text'.
pieces :: Source -> [Piece] -> Q Exp
pieces src ps = case ps of
[] -> textE ""
[PLit t] -> textE t
_ -> [|T.concat $(listE (map one ps))|]
where
one = \case
PLit t -> textE t
PInterp p e -> [|escape $(expr src p e)|]
PEmbed p e -> [|toRawHtml $(expr src p e)|]
textE :: Text -> Q Exp
textE t = [|T.pack $(litE (stringL (T.unpack t)))|]
-- | A Haskell expression, parsed the way Hamlet parses @#{...}@.
expr :: Source -> Pos -> String -> Q Exp
expr src p s = case P.parse (parseDeref <* P.eof) "" (trim s) of
Right d -> pure (derefToExp [] d)
Left e -> failAt src p ("cannot parse the expression " <> T.pack (show (trim s)) <> ": " <> T.pack (show e))
where
trim = T.unpack . T.strip . T.pack
-- | A pattern in @$forall@, @$maybe@, @$with@ or @$of@: variables,
-- wildcards, constructors, tuples and literals.
patt :: Source -> Pos -> String -> Q Pat
patt src p s = case M.parse (M.space *> pat <* M.eof) "" (T.pack s) of
Right x -> x
Left e -> failAt src p ("cannot parse the pattern " <> T.pack (show s) <> ": " <> T.pack (M.errorBundlePretty e))
where
lexeme :: M.Parsec Void Text a -> M.Parsec Void Text a
lexeme x = x <* M.space
sym = lexeme . M.string
ident = lexeme (M.takeWhile1P Nothing (\c -> isAlphaNum c || c `elem` ("_'." :: String)))
pat = M.choice [M.try applied, apat]
applied = do
x <- ident
case T.uncons x of
Just (c, _) | isUpper c -> do
args <- M.some apat
pure (conP (mkName (T.unpack x)) args)
_ -> fail "not a constructor"
apat =
M.choice
[ do
ps <- sym "(" *> M.sepBy pat (sym ",") <* sym ")"
pure $ case ps of
[one] -> one
_ -> tupP ps
, do
lit <- lexeme (M.char '"' *> M.manyTill M.anySingle (M.char '"'))
pure (litP (stringL lit))
, do
x <- ident
pure $ case T.uncons x of
Just (c, _) | isUpper c -> conP (mkName (T.unpack x)) []
_ -> simple x
]
simple x
| x == "_" = wildP
| T.all (`elem` ['0' .. '9']) x = litP (integerL (read (T.unpack x)))
| Just (c, _) <- T.uncons x, isLower c || c == '_' = varP (mkName (T.unpack x))
| otherwise = varP (mkName (T.unpack x))