packages feed

nirum-0.5.0: src/Nirum/Docs.hs

{-# LANGUAGE LambdaCase #-}
module Nirum.Docs ( Block (..)
                  , HeadingLevel (H1, H2, H3, H4, H5, H6)
                  , Html
                  , Inline ( Code
                           , Emphasis
                           , HardLineBreak
                           , HtmlInline
                           , Image
                           , Link
                           , SoftLineBreak
                           , Strong
                           , Text
                           , imageTitle
                           , imageUrl
                           , linkContents
                           , linkTitle
                           , linkUrl
                           )
                  , ItemList (LooseItemList, TightItemList)
                  , ListType (BulletList, OrderedList, startNumber, delimiter)
                  , ListDelimiter (Parenthesis, Period)
                  , ListItem
                  , TableCell
                  , TableColumn (..)
                  , TableRow
                  , Url
                  , extractTitle
                  , filterReferences
                  , headingLevelFromInt
                  , headingLevelInt
                  , parse
                  , transformReferences
                  , trimTitle
                  ) where

import Data.Char
import Data.List.NonEmpty
import Data.String (IsString (fromString))

import qualified CMarkGFM as M
import qualified Data.Text as T

type Url = T.Text
type Title = T.Text
type Html = T.Text
type AnchorId = T.Text

-- | The level of heading.
-- See also: http://spec.commonmark.org/0.25/#atx-heading
data HeadingLevel = H1 | H2 | H3 | H4 | H5 | H6 deriving (Eq, Ord, Show)

headingLevelFromInt :: Int -> HeadingLevel
headingLevelFromInt 2 = H2
headingLevelFromInt 3 = H3
headingLevelFromInt 4 = H4
headingLevelFromInt 5 = H5
headingLevelFromInt i = if i > 5 then H6 else H1

headingLevelInt :: HeadingLevel -> Int
headingLevelInt H1 = 1
headingLevelInt H2 = 2
headingLevelInt H3 = 3
headingLevelInt H4 = 4
headingLevelInt H5 = 5
headingLevelInt H6 = 6

-- | Whether a list is a bullet list or an ordered list.
-- See also: http://spec.commonmark.org/0.25/#of-the-same-type
data ListType = BulletList
              | OrderedList { startNumber :: Int
                            , delimiter :: ListDelimiter
                            }
              deriving (Eq, Ord, Show)

-- | Whether ordered list markers are followed by period (@.@) or
-- parenthesis (@)@).
-- See also: http://spec.commonmark.org/0.25/#ordered-list-marker
data ListDelimiter = Period | Parenthesis deriving (Eq, Ord, Show)

data Block = Document [Block]
           | ThematicBreak
           | Paragraph [Inline]
           | BlockQuote [Block]
           | HtmlBlock Html
           | CodeBlock { infoString :: T.Text, code :: T.Text }
           | Heading HeadingLevel [Inline] (Maybe AnchorId)
           | List ListType ItemList
           | Table (NonEmpty TableColumn) (NonEmpty TableRow)
           deriving (Eq, Ord, Show)

data ItemList
    = LooseItemList [ListItem]
    | TightItemList [ListItem]
    deriving (Eq, Ord, Show)

type ListItem = [Block]

data TableColumn
    = NotAligned
    | LeftAligned
    | CenterAligned
    | RightAligned
    deriving (Eq, Ord, Show)

type TableRow = NonEmpty TableCell

type TableCell = [Inline]

data Inline
    = Text T.Text
    | SoftLineBreak -- | See also:
                    -- http://spec.commonmark.org/0.25/#soft-line-breaks
    | HardLineBreak -- | See also:
                    -- http://spec.commonmark.org/0.25/#hard-line-breaks
    | HtmlInline Html
    | Code T.Text
    | Emphasis [Inline]
    | Strong [Inline]
    | Link { linkUrl :: Url, linkTitle :: Title, linkContents :: [Inline] }
    | Image { imageUrl :: Url, imageTitle :: Title }
    deriving (Eq, Ord, Show)

-- | Extract the top-level first heading from the block, if it exists.
extractTitle :: Block -> Maybe (HeadingLevel, [Inline])
extractTitle (Document (Heading lv inlines _ : _)) = Just (lv, inlines)
extractTitle _ = Nothing

-- | Trim the top-level first heading from the block, if it exists.
trimTitle :: Block -> Block
trimTitle block =
    case block of
        Document (Heading {} : rest) -> Document rest
        b -> b

