packages feed

seonbi-0.3.0: src/Text/Seonbi/ContentTypes.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
module Text.Seonbi.ContentTypes
    ( ContentType
    , HtmlTransformer
    , TextTransformer
    , asCommonMarkTransformer
    , asHtmlTransformer
    , asHtmlTransformer'
    , asPlainTextTransformer
    , asXhtmlTransformer
    , contentTypeFromText
    , contentTypes
    , contentTypeText
    , transformWithContentType
    ) where

#if MIN_VERSION_base(4,13,0)
import Prelude hiding (MonadFail)
#endif

import Control.Monad (forM)
import Control.Monad.Fail (MonadFail)
import Data.Maybe (fromMaybe, isNothing)
import Data.List
import Text.Read (readMaybe)

import CMark
import Data.CaseInsensitive
import Data.Set
import Data.Text as ST
import Data.Text.Lazy as LT
import Data.Text.Lazy.Builder
import HTMLEntities.Builder
import HTMLEntities.Decoder

import Text.Seonbi.Html
import Text.Seonbi.Html.Tag (headingLevel, headingTag')
import qualified Text.Seonbi.Html.TagStack as TagStack

-- | Represents a function that transforms an 'HtmlEntity' list.
type HtmlTransformer m
    = (Monad m, MonadFail m) => [HtmlEntity] -> m [HtmlEntity]

-- | Represents a function that transforms a text.
type TextTransformer m
    = (Monad m, MonadFail m) => LT.Text -> m LT.Text

-- | Represents a function that transforms an 'HtmlTransformer' into
-- a 'TextTransformer'.
type TransformerTransformer m
    = (Monad m, MonadFail m) => HtmlTransformer m -> TextTransformer m

-- | Gets a 'TransformerTransformer' that transforms 'HtmlTransformer' into
-- a 'TextTransformer' which transforms an HTML/XHTML text.
asHtmlTransformer'
    :: (Monad m, MonadFail m)
    => Bool
    -- ^ 'True' for XHTML, and 'False' for HTML.
    -> TransformerTransformer m
    -- ^ A 'TransformerTransformer' that transforms an 'HtmlTransformer' into
    -- a 'TextTransformer' which transforms an HTML/XHTML text.
asHtmlTransformer' xhtml transformer htmlText = do
    case scanHtml htmlText of
        Done "" input -> do
            output <- transformer input
            return $ printHtml' output
        _ ->
            fail "failed to parse input"
  where
    printHtml' :: [HtmlEntity] -> LT.Text
    printHtml'
      | xhtml = printXhtml
      | otherwise = printHtml

-- | Transforms an 'HtmlTransformer' into a 'TextTransformer' which transforms
-- an HTML text.
asHtmlTransformer :: (Monad m, MonadFail m) => TransformerTransformer m
asHtmlTransformer = asHtmlTransformer' False

-- | Transforms an 'HtmlTransformer' into a 'TextTransformer' which transforms
-- an XHTML text.
asXhtmlTransformer :: (Monad m, MonadFail m) => TransformerTransformer m
asXhtmlTransformer = asHtmlTransformer' True

-- | Transforms an 'HtmlTransformer' into a 'TextTransformer' which transforms
-- a plain text.
asPlainTextTransformer :: (Monad m, MonadFail m) => TransformerTransformer m
asPlainTextTransformer transformer text' = do
    let escaped = toLazyText $ HTMLEntities.Builder.text $ LT.toStrict text'
    let entities = [HtmlText TagStack.empty $ LT.toStrict escaped]
    output <- transformer entities
    return $ printText output

-- | Transforms an 'HtmlTransformer' into a 'TextTransformer' which transforms
-- a CommonMark (Markdown) text.
asCommonMarkTransformer :: (Monad m, MonadFail m) => TransformerTransformer m
asCommonMarkTransformer transformer input = do
    let inputNode = commonmarkToNode [optSourcePos, optUnsafe] $
            LT.toStrict input
    inputEntities <- fromNode [] inputNode
    outputEntities <- transformer $ normalizeText inputEntities
    let outputNodes = toNode outputEntities
    let outputNode = case outputNodes of
            [node@(Node _ DOCUMENT _)] -> node
            nodes -> Node Nothing DOCUMENT nodes
    return $ LT.fromStrict $
        nodeToCommonmark [optSourcePos , optUnsafe] Nothing outputNode
  where
    fromNode :: (Monad m, MonadFail m) => HtmlTagStack -> Node -> m [HtmlEntity]
    fromNode stack (Node posInfo nodeType children) = case nodeType of
        DOCUMENT ->
            nodeWithChildren Article
        THEMATIC_BREAK -> return
            [ HtmlStartTag stack HR (posAttr posInfo)
            , HtmlEndTag stack HR
            ]
        PARAGRAPH ->
            nodeWithChildren P
        BLOCK_QUOTE ->
            nodeWithChildren BlockQuote
        HTML_BLOCK html ->
            case scanHtml $ LT.fromStrict html of
                Done "" input' -> return $ rebaseStack input'
                _ -> return [HtmlCdata stack html]
        CUSTOM_BLOCK _ _ ->
            return []
        CODE_BLOCK info text' -> return
            [ HtmlStartTag stack Pre (posAttr posInfo <> attr' "info" info)
            , HtmlCdata (nextStack Pre) text'
            , HtmlEndTag stack Pre
            ]
        HEADING level ->
            nodeWithChildren $ headingTag' level
        LIST listAttrs ->
            nodeWithChildren' UL $ attr' "list-attrs" listAttrs
        ITEM ->
            nodeWithChildren LI
        TEXT text' ->
            return [HtmlCdata stack text']
        SOFTBREAK -> return
            [ HtmlStartTag stack BR (posAttr posInfo <> attr' "softbreak" True)
            , HtmlEndTag stack BR
            ]
        LINEBREAK -> return
            [ HtmlStartTag stack BR (posAttr posInfo)
            , HtmlEndTag stack BR
            ]
        HTML_INLINE html ->
            case scanHtml $ LT.fromStrict html of
                Done "" input' -> return $ rebaseStack input'
                _ -> return [HtmlCdata stack html]
        CUSTOM_INLINE _ _ ->
            return []
        CODE text' -> return
            [ HtmlStartTag stack Code (posAttr posInfo)
            , HtmlCdata (nextStack Code) text'
            , HtmlEndTag stack Code
            ]
        EMPH ->
            nodeWithChildren Em
        STRONG ->
            nodeWithChildren Strong
        LINK href title ->
            nodeWithChildren' A $ rawAttr " href" href <> rawAttr " title" title
        IMAGE src title ->
            nodeWithChildren' Img $ rawAttr " src" src <> rawAttr " title" title
      where
        nextStack :: HtmlTag -> HtmlTagStack
        nextStack = (`TagStack.push` stack)
        nodeWithChildren :: (Monad m, MonadFail m) => HtmlTag -> m [HtmlEntity]
        nodeWithChildren tag' = nodeWithChildren' tag' ""
        nodeWithChildren' :: (Monad m, MonadFail m)
                          => HtmlTag -> ST.Text -> m [HtmlEntity]
        nodeWithChildren' tag' extraAttrs = do
            mid <- forM children $ do
                fromNode (nextStack tag')
            let middle = Data.List.concat mid
            return $ HtmlStartTag stack tag' (posAttr posInfo <> extraAttrs) :
                middle ++ [HtmlEndTag stack tag']
        rebase' :: HtmlTagStack -> HtmlTagStack
        rebase' = TagStack.rebase [] stack
        rebaseStack :: [HtmlEntity] -> [HtmlEntity]
        rebaseStack = Prelude.map (\e -> e { tagStack = rebase' $ tagStack e })
    toNode :: [HtmlEntity] -> [Node]
    toNode [] = []
    toNode (x:xs) = case x of
        HtmlComment _ comment' ->
            Node Nothing (htmlNode comment') [] : toNode xs
        HtmlCdata _ cdata ->
            Node Nothing (TEXT cdata) [] : toNode xs
        HtmlText _ rawText' ->
            Node Nothing (TEXT $ unescape rawText') [] : toNode xs
        HtmlEndTag _ _ ->
            toNode xs
        start@(HtmlStartTag stack tag' attrs) ->
            let (children', rest) = Data.List.break (endOf stack tag') xs
                (end, rest') = case rest of
                    end'@(HtmlEndTag endStack endTag):afterEnd ->
                        if endStack == stack && endTag == tag'
                            then ([end'], afterEnd)
                            else ([], rest)
                    _ -> ([], rest)
                posInfo = fromMaybe Nothing
                    (getAttr attrs "posinfo" :: Maybe (Maybe PosInfo))
                softbreak = getAttr attrs "softbreak" :: Maybe Bool
                childrenHtmlNode = htmlNode $ LT.toStrict $ printHtml $
                    start : children' ++ end
                nodeType = case tag' of
                    Article -> DOCUMENT
                    BlockQuote -> BLOCK_QUOTE
                    HR -> THEMATIC_BREAK
                    P -> PARAGRAPH
                    Pre -> case getAttr "info" attrs of
                        Just info -> CODE_BLOCK info $ printText' children'
                        Nothing -> childrenHtmlNode
                    UL ->
                        maybe childrenHtmlNode LIST (getAttr attrs "list-attrs")
                    LI -> ITEM
                    BR ->
                        if softbreak == Just True then SOFTBREAK else LINEBREAK
                    Code -> CODE $ printText' children'
                    Em -> EMPH
                    Strong -> STRONG
                    A -> LINK
                        (fromMaybe ST.empty $ getRawAttr attrs "href")
                        (fromMaybe ST.empty $ getRawAttr attrs "title")
                    Img -> IMAGE
                        (fromMaybe ST.empty $ getRawAttr attrs "src")
                        (fromMaybe ST.empty $ getRawAttr attrs "title")
                    _ ->
                        maybe childrenHtmlNode HEADING (headingLevel tag')
                (nodeType', nodeChildren) =
                    if isNothing posInfo && isNothing softbreak
                        then (childrenHtmlNode, [])
                        else (nodeType, toNode children')
            in
                Node posInfo nodeType' nodeChildren : toNode rest'
      where
        block :: Bool
        block = case TagStack.last (tagStack x) of
            Just A -> False
            Just Em -> False
            Just H1 -> False
            Just H2 -> False
            Just H3 -> False
            Just H4 -> False
            Just H5 -> False
            Just H6 -> False
            Just P -> False
            Just Strong -> False
            Just tag' -> isNothing (headingLevel tag')
            _ -> True
        htmlNode :: ST.Text -> NodeType
        htmlNode
          | block = HTML_BLOCK
          | otherwise = HTML_INLINE
    unescape :: ST.Text -> ST.Text
    unescape = toStrict . toLazyText . htmlEncodedText
    rawAttr :: ST.Text -> ST.Text -> ST.Text
    rawAttr name value = ST.append name $ toStrict $
        "=\"" <> toLazyText (HTMLEntities.Builder.text value) <> "\""
    attr :: Show a => ST.Text -> a -> ST.Text
    attr name value =
        rawAttr ("data-seonbi-cmark-" <> name) $ ST.pack (show value)
    attr' :: Show a => ST.Text -> a -> ST.Text
    attr' name = ST.cons ' ' . attr name
    posAttr :: Maybe PosInfo -> ST.Text
    posAttr = attr "posinfo"
    getRawAttr :: HtmlRawAttrs -> ST.Text -> Maybe ST.Text
    getRawAttr attrs name =
        case ST.breakOn prefix attrs of
            (_, "") -> Nothing
            (_, head') ->
                case ST.break (== '"') (ST.drop (ST.length prefix) head') of
                    (_, "") -> Nothing
                    (value, _) ->
                        Just $ toStrict $ toLazyText $ htmlEncodedText value
      where
        prefix :: ST.Text
        prefix = name <> "=\""
    getAttr :: Read a => HtmlRawAttrs -> ST.Text -> Maybe a
    getAttr attrs name =
        case getRawAttr attrs ("data-seonbi-cmark-" <> name) of
            Nothing -> Nothing
            Just value -> readMaybe $ ST.unpack value
    endOf :: HtmlTagStack -> HtmlTag -> HtmlEntity -> Bool
    endOf stack tag' (HtmlEndTag endStack endTag) =
        not (endStack `TagStack.descendsFrom` stack) || endTag == tag'
    endOf _ _ _ = False
    printText' :: [HtmlEntity] -> ST.Text
    printText' = toStrict . printText


-- | Represents a case-insensitive content type.
type ContentType = CI ST.Text

-- | Converts a 'Text' to a 'ContentType'.
contentTypeFromText :: ST.Text -> ContentType
contentTypeFromText = mk

-- | Converts a 'ContentType' to a 'Text'.
contentTypeText :: ContentType -> ST.Text
contentTypeText = original

newtype TransformerTransformer' m =
    TransformerTransformer' (TransformerTransformer m)
transformers :: (Monad m, MonadFail m)
             => [(ContentType, TransformerTransformer' m)]
transformers =
    [ ("text/html", TransformerTransformer' asHtmlTransformer)
    , ("application/xhtml+xml", TransformerTransformer' asXhtmlTransformer)
    , ("text/plain", TransformerTransformer' asPlainTextTransformer)
    , ("text/markdown", TransformerTransformer' asCommonMarkTransformer)
    ]

-- | Supported content types.
contentTypes :: Set ContentType
contentTypes = (Data.Set.fromList . Prelude.map fst)
    (transformers :: [(ContentType, TransformerTransformer' IO)])

getTransformerTransformer :: (Monad m, MonadFail m)
                          => ContentType
                          -> Maybe (TransformerTransformer' m)
getTransformerTransformer contentType =
    snd <$> Data.List.find ((== contentType) . fst) transformers

-- | Applies an 'HtmlTransformer' to the given text with respect to the
-- given content type.
transformWithContentType
    :: (Monad m, MonadFail m)
    => ContentType
    -- ^ A content type.  If the content type is unsupported (i.e. not in
    -- 'contentTypes'), this function fails.
    -> HtmlTransformer m
    -- ^ An 'HtmlTransformer' to apply.
    -> LT.Text
    -- ^ A input text to transform.
    -> m LT.Text
    -- ^ A transformed text.
transformWithContentType contentType transformer inputText =
    case getTransformerTransformer contentType of
        Nothing -> fail $ ST.unpack $
            "unknown content type: " <> contentTypeText contentType
        Just (TransformerTransformer' transformTransformer) ->
            transformTransformer transformer inputText