onama-0.2.3.0: src/lib/Text/HTML/Onama.hs
{-# LANGUAGE OverloadedStrings #-}
{-|
Module: Text.HTML.Onama
Description: Parsec extended with functions to handle HTML parsing.
Copyright: (c) William Yao, 2017-2024
License: BSD-3
Maintainer: williamyaoh@gmail.com
Stability: experimental
Some extra primitives to parse HTMl with Parsec.
You'll still need to import "Text.Parsec" along with this library. These
primitives will work with all the combinators from Parsec. Note that you'll
need to override Parsec's @satisfies@, since that one only works on
character streams (for some reason).
> testParser = dp
> tagOpen "b"
> bolded <- text
> tagClose "b"
> testParser2 = do
> tagClose "div"
> tagOpen "p"
> inner <- text
> tagClose "p"
-}
module Text.HTML.Onama
( Tag(..)
, Position
, parseTags
, tag
, satisfy
, TagOpenSelector(..)
, TagCloseSelector(..)
, AttrName(..)
, AttrValue(..)
, AttrSelector(..)
, (@:), (@=)
, tagOpen_, tagOpen
, tagClose_, tagClose
, tagText
, voidElement
, balancedTags
, anyOpenTag, anyCloseTag, anyValue
, innerText
, skip
)
where
import qualified Text.HTML.TagSoup as TS
import Text.StringLike
import qualified Text.Parsec as P
import Text.Parsec
( (<|>), (<?>), label, labels
, try, unexpected
, choice
, count, skipMany1, many1
, sepBy, sepBy1, endBy, endBy1, sepEndBy, sepEndBy1
, chainl, chainl1, chainr, chainr1
, eof
, notFollowedBy
, manyTill
, lookAhead
, anyToken
, between
, option, optionMaybe, optional
, unknownError, sysUnExpectError, mergeErrorReply
)
import qualified Data.Sequence as S
import Data.Sequence
( (<|), (|>), (><) )
import Data.String (IsString, fromString)
import Data.Foldable (toList)
type Position = (TS.Row, TS.Column)
data Tag str
= TagOpen str [TS.Attribute str] Position
| TagClose str Position
| TagText str Position
deriving (Eq, Show)
parseOptions :: StringLike str => TS.ParseOptions str
parseOptions = TS.parseOptions { TS.optTagPosition = True }
type CurrentPos str = (Position, [Tag str])
startPos :: CurrentPos str
startPos = ((1, 1), [])
-- | Return a list of tags parsed from some sort of string.
-- This list should then get fed into an Onama parser.
parseTags :: StringLike str => str -> [Tag str]
parseTags str =
reverse $ snd $ foldl attachPos startPos $
TS.canonicalizeTags $ TS.parseTagsOptions parseOptions str
where attachPos (pos, tags) tag =
case tag of
TS.TagOpen name attrs -> (pos, TagOpen name attrs pos : tags)
TS.TagClose name -> (pos, TagClose name pos : tags)
TS.TagText text -> (pos, TagText text pos : tags)
TS.TagComment _ -> (pos, tags)
TS.TagWarning _ -> (pos, tags)
TS.TagPosition row col -> ((row, col), tags)
updatePos :: P.SourcePos -> Tag str -> [Tag str] -> P.SourcePos
updatePos pos tok _ =
let (row, col) = case tok of
TagOpen _ _ pos -> pos
TagClose _ pos -> pos
TagText _ pos -> pos
in flip P.setSourceLine row $ flip P.setSourceColumn col $ pos
tagName :: Tag str -> Maybe str
tagName (TagOpen name _ _) = Just name
tagName (TagClose name _ ) = Just name
tagName _other = Nothing
data TagOpenSelector
= AnyOpenTag [AttrSelector]
| TagOpenSelector String [AttrSelector]
data TagCloseSelector
= AnyCloseTag
| TagCloseSelector String
instance IsString TagOpenSelector where
fromString str = TagOpenSelector str []
instance IsString TagCloseSelector where
fromString = TagCloseSelector
tagSelectorAttrs :: TagOpenSelector -> [AttrSelector]
tagSelectorAttrs (AnyOpenTag attrs) = attrs
tagSelectorAttrs (TagOpenSelector _ attrs) = attrs
newtype AttrName = AttrName String
instance IsString AttrName where
fromString = AttrName
data AttrValue
= AnyAttr
| AttrValue String
instance IsString AttrValue where
fromString = AttrValue
data AttrSelector = AttrSelector AttrName AttrValue
instance IsString AttrSelector where
fromString str = AttrSelector (AttrName str) AnyAttr
(@:) :: TagOpenSelector -> [AttrSelector] -> TagOpenSelector
(@:) tagS attrS =
case tagS of
AnyOpenTag _ -> AnyOpenTag attrS
TagOpenSelector name _ -> TagOpenSelector name attrS
(@=) :: AttrName -> AttrValue -> AttrSelector
(@=) = AttrSelector
-- | Primitive. Return the next input tag.
-- All other primitive parsers should be implemented in terms of this.
tag :: (Monad m, Show str) => P.ParsecT [Tag str] u m (Tag str)
tag = P.tokenPrim show updatePos Just
-- | Create a parser which parses a single HTML tag if it passes
-- the given predicate. Return the parsed tag.
satisfy :: (Monad m, Show str) => (Tag str -> Bool) -> P.ParsecT [Tag str] u m (Tag str)
satisfy f = P.tokenPrim show updatePos $ \tag ->
if f tag then Just tag else Nothing
matchAttrValue :: StringLike str => str -> AttrValue -> Bool
matchAttrValue val attrS = case attrS of
AnyAttr -> True
AttrValue val' -> toString val == val'
tagOpen_ :: (Monad m, StringLike str, Show str)
=> TagOpenSelector
-> P.ParsecT [Tag str] u m (Tag str)
tagOpen_ tagS =
satisfy (\tag -> case tag of
TagOpen name attrs _ ->
let attrS = tagSelectorAttrs tagS in
case tagS of
AnyOpenTag _ -> matchAttrs attrS attrs
TagOpenSelector name' _ ->
toString name == name' && matchAttrs attrS attrs
_other -> False)
<?> "Couldn't parse an open tag."
where matchAttrs attrS attrs =
all (\(AttrSelector (AttrName name) attrValS) ->
case lookup (fromString name) attrs of
Just val -> matchAttrValue val attrValS
Nothing -> False)
attrS
tagOpen :: (Monad m, StringLike str, Show str)
=> TagOpenSelector
-> P.ParsecT [Tag str] u m (Tag str)
tagOpen tagS = try (optional tagText >> tagOpen_ tagS)
tagClose_ :: (Monad m, StringLike str, Show str)
=> TagCloseSelector
-> P.ParsecT [Tag str] u m (Tag str)
tagClose_ tagS =
satisfy (\tag -> case tag of
TagClose name _ ->
case tagS of
AnyCloseTag -> True
TagCloseSelector name' -> toString name == name'
_other -> False)
tagClose :: (Monad m, StringLike str, Show str)
=> TagCloseSelector
-> P.ParsecT [Tag str] u m (Tag str)
tagClose tagS = try (optional tagText >> tagClose_ tagS)
-- | Take a parser, return a parser which only succeeds if the given parser
-- fails. Consumes no input.
notParse :: P.Stream s m t => P.ParsecT s u m t -> P.ParsecT s u m ()
notParse parser = do
parsed <- try $ Just <$> parser
<|> return Nothing
case parsed of
Nothing -> return ()
Just _ -> unexpected "parser given to notParse succeeded"
tagText :: (Monad m, Show str) => P.ParsecT [Tag str] u m str
tagText = P.tokenPrim show updatePos $ \tag -> case tag of
TagText text _ -> Just text
_other -> Nothing
-- | Certain HTML elements are self closing. In addition, they can show
-- up /without/ their closing slash. For these, we just want to go over
-- their opening tag.
-- These elements are void, according to the W3C spec:
-- <https://www.w3.org/TR/2012/WD-html-markup-20121025/syntax.html#syntax-elements>
--
-- * area
-- * base
-- * br
-- * col
-- * command
-- * embed
-- * hr
-- * img
-- * input
-- * keygen
-- * link
-- * meta
-- * param
-- * source
-- * track
-- * wbr
voidElement :: (Monad m, StringLike str, Show str) => P.ParsecT [Tag str] u m (Tag str)
voidElement = choice $ fmap tagOpen_ [ "area", "base", "br", "col", "command"
, "embed", "hr", "img", "input", "keygen"
, "link", "meta", "param", "source", "track"
, "wbr"
]
balancedTags_ :: (Monad m, StringLike str, Show str)
=> TagOpenSelector
-> P.ParsecT [Tag str] u m (S.Seq (Tag str))
balancedTags_ tagS = do
openTag <- tagOpen_ tagS
tailTags <- tagTail openTag
return $ openTag <| tailTags
tagTail :: (Monad m, StringLike str, Show str)
=> Tag str
-> P.ParsecT [Tag str] u m (S.Seq (Tag str))
tagTail (TagOpen name _ _) = do
innerTags <- P.many $ try notMatchingClose
matchingClose <- tagClose_ closeS
return $ mconcat innerTags |> matchingClose
where closeS = TagCloseSelector $ toString name
notMatchingClose = ( S.singleton <$> (notParse (tagClose_ closeS) >> tag)
<|> S.singleton <$> voidElement
<|> (balancedTags_ anyOpenTag)
)
balancedTags :: (Monad m, StringLike str, Show str)
=> TagOpenSelector
-> P.ParsecT [Tag str] u m [Tag str]
balancedTags tagS = optional tagText >> toList <$> balancedTags_ tagS
anyOpenTag :: TagOpenSelector
anyOpenTag = AnyOpenTag []
anyCloseTag :: TagCloseSelector
anyCloseTag = AnyCloseTag
anyValue :: AttrValue
anyValue = AnyAttr
-- | @skip p@ produces a parser which will ignore the output of @p@.
skip :: P.Stream s m t => P.ParsecT s u m a -> P.ParsecT s u m ()
skip p = p >> return ()
innerText :: StringLike str => [Tag str] -> str
innerText tags = fromString $ toList $ mconcat $ fmap tagInner tags
where tagInner (TagOpen "br" _ _) = S.singleton '\n'
tagInner (TagText text _) = S.fromList $ toString text
tagInner _other = S.empty