packages feed

gemmula-1.2.0: src/Text/Gemini.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      :  Text.Gemini
-- Copyright   :  (c) 2024 Sena
-- License     :  LGPL-3.0-or-later
--
-- Maintainer  :  contact@sena.pink
-- Stability   :  stable
-- Portability :  portable
--
-- Parse gemtext (@text/gemini@) documents from and back into `Text'.
--
-- See the Gemini hypertext format specification at
-- <https://geminiprotocol.net/docs/gemtext-specification.gmi>.
module Text.Gemini
    ( -- * Gemtext types
      GemDocument
    , GemItem (..)

      -- * Decoding from text
    , decode
    , decodeLine

      -- * Encoding into text
    , encode
    , encodeItem

      -- * Utility functions
    , documentTitle
    ) where

import Data.Bool (bool)
import Data.Char (isSpace)
import Data.Maybe (listToMaybe)
import Data.Text (Text)
import qualified Data.Text as T

-- | A gemtext document, in the form of a list of `GemItem's
type GemDocument = [GemItem]

-- | A gemtext item
data GemItem
    = -- | A regular gemtext line -- @`GemText' \<Text>@
      GemText !Text
    | -- | A gemtext link -- @`GemLink' \<Link> \[Optional Description]@
      GemLink !Text !(Maybe Text)
    | -- | A gemtext heading of 3 levels max -- @`GemHeading' \<Level> \<Text>@
      GemHeading !Int !Text
    | -- | An unordered gemtext list -- @`GemList' \<Lines>@
      --
      -- Gemtext specification does /not/ endorse grouping list items this way.
      -- This approach is purely for practical reasons, such as ease of conversion
      -- to unordered HTML lists.
      GemList ![Text]
    | -- | A gemtext quote -- @`GemQuote' \<Text>@
      GemQuote !Text
    | -- | A preformatted gemtext block -- @`GemPre' \<Lines> [Optional Alt Text]@
      GemPre ![Text] !(Maybe Text)
    deriving (Show, Eq)

-- | Parse a gemtext block as a `GemDocument'.
--
-- The input `Text' must use either CRLF or LF.
decode :: Text -> GemDocument
decode = parse . T.lines . T.replace "\CR" ""
  where
    parse :: [Text] -> GemDocument
    parse (l : ls)
        | "* " `T.isPrefixOf` l =
            let (items, rest) = span ("* " `T.isPrefixOf`) ls
             in (GemList . map (value 2) $ [l] <> items) : parse rest
        | "```" `T.isPrefixOf` l =
            let (pre, rest) = break ("```" `T.isPrefixOf`) ls
             in case rest of
                    [] -> GemText l : parse ls
                    (_ : after) -> GemPre pre (optional . value 3 $ l) : parse after
        | otherwise = decodeLine l : parse ls
    parse [] = []

-- | Parse a /single/ gemtext line as `GemItem'.
--
-- There /should not/ be any line breaks in the input; if present, they are
-- treated as spaces. Preformatted text blocks are regarded as `GemText's as they
-- are strictly multiline.
decodeLine :: Text -> GemItem
decodeLine line
    | "=>" `T.isPrefixOf` sane =
        let (link, desc) = T.break isSpace $ value 2 sane
         in if T.null link
                then GemText . T.stripEnd $ sane
                else GemLink link . optional . T.strip $ desc
    | "#" `T.isPrefixOf` sane =
        let level = min (T.length $ fst . T.span (== '#') $ sane) 3
         in GemHeading level . value level $ sane
    | "* " `T.isPrefixOf` sane = GemList [value 2 sane]
    | ">" `T.isPrefixOf` sane = GemQuote . value 1 $ sane
    | otherwise = GemText . T.stripEnd $ sane
  where
    sane = escapeLineBreaks line

-- | Encode a parsed `GemDocument' into a gemtext block.
--
-- The output `Text' uses CRLF line breaks and ends with a newline.
--
-- Empty lists are ignored completely if present. See the `encodeItem' function
-- for additional quirks.
encode :: GemDocument -> Text
encode = T.unlines . map ((<> "\CR") . encodeItem) . filter (not . empty)
  where
    empty (GemList list) = null list
    empty _ = False

-- | Encode a /single/ parsed `GemItem' into gemtext.
--
-- The output `Text' does /not/ end with an additional newline, and might be
-- multiple lines, in which case it uses CRLF line breaks.
--
-- Prefixes are escaped as necessary before encoding and the spaces in `GemLink's
-- are replaced with @%20@ to normalize them. Line breaks inside the body of an
-- item are also replaced with a space.
encodeItem :: GemItem -> Text
encodeItem item = case item of
    GemText text ->
        let shouldEscape =
                any (`T.isPrefixOf` text) ["#", "* ", ">"]
                    || "```" `T.isPrefixOf` text
                    || ("=>" `T.isPrefixOf` text && not (T.null $ value 2 text))
         in T.stripEnd . escapeLineBreaks $ bool text (T.cons ' ' text) shouldEscape
    GemLink link desc -> "=> " <> (T.replace " " "%20" . sanitize $ link) <> append desc
    GemHeading level text -> T.replicate (min level 3) "#" <> append (optional text)
    GemList list -> T.intercalate "\CR\LF" $ map (("* " <>) . sanitize) list
    GemQuote text -> ">" <> append (optional text)
    GemPre text alt -> T.intercalate "\CR\LF" $ ["```" <> append alt] <> text <> ["```"]

-- | Get the title of the `GemDocument', which is the very first `GemHeading'
-- in the document, regardless of the level of the heading.
documentTitle :: GemDocument -> Maybe Text
documentTitle doc = listToMaybe [h | GemHeading _ h <- doc]

-- `Nothing' if the text is empty.
optional :: Text -> Maybe Text
optional text = bool (Just text) Nothing $ T.null text

-- Sanitize and add a single space at the beginning if the `Text' exist,
-- otherwise return an empty `Text'.
append :: Maybe Text -> Text
append = maybe "" ((" " <>) . sanitize)

-- Strip the prefix of the line and then sanitize it to get the value.
value :: Int -> Text -> Text
value prefix = sanitize . T.drop prefix

-- Replace the line breaks with spaces and strip the `Text'.
sanitize :: Text -> Text
sanitize = T.strip . escapeLineBreaks

-- Replace the line breaks with spaces.
escapeLineBreaks :: Text -> Text
escapeLineBreaks = T.replace "\LF" " " . T.replace "\CR" ""