scrod-0.2026.2.21: source/library/Scrod/Xml/Element.hs
{-# LANGUAGE TemplateHaskellQuotes #-}
module Scrod.Xml.Element where
import qualified Data.ByteString.Builder as Builder
import qualified Data.Set as Set
import qualified Data.Text as Text
import qualified Scrod.Extra.Builder as Builder
import qualified Scrod.Spec as Spec
import qualified Scrod.Xml.Attribute as Attribute
import qualified Scrod.Xml.Content as Content
import qualified Scrod.Xml.Name as Name
-- | XML Element, like @\<name attr="value">content\</name>@. Can be
-- self-closing: @\<name />@.
data Element = MkElement
{ name :: Name.Name,
attributes :: [Attribute.Attribute],
contents :: [Content.Content Element]
}
deriving (Eq, Ord, Show)
encode :: Element -> Builder.Builder
encode el =
if null (contents el)
then encodeSelfClosing el
else encodeWithContents el
encodeSelfClosing :: Element -> Builder.Builder
encodeSelfClosing el =
Builder.charUtf8 '<'
<> Name.encode (name el)
<> encodeAttributes (attributes el)
<> Builder.stringUtf8 " />"
encodeWithContents :: Element -> Builder.Builder
encodeWithContents el =
Builder.charUtf8 '<'
<> Name.encode (name el)
<> encodeAttributes (attributes el)
<> Builder.charUtf8 '>'
<> foldMap (Content.encode encode) (contents el)
<> Builder.stringUtf8 "</"
<> Name.encode (name el)
<> Builder.charUtf8 '>'
encodeAttributes :: [Attribute.Attribute] -> Builder.Builder
encodeAttributes = foldMap (\a -> Builder.charUtf8 ' ' <> Attribute.encode a)
-- | Encode an element as HTML. Only void elements are self-closing; all
-- other elements use open and close tags even when empty. Recursively
-- fills empty non-void elements with an empty text node so that the
-- regular XML encoder produces paired tags.
encodeHtml :: Element -> Builder.Builder
encodeHtml = encode . fillNonVoidElements
fillNonVoidElements :: Element -> Element
fillNonVoidElements el =
let cs = fmap fillNonVoidContent (contents el)
in if null cs && not (isHtmlVoidElement (Name.unwrap $ name el))
then el {contents = [Content.Text Text.empty]}
else el {contents = cs}
fillNonVoidContent :: Content.Content Element -> Content.Content Element
fillNonVoidContent c = case c of
Content.Element e -> Content.Element (fillNonVoidElements e)
_ -> c
-- | Whether a tag name is an HTML void element that may be self-closing.
-- See <https://html.spec.whatwg.org/multipage/syntax.html#void-elements>.
isHtmlVoidElement :: Text.Text -> Bool
isHtmlVoidElement = (`Set.member` htmlVoidElements)
htmlVoidElements :: Set.Set Text.Text
htmlVoidElements =
Set.fromList $
fmap
Text.pack
[ "area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr"
]
spec :: (Applicative m, Monad n) => Spec.Spec m n -> n ()
spec s = do
let mkName :: String -> Name.Name
mkName = Name.MkName . Text.pack
mkAttr :: String -> String -> Attribute.Attribute
mkAttr n v = Attribute.MkAttribute (mkName n) (Text.pack v)
mkText :: String -> Content.Content Element
mkText = Content.Text . Text.pack
mkElement :: String -> [Attribute.Attribute] -> [Content.Content Element] -> Content.Content Element
mkElement n as cs = Content.Element $ MkElement (mkName n) as cs
Spec.named s 'encode $ do
Spec.it s "encodes self-closing tag" $ do
Spec.assertEq s (Builder.toString . encode $ MkElement (mkName "foo") [] []) "<foo />"
Spec.it s "encodes with text content" $ do
Spec.assertEq s (Builder.toString . encode $ MkElement (mkName "foo") [] [mkText "bar"]) "<foo>bar</foo>"
Spec.it s "encodes with attribute" $ do
Spec.assertEq s (Builder.toString . encode $ MkElement (mkName "foo") [mkAttr "bar" "baz"] []) "<foo bar=\"baz\" />"
Spec.it s "encodes with multiple attributes" $ do
Spec.assertEq s (Builder.toString . encode $ MkElement (mkName "foo") [mkAttr "a" "1", mkAttr "b" "2"] []) "<foo a=\"1\" b=\"2\" />"
Spec.it s "encodes nested elements" $ do
Spec.assertEq s (Builder.toString . encode $ MkElement (mkName "foo") [] [mkElement "bar" [] []]) "<foo><bar /></foo>"
Spec.it s "escapes text content" $ do
Spec.assertEq s (Builder.toString . encode $ MkElement (mkName "foo") [] [Content.Text $ Text.pack "a & b"]) "<foo>a & b</foo>"
Spec.named s 'encodeHtml $ do
Spec.it s "self-closes void elements" $ do
Spec.assertEq s (Builder.toString . encodeHtml $ MkElement (mkName "br") [] []) "<br />"
Spec.it s "self-closes void elements with attributes" $ do
Spec.assertEq s (Builder.toString . encodeHtml $ MkElement (mkName "img") [mkAttr "src" "x.png"] []) "<img src=\"x.png\" />"
Spec.it s "uses paired tags for non-void elements when empty" $ do
Spec.assertEq s (Builder.toString . encodeHtml $ MkElement (mkName "div") [] []) "<div></div>"
Spec.it s "uses paired tags for non-void elements with content" $ do
Spec.assertEq s (Builder.toString . encodeHtml $ MkElement (mkName "div") [] [mkText "hello"]) "<div>hello</div>"
Spec.it s "applies recursively to nested elements" $ do
Spec.assertEq s (Builder.toString . encodeHtml $ MkElement (mkName "div") [] [mkElement "span" [] []]) "<div><span></span></div>"
Spec.it s "applies recursively to nested void elements" $ do
Spec.assertEq s (Builder.toString . encodeHtml $ MkElement (mkName "div") [] [mkElement "br" [] []]) "<div><br /></div>"