parse :: T.Text -> Block
parse =
    transBlock . M.commonmarkToNode [M.optSmart] [M.extTable]
  where
    transBlock :: M.Node -> Block
    transBlock n@(M.Node _ nodeType children) =
        case nodeType of
            M.DOCUMENT -> Document blockChildren
            M.THEMATIC_BREAK -> ThematicBreak
            M.PARAGRAPH -> Paragraph inlineChildren
            M.BLOCK_QUOTE -> BlockQuote blockChildren
            M.HTML_BLOCK rawHtml -> HtmlBlock rawHtml
            M.CUSTOM_BLOCK _ _ -> error $ "custom block is unsupported: " ++ n'
            M.CODE_BLOCK info codeText -> CodeBlock info codeText
            M.HEADING lv -> case extractAnchorId (last' children) of
                Nothing ->
                    Heading (headingLevelFromInt lv) inlineChildren Nothing
                Just (initial, anchorId) ->
                    Heading
                        (headingLevelFromInt lv)
                        (Prelude.init (fmap transInline children) ++ initial)
                        (Just anchorId)
            M.LIST (M.ListAttributes listType' tight start delim) ->
                List (case listType' of
                          M.BULLET_LIST -> BulletList
                          M.ORDERED_LIST ->
                              OrderedList start $
                                          case delim of
                                              M.PERIOD_DELIM -> Period
                                              M.PAREN_DELIM -> Parenthesis
                     ) $
                     (if tight then TightItemList else LooseItemList) $
                        fmap (fmap transBlock) listItems
            M.TABLE cellAligns ->
                Table
                    (fromList $ fmap toTableColumn cellAligns)
                    (fromList $ fmap transRow children)
            _ -> error $ "expected block, but got inline: " ++ n'
      where
        blockChildren :: [Block]
        blockChildren = fmap transBlock children
        inlineChildren :: [Inline]
        inlineChildren = fmap transInline children
        listItems :: [[M.Node]]
        listItems = [nodes | (M.Node _ M.ITEM nodes) <- children]
        n' :: String
        n' = show n
    transInline :: M.Node -> Inline
    transInline n@(M.Node _ nodeType childNodes) =
        case nodeType of
            M.TEXT text -> Text text
            M.SOFTBREAK -> SoftLineBreak
            M.LINEBREAK -> HardLineBreak
            M.HTML_INLINE rawHtml -> HtmlInline rawHtml
            M.CODE code' -> Code code'
            M.EMPH -> Emphasis children
            M.STRONG -> Strong children
            M.LINK url title -> Link url title children
            M.IMAGE url title -> Image url title
            _ -> error $ "expected inline, but got block: " ++ show n
      where
        children :: [Inline]
        children = fmap transInline childNodes
    toTableColumn :: M.TableCellAlignment -> TableColumn
    toTableColumn M.NoAlignment = NotAligned
    toTableColumn M.LeftAligned = LeftAligned
    toTableColumn M.CenterAligned = CenterAligned
    toTableColumn M.RightAligned = RightAligned
    transRow :: M.Node -> TableRow
    transRow (M.Node _ M.TABLE_ROW nodes) = fromList (fmap transCell nodes)
    transRow node = error $ "expected a table row, but got " ++ show node
    transCell :: M.Node -> TableCell
    transCell (M.Node _ M.TABLE_CELL nodes) = fmap transInline nodes
    transCell node = error $ "expected a table cell, but got " ++ show node
    extractAnchorId :: Maybe M.Node -> Maybe ([Inline], AnchorId)
    extractAnchorId (Just (M.Node _ (M.TEXT text) []))
      | T.null tail' = Nothing
      | T.last tail' /= '}' = Nothing
      | T.length tail' < 2 = Nothing
      | otherwise =
            let
                aid = T.init tail'
            in
                if isValidId aid
                     then Just ([Text $ T.stripEnd $ T.dropEnd 3 head'], aid)
                     else Nothing
      where
        (head', tail') = T.breakOnEnd " {#" text
        isValidId :: T.Text -> Bool
        isValidId = T.all $ \ c ->
            isAscii c && isAlphaNum c || c `elem` ['-', '_', '.']
    extractAnchorId _ = Nothing
    last' :: [a] -> Maybe a
    last' [] = Nothing
    last' l = Just $ Prelude.last l

instance IsString Block where
    fromString = parse . T.pack

instance IsString Inline where
    fromString = Text . T.pack

-- | Replace all 'Link' and 'Image' nodes with normal 'Text' nodes.
filterReferences :: [Inline] -> [Inline]
filterReferences [] = []
filterReferences (Image { imageTitle = t } : ix) = Text t : filterReferences ix
filterReferences (Link { linkContents = children } : ix) =
    children ++ filterReferences ix
filterReferences (i : ix) = i : filterReferences ix

transformReferences :: (Url -> Url) -> Block -> Block
transformReferences replace = \ case
    Document blocks ->
        Document (transformReferences replace <$> blocks)
    Paragraph inlines ->
        Paragraph (transformInline <$> inlines)
    BlockQuote blocks ->
        BlockQuote (transformReferences replace <$> blocks)
    Heading lv inlines anchorId ->
        Heading lv (transformInline <$> inlines) anchorId
    List lt (LooseItemList items) ->
        List lt $ LooseItemList (fmap (transformReferences replace) <$> items)
    List lt (TightItemList items) ->
        List lt $ TightItemList (fmap (transformReferences replace) <$> items)
    Table cols rows ->
        Table cols (fmap (fmap transformInline) <$> rows)
    block ->
        block
  where
    transformInline :: Inline -> Inline
    transformInline (Emphasis inlines) = Emphasis (transformInline <$> inlines)
    transformInline (Strong inlines) = Strong (transformInline <$> inlines)
    transformInline (Link url title inlines) =
        Link (replace url) title (transformInline <$> inlines)
    transformInline (Image url title) = Image (replace url) title
    transformInline inline = inline