{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneKindSignatures #-} import ChaoDoc import Control.Concurrent (forkIO, threadDelay) import Control.Exception (SomeException, bracket_, try) import Control.Monad (filterM, unless, void, when) import Data.Char (isSpace, toLower) import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Kind (Type) import Data.List (intercalate, isPrefixOf, sort, sortOn) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.String (fromString) import qualified Data.Text as T import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.LocalTime (getZonedTime) import Hakyll import Hakyll.Core.Runtime (RunMode (RunModeNormal)) import Network.Wai.Application.Static (staticApp) import qualified Network.Wai.Handler.Warp as Warp import qualified Paths_hakysidian as Paths import System.Directory ( canonicalizePath, doesDirectoryExist, doesFileExist, getCurrentDirectory, getModificationTime, listDirectory, ) import System.Environment (getArgs, getExecutablePath, lookupEnv) import System.Exit (ExitCode (..), die, exitSuccess, exitWith) import System.FilePath import System.IO ( BufferMode (NoBuffering), hFlush, hGetBuffering, hGetChar, hIsTerminalDevice, hSetBuffering, hWaitForInput, stdin, stdout, ) import System.Posix.IO (stdInput) import System.Posix.Terminal ( TerminalAttributes, TerminalMode (EnableEcho, ProcessInput), TerminalState (Immediately), getTerminalAttributes, setTerminalAttributes, withMinInput, withTime, withoutMode, ) import System.Process (CreateProcess (cwd), proc, readCreateProcessWithExitCode) import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate) import Text.Read (readMaybe) -------------------------------------------------------------------------------- notesPattern :: Pattern notesPattern = fromGlob "notes/**" bundledCssFiles :: [FilePath] bundledCssFiles = [ "css/fonts.css", "css/default.css", "css/pygentize.css", "css/chao-theorems.css", "css/sidenotes.css" ] bundledFontFiles :: [FilePath] bundledFontFiles = [ "fonts/Lato-BoldItalic.woff2", "fonts/IosevkaCustom-Bold.woff2", "fonts/IosevkaCustom-Regular.woff2", "fonts/Lato-Bold.woff2", "fonts/Lato-Regular.woff2", "fonts/IosevkaCustom-Italic.woff2", "fonts/LeteSansMath.woff2", "fonts/LeteSansMath-Bold.woff2", "fonts/Lato-Italic.woff2" ] bundledTemplateFiles :: [FilePath] bundledTemplateFiles = [ "templates/head.html", "templates/note.html", "templates/notes.html", "templates/notes-list.html", "templates/index.html", "templates/navbar.html" ] type WatchSettings :: Type data WatchSettings = WatchSettings { watchHost :: String, watchPort :: Int, watchServerEnabled :: Bool } type CliCommand :: Type data CliCommand = BuildCommand | CleanCommand | HelpCommand | RebuildCommand | TuiCommand WatchSettings | WatchCommand WatchSettings type TuiAction :: Type data TuiAction = TuiClean | TuiQuit | TuiStartWatching | TuiStopWatching type FileSnapshot :: Type type FileSnapshot = M.Map FilePath UTCTime type TuiWatchState :: Type data TuiWatchState = TuiWatchStopped | TuiWatching FileSnapshot type ServerStatus :: Type data ServerStatus = ServerDisabled | ServerStarting | ServerRunning | ServerFailed String deriving stock (Eq) type DashboardState :: Type data DashboardState = DashboardState { dashboardStatus :: String, dashboardLastChange :: String, dashboardLastBuild :: String, dashboardLogLines :: [String] } deriving stock (Eq) type TerminalSize :: Type data TerminalSize = TerminalSize { terminalRows :: Int, terminalCols :: Int } deriving stock (Eq) -------------------------------------------------------------------------------- -- https://www.rohanjain.in/hakyll-clean-urls/ cleanRoute :: Routes cleanRoute = customRoute createIndexRoute where createIndexRoute ident = takeDirectory path takeBaseName path "index.html" where path = toFilePath ident cleanIndexHtmls :: Item String -> Compiler (Item String) cleanIndexHtmls = return . fmap (replaceAll pattern replacement) where pattern :: String pattern = "/index.html" replacement :: String -> String replacement = const "/" loadNoteLinks :: Compiler [Item String] loadNoteLinks = do noteIds <- sortOn toFilePath <$> getMatches notesPattern pure [Item noteId "" | noteId <- noteIds] -------------------------------------------------------------------------------- siteConfiguration :: FilePath -> Configuration siteConfiguration projectRoot = defaultConfiguration { destinationDirectory = projectRoot "_site", storeDirectory = projectRoot "_cache", tmpDirectory = projectRoot "_cache" "tmp", providerDirectory = projectRoot, ignoreFile = ignoreProjectFile (ignoreFile defaultConfiguration), watchIgnore = ignoreProjectFile (watchIgnore defaultConfiguration) } ignoreProjectFile :: (FilePath -> Bool) -> FilePath -> Bool ignoreProjectFile defaultIgnore path = defaultIgnore path || ignoredProjectPath path ignoredProjectPath :: FilePath -> Bool ignoredProjectPath path = any (`elem` [".git", ".obsidian"]) (splitDirectories (normalise path)) -------------------------------------------------------------------------------- main :: IO () main = do args <- getArgs projectRoot <- canonicalizePath =<< getCurrentDirectory let config = siteConfiguration projectRoot cslPath <- Paths.getDataFileName "bib_style.csl" case parseCliCommand config args of Left err -> die (err <> "\n\n" <> usageText) Right HelpCommand -> putStrLn usageText >> exitSuccess Right CleanCommand -> exitWith =<< runSiteCommand config cleanOptions cslPath Right BuildCommand -> do validateProject projectRoot exitWith =<< runSiteCommand config buildOptions cslPath Right RebuildCommand -> do validateProject projectRoot exitWith =<< runSiteCommand config rebuildOptions cslPath Right (TuiCommand watchSettings) -> do validateProject projectRoot exitWith =<< runTui projectRoot config cslPath watchSettings Right (WatchCommand watchSettings) -> do validateProject projectRoot exitWith =<< runSiteCommand config (watchOptions watchSettings) cslPath usageText :: String usageText = unlines [ "usage: hakysidian [build|clean|rebuild|watch [--host HOST] [--port PORT] [--no-server]|-tui [--host HOST] [--port PORT] [--no-server]]", "", "Run inside a project directory containing notes/, reference.bib, math-macros.md, and optional images/." ] parseCliCommand :: Configuration -> [String] -> Either String CliCommand parseCliCommand config args | any (`elem` ["-h", "--help"]) args = Right HelpCommand | otherwise = case args of [] -> Right BuildCommand ["build"] -> Right BuildCommand ["clean"] -> Right CleanCommand ["rebuild"] -> Right RebuildCommand "-tui" : rest -> Right (TuiCommand (parseWatchSettings config rest)) "--tui" : rest -> Right (TuiCommand (parseWatchSettings config rest)) "watch" : rest -> Right (WatchCommand (parseWatchSettings config rest)) command : _ -> Left ("Unknown command: " <> command) validateProject :: FilePath -> IO () validateProject projectRoot = do notesExists <- doesDirectoryExist (projectRoot "notes") bibExists <- doesFileExist (projectRoot "reference.bib") macrosExists <- doesFileExist (projectRoot "math-macros.md") let missing :: [String] missing = [ "notes/" | not notesExists ] ++ [ "reference.bib" | not bibExists ] ++ [ "math-macros.md" | not macrosExists ] unless (null missing) $ die $ unlines $ "hakysidian is missing required project inputs:" : map (" - " ++) missing initialDashboardState :: DashboardState initialDashboardState = DashboardState { dashboardStatus = "idle", dashboardLastChange = "press w to start watching", dashboardLastBuild = "no command run yet", dashboardLogLines = [ "tui ready", "controls: w watch, s stop, c clean, q quit" ] } parseWatchSettings :: Configuration -> [String] -> WatchSettings parseWatchSettings config args = WatchSettings { watchHost = fromMaybe (previewHost config) (extractOptionValue "--host" args), watchPort = fromMaybe (previewPort config) $ extractOptionValue "--port" args >>= readMaybe, watchServerEnabled = "--no-server" `notElem` args } watchUrl :: WatchSettings -> Maybe String watchUrl settings | watchServerEnabled settings = Just $ "http://" ++ displayHost (watchHost settings) ++ ":" ++ show (watchPort settings) ++ "/" | otherwise = Nothing where displayHost :: String -> String displayHost "0.0.0.0" = "127.0.0.1" displayHost hostName = hostName extractOptionValue :: String -> [String] -> Maybe String extractOptionValue option = go where optionPrefix = option ++ "=" go :: [String] -> Maybe String go [] = Nothing go [arg] | optionPrefix `isPrefixOf` arg = Just (drop (length optionPrefix) arg) | otherwise = Nothing go (arg : value : rest) | arg == option = Just value | optionPrefix `isPrefixOf` arg = Just (drop (length optionPrefix) arg) | otherwise = go (value : rest) withWatchTui :: IO a -> IO a withWatchTui action = do stdoutInteractive <- hIsTerminalDevice stdout stdinInteractive <- hIsTerminalDevice stdin if stdoutInteractive then do originalBuffering <- hGetBuffering stdout originalInputBuffering <- hGetBuffering stdin originalInputMode <- if stdinInteractive then Just <$> getTerminalAttributes stdInput else pure Nothing bracket_ ( do hSetBuffering stdout NoBuffering when stdinInteractive do hSetBuffering stdin NoBuffering maybe (pure ()) (\inputMode -> setTerminalAttributes stdInput (watchInputMode inputMode) Immediately) originalInputMode putStr "\ESC[?1049h\ESC[2J\ESC[H\ESC[?25l" hFlush stdout ) ( do putStr "\ESC[0m\ESC[?25h\ESC[?1049l" hFlush stdout maybe (pure ()) (\inputMode -> setTerminalAttributes stdInput inputMode Immediately) originalInputMode when stdinInteractive do hSetBuffering stdin originalInputBuffering hSetBuffering stdout originalBuffering ) action else action watchInputMode :: TerminalAttributes -> TerminalAttributes watchInputMode inputMode = withTime ( withMinInput ( withoutMode (withoutMode inputMode ProcessInput) EnableEcho ) 1 ) 0 renderWatchDashboard :: IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> FilePath -> WatchSettings -> IORef ServerStatus -> DashboardState -> IO () renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef dashboard = do terminalSize <- getTerminalSize serverStatus <- readIORef serverStatusRef previousRenderState <- readIORef renderStateRef let currentRenderState = Just (terminalSize, serverStatus, dashboard) unless (currentRenderState == previousRenderState) do let rows = max 1 (terminalRows terminalSize) cols = max 4 (terminalCols terminalSize) border = "+" ++ replicate (cols - 2) '-' ++ "+" infoRows = [ dashboardRow cols ("Project : " ++ projectRoot), dashboardRow cols ("Preview : " ++ renderServerStatus watchSettings serverStatus), dashboardRow cols ("Change : " ++ dashboardLastChange dashboard), dashboardRow cols ("Last op : " ++ dashboardLastBuild dashboard) ] headerRows = [ border, dashboardTitleRow cols "hakysidian tui" (dashboardStatus dashboard), border ] ++ infoRows ++ [border, dashboardRow cols "Recent activity", border] footerRows = [ border, dashboardRow cols "Controls: w watch, s stop, c clean, q quit, Ctrl-C interrupt", border ] availableLogRows = max 1 (rows - length headerRows - length footerRows) logRows = map (dashboardRow cols) $ padRows availableLogRows $ takeLast availableLogRows (dashboardLogLines dashboard) screenRows = take rows (headerRows ++ logRows ++ footerRows) putStr "\ESC[2J\ESC[H" putStr (intercalate "\n" screenRows) hFlush stdout writeIORef renderStateRef currentRenderState dashboardTitleRow :: Int -> String -> String -> String dashboardTitleRow width leftText rightText = dashboardRow width (leftText ++ spacer ++ clippedRight) where usableWidth = max 1 (width - 4) rightWidth = min (usableWidth `div` 3) (length rightText) clippedRight = if null rightText then "" else ellipsize rightWidth rightText leftWidth = max 1 (usableWidth - length clippedRight - 1) clippedLeft = ellipsize leftWidth leftText spacer | null clippedRight = "" | otherwise = replicate (max 1 (usableWidth - length clippedLeft - length clippedRight)) ' ' dashboardRow :: Int -> String -> String dashboardRow width content = "| " ++ padRight usableWidth (ellipsize usableWidth content) ++ " |" where usableWidth = max 1 (width - 4) padRight :: Int -> String -> String padRight width text = text ++ replicate (max 0 (width - length text)) ' ' padRows :: Int -> [String] -> [String] padRows count rows = rows ++ replicate (max 0 (count - length rows)) "" takeLast :: Int -> [a] -> [a] takeLast count xs = drop (max 0 (length xs - count)) xs ellipsize :: Int -> String -> String ellipsize width text | width <= 0 = "" | length text <= width = text | width <= 3 = take width text | otherwise = take (width - 3) text ++ "..." renderServerStatus :: WatchSettings -> ServerStatus -> String renderServerStatus watchSettings serverStatus = case serverStatus of ServerDisabled -> "disabled (--no-server)" ServerStarting -> maybe "starting" (++ " (starting)") (watchUrl watchSettings) ServerRunning -> fromMaybe "running" (watchUrl watchSettings) ServerFailed err -> "failed: " ++ err getTerminalSize :: IO TerminalSize getTerminalSize = do sttySize <- queryTerminalSize case sttySize of Just terminalSize -> pure terminalSize Nothing -> do rows <- fromMaybe 24 . (>>= readMaybe) <$> lookupEnv "LINES" cols <- fromMaybe 80 . (>>= readMaybe) <$> lookupEnv "COLUMNS" pure (TerminalSize rows cols) queryTerminalSize :: IO (Maybe TerminalSize) queryTerminalSize = do result <- try $ readCreateProcessWithExitCode (proc "sh" ["-c", "stty size case words stdoutText of [rowsText, colsText] -> do rows <- readMaybe rowsText cols <- readMaybe colsText Just (TerminalSize rows cols) _ -> Nothing ExitFailure _ -> Nothing watchTimestamp :: IO String watchTimestamp = formatTime defaultTimeLocale "%H:%M:%S" <$> getZonedTime watchLoopDelayMicros :: Int watchLoopDelayMicros = 1000000 watchInputPollMicros :: Int watchInputPollMicros = 100000 waitForTuiAction :: Bool -> Int -> IO (Maybe TuiAction) waitForTuiAction watchInputEnabled remainingMicros | remainingMicros <= 0 = pure Nothing | otherwise = do nextAction <- pollTuiAction watchInputEnabled case nextAction of Just action -> pure (Just action) Nothing -> do threadDelay (min watchInputPollMicros remainingMicros) waitForTuiAction watchInputEnabled (remainingMicros - watchInputPollMicros) pollTuiAction :: Bool -> IO (Maybe TuiAction) pollTuiAction watchInputEnabled | not watchInputEnabled = pure Nothing | otherwise = drainInput where drainInput = do hasInput <- hWaitForInput stdin 0 if hasInput then do inputChar <- hGetChar stdin case parseTuiAction inputChar of Just action -> pure (Just action) Nothing -> drainInput else pure Nothing parseTuiAction :: Char -> Maybe TuiAction parseTuiAction inputChar = case toLower inputChar of 'c' -> Just TuiClean 'q' -> Just TuiQuit 's' -> Just TuiStopWatching 'w' -> Just TuiStartWatching _ -> Nothing trimTrailingSpace :: String -> String trimTrailingSpace = reverse . dropWhile isSpace . reverse normalizeLogLines :: String -> String -> [String] normalizeLogLines stdoutText stderrText = filter (not . null) $ map trimTrailingSpace $ lines (stdoutText ++ if null stderrText then "" else "\n" ++ stderrText) appendLogBatch :: DashboardState -> String -> String -> [String] -> DashboardState appendLogBatch dashboard title timestamp buildLines = dashboard { dashboardLogLines = takeLast 200 $ dashboardLogLines dashboard ++ ("[" ++ timestamp ++ "] " ++ title) : map (" " ++) buildLines } runCapturedSiteCommand :: FilePath -> String -> IO (ExitCode, [String]) runCapturedSiteCommand projectRoot command = do executablePath <- getExecutablePath (exitCode, stdoutText, stderrText) <- readCreateProcessWithExitCode (proc executablePath [command]) {cwd = Just projectRoot} "" pure (exitCode, normalizeLogLines stdoutText stderrText) buildOptions :: Options buildOptions = Options {verbosity = False, optCommand = Build RunModeNormal} cleanOptions :: Options cleanOptions = Options {verbosity = False, optCommand = Clean} rebuildOptions :: Options rebuildOptions = Options {verbosity = False, optCommand = Rebuild} watchOptions :: WatchSettings -> Options watchOptions watchSettings = Options { verbosity = False, optCommand = Watch { host = watchHost watchSettings, port = watchPort watchSettings, no_server = not (watchServerEnabled watchSettings) } } runSiteCommand :: Configuration -> Options -> FilePath -> IO ExitCode runSiteCommand config options cslPath = hakyllWithExitCodeAndArgs config options (siteRules cslPath) runTui :: FilePath -> Configuration -> FilePath -> WatchSettings -> IO ExitCode runTui projectRoot config _cslPath watchSettings = do stdoutInteractive <- hIsTerminalDevice stdout stdinInteractive <- hIsTerminalDevice stdin let watchInputEnabled = stdoutInteractive && stdinInteractive if watchInputEnabled then withWatchTui do serverStatusRef <- newIORef initialServerStatus renderStateRef <- newIORef Nothing startPreviewServer config watchSettings serverStatusRef renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef initialDashboardState tuiLoop watchInputEnabled renderStateRef serverStatusRef TuiWatchStopped initialDashboardState else do putStrLn "hakysidian -tui requires an interactive terminal." pure (ExitFailure 1) where initialServerStatus | watchServerEnabled watchSettings = ServerStarting | otherwise = ServerDisabled tuiLoop :: Bool -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef ServerStatus -> TuiWatchState -> DashboardState -> IO ExitCode tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard = do renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef dashboard nextAction <- waitForTuiAction watchInputEnabled watchLoopDelayMicros case nextAction of Just TuiQuit -> pure ExitSuccess Just TuiStartWatching -> case watchState of TuiWatching _ -> tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard TuiWatchStopped -> do (_, nextDashboard) <- runDashboardCommand "rebuild" "watch start" "manual start" "building (watch start)" (watchCommandStatus "watch start") projectRoot watchSettings renderStateRef serverStatusRef dashboard nextSnapshot <- snapshotInputs projectRoot tuiLoop watchInputEnabled renderStateRef serverStatusRef (TuiWatching nextSnapshot) nextDashboard Just TuiStopWatching -> case watchState of TuiWatchStopped -> tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard TuiWatching _ -> do nextDashboard <- appendDashboardMessage ( dashboard { dashboardStatus = "idle", dashboardLastChange = "watch stopped" } ) "watch stopped" tuiLoop watchInputEnabled renderStateRef serverStatusRef TuiWatchStopped nextDashboard Just TuiClean -> do (_, nextDashboard) <- runDashboardCommand "clean" "clean" "manual clean" "cleaning" cleanCommandStatus projectRoot watchSettings renderStateRef serverStatusRef dashboard tuiLoop watchInputEnabled renderStateRef serverStatusRef TuiWatchStopped nextDashboard Nothing -> case watchState of TuiWatchStopped -> tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard TuiWatching previousSnapshot -> do nextSnapshot <- snapshotInputs projectRoot if nextSnapshot == previousSnapshot then tuiLoop watchInputEnabled renderStateRef serverStatusRef watchState dashboard else do let changedFiles = diffSnapshots previousSnapshot nextSnapshot command :: String command = if any (`M.notMember` nextSnapshot) (M.keys previousSnapshot) then "rebuild" else "build" changeSummary = intercalate ", " changedFiles (_, nextDashboard) <- runDashboardCommand command command changeSummary ("building (" ++ command ++ ")") (watchCommandStatus command) projectRoot watchSettings renderStateRef serverStatusRef dashboard tuiLoop watchInputEnabled renderStateRef serverStatusRef (TuiWatching nextSnapshot) nextDashboard watchCommandStatus :: String -> ExitCode -> String watchCommandStatus label exitCode | exitCode == ExitSuccess = "watching" | otherwise = "watching after failed " ++ label cleanCommandStatus :: ExitCode -> String cleanCommandStatus exitCode | exitCode == ExitSuccess = "idle" | otherwise = "idle after failed clean" renderBuildResult :: ExitCode -> String renderBuildResult ExitSuccess = "success" renderBuildResult (ExitFailure code) = "failed (" ++ show code ++ ")" appendDashboardMessage :: DashboardState -> String -> IO DashboardState appendDashboardMessage dashboard message = do timestamp <- watchTimestamp pure (appendLogBatch dashboard message timestamp []) runDashboardCommand :: String -> String -> String -> String -> (ExitCode -> String) -> FilePath -> WatchSettings -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef ServerStatus -> DashboardState -> IO (ExitCode, DashboardState) runDashboardCommand command label changeSummary runningStatus completedStatus projectRoot watchSettings renderStateRef serverStatusRef dashboard = do startedAt <- watchTimestamp let runningDashboard = dashboard { dashboardStatus = runningStatus, dashboardLastChange = changeSummary, dashboardLastBuild = "running since " ++ startedAt } renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef runningDashboard (exitCode, buildLines) <- runCapturedSiteCommand projectRoot command finishedAt <- watchTimestamp let loggedDashboard = appendLogBatch runningDashboard (label ++ ": " ++ changeSummary) finishedAt buildLines completedDashboard = loggedDashboard { dashboardStatus = completedStatus exitCode, dashboardLastBuild = renderBuildResult exitCode ++ " at " ++ finishedAt ++ " via " ++ label } renderWatchDashboard renderStateRef projectRoot watchSettings serverStatusRef completedDashboard pure (exitCode, completedDashboard) startPreviewServer :: Configuration -> WatchSettings -> IORef ServerStatus -> IO () startPreviewServer config watchSettings serverStatusRef | watchServerEnabled watchSettings = void $ forkIO $ do result <- ( try $ Warp.runSettings settings $ staticApp $ previewSettings config (destinationDirectory config) ) :: IO (Either SomeException ()) case result of Left err -> writeIORef serverStatusRef (ServerFailed (show err)) Right () -> pure () | otherwise = writeIORef serverStatusRef ServerDisabled where settings = Warp.setBeforeMainLoop (writeIORef serverStatusRef ServerRunning) $ Warp.setPort (watchPort watchSettings) $ Warp.setHost (fromString (watchHost watchSettings)) Warp.defaultSettings snapshotInputs :: FilePath -> IO FileSnapshot snapshotInputs projectRoot = do inputFiles <- trackedInputs projectRoot entries <- traverse toSnapshotEntry inputFiles pure (M.fromList entries) where toSnapshotEntry :: FilePath -> IO (FilePath, UTCTime) toSnapshotEntry path = do modifiedAt <- getModificationTime path pure (makeRelative projectRoot path, modifiedAt) trackedInputs :: FilePath -> IO [FilePath] trackedInputs projectRoot = do requiredFiles <- filterM doesFileExist [ projectRoot "reference.bib", projectRoot "math-macros.md" ] noteFiles <- trackedFilesIn (projectRoot "notes") imageFiles <- trackedFilesIn (projectRoot "images") pure (sort (requiredFiles ++ noteFiles ++ imageFiles)) trackedFilesIn :: FilePath -> IO [FilePath] trackedFilesIn root = do exists <- doesDirectoryExist root if exists then do entries <- listDirectory root fmap concat <$> traverse ( \name -> do let path = root name isDir <- doesDirectoryExist path if isDir then trackedFilesIn path else pure [path] ) entries else pure [] diffSnapshots :: FileSnapshot -> FileSnapshot -> [FilePath] diffSnapshots previousSnapshot nextSnapshot = sort [ path | path <- M.keys (M.union previousSnapshot nextSnapshot), M.lookup path previousSnapshot /= M.lookup path nextSnapshot ] -------------------------------------------------------------------------------- siteRules :: FilePath -> Rules () siteRules cslPath = do match "images/**" $ do route idRoute compile copyFileCompiler match "math-macros.md" $ compile getResourceBody match "reference.bib" $ compile getResourceBody mapM_ createBundledCss bundledCssFiles mapM_ createBundledCopy bundledFontFiles createBundledCopy "favicon.ico" mapM_ createBundledTemplate bundledTemplateFiles match notesPattern $ do route cleanRoute compile $ do notes <- loadNoteLinks tocCtx <- getTocCtx cslPath (listField "notes" defaultContext (pure notes) <> defaultContext) chaoDocCompiler cslPath >>= loadAndApplyTemplate "templates/note.html" tocCtx >>= relativizeUrls create ["index.html"] $ do route idRoute compile $ do notes <- loadNoteLinks let notesCtx = listField "notes" defaultContext (pure notes) <> constField "title" "Notes" <> defaultContext makeItem "" >>= loadAndApplyTemplate "templates/notes.html" notesCtx >>= loadAndApplyTemplate "templates/index.html" notesCtx >>= relativizeUrls >>= cleanIndexHtmls createBundledCss :: FilePath -> Rules () createBundledCss relPath = create [fromFilePath relPath] $ do route idRoute compile bundledCssCompiler createBundledCopy :: FilePath -> Rules () createBundledCopy relPath = create [fromFilePath relPath] $ do route idRoute compile bundledCopyCompiler createBundledTemplate :: FilePath -> Rules () createBundledTemplate relPath = create [fromFilePath relPath] $ compile bundledTemplateCompiler bundledAssetPath :: Compiler FilePath bundledAssetPath = do ident <- getUnderlying unsafeCompiler $ Paths.getDataFileName (toFilePath ident) bundledTextCompiler :: Compiler (Item String) bundledTextCompiler = do assetPath <- bundledAssetPath contents <- unsafeCompiler (readFile assetPath) makeItem contents bundledCssCompiler :: Compiler (Item String) bundledCssCompiler = fmap compressCss <$> bundledTextCompiler bundledCopyCompiler :: Compiler (Item CopyFile) bundledCopyCompiler = do assetPath <- bundledAssetPath makeItem (CopyFile assetPath) bundledTemplateCompiler :: Compiler (Item Template) bundledTemplateCompiler = do item <- bundledTextCompiler template <- compileTemplateItem item pure (itemSetBody template item) -------------------------------------------------------------------------------- -- toc from https://github.com/slotThe/slotThe.github.io getTocCtx :: FilePath -> Context a -> Compiler (Context a) getTocCtx cslPath ctx = do noToc <- (Just "true" ==) <$> (getUnderlying >>= (`getMetadataField` "no-toc")) writerOpts <- mkTocWriter defaultHakyllWriterOptions toc <- writePandocWith writerOpts <$> chaoDocPandocCompiler cslPath pure $ mconcat [ ctx, constField "toc" $ killLinkIds (itemBody toc), if noToc then boolField "no-toc" (pure noToc) else mempty ] where mkTocWriter :: WriterOptions -> Compiler WriterOptions mkTocWriter writerOpts = do tmpl <- either (const Nothing) Just <$> unsafeCompiler (compileTemplate "" "$toc$") pure $ writerOpts { writerTableOfContents = True, writerTOCDepth = 2, writerTemplate = tmpl, writerHTMLMathMethod = MathML } asTxt :: (T.Text -> T.Text) -> String -> String asTxt f = T.unpack . f . T.pack killLinkIds :: String -> String killLinkIds = asTxt (mconcat . go . T.splitOn "id=\"toc-") where go :: [T.Text] -> [T.Text] go = \case [] -> [] x : xs -> x : map (T.drop 1 . T.dropWhile (/= '\"')) xs