packages feed

gemmula-altera-2.1.1: src/Text/Gemini/Markdown.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      :  Text.Gemini.Markdown
-- Copyright   :  (c) Sena, 2024
-- License     :  AGPL-3.0-or-later
--
-- Maintainer  :  Sena <jn-sena@proton.me>
-- Stability   :  stable
-- Portability :  portable
--
-- A tiny gemtext to Markdown converter for gemmula.
--
-- Encodes parsed gemtext documents and lines into Markdown 'Text'.
-- Follows the [CommonMark specification](https://spec.commonmark.org/current).

module Text.Gemini.Markdown
    ( -- * Encoding documents
      encode

      -- * Encoding single items
    , prettyItem
    , encodeItem

      -- * Rewriting links
    , rewriteLink
    ) where

import Control.Arrow (second, (***))
import Control.Monad (join)
import Data.Bool (bool)
import Data.Char (isDigit)
import Data.Either (isRight)
import Data.List (groupBy, intercalate)
import Data.Maybe (fromMaybe, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Text.URI as URI

import Text.Gemini (GemDocument, GemItem (..))

-- | Encode parsed 'GemDocument' into a Markdown file.
-- The output 'Text' uses LF line breaks.
--
-- Valid Markdown characters are escaped before encoding. Uses the 'prettyItem' function below.
--
-- The adjacent links are grouped together in a paragraph to make them look pretty.
--
-- Empty 'GemText's and 'GemList's are ignored.
encode :: GemDocument -> Text
encode = T.unlines . map prettyItem . intercalate [GemText ""] . groupBy links . filter (not . empty)
  where
    empty :: GemItem -> Bool
    empty (GemText line) = T.null . T.strip $ line
    empty (GemList list) = null list
    empty _ = False

    links :: GemItem -> GemItem -> Bool
    links (GemLink _ _) (GemLink _ _) = True
    links _ _ = False

-- | Encode a /single/ parsed 'GemItem' into Markdown text.
--
-- The output 'Text' might be multiple lines, in which case it uses LF line breaks.
--
-- Valid Markdown characters are escaped before encoding.
--
-- Unlike 'encodeItem', long lines (> 80) will be split to multiple lines to
-- make it look prettier. The link items are also put in a seperate line to make them
-- look nice.
--
-- /Beware/ that the output text does /not/ end with a newline.
prettyItem :: GemItem -> Text
prettyItem (GemText line) = multiline Nothing $ escapeContent line
prettyItem (GemLink link desc) =
    let desc' = maybe (escapeContent link) (multiline Nothing . escapeContent) desc
     in " => [" <> desc' <> "](" <> link <> ")  "
prettyItem (GemHeading level text) = "\n" <> T.replicate (min level 6) "#" <> " " <> escapeContent text
prettyItem (GemList list) = T.intercalate "\n" $ map ((" * " <>) . multiline Nothing . escapeContent) list
prettyItem (GemQuote text) = multiline (Just " > ") $ escapeContent text
prettyItem (GemPre text alt) = T.intercalate "\n" $ ["```" <> fromMaybe "" alt] <> map escapePre text <> ["```"]

-- | Encode a /single/ parsed 'GemItem' into Markdown text.
--
-- The output 'Text' might be multiple lines, in which case it uses LF line breaks.
--
-- Valid Markdown characters are escaped before encoding.
--
-- /Beware/ that the output text does /not/ end with a newline.
encodeItem :: GemItem -> Text
encodeItem (GemText line) = escapePrefixes $ escapeContent line
encodeItem (GemLink link desc) = "[" <> escapeContent (fromMaybe link desc) <> "](" <> link <> ")"
encodeItem (GemHeading level text) = T.replicate (min level 6) "#" <> " " <> escapeContent text
encodeItem (GemList list) = T.intercalate "\n" $ map ((" * " <>) . escapeContent) list
encodeItem (GemQuote text) = " > " <> escapeContent text
encodeItem (GemPre text alt) = T.intercalate "\n" $ ["```" <> fromMaybe "" alt] <> map escapePre text <> ["```"]

-- | Rewrite @.gmi@ links as @.md@ links.
--
-- /Beware/ that this only applies to local 'GemLink's.
-- For rewriting non-local links as @http@, see 'Text.Gemini.Web.webifyLink'.
rewriteLink :: GemItem -> GemItem
rewriteLink (GemLink link desc)
    | isNothing (URI.uriPath uri) || isRight (URI.uriAuthority uri) = GemLink link desc
    | otherwise = GemLink (maybe link (<> ".md") $ T.stripSuffix ".gmi" link) desc
  where
    uri = fromMaybe URI.emptyURI $ URI.mkURI link
rewriteLink item = item

-- Split the text to multiple lines if the text is longer than 80 characters.
-- If given, adds the prefix to the beginning of every line.
-- Escapes the valid prefixes of every line if the text has any.
multiline :: Maybe Text -> Text -> Text
multiline pre text = T.intercalate "\n" $ map (maybe id (<>) pre . escapePrefixes) $ split [] [] $ T.words text
  where
    split :: [Text] -> [Text] -> [Text] -> [Text]
    split line ls (w : ws)
        | T.length (T.unwords line) < 80 = split (line <> [w]) ls ws
        | otherwise = split [w] (ls <> [T.unwords line]) ws
    split line ls [] = ls <> [T.unwords line]

-- Escapes the line prefixes such as list items and quotes.
escapePrefixes :: Text -> Text
escapePrefixes text = foldr escapePrefix text chars
  where
    escapePrefix :: Char -> Text -> Text
    escapePrefix c t
        | T.null end = t
        -- Ordered lists
        | c == '.' = bool t (pre <> "\\" <> end) (all isDigit before && (not . null $ before))
        | otherwise = bool t (pre <> "\\" <> end) (null before)
      where
        (pre, end) = T.break (== c) t
        before = T.unpack . T.stripStart $ pre

    chars = ['.', '-', '+', '#', '>', '*']

-- Escapes the content of the text, such as the backslashes; as well as the
-- surround characters, such as emphasis, links and codeblocks.
escapeContent :: Text -> Text
escapeContent text = foldr escapeSurround (T.replace "\\" "\\\\" text) chars
  where
    escapeSurround :: (Char, Char) -> Text -> Text
    escapeSurround del@(op, cl) t
        | T.null t = t
        | otherwise =
            let (pre, (ins, post)) = second (T.break (== cl) . T.drop 1 . (<> " ")) $ T.break (== op) t
                (op', cl') = join (***) (("\\" <>) . T.singleton) del
             in pre
                    <> T.dropEnd
                        1
                        ( if T.null post
                            then bool (T.singleton op <> ins) ins (T.null ins)
                            else op' <> ins <> cl' <> escapeSurround del (T.drop 1 post)
                        )

    chars = [('~', '~'), ('`', '`'), ('(', ')'), ('<', '>'), ('[', ']'), ('{', '}'), ('_', '_'), ('*', '*')]

-- Escapes the preformatted delimiter inside a preformatted text.
escapePre :: Text -> Text
escapePre text = bool (T.replace "```" "    ```" text) text (T.null text)