mirror of
http://101.35.51.105:3000/congyu/Hakysidian.git
synced 2026-04-27 21:40:50 +08:00
944 lines
31 KiB
Haskell
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
|