{-# LANGUAGE RecordWildCards #-} module Text.Blogination (Blog(..) ,Blogination ,runBloginator ,buildBlog ,ensureProperState ,renderIndex ,renderEntries ,pageToHtml ,highlight) where import Control.Applicative import Control.Arrow hiding ((+++)) import Control.Monad.State import Control.Monad.Error import Data.Char hiding (Space) import Data.List.Higher import Data.Maybe import Data.Monoid import Data.Ord import Data.Time.Format import Data.Time.Clock import Prelude hiding (readFile,writeFile) import Text.Highlighting.Kate import Text.Pandoc import Text.Printf import Text.RSS.Export import Text.RSS.Syntax import Text.XHtml.Strict import Text.XML.Light.Output import System.Directory import System.FilePath import System.IO.UTF8 (readFile,writeFile) import System.Time import System.Locale data Blog = Blog { blogName :: String -- e.g. Chris Done's Blog , blogRoot :: String -- /blog , blogCSS :: [String] -- e.g. ["style.css","highlight.css"] , blogEntries :: FilePath , blogHtml :: FilePath , blogAuthor :: String , blogForce :: Bool , blogDate :: String -- date format e.g. -- "%A %d %b, %Y" makes "Tuesday 10 Feb, 2009" , blogTags :: FilePath , blogURL :: URL -- e.g. "http://chrisdone.com/blog" , blogAnalytics :: Maybe String -- google analytics ID e.g. UA-7443395-1 , blogHome :: Maybe URL -- home page e.g. "http://chrisdone.com/" , blogHomeName :: Maybe String -- home page e.g. Chris Done's Home Page } deriving (Read,Show) type Blogination = ErrorT String (StateT Blog IO) runBloginator :: Blogination a -> Blog -> IO (Either String a) runBloginator m blog = evalStateT (runErrorT m) blog buildBlog :: Blogination () buildBlog = do ensureProperState changed <- renderEntries when (not $ null changed) $ do renderTags changed renderIndex renderIndexRSS renderIndexRSS :: Blogination () renderIndexRSS = do getEntryNames >>= renderEntriesRSS . take 5 >>= liftIO . writeFile "rss.xml" renderTags :: [FilePath] -> Blogination () renderTags entries = do mapM_ (renderTag entries) =<< getTags renderTag :: [FilePath] -> FilePath -> Blogination () renderTag entries tag = do blog@Blog{..} <- lift get changedInThisTag <- intersect entries <$> getTagEntryNames tag when (not $ null changedInThisTag) $ do getTagEntryNames tag >>= renderEntriesRSS . take 5 >>= liftIO . writeFile (blogTagstag++".xml") renderTagHtml tag renderEntriesRSS :: [FilePath] -> Blogination String renderEntriesRSS names = showElement . xmlRSS . flip (RSS "2.0" []) [] <$> renderToRSS names renderToRSS :: [FilePath] -> Blogination RSSChannel renderToRSS names = do blog@Blog{..} <- lift get items <- mapM entryToItem names return $ (nullChannel blogName blogURL) { rssItems = items } entryToItem :: FilePath -> Blogination RSSItem entryToItem path = do blog@Blog{..} <- lift get let get = liftIO . readFile . (blogEntries) item (title,content) = (nullItem title) { rssItemDescription = Just $ showHtmlFragment content , rssItemPubDate = show `fmap` makeDate path , rssItemLink = Just $ blogURL ++ "/html/" ++ path ++ ".html" } fmap (item . (getTitle &&& (write . hideTitle)) . read) . get $ path where read = readMarkdown defaultParserState write = writeHtml defaultWriterOptions hideTitle :: Pandoc -> Pandoc hideTitle (Pandoc meta blocks) = Pandoc meta newblocks where newblocks = case blocks of (Header 1 _:content) -> content content -> content renderTagHtml :: FilePath -> Blogination () renderTagHtml tag = do blog@Blog{..} <- lift get alltags <- getTags links <- mapM getEntryLink =<< getTagEntryNames tag tags <- mapM (entryTags alltags) =<< getEntryNames anal <- analytics let html = [head,thebody] head = header << [toHtml $ map style blogCSS ,encoding ,thetitle << title ,rss] thebody = body << [back,hr,name,menu,hr,back,anal] title = tag ++ " - " ++ blogName name = h2 << ("Tag: " ++ tag) menu = ulist << (map ((li<<) . showLink blog) $ zip links tags) back = toHtml $ p << hotlink blogRoot << ("« Back to " ++ blogName) style css = thelink ! [rel "stylesheet",href (blogRoot++css)] << noHtml rss = thelink ! [rel "alternate",thetype "application/rss+xml" ,href $ blogRoot++"tags/"++tag++".xml"] << noHtml liftIO $ writeFile (blogTagstag++".html") $ showHtml html getTagEntryNames :: FilePath -> Blogination [FilePath] getTagEntryNames path = do blog@Blog{..} <- lift get names <- lines `fmap` liftIO (readFile (blogTagspath)) liftIO $ fmap dateSort $ filterM (doesFileExist . (blogEntries)) names renderIndex :: Blogination () renderIndex = do blog@Blog{..} <- lift get entries <- getEntryNames links <- mapM getEntryLink entries alltags <- getTags tagEntries <- mapM getTagEntryNames alltags entryTags <- mapM (entryTags alltags) entries anal <- analytics let html = toHtml [header<<[title,encoding,rss,toHtml $ map style blogCSS] ,body<<[back,hr,name,menu,tags,hr,back,anal]] title = thetitle << blogName name = h1 << blogName menu = h2 << "Posts" +++ ul (map (showLink blog) $ zip links entryTags) tags = h2 << "Tags" +++ ul (map (mkTagLink blog) $ zip alltags tagEntries) ul l = ulist << map (li<<) l rss = thelink ! [rel "alternate",thetype "application/rss+xml" ,href $ blogRoot++"rss.xml"] << noHtml style css = thelink ! [rel "stylesheet",href (blogRoot++css)] << noHtml back = fromMaybe noHtml $ do url <- blogHome name <- blogHomeName return $ p << hotlink url << ("« Back to " ++ name) liftIO $ writeFile "index.html" $ showHtml html showLink :: Blog -> ((URL,String,UTCTime,ClockTime),[FilePath]) -> Html showLink blog@Blog{..} ((url,name,created,modified),tags) = toHtml [p ! [theclass "link"] << hotlink url << name ,p ! [theclass "dates"] << [small << ("Created: " +++ showTime created) ,toHtml ", " ,small << ("Modified: " +++ showTime modified')] ,p ! [theclass "tagged"] << small << tag] where showTime = formatTime defaultTimeLocale blogDate modified' = clockToUTCTime modified tag = list noHtml (("Tags: " +++) . mconcat . intersperse (toHtml ", ")) taglinks taglinks = map (mkTagLink blog) $ zip tags (repeat []) clockToUTCTime :: ClockTime -> UTCTime clockToUTCTime = readTime l "%Y%m%d%H%M%S" . formatCalendarTime l "%Y%m%d%H%M%S" . toUTCTime where l = defaultTimeLocale getEntryLink :: FilePath -> Blogination (URL,String,UTCTime,ClockTime) getEntryLink path = do blog@Blog{..} <- lift get liftIO $ do contents <- readFile (blogEntriespath) modified <- getModificationTime (blogEntriespath) return (blogRoot++blogHtml++"/"++path++".html" ,getTitle $ read $ contents ,fromMaybe undefined $ makeDate path ,modified) where read = readMarkdown defaultParserState renderEntries :: Blogination [FilePath] renderEntries = do blog@Blog{..} <- lift get names <- getEntryNames let times dir = liftIO $ mapM (getModificationTime' . dir) names entryTimes <- times (blogEntries) htmlTimes <- times ((blogHtml).(++".html")) let updated = catMaybes $ zipWith compare names $ zip entryTimes htmlTimes compare name (entry,html) | entry > html = Just name | otherwise = Nothing toChange | blogForce = names | otherwise = updated mapM_ renderEntry toChange return toChange getModificationTime' :: FilePath -> IO (Maybe ClockTime) getModificationTime' path = do exists <- doesFileExist path if exists then Just `fmap` getModificationTime path else return Nothing renderEntry :: FilePath -> Blogination () renderEntry path = do blog@Blog{..} <- lift get alltags <- getTags tags <- entryTags alltags path liftIO $ do contents <- readFile (blogEntriespath) writeFile (blogHtmlpath++".html") $ showHtml $ pageToHtml blog path tags contents where match = map fst . filter (any (== path) . snd) entryTags :: [FilePath] -> FilePath -> Blogination [FilePath] entryTags tags path = do tagEntries <- mapM getTagEntryNames tags return $ map fst . filter (any (==path) . snd) $ zip tags tagEntries getEntryNames :: Blogination [FilePath] getEntryNames = do Blog{..} <- lift get fileClean `fmap` liftIO (getDirectoryContents blogEntries) getTags :: Blogination [FilePath] getTags = do Blog{..} <- lift get (fileClean . filterPlain) `fmap` liftIO (getDirectoryContents blogTags) filterPlain = filter (all (flip any [isLetter,isSpace,isDigit] . flip ($))) fileClean = dateSort . filter (not . all (=='.')) ensureProperState :: Blogination () ensureProperState = do Blog{..} <- lift get entries <- liftIO $ doesDirectoryExist blogEntries when (not entries) $ do throwError $ printf "Blog entries directory \"%s\" does not exist." blogEntries return () liftIO $ createDirectoryIfMissing False blogHtml fixBlogRoot fixBlogRoot :: Blogination () fixBlogRoot = do Blog{..} <- lift get modify $ \s -> s { blogRoot = fix blogRoot } where fix = (++"/") . reverse . dropWhile (=='/') . reverse pageToHtml :: Blog -> FilePath -> [String] -> String -> Html pageToHtml blog fname tags = html . second write . (getTitle &&& highlight) . read where read = readMarkdown defaultParserState write = writeHtml defaultWriterOptions html = template blog fname tags template :: Blog -> FilePath -> [String] -> (String,Html) -> Html template blog@Blog{..} path tags (title,html) = toHtml [head,thebody] where head = header << [toHtml $ map style blogCSS ,encoding ,thetitle << title] thebody = body << [back,hr,tagndate,html,hr,back,anal] anal = analyticsScript blogAnalytics back = toHtml $ p << hotlink blogRoot << ("« Back to " ++ blogName) style css = thelink ! [rel "stylesheet",href (blogRoot++css)] << noHtml tagndate = p << (small << (date +++ tag)) date = "Date: " +++ (maybe noHtml showTime $ makeDate path) tag = list noHtml ((", Tags: " +++) . mconcat . intersperse (toHtml ", ")) taglinks taglinks = map (mkTagLink blog) $ zip tags (repeat []) showTime = toHtml . formatTime defaultTimeLocale blogDate analytics :: Blogination Html analytics = do Blog{..} <- lift get return $ analyticsScript blogAnalytics analyticsScript :: Maybe String -> Html analyticsScript = maybe noHtml script where script blogAnalytics = primHtml $ " \ \ " mkTagLink :: Blog -> (FilePath,[FilePath]) -> Html mkTagLink Blog{..} (tag,entries) = toHtml $ hotlink (blogRoot++"tags/"++tag++".html") << tag +++ if null entries then noHtml else toHtml $ " (" ++ show (length entries) ++ ")" makeDate :: FilePath -> Maybe UTCTime makeDate path = parse $ take (length "0000-00-00") path where parse = parseTime defaultTimeLocale "%Y-%m-%d" getTitle :: Pandoc -> String getTitle (Pandoc meta blocks) = title blocks where title = list "" head . catMaybes . map getHeading getHeading (Header 1 parts) = Just $ join $ map getPart parts getHeading _ = Nothing getPart (Str str) = str getPart Space = " " getPart _ = " " highlight :: Pandoc -> Pandoc highlight (Pandoc meta blocks) = Pandoc meta newblocks where newblocks = map tryHighlight blocks tryHighlight (CodeBlock opts ('$':rest)) | take 1 rest == "$" = CodeBlock opts rest | otherwise = case highlightOpts ('$':rest) of Just (lang,code) -> highlightWith lang code Nothing -> CodeBlock opts ('$':rest) tryHighlight other = other highlightOpts :: String -> Maybe (String,String) highlightOpts ('$':rest) = langAndCode $ break (=='$') $ rest where langAndCode (lang,'$':code) = Just (lang,dropWhile isSpace code) langAndCode _ = Nothing highlightOpts _ = Nothing highlightWith :: String -> String -> Block highlightWith lang code = RawHtml $ showHtmlFragment html where html = either (const def) format highlight format = formatAsXHtml [] lang highlight = highlightAs lang code def = pre << code encoding = meta ! [httpequiv "Content-Type" ,content "text/html; charset=utf-8"] dateSort :: [String] -> [String] dateSort = sortBy (flip $ comparing makeDate)