Files
2026-03-30 11:05:44 +08:00

944 lines
31 KiB
Haskell

{-# 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 </dev/tty"])
"" ::
IO (Either SomeException (ExitCode, String, String))
pure $ do
(exitCode, stdoutText, _) <- either (const Nothing) Just result
case exitCode of
ExitSuccess -> 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