|
| 1 | +-- src/HsBlog/Directory.hs |
| 2 | + |
| 3 | +-- | Process multiple files and convert directories |
| 4 | + |
| 5 | +module HsBlog.Directory |
| 6 | + ( convertDirectory |
| 7 | + , buildIndex |
| 8 | + ) |
| 9 | + where |
| 10 | + |
| 11 | +import qualified HsBlog.Markup as Markup |
| 12 | +import qualified HsBlog.Html as Html |
| 13 | +import HsBlog.Convert (convert, convertStructure) |
| 14 | + |
| 15 | +import Data.List (partition) |
| 16 | +import Data.Traversable (for) |
| 17 | +import Control.Monad (void, when) |
| 18 | + |
| 19 | +import System.IO (hPutStrLn, stderr) |
| 20 | +import Control.Exception (catch, displayException, SomeException(..)) |
| 21 | +import System.Exit (exitFailure) |
| 22 | +import System.FilePath |
| 23 | + ( takeExtension |
| 24 | + , takeBaseName |
| 25 | + , (<.>) |
| 26 | + , (</>) |
| 27 | + , takeFileName |
| 28 | + ) |
| 29 | +import System.Directory |
| 30 | + ( createDirectory |
| 31 | + , removeDirectoryRecursive |
| 32 | + , listDirectory |
| 33 | + , doesDirectoryExist |
| 34 | + , copyFile |
| 35 | + ) |
| 36 | + |
| 37 | +-- | Copy files from one directory to another, converting '.txt' files to |
| 38 | +-- '.html' files in the process. Recording unsuccessful reads and writes to stderr. |
| 39 | +-- |
| 40 | +-- May throw an exception on output directory creation. |
| 41 | +convertDirectory :: FilePath -> FilePath -> IO () |
| 42 | +convertDirectory inputDir outputDir = do |
| 43 | + DirContents filesToProcess filesToCopy <- getDirFilesAndContent inputDir |
| 44 | + createOutputDirectoryOrExit outputDir |
| 45 | + let |
| 46 | + outputHtmls = txtsToRenderedHtml filesToProcess |
| 47 | + copyFiles outputDir filesToCopy |
| 48 | + writeFiles outputDir outputHtmls |
| 49 | + putStrLn "Done." |
| 50 | + |
| 51 | +------------------------------------ |
| 52 | +-- * Read directory content |
| 53 | + |
| 54 | +-- | Returns the directory content |
| 55 | +getDirFilesAndContent :: FilePath -> IO DirContents |
| 56 | +getDirFilesAndContent inputDir = do |
| 57 | + files <- map (inputDir </>) <$> listDirectory inputDir |
| 58 | + let |
| 59 | + (txtFiles, otherFiles) = |
| 60 | + partition ((== ".txt") . takeExtension) files |
| 61 | + txtFilesAndContent <- |
| 62 | + applyIoOnList readFile txtFiles >>= filterAndReportFailures |
| 63 | + pure $ DirContents |
| 64 | + { dcFilesToProcess = txtFilesAndContent |
| 65 | + , dcFilesToCopy = otherFiles |
| 66 | + } |
| 67 | + |
| 68 | +-- | The relevant directory content for our application |
| 69 | +data DirContents |
| 70 | + = DirContents |
| 71 | + { dcFilesToProcess :: [(FilePath, String)] |
| 72 | + -- ^ File paths and their content |
| 73 | + , dcFilesToCopy :: [FilePath] |
| 74 | + -- ^ Other file paths, to be copied directly |
| 75 | + } |
| 76 | + |
| 77 | +------------------------------------ |
| 78 | +-- * Build index page |
| 79 | + |
| 80 | +buildIndex :: [(FilePath, Markup.Document)] -> Html.Html |
| 81 | +buildIndex files = |
| 82 | + let |
| 83 | + previews = |
| 84 | + map |
| 85 | + ( \(file, doc) -> |
| 86 | + case doc of |
| 87 | + Markup.Heading 1 heading : article -> |
| 88 | + Html.h_ 3 (Html.link_ file (Html.txt_ heading)) |
| 89 | + <> foldMap convertStructure (take 2 article) |
| 90 | + <> Html.p_ (Html.link_ file (Html.txt_ "...")) |
| 91 | + _ -> |
| 92 | + Html.h_ 3 (Html.link_ file (Html.txt_ file)) |
| 93 | + ) |
| 94 | + files |
| 95 | + in |
| 96 | + Html.html_ |
| 97 | + "Blog" |
| 98 | + ( Html.h_ 1 (Html.link_ "index.html" (Html.txt_ "Blog")) |
| 99 | + <> Html.h_ 2 (Html.txt_ "Posts") |
| 100 | + <> mconcat previews |
| 101 | + ) |
| 102 | + |
| 103 | +------------------------------------ |
| 104 | +-- * Conversion |
| 105 | + |
| 106 | +-- | Convert text files to Markup, build an index, and render as html. |
| 107 | +txtsToRenderedHtml :: [(FilePath, String)] -> [(FilePath, String)] |
| 108 | +txtsToRenderedHtml txtFiles = |
| 109 | + let |
| 110 | + txtOutputFiles = map toOutputMarkupFile txtFiles |
| 111 | + index = ("index.html", buildIndex txtOutputFiles) |
| 112 | + in |
| 113 | + map (fmap Html.render) (index : map convertFile txtOutputFiles) |
| 114 | + |
| 115 | +toOutputMarkupFile :: (FilePath, String) -> (FilePath, Markup.Document) |
| 116 | +toOutputMarkupFile (file, content) = |
| 117 | + (takeBaseName file <.> "html", Markup.parse content) |
| 118 | + |
| 119 | +convertFile :: (FilePath, Markup.Document) -> (FilePath, Html.Html) |
| 120 | +convertFile (file, doc) = (file, convert file doc) |
| 121 | + |
| 122 | +------------------------------------ |
| 123 | +-- * Output to directory |
| 124 | + |
| 125 | +-- | Creates an output directory or terminates the program |
| 126 | +createOutputDirectoryOrExit :: FilePath -> IO () |
| 127 | +createOutputDirectoryOrExit outputDir = |
| 128 | + whenIO |
| 129 | + (not <$> createOutputDirectory outputDir) |
| 130 | + (hPutStrLn stderr "Cancelled." *> exitFailure) |
| 131 | + |
| 132 | +-- | Creates the output directory. |
| 133 | +-- Returns whether the directory was created or not. |
| 134 | +createOutputDirectory :: FilePath -> IO Bool |
| 135 | +createOutputDirectory dir = do |
| 136 | + dirExists <- doesDirectoryExist dir |
| 137 | + create <- |
| 138 | + if dirExists |
| 139 | + then do |
| 140 | + override <- confirm "Output directory exists. Override?" |
| 141 | + when override (removeDirectoryRecursive dir) |
| 142 | + pure override |
| 143 | + else |
| 144 | + pure True |
| 145 | + when create (createDirectory dir) |
| 146 | + pure create |
| 147 | + |
| 148 | +-- | Copy files to a directory, recording errors to stderr. |
| 149 | +copyFiles :: FilePath -> [FilePath] -> IO () |
| 150 | +copyFiles outputDir files = do |
| 151 | + let |
| 152 | + copyFromTo file = copyFile file (outputDir </> takeFileName file) |
| 153 | + void $ applyIoOnList copyFromTo files >>= filterAndReportFailures |
| 154 | + |
| 155 | +-- | Write files to a directory, recording errors to stderr. |
| 156 | +writeFiles :: FilePath -> [(FilePath, String)] -> IO () |
| 157 | +writeFiles outputDir files = do |
| 158 | + let |
| 159 | + writeFileContent (file, content) = |
| 160 | + writeFile (outputDir </> file) content |
| 161 | + void $ applyIoOnList writeFileContent files >>= filterAndReportFailures |
| 162 | + |
| 163 | +------------------------------------ |
| 164 | +-- * IO work and handling errors |
| 165 | + |
| 166 | +-- | Try to apply an IO function on a list of values, document successes and failures |
| 167 | +applyIoOnList :: (a -> IO b) -> [a] -> IO [(a, Either String b)] |
| 168 | +applyIoOnList action files = do |
| 169 | + for files $ \file -> do |
| 170 | + maybeContent <- |
| 171 | + catch |
| 172 | + (Right <$> action file) |
| 173 | + ( \(SomeException e) -> do |
| 174 | + pure $ Left (displayException e) |
| 175 | + ) |
| 176 | + pure (file, maybeContent) |
| 177 | + |
| 178 | +-- | Filter out unsuccessful operations on files and report errors to stderr. |
| 179 | +filterAndReportFailures :: [(a, Either String b)] -> IO [(a, b)] |
| 180 | +filterAndReportFailures = |
| 181 | + foldMap $ \ (file, contentOrErr) -> |
| 182 | + case contentOrErr of |
| 183 | + Left err -> do |
| 184 | + hPutStrLn stderr err |
| 185 | + pure [] |
| 186 | + Right content -> |
| 187 | + pure [(file, content)] |
| 188 | + |
| 189 | +------------------------------------ |
| 190 | +-- * Utilities |
| 191 | + |
| 192 | +confirm :: String -> IO Bool |
| 193 | +confirm question = do |
| 194 | + putStrLn (question <> " (y/n)") |
| 195 | + answer <- getLine |
| 196 | + case answer of |
| 197 | + "y" -> pure True |
| 198 | + "n" -> pure False |
| 199 | + _ -> do |
| 200 | + putStrLn "Invalid response. Use y or n." |
| 201 | + confirm question |
| 202 | + |
| 203 | +whenIO :: IO Bool -> IO () -> IO () |
| 204 | +whenIO cond action = do |
| 205 | + result <- cond |
| 206 | + if result |
| 207 | + then action |
| 208 | + else pure () |
0 commit comments