packages feed

yesod-articles-0.1.0.0: src/Yesod/Articles.hs

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
module Yesod.Articles where
import Prelude
import Language.Haskell.TH
import System.Directory
import Text.Hamlet as NP
import qualified Data.Text as T 
import Data.Text (Text, pack, splitOn, intercalate, isSuffixOf, unpack)
import qualified Data.Text.IO as TI
import Data.Dates (DateTime, parseDate, getCurrentDateTime)
import Language.Haskell.TH.Quote (QuasiQuoter (..))
import Language.Haskell.TH.Syntax
import Text.Regex
import Data.List (sortBy)
import Yesod.Core.Widget
import Instances.TH.Lift
import Text.Blaze.Html (toHtml)
import Yesod.Core.Handler (getMessageRender, getUrlRenderParams, notFound)
import System.FilePath.Posix

-- | Snatched from Yesod.Core.Widget, used in other functions
rules :: Q NP.HamletRules
rules = do
    ah <- [|asWidgetT . toWidget|]
    let helper qg f = do
            x <- newName "urender"
            e <- f $ VarE x
            let e' = LamE [VarP x] e
            g <- qg
            bind <- [|(>>=)|]
            return $ InfixE (Just g) bind (Just e')
    let ur f = do
            let env = NP.Env
                    (Just $ helper [|getUrlRenderParams|])
                    (Just $ helper [|fmap (toHtml .) getMessageRender|])
            f env
    return $ NP.HamletRules ah ur $ \_ b -> return $ ah `AppE` b

-- | Takes a file path/name (ex. templates/articles/foo.hamlet)
-- Looks at the first line, which should contain the date in the form <!--YYYY-MM-DD-->
-- If date is malformed or nonexistent, it returns todays date.
getDate :: Text -> IO DateTime
getDate file = 
    do
      contents <- TI.readFile $ unpack file
      today <- getCurrentDateTime
      return $ 
             let header = getHeader contents
                 rgx = mkRegexWithOpts "<!--([[:digit:]]{4}.[[:digit:]]{2}.[[:digit:]]{2})-->" False True 
                 date_str = case matchRegex rgx $ unpack header of
                              Just matches -> head matches
                              Nothing -> "1970-01-01"
                 date_str' = map (\x -> if x == '-' then '/' else x) date_str -- parseDate wants dates seperated by / not -
                 date = case Data.Dates.parseDate today date_str' of
                          Right d -> d
                          _ -> today
             in date

-- | Recursively fetches dates for a given list of file paths
getDates :: [Text] -> IO [DateTime]
getDates [] = return []
getDates (x:xs) = 
    do 
      date <- getDate x
      dates <- getDates xs
      return (date:dates)
      
-- | Fetch the paths for all the .hamlet files in the templates/articles/ dir.
-- Returns the articles as a list of strings, sorted by date, newest->oldest,
-- based on the dates from getDate(s). 
-- File names returned are of the form "templates/articles/foo.hamlet"
getArticleNames :: Text -> IO [Text]
getArticleNames path = 
    do {
      files <- getDirectoryContents $ unpack path;
      let hamlet_files = filter (isSuffixOf ".hamlet") (map pack files)
          file_names_2 = map (\fname -> pack $ joinPath [unpack path, unpack fname]) hamlet_files
      in do {
           dates <- getDates file_names_2;
           return (map fst $ sortBy (\(x,y) -> \(x', y') -> compare y' y) $ zip file_names_2 dates);
         }
      }

-- | Takes a file path (templates/articles/foo.hamlet), and returns
-- a string of just the file name without the extension (foo).
stripName :: Text -> Text
stripName file_name = pack $ takeBaseName $ unpack file_name

-- | Takes a file path (templates/articles/foo.hamlet), and returns
-- a route name for it (fooR). Undefined behaviour if file names not fetched from
-- getArticleNames (or if not prefixed by templates/articles/ and suffixed by .hamlet)
makeRouteName :: Text -> Text
makeRouteName file_name = T.concat [stripName file_name, "R"]

-- | Generates the Read More... link for an article. 
readMore :: Text -> Text
readMore fp = T.concat ["<p><a href=@{ArticleR \"", stripName fp, "\"}>Read More...</a>\n"]
                  --read_more = concat ["<p><a href=@{", makeRouteName fp, "}>Read More...</a>\n"]

contentsLink :: Text -> Text
contentsLink fp = T.concat ["<div syle=\"display:none\"  #", stripName fp, ">\n"]

-- | See https://hackage.haskell.org/package/shakespeare-2.0.12.1/docs/src/Text-Hamlet.html#hamletFileWithSettings
-- This replaces hamletFileWithSettings, instead of just passing the full contents to hamletFromString,
-- We truncate the contents (currently just up to not including the third <p> tag
-- and append the 'Read More...' link.
getPreview :: Q HamletRules -> HamletSettings -> FilePath -> Q Exp
getPreview qhr set fp = do
-- #ifdef GHC_7_4
--    qAddDependentFile fp
-- #endif
    contents <- fmap T.unpack $ runIO $ TI.readFile fp
    -- hear we can truncate the contents
    let split_contents = splitOn "<p>" $ pack contents
    let take_n = if Prelude.length split_contents > 3 then 3 else Prelude.length split_contents
    let preview = intercalate "<p>" $ take take_n split_contents
    let read_more = readMore $ pack fp
    let contents_link = contentsLink $ pack fp
    hamletFromString qhr set $ unpack $ intercalate "\n" [contents_link, preview, read_more]


makePreviews :: Q Exp
makePreviews = makePreviewsWithOptions "templates/articles"

-- | This can be used to make the previews for all the articles
-- It fetches the articles to display, and for each one calls our modified
-- hamletFileWithSettings (getPreview) 
makePreviewsWithOptions :: Text -> Q Exp
makePreviewsWithOptions path = 
    do 
      articles <- runIO $ getArticleNames  path
      xs <- mapM (\x -> getPreview rules NP.defaultHamletSettings $ unpack x) articles
      return $ DoE (map NoBindS xs)
          
      
-- | Given a stripped article names (eg 'civ6', 'foo'), makeGet
-- will create the functions which 'gets' that article. It names the function
-- by prefixing the article name with 'get' and suffixing with 'R' (eg 'foo' -> 'getfooR')
-- The function returned calls defaultLayout, sets the title based on the 
-- given prefix and the article name, and calls widgetFile on the article.
-- The function returned effectively does:
-- defaultLayout $ do {
--   $(setTitle prefix ++ article_name)
--   $(widgetFile articles/article_name)}
makeGet :: Text -> Text -> Q Exp -> Text -> Q Dec
makeGet prefix path exp article_name = 
    let article_name' = unpack article_name
        prefix' = unpack prefix
        fname = mkName $ concat ["get", article_name', "R"]
    in 
      do {
        dl <- exp;
        widget <- whamletFile $ joinPath [unpack path, addExtension article_name' ".hamlet"];
        --dl <- [e|defaultLayout|];
        title <- runIO $ getTitle (pack $ joinPath [unpack path, addExtension article_name' ".hamlet"]);
        set_title <- [e| setTitle $ toHtml (prefix' ++ " " ++ (unpack title)) |];
        --decs <- makeGet rest;
        return (ValD (VarP fname) 
            (NormalB (AppE dl {-(VarE (mkName "defaultLayout"))-}  (ParensE
                      (DoE [NoBindS set_title,
                            NoBindS widget])))) []);
      }


makeGets :: Q [Dec]
makeGets = makeGetsWithOptions "" "templates/articles/" (return (VarE (mkName "defaultLayout"))) -- maybe change this

--                     arg         example
-- makeGetsWithOptions prefix      "Matt Eads:"
--                     path        "templates/articles/" -- make sure we add a / to the end if needed
--                     exp         [e|\x -> do defaultLayout $ x|] -- not sure what the default should be here
--                     

-- | Gets the list of articles in templates/articles, makes all
-- the 'getter' functions for the articles (see makeGet), and creates
-- the 'getArticleR' function (see makeGetArticle).
makeGetsWithOptions :: Text -> Text -> Q Exp -> Q [Dec]
makeGetsWithOptions prefix path exp =
    do
      articles <- runIO $ getArticleNames path
      let articles' = map stripName articles
      decs <- mapM (makeGet prefix path exp) articles'
      getArticle <- makeGetArticle articles'
      return (decs ++ getArticle)

-- | Given a list of articles, returns the 'getArticleR' function.
-- The function returned will take the article_name as an arg, and
-- defer to the appropriate get<article_name>R function. This relies
-- on the makeGet function being already run on the same articles given to 
-- it. This is the handler for the /article/#Text route. 
makeGetArticle :: [Text] -> Q [Dec]
makeGetArticle articles  = 
    let fname = mkName "getArticleR"
        arg = mkName "article_name"
        article_cases = map (\article -> Match (LitP (StringL $ unpack article)) 
                                          (NormalB (VarE $ mkName $ concat ["get", unpack article, "R"]))[]) articles
    in
    do {
      notfound <- [e|notFound|];
      return [FunD fname [Clause [VarP arg]
               (NormalB (CaseE (VarE arg)
               (article_cases ++ [Match WildP (NormalB notfound) []])))[]]] 
                        
    }

-- | Given the contents of an article, returns the 'article header'
-- that is the contents of the file up to the first html tag (<p>, <h3>, etc.)
getHeader :: Text -> Text
getHeader contents = 
    let rgx = mkRegexWithOpts "(.*)<[a-zA-Z]" False True 
    in case matchRegex rgx $ unpack contents of
         (Just matches) -> pack $ head matches
         Nothing -> ""

-- | Given a file path, returns the page title for the article.
-- The title should be a html comment (<!--title-->) and occur before
-- any other text, other than the date comment. The date and title can
-- be put in either order. If no title can be found, the stripped file
-- path is used.
getTitle :: Text -> IO Text
getTitle fp = 
    do
      contents <- TI.readFile $ unpack fp
      return $ 
             let header = getHeader contents
                 rgx = mkRegexWithOpts "<!--[[:digit:]]{4}.[[:digit:]]{2}.[[:digit:]]{2}-->" False True 
                 non_match = case matchRegexAll rgx $ unpack header of 
                               Nothing -> ""
                               Just (before, _, after, _) -> concat [before, after]
                 rgx2 = mkRegexWithOpts "<!--(.*)-->" False True 
             in case matchRegex rgx2 non_match of
                  (Just matches) -> pack $ head matches
                  Nothing -> stripName fp


makeContentsEntry :: (Text, Text) -> Text
makeContentsEntry (id, title) = T.concat ["<li><a href=\"#", id, "\">", title, "</a>"]

makeContents :: Q Exp
makeContents = makeContentsWithOptions "templates/articles"

-- | Makes the contents for the page of previews. 
makeContentsWithOptions :: Text -> Q Exp
makeContentsWithOptions path = 
    do 
      articles <- runIO $ getArticleNames path
      titles <- runIO $ mapM getTitle articles
      let articles' = zip (map stripName articles) titles
      let entries = map makeContentsEntry articles'
      ham <- hamletFromString rules NP.defaultHamletSettings $ 
               unpack $ intercalate "\n    " (["<div #contents>\n  <ul>"] ++ entries)
      return $ DoE [NoBindS ham]