packages feed

gemoire-0.1.0: src/Gemoire/Template.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      :  Gemoire.Template
-- Copyright   :  (c) 2024 Sena
-- License     :  GPL-3.0-or-later
--
-- Maintainer  :  contact@sena.pink
-- Stability   :  stable
-- Portability :  portable
--
-- A minimal template parser to use with generated static gemlogs
--
-- Gemoire uses its own template system, where the templates are plain texts
-- with special inline components. The valid components are:
--
--     * @{$pholder$}@ - A placeholder with the key @pholder@
--     * @{$pholder:default value$}@ - A placeholder with a default value
--     * @{&pholder:fallback&}@ - With a fallback /key/ to use in case @pholder@ has no value
--     * @{&pholder:fallback:default value&}@ - With a fallback /key/ and a default /value/
--     * @{# comment! #}@ - An unrendered comment
--     * @{\ constant \}@ - An unevaluated block, akin to escaping things inside it
--
-- All inline components can be multiline in any way, including placeholder keys.
-- The arguments can also be surrounded by any amount of whitespace, which will
-- get stripped /except/ when specifying a default value.
--
-- The surrounding braces are parsed in order, so the components /can not/ be nested.
module Gemoire.Template
    ( -- * Formatting
      template
    , format

      -- * Types
    , Values
    , Template
    , Component (..)

      -- * Utility
    , vempty
    , vlist
    ) where

import Control.Arrow (second, (&&&), (***))
import Control.Monad (join)
import Data.Bool (bool)
import Data.HashMap.Strict (HashMap, empty, findWithDefault, fromList, (!?))
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T

-- | A basic template, in the form of a string of either plain text
-- blocks or meaningful components
type Template = [Either Text Component]

-- | A parsed template component with a special meaning
data Component
    = -- | A placeholder with an optional default value
      Placeholder !Text !(Maybe Text)
    | -- | A placeholder with another key to fallback to if the first one
      -- has no value, and an optional default value
      Fallback !Text !Text !(Maybe Text)
    deriving (Eq, Show)

-- | An unordered `HashMap' of formatting keys and values
type Values = HashMap Text Text

-- | Construct a `Template' from the given `Text'.
--
-- See the module description for how the templates are formed.
--
-- The whitespaces in the arguments will be stripped /except/ for default values.
--
-- The surrounding braces are parsed in order, so the components /can not/ be nested.
template :: Text -> Template
template text
    | T.null text = []
    | otherwise =
        let (str, ((pfx, inside), (sfx, rest))) =
                second (shiftDelimiters . join (***) (T.splitAt 1) . T.break (== '}')) $
                    T.break (== '{') text
            optstr = bool [Left str] [] $ T.null str
         in case (pfx, sfx) of
                ("{$", "$}") ->
                    let (name, def) = second (T.stripPrefix ":") $ T.break (== ':') inside
                     in optstr <> [Right $ Placeholder (T.strip name) def] <> template rest
                ("{&", "&}") ->
                    let (name, (fback, def)) =
                            second (second (T.stripPrefix ":") . T.break (== ':') . T.drop 1) $
                                T.break (== ':') inside
                     in optstr <> [Right $ Fallback (T.strip name) (T.strip fback) def] <> template rest
                ("{#", _) ->
                    let (_, end) = second (T.drop 2) $ T.breakOn "#}" (inside <> sfx <> rest)
                     in optstr <> template end
                ("{\\", _) ->
                    let (constant, end) = second (T.drop 2) $ T.breakOn "\\}" (inside <> sfx <> rest)
                     in optstr <> [Left constant] <> template end
                _ -> [Left (str <> pfx)] <> template (inside <> sfx)
  where
    shiftDelimiters :: ((Text, Text), (Text, Text)) -> ((Text, Text), (Text, Text))
    shiftDelimiters ((pfx, pholder), (sfx, rest)) =
        ( (((pfx <>) . T.take 1) &&& (T.drop 1 . T.dropEnd 1)) pholder
        , (T.takeEnd 1 pholder <> sfx, rest)
        )

-- | Format the `Template' using given `Values' map.
format :: Template -> Values -> Text
format [] _ = ""
format (p : ps) vars =
    ( case p of
        Left text -> text
        Right (Placeholder key def) -> findWithDefault (fromMaybe "" def) key vars
        Right (Fallback key1 key2 def) ->
            findWithDefault (fromMaybe (fromMaybe "" def) (vars !? key2)) key1 vars
    )
        <> format ps vars

-- | An empty `Values' map for convenience
vempty :: Values
vempty = empty

-- | `fromList' for convenience
vlist :: [(Text, Text)] -> Values
vlist = fromList