packages feed

gemoire-0.1.0: src/Gemoire/Gemlog.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      :  Gemoire.Gemlog
-- Copyright   :  (c) 2024 Sena
-- License     :  GPL-3.0-or-later
--
-- Maintainer  :  contact@sena.pink
-- Stability   :  stable
-- Portability :  portable
--
-- A tiny static gemlog generator
--
-- To make a gemlog, you need two essential types of materials: posts and
-- templates.
--
-- The template syntax detailed in `Gemoire.Template' is also usable in posts.
-- The posts processed using this module have some special variables available
-- to them while generating. Those are, including others:
--
--     * @title@ - The first heading in the document if it exists
--     * @path@ - The given file path /(non-overrideable)/
--     * @fname@ - The file name without the extension and the directories
--     * @modified@ - File modification date and time (@yyyy-mm-ddThh:mm:ss[.ss]±hh:mm@)
--     * @modified_date@ - File modification date (@yyyy-mm-dd@)
--     * @url@ - The permanent link to post /(non-overrideable)/
--     * @gemlog@ - The title of the gemlog /(non-overrideable)/
--     * @author@ - The author of the gemlog /(non-overrideable)/
--
-- Additionally, variables can also be set (or overridden) in the post like so,
-- assuming a /single variable per line/:
--
--     > {= variable value =}
--
-- For the templates, see `Gemoire.Template'. However, as with posts, feeds also
-- have special variables available. Those are, for the /feed itself/:
--
--     * @title@ - The title of the gemlog
--     * @author@ - The author of the gemlog
--     * @base@ - The base URL of the gemlog
--     * @url@ - The permanent link to the feed
--     * @entries@ - The formatted entries
--
-- For each entry in the feed, every variable the post associated with the entry has
-- is available.
--
-- See the README.md for a roughly step-by-step guide.
module Gemoire.Gemlog
    ( -- * Gemlog generation
      Gemlog (..)
    , generatePosts
    , generateGemfeed
    , generateAtom

      -- * Re-exports
    , defPost
    , defGemfeed
    , defGemfeedEntry
    , defAtom
    , defAtomEntry
    , vempty
    , vlist
    ) where

import Data.Bool (bool)
import Data.HashMap.Strict (findWithDefault, fromList, singleton)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory (createDirectoryIfMissing, doesDirectoryExist)
import System.Directory.Extra (listFilesRecursive)
import System.FilePath (takeExtension, takeFileName, (</>))

import Gemoire.Gemlog.Feed
    ( defAtom
    , defAtomEntry
    , defGemfeed
    , defGemfeedEntry
    , escapeFeed
    , lastModified
    , sortPosts
    )
import Gemoire.Gemlog.Post (defPost, readPost)
import Gemoire.Template (Template, Values, format, vempty, vlist)

-- | A gemlog recipe to generate files for
data Gemlog = Gemlog
    { title :: !Text
    -- ^ The title of the gemlog
    , author :: !Text
    -- ^ The author of the gemlog
    , sourceDir :: !FilePath
    -- ^ The source directory for posts, scanned recursively,
    -- /not/ retaining the directory structure
    , baseURL :: !Text
    -- ^ The base URL of the gemlog
    , postTemplate :: Template
    -- ^ The post template
    , gemfeedTemplates :: (Template, Template)
    -- ^ The gemfeed templates, where the first is the feed itself
    -- and the second is a single entry
    , atomTemplates :: (Template, Template)
    -- ^ The Atom feed templates, where the first is the feed itself
    -- and the second is a single entry
    , overrides :: !Values
    }
    deriving (Show, Eq)

-- | Generates and writes all the posts in the given `Gemlog' to the given directory.
--
-- The output directory is /flat/ and the structure of the source is /never/ retained.
--
-- See the module description for the variables available in the post.
generatePosts :: Gemlog -> FilePath -> IO ()
generatePosts glog@(Gemlog{postTemplate}) outputDir = do
    posts <- parsePosts glog
    createDirectoryIfMissing True outputDir
    mapM_ (\p -> writeFile (outputDir </> fileName p) $ T.unpack . crlf $ format postTemplate p) posts

-- | Generates a gemfeed in the given path.
--
-- See the module description for the variables available in the feed.
--
-- If the given path is a directory, the feed file will be named
-- @index.gmi@ by default.
--
-- The entries are sorted by last modified in the feed. Every variable in the
-- post is available in its respective entry. See `generatePosts' above.
generateGemfeed :: Gemlog -> FilePath -> IO ()
generateGemfeed = generateFeed True

-- | Generates an feed Atom in the given path.
--
-- See the module description for the variables available in the feed.
--
-- If the given path is a directory, the feed file will be named
-- @atom.xml@ by default.
--
-- The entries are sorted by last modified in the feed. Every variable in the post
-- is available in its respective entry. See `generatePosts' above.
--
-- Additionally, every variable is escaped to make the XML valid. Variables ending with
-- @url@ are escaped using percent encodings for URLs instead of ampersand. See `escapeAtom'.
generateAtom :: Gemlog -> FilePath -> IO ()
generateAtom = generateFeed False

-- The first parameter is @True@ for gemfeeds and @False@ for Atom.
-- Adds some additional variables described above.
generateFeed :: Bool -> Gemlog -> FilePath -> IO ()
generateFeed gemfeed glog@(Gemlog{title, author, baseURL, overrides}) path = do
    let (feed, entry) = bool atomTemplates gemfeedTemplates gemfeed glog
        ending = bool "" "\CR" gemfeed <> "\LF"
        fname = bool "atom.xml" "index.gmi" gemfeed
    output <- bool path (path </> fname) <$> doesDirectoryExist path
    posts <- sortPosts <$> parsePosts glog
    TIO.writeFile output
        . bool id crlf gemfeed
        . format feed
        . escapeFeed gemfeed
        . (overrides <>)
        . (lastModified posts <>)
        . fromList
        $ [ ("title", title)
          , ("author", author)
          , ("base", baseURL)
          , ("url", T.dropWhileEnd (== '/') baseURL <> "/" <> T.pack (takeFileName output))
          ,
              ( "entries"
              , T.intercalate ending $
                    map (T.stripEnd . bool id crlf gemfeed . format entry . escapeFeed gemfeed) posts
              )
          ]

-- Parse all the posts /recursively/ in a `Gemlog's source directory.
-- Adds the @url@, @gemlog@, and @author@ fields.
parsePosts :: Gemlog -> IO [Values]
parsePosts (Gemlog{title, author, sourceDir, baseURL, overrides}) =
    map (\p -> singleton "url" (T.dropWhileEnd (== '/') baseURL <> "/" <> T.pack (fileName p)) <> p)
        <$> ( mapM (readPost (fromList [("gemlog", title), ("author", author)] <> overrides))
                . filter ((== ".gmi") . takeExtension)
                =<< listFilesRecursive sourceDir
            )

-- Take the file name from the path in a parsed post.
fileName :: Values -> FilePath
fileName p = takeFileName . T.unpack $ findWithDefault "post.gmi" "path" p

-- Make the newlines CRLF in the given text if the boolean is @True@.
crlf :: Text -> Text
crlf = T.unlines . map ((<> "\CR") . T.dropWhileEnd (== '\CR')) . T.lines