Compare commits
12 Commits
modern_the
...
3652459503
| Author | SHA1 | Date | |
|---|---|---|---|
| 3652459503 | |||
| fc4cac00d5 | |||
| 55719f3444 | |||
| d4629ec8e7 | |||
| e419366615 | |||
| 6c59abb9cc | |||
| 6a3b4c5f88 | |||
| 720a19e24d | |||
| 1789c75f18 | |||
| 3d2c5a8852 | |||
| 614de591ba | |||
| 71611b0641 |
@@ -1,6 +1,7 @@
|
|||||||
.theorem-environment {
|
.theorem-environment {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
|
padding: 0.5em;
|
||||||
background-color: whitesmoke;
|
background-color: whitesmoke;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.Proof {
|
.Proof {
|
||||||
|
background: none;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ body {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 125%;
|
line-height: 140%;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -404,6 +404,12 @@ code {
|
|||||||
margin-left: 0
|
margin-left: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div#contents ul.notes-list,
|
||||||
|
div#contents-big ul.notes-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
div#contents-big ul ul {
|
div#contents-big ul ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
name: hakyll-blog
|
cabal-version: 2.4
|
||||||
|
name: hakysidian
|
||||||
version: 0.1.0.0
|
version: 0.1.0.0
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
cabal-version: >= 1.10
|
|
||||||
|
|
||||||
|
data-files:
|
||||||
|
bib_style.csl
|
||||||
|
favicon.ico
|
||||||
|
css/*.css
|
||||||
|
fonts/*.otf
|
||||||
|
fonts/*.ttf
|
||||||
|
fonts/*.woff2
|
||||||
|
templates/*.html
|
||||||
|
|
||||||
executable site
|
executable hakysidian
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
main-is: site.hs
|
main-is: site.hs
|
||||||
other-modules: ChaoDoc, SideNoteHTML, Pangu
|
other-modules: ChaoDoc, SideNoteHTML, Pangu, Paths_hakysidian
|
||||||
|
autogen-modules: Paths_hakysidian
|
||||||
build-depends: base >= 4.18
|
build-depends: base >= 4.18
|
||||||
, hakyll >= 4.15
|
, hakyll >= 4.15
|
||||||
, mtl >= 2.2.2
|
, mtl >= 2.2.2
|
||||||
@@ -20,7 +29,13 @@ executable site
|
|||||||
-- , process
|
-- , process
|
||||||
-- , regex-compat
|
-- , regex-compat
|
||||||
, array
|
, array
|
||||||
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
|
, process
|
||||||
|
, time
|
||||||
|
, unix
|
||||||
|
, wai-app-static
|
||||||
|
, warp
|
||||||
-- , ghc-syntax-highlighter
|
-- , ghc-syntax-highlighter
|
||||||
-- , blaze-html >= 0.9
|
-- , blaze-html >= 0.9
|
||||||
, megaparsec
|
, megaparsec
|
||||||
13
makefile
13
makefile
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
COMMANDS := build watch rebuild clean
|
COMMANDS := build watch rebuild clean
|
||||||
|
BIN := hakysidian
|
||||||
.PHONY: $(COMMANDS), publish
|
.PHONY: $(COMMANDS), publish
|
||||||
|
|
||||||
# Set the default goal, so running 'make' without arguments will run 'make build'.
|
# Set the default goal, so running 'make' without arguments will run 'make build'.
|
||||||
@@ -7,18 +8,18 @@ COMMANDS := build watch rebuild clean
|
|||||||
|
|
||||||
|
|
||||||
# ---
|
# ---
|
||||||
$(COMMANDS): site
|
$(COMMANDS): $(BIN)
|
||||||
@echo "Running command: ./site $@"
|
@echo "Running command: ./$(BIN) $@"
|
||||||
-@./site $@
|
-@./$(BIN) $@
|
||||||
|
|
||||||
|
|
||||||
# --- Rules ---
|
# --- Rules ---
|
||||||
# using relative symlinks should be fine since everything only works at ./
|
# using relative symlinks should be fine since everything only works at ./
|
||||||
|
|
||||||
|
|
||||||
site: src/site.hs src/ChaoDoc.hs
|
$(BIN): src/site.hs src/ChaoDoc.hs
|
||||||
cabal build
|
cabal build exe:hakysidian
|
||||||
ln -sf "$(shell cabal list-bin exe:site)" site
|
ln -sf "$(shell cabal list-bin exe:hakysidian)" $(BIN)
|
||||||
|
|
||||||
# move from katex to mathjax
|
# move from katex to mathjax
|
||||||
# katex_cli:
|
# katex_cli:
|
||||||
|
|||||||
110
readme.md
110
readme.md
@@ -1,6 +1,106 @@
|
|||||||
things don't work:
|
# hakysidian
|
||||||
|
|
||||||
1. equation labels & paragraph labels
|
`hakysidian` is a static site generator for note projects.
|
||||||
2. pandoc does not support mathtools: <https://github.com/jgm/texmath/issues/249>
|
|
||||||
3. cross document refs
|
It is built on Hakyll, but packaged as a reusable CLI so you can run the same site generator across multiple note repositories without copying shared assets around. The executable bundles its shared `css/`, `fonts/`, `templates/`, `favicon.ico`, and `bib_style.csl` files with Cabal, then reads project-specific content from the current working directory.
|
||||||
4.
|
|
||||||
|
## What It Expects
|
||||||
|
|
||||||
|
Run `hakysidian` inside a project directory with this layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
your-project/
|
||||||
|
├── notes/
|
||||||
|
│ ├── first-note.md
|
||||||
|
│ └── another-note.md
|
||||||
|
├── reference.bib
|
||||||
|
├── math-macros.md
|
||||||
|
└── images/ # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
Required inputs:
|
||||||
|
|
||||||
|
- `notes/`: markdown notes to compile.
|
||||||
|
- `reference.bib`: bibliography used by Pandoc citeproc.
|
||||||
|
- `math-macros.md`: math macro definitions prepended before note parsing.
|
||||||
|
|
||||||
|
Optional inputs:
|
||||||
|
|
||||||
|
- `images/`: copied into the generated site as-is.
|
||||||
|
|
||||||
|
Shared assets are not required in each project. They come from the installed `hakysidian` package.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
By default, `hakysidian` writes:
|
||||||
|
|
||||||
|
- `_site/`: generated site output.
|
||||||
|
- `_cache/`: Hakyll cache and temporary files.
|
||||||
|
|
||||||
|
Note pages use clean URLs. For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/graph.md -> _site/notes/graph/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
From this repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cabal build exe:hakysidian
|
||||||
|
cabal install exe:hakysidian
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
The CLI mirrors the common Hakyll workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hakysidian build
|
||||||
|
hakysidian clean
|
||||||
|
hakysidian rebuild
|
||||||
|
hakysidian watch
|
||||||
|
```
|
||||||
|
|
||||||
|
`watch` also supports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hakysidian watch --host 127.0.0.1 --port 8000
|
||||||
|
hakysidian watch --no-server
|
||||||
|
```
|
||||||
|
|
||||||
|
What each command does:
|
||||||
|
|
||||||
|
- `build`: incremental site build.
|
||||||
|
- `clean`: removes generated output and cache.
|
||||||
|
- `rebuild`: clears output/cache and builds from scratch.
|
||||||
|
- `watch`: shows an in-place terminal dashboard, watches project inputs, and rebuilds automatically on change.
|
||||||
|
|
||||||
|
## Watch Mode
|
||||||
|
|
||||||
|
`watch` tracks:
|
||||||
|
|
||||||
|
- `notes/**`
|
||||||
|
- `reference.bib`
|
||||||
|
- `math-macros.md`
|
||||||
|
- `images/**`
|
||||||
|
|
||||||
|
The watch UI:
|
||||||
|
|
||||||
|
- uses the terminal’s current size to keep the dashboard within the visible screen,
|
||||||
|
- keeps recent build output in a bounded activity pane,
|
||||||
|
- avoids scrolling raw Hakyll logs through the terminal,
|
||||||
|
- can start a local preview server unless `--no-server` is passed.
|
||||||
|
|
||||||
|
## Notes Format
|
||||||
|
|
||||||
|
This generator is opinionated toward the current note pipeline in this repository:
|
||||||
|
|
||||||
|
- Markdown is parsed with Pandoc and custom theorem/callout handling.
|
||||||
|
- math is rendered with MathML. looks good in firefox
|
||||||
|
- sidenotes are supported
|
||||||
|
- spacing between CJK chars and ascii is automatically handled by a filter.
|
||||||
|
- Citations are processed through `reference.bib` and the bundled `bib_style.csl`.
|
||||||
|
- `math-macros.md` is injected before parsing so note content and theorem titles can use the same macros.
|
||||||
|
- Notes are rendered with the bundled templates and stylesheet set.
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ preprocessTheorems (Div attr xs)
|
|||||||
attr' = addAttr attr "type" theoremType
|
attr' = addAttr attr "type" theoremType
|
||||||
preprocessTheorems x = return x
|
preprocessTheorems x = return x
|
||||||
|
|
||||||
theoremFilter :: Pandoc -> Pandoc
|
theoremFilter :: Text -> Pandoc -> Pandoc
|
||||||
theoremFilter doc =
|
theoremFilter mathMacros doc =
|
||||||
walk makeTheorem $
|
walk (makeTheorem mathMacros) $
|
||||||
autorefFilter $
|
autorefFilter $
|
||||||
evalState (walkM preprocessTheorems normalizedDoc) 1
|
evalState (walkM preprocessTheorems normalizedDoc) 1
|
||||||
where
|
where
|
||||||
@@ -171,24 +171,13 @@ autorefFilter x = walk (autoref links) x
|
|||||||
where
|
where
|
||||||
links = query theoremIndex x
|
links = query theoremIndex x
|
||||||
|
|
||||||
-- processCitations works on AST. If you want to use citations in theorem name,
|
|
||||||
-- then you need to convert citations there to AST as well and then use processCitations\
|
|
||||||
-- Thus one need to apply the theorem filter first.
|
|
||||||
-- autoref still does not work.
|
|
||||||
mathMacros :: Text
|
|
||||||
mathMacros = unsafePerformIO (pack <$> readFile "math-macros.md")
|
|
||||||
{-# NOINLINE mathMacros #-}
|
|
||||||
|
|
||||||
prependMacros :: Text -> Text -> Text
|
prependMacros :: Text -> Text -> Text
|
||||||
prependMacros macros body = macros <> "\n\n" <> body
|
prependMacros macros body = macros <> "\n\n" <> body
|
||||||
|
|
||||||
prependMathMacros :: Text -> Text
|
thmNamePandoc :: Text -> Text -> Pandoc
|
||||||
prependMathMacros = prependMacros mathMacros
|
thmNamePandoc mathMacros x =
|
||||||
|
|
||||||
thmNamePandoc :: Text -> Pandoc
|
|
||||||
thmNamePandoc x =
|
|
||||||
fromRight (Pandoc nullMeta []) . runPure $
|
fromRight (Pandoc nullMeta []) . runPure $
|
||||||
readMarkdown chaoDocRead (prependMathMacros x)
|
readMarkdown chaoDocRead (prependMacros mathMacros x)
|
||||||
|
|
||||||
obsidianTheoremFilter :: Pandoc -> Pandoc
|
obsidianTheoremFilter :: Pandoc -> Pandoc
|
||||||
obsidianTheoremFilter = attachStandaloneLabels . walk rewriteObsidianBlockQuote
|
obsidianTheoremFilter = attachStandaloneLabels . walk rewriteObsidianBlockQuote
|
||||||
@@ -390,8 +379,8 @@ unsnoc (x : xs) = do
|
|||||||
(prefix, lastElem) <- unsnoc xs
|
(prefix, lastElem) <- unsnoc xs
|
||||||
return (x : prefix, lastElem)
|
return (x : prefix, lastElem)
|
||||||
|
|
||||||
makeTheorem :: Block -> Block
|
makeTheorem :: Text -> Block -> Block
|
||||||
makeTheorem (Div attr xs)
|
makeTheorem mathMacros (Div attr xs)
|
||||||
| isNothing t = Div attr xs
|
| isNothing t = Div attr xs
|
||||||
| otherwise = Div (addClass attr "theorem-environment") (Plain [header] : xs)
|
| otherwise = Div (addClass attr "theorem-environment") (Plain [header] : xs)
|
||||||
where
|
where
|
||||||
@@ -408,26 +397,23 @@ makeTheorem (Div attr xs)
|
|||||||
nametext =
|
nametext =
|
||||||
if isNothing name
|
if isNothing name
|
||||||
then Str ""
|
then Str ""
|
||||||
else Span (addClass nullAttr "name") (pandocToInline $ thmNamePandoc $ fromJust name)
|
else Span (addClass nullAttr "name") (pandocToInline $ thmNamePandoc mathMacros $ fromJust name)
|
||||||
makeTheorem x = x
|
makeTheorem _ x = x
|
||||||
|
|
||||||
-- bib from https://github.com/chaoxu/chaoxu.github.io/tree/develop
|
bibFile :: T.Text
|
||||||
cslFile :: String
|
|
||||||
cslFile = "bib_style.csl"
|
|
||||||
|
|
||||||
bibFile :: String
|
|
||||||
bibFile = "reference.bib"
|
bibFile = "reference.bib"
|
||||||
|
|
||||||
chaoDocPandocCompiler :: Compiler (Item Pandoc)
|
chaoDocPandocCompiler :: FilePath -> Compiler (Item Pandoc)
|
||||||
chaoDocPandocCompiler = do
|
chaoDocPandocCompiler cslPath = do
|
||||||
macros <- T.pack <$> loadBody "math-macros.md"
|
macros <- T.pack <$> loadBody "math-macros.md"
|
||||||
|
void (loadBody "reference.bib" :: Compiler String)
|
||||||
body <- getResourceBody
|
body <- getResourceBody
|
||||||
let bodyWithMacros =
|
let bodyWithMacros =
|
||||||
fmap (T.unpack . prependMacros macros . T.pack) body
|
fmap (T.unpack . prependMacros macros . T.pack) body
|
||||||
myReadPandocBiblio chaoDocRead (T.pack cslFile) (T.pack bibFile) myFilter bodyWithMacros
|
myReadPandocBiblio chaoDocRead (T.pack cslPath) bibFile (myFilter macros) bodyWithMacros
|
||||||
|
|
||||||
chaoDocCompiler :: Compiler (Item String)
|
chaoDocCompiler :: FilePath -> Compiler (Item String)
|
||||||
chaoDocCompiler = chaoDocPandocCompiler <&> writePandocWith chaoDocWrite
|
chaoDocCompiler cslPath = chaoDocPandocCompiler cslPath <&> writePandocWith chaoDocWrite
|
||||||
|
|
||||||
addMeta :: T.Text -> MetaValue -> Pandoc -> Pandoc
|
addMeta :: T.Text -> MetaValue -> Pandoc -> Pandoc
|
||||||
addMeta name value (Pandoc meta a) =
|
addMeta name value (Pandoc meta a) =
|
||||||
@@ -465,8 +451,9 @@ myReadPandocBiblio ropt csl biblio pdfilter item = do
|
|||||||
-- let a x = itemSetBody (pandoc' x)
|
-- let a x = itemSetBody (pandoc' x)
|
||||||
return $ fmap (const pandoc') item
|
return $ fmap (const pandoc') item
|
||||||
|
|
||||||
myFilter :: Pandoc -> Pandoc
|
myFilter :: Text -> Pandoc -> Pandoc
|
||||||
myFilter = usingSideNotesHTML chaoDocWrite . theoremFilter . panguFilter . displayMathFilter
|
myFilter mathMacros =
|
||||||
|
usingSideNotesHTML chaoDocWrite . theoremFilter mathMacros . panguFilter . displayMathFilter
|
||||||
|
|
||||||
-- pangu filter
|
-- pangu filter
|
||||||
lastChar :: Inline -> Maybe Char
|
lastChar :: Inline -> Maybe Char
|
||||||
|
|||||||
934
src/site.hs
934
src/site.hs
@@ -1,230 +1,828 @@
|
|||||||
{-# LANGUAGE BlockArguments #-}
|
{-# LANGUAGE BlockArguments #-}
|
||||||
|
{-# LANGUAGE DerivingStrategies #-}
|
||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE ViewPatterns #-}
|
{-# LANGUAGE StandaloneKindSignatures #-}
|
||||||
|
|
||||||
import ChaoDoc
|
import ChaoDoc
|
||||||
import Data.List (sortOn)
|
import Control.Concurrent (forkIO, threadDelay)
|
||||||
|
import Control.Exception (SomeException, bracket_, try)
|
||||||
|
import Control.Monad (filterM, unless, void, when)
|
||||||
|
import Data.Char (isSpace)
|
||||||
|
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 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
|
||||||
|
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.FilePath
|
||||||
import Text.Pandoc
|
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
|
||||||
|
| WatchCommand WatchSettings
|
||||||
|
|
||||||
|
type FileSnapshot :: Type
|
||||||
|
type FileSnapshot = M.Map FilePath UTCTime
|
||||||
|
|
||||||
|
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/
|
-- https://www.rohanjain.in/hakyll-clean-urls/
|
||||||
cleanRoute :: Routes
|
cleanRoute :: Routes
|
||||||
cleanRoute = customRoute createIndexRoute
|
cleanRoute = customRoute createIndexRoute
|
||||||
where
|
where
|
||||||
createIndexRoute ident = takeDirectory p </> takeBaseName p </> "index.html"
|
createIndexRoute ident = takeDirectory path </> takeBaseName path </> "index.html"
|
||||||
where
|
where
|
||||||
p = toFilePath ident
|
path = toFilePath ident
|
||||||
|
|
||||||
cleanIndexHtmls :: Item String -> Compiler (Item String)
|
cleanIndexHtmls :: Item String -> Compiler (Item String)
|
||||||
cleanIndexHtmls = return . fmap (replaceAll pattern replacement)
|
cleanIndexHtmls = return . fmap (replaceAll pattern replacement)
|
||||||
where
|
where
|
||||||
pattern :: String = "/index.html"
|
pattern :: String
|
||||||
replacement :: String -> String = const "/"
|
pattern = "/index.html"
|
||||||
|
|
||||||
|
replacement :: String -> String
|
||||||
|
replacement = const "/"
|
||||||
|
|
||||||
|
loadNoteLinks :: Compiler [Item String]
|
||||||
|
loadNoteLinks = do
|
||||||
|
noteIds <- sortOn toFilePath <$> getMatches notesPattern
|
||||||
|
pure [Item noteId "" | noteId <- noteIds]
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
config :: Configuration
|
siteConfiguration :: FilePath -> Configuration
|
||||||
config =
|
siteConfiguration projectRoot =
|
||||||
defaultConfiguration
|
defaultConfiguration
|
||||||
{ ignoreFile = \path ->
|
{ destinationDirectory = projectRoot </> "_site",
|
||||||
ignoreFile defaultConfiguration path
|
storeDirectory = projectRoot </> "_cache",
|
||||||
|| ".git" `elem` splitDirectories (normalise path)
|
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 :: IO ()
|
||||||
main = hakyllWith config $ do
|
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 (WatchCommand watchSettings) -> do
|
||||||
|
validateProject projectRoot
|
||||||
|
exitWith =<< runWatch projectRoot config cslPath watchSettings
|
||||||
|
|
||||||
|
usageText :: String
|
||||||
|
usageText =
|
||||||
|
unlines
|
||||||
|
[ "usage: hakysidian [build|clean|rebuild|watch [--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
|
||||||
|
"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 = "starting",
|
||||||
|
dashboardLastChange = "waiting for first build",
|
||||||
|
dashboardLastBuild = "pending",
|
||||||
|
dashboardLogLines =
|
||||||
|
[ "watcher ready",
|
||||||
|
"watching notes/, reference.bib, math-macros.md, images/ (optional)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
Configuration ->
|
||||||
|
WatchSettings ->
|
||||||
|
IORef ServerStatus ->
|
||||||
|
DashboardState ->
|
||||||
|
IO ()
|
||||||
|
renderWatchDashboard renderStateRef projectRoot config 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 ("Output : " ++ destinationDirectory config),
|
||||||
|
dashboardRow cols ("Preview : " ++ renderServerStatus watchSettings serverStatus),
|
||||||
|
-- dashboardRow cols "Watch : notes/, reference.bib, math-macros.md, images/ (optional)",
|
||||||
|
-- dashboardRow cols ("Change : " ++ dashboardLastChange dashboard),
|
||||||
|
dashboardRow cols ("Build : " ++ dashboardLastBuild dashboard)
|
||||||
|
]
|
||||||
|
headerRows =
|
||||||
|
[ border,
|
||||||
|
dashboardTitleRow cols "hakysidian watch" (dashboardStatus dashboard),
|
||||||
|
border
|
||||||
|
]
|
||||||
|
++ infoRows
|
||||||
|
++ [border, dashboardRow cols "Recent activity", border]
|
||||||
|
footerRows =
|
||||||
|
[ border,
|
||||||
|
dashboardRow cols "Controls: 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
|
||||||
|
|
||||||
|
waitForWatchQuit :: Bool -> Int -> IO Bool
|
||||||
|
waitForWatchQuit watchInputEnabled remainingMicros
|
||||||
|
| remainingMicros <= 0 = pure False
|
||||||
|
| otherwise = do
|
||||||
|
shouldQuit <- pollWatchQuit watchInputEnabled
|
||||||
|
if shouldQuit
|
||||||
|
then pure True
|
||||||
|
else do
|
||||||
|
threadDelay (min watchInputPollMicros remainingMicros)
|
||||||
|
waitForWatchQuit watchInputEnabled (remainingMicros - watchInputPollMicros)
|
||||||
|
|
||||||
|
pollWatchQuit :: Bool -> IO Bool
|
||||||
|
pollWatchQuit watchInputEnabled
|
||||||
|
| not watchInputEnabled = pure False
|
||||||
|
| otherwise = drainInput
|
||||||
|
where
|
||||||
|
drainInput = do
|
||||||
|
hasInput <- hWaitForInput stdin 0
|
||||||
|
if hasInput
|
||||||
|
then do
|
||||||
|
inputChar <- hGetChar stdin
|
||||||
|
if inputChar == 'q'
|
||||||
|
then pure True
|
||||||
|
else drainInput
|
||||||
|
else pure False
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
runSiteCommand :: Configuration -> Options -> FilePath -> IO ExitCode
|
||||||
|
runSiteCommand config options cslPath =
|
||||||
|
hakyllWithExitCodeAndArgs config options (siteRules cslPath)
|
||||||
|
|
||||||
|
runWatch :: FilePath -> Configuration -> FilePath -> WatchSettings -> IO ExitCode
|
||||||
|
runWatch projectRoot config _cslPath watchSettings = do
|
||||||
|
stdoutInteractive <- hIsTerminalDevice stdout
|
||||||
|
stdinInteractive <- hIsTerminalDevice stdin
|
||||||
|
let watchInputEnabled = stdoutInteractive && stdinInteractive
|
||||||
|
withWatchTui do
|
||||||
|
serverStatusRef <- newIORef initialServerStatus
|
||||||
|
renderStateRef <- newIORef Nothing
|
||||||
|
startPreviewServer config watchSettings serverStatusRef
|
||||||
|
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef initialDashboardState
|
||||||
|
(_, initialDashboard) <-
|
||||||
|
runWatchBuild
|
||||||
|
"build"
|
||||||
|
"initial build"
|
||||||
|
"initial build"
|
||||||
|
projectRoot
|
||||||
|
config
|
||||||
|
watchSettings
|
||||||
|
renderStateRef
|
||||||
|
serverStatusRef
|
||||||
|
initialDashboardState
|
||||||
|
initialSnapshot <- snapshotInputs projectRoot
|
||||||
|
watchLoop watchInputEnabled renderStateRef serverStatusRef initialSnapshot initialDashboard
|
||||||
|
where
|
||||||
|
initialServerStatus
|
||||||
|
| watchServerEnabled watchSettings = ServerStarting
|
||||||
|
| otherwise = ServerDisabled
|
||||||
|
|
||||||
|
watchLoop ::
|
||||||
|
Bool ->
|
||||||
|
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
|
||||||
|
IORef ServerStatus ->
|
||||||
|
FileSnapshot ->
|
||||||
|
DashboardState ->
|
||||||
|
IO ExitCode
|
||||||
|
watchLoop watchInputEnabled renderStateRef serverStatusRef previousSnapshot dashboard = do
|
||||||
|
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard
|
||||||
|
shouldQuit <- waitForWatchQuit watchInputEnabled watchLoopDelayMicros
|
||||||
|
if shouldQuit
|
||||||
|
then pure ExitSuccess
|
||||||
|
else do
|
||||||
|
nextSnapshot <- snapshotInputs projectRoot
|
||||||
|
if nextSnapshot == previousSnapshot
|
||||||
|
then watchLoop watchInputEnabled renderStateRef serverStatusRef previousSnapshot 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) <-
|
||||||
|
runWatchBuild
|
||||||
|
command
|
||||||
|
command
|
||||||
|
changeSummary
|
||||||
|
projectRoot
|
||||||
|
config
|
||||||
|
watchSettings
|
||||||
|
renderStateRef
|
||||||
|
serverStatusRef
|
||||||
|
dashboard
|
||||||
|
watchLoop watchInputEnabled renderStateRef serverStatusRef nextSnapshot nextDashboard
|
||||||
|
|
||||||
|
renderBuildResult :: ExitCode -> String
|
||||||
|
renderBuildResult ExitSuccess = "success"
|
||||||
|
renderBuildResult (ExitFailure code) = "failed (" ++ show code ++ ")"
|
||||||
|
|
||||||
|
runWatchBuild ::
|
||||||
|
String ->
|
||||||
|
String ->
|
||||||
|
String ->
|
||||||
|
FilePath ->
|
||||||
|
Configuration ->
|
||||||
|
WatchSettings ->
|
||||||
|
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
|
||||||
|
IORef ServerStatus ->
|
||||||
|
DashboardState ->
|
||||||
|
IO (ExitCode, DashboardState)
|
||||||
|
runWatchBuild command label changeSummary projectRoot config watchSettings renderStateRef serverStatusRef dashboard = do
|
||||||
|
startedAt <- watchTimestamp
|
||||||
|
let runningDashboard =
|
||||||
|
dashboard
|
||||||
|
{ dashboardStatus = "building (" ++ label ++ ")",
|
||||||
|
dashboardLastChange = changeSummary,
|
||||||
|
dashboardLastBuild = "running since " ++ startedAt
|
||||||
|
}
|
||||||
|
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef runningDashboard
|
||||||
|
(exitCode, buildLines) <- runCapturedSiteCommand projectRoot command
|
||||||
|
finishedAt <- watchTimestamp
|
||||||
|
let loggedDashboard =
|
||||||
|
appendLogBatch runningDashboard (label ++ ": " ++ changeSummary) finishedAt buildLines
|
||||||
|
completedDashboard =
|
||||||
|
loggedDashboard
|
||||||
|
{ dashboardStatus =
|
||||||
|
if exitCode == ExitSuccess
|
||||||
|
then "watching"
|
||||||
|
else "watching after failed " ++ label,
|
||||||
|
dashboardLastBuild =
|
||||||
|
renderBuildResult exitCode
|
||||||
|
++ " at "
|
||||||
|
++ finishedAt
|
||||||
|
++ " via "
|
||||||
|
++ label
|
||||||
|
}
|
||||||
|
renderWatchDashboard renderStateRef projectRoot config 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
|
match "images/**" $ do
|
||||||
route idRoute
|
route idRoute
|
||||||
compile copyFileCompiler
|
compile copyFileCompiler
|
||||||
|
|
||||||
match "math-macros.md" $ compile getResourceBody
|
match "math-macros.md" $
|
||||||
|
compile getResourceBody
|
||||||
|
|
||||||
match "fonts/*.woff2" $ do
|
match "reference.bib" $
|
||||||
route idRoute
|
compile getResourceBody
|
||||||
compile copyFileCompiler
|
|
||||||
|
|
||||||
match "favicon.ico" $ do
|
mapM_ createBundledCss bundledCssFiles
|
||||||
route idRoute
|
mapM_ createBundledCopy bundledFontFiles
|
||||||
compile copyFileCompiler
|
createBundledCopy "favicon.ico"
|
||||||
|
mapM_ createBundledTemplate bundledTemplateFiles
|
||||||
|
|
||||||
-- match "404.html" $ do
|
match notesPattern $ do
|
||||||
-- route cleanRoute
|
|
||||||
-- compile copyFileCompiler
|
|
||||||
|
|
||||||
match "css/*" $ do
|
|
||||||
route idRoute
|
|
||||||
compile compressCssCompiler
|
|
||||||
|
|
||||||
-- match "about.md" $ do
|
|
||||||
-- route cleanRoute
|
|
||||||
-- compile $
|
|
||||||
-- chaoDocCompiler
|
|
||||||
-- >>= loadAndApplyTemplate "templates/about.html" defaultContext
|
|
||||||
-- >>= relativizeUrls
|
|
||||||
|
|
||||||
-- -- build up tags
|
|
||||||
-- tags <- buildTags "posts/*" (fromCapture "tags/*.html")
|
|
||||||
-- tagsRules tags $ \tag pattern -> do
|
|
||||||
-- let title = "Posts tagged \"" ++ tag ++ "\""
|
|
||||||
-- route cleanRoute
|
|
||||||
-- compile $ do
|
|
||||||
-- posts <- recentFirst =<< loadAll pattern
|
|
||||||
-- let ctx =
|
|
||||||
-- constField "title" title
|
|
||||||
-- `mappend` listField "posts" (postCtxWithTags tags) (return posts)
|
|
||||||
-- `mappend` defaultContext
|
|
||||||
-- makeItem ""
|
|
||||||
-- >>= loadAndApplyTemplate "templates/tag.html" ctx
|
|
||||||
-- >>= loadAndApplyTemplate "templates/default.html" ctx
|
|
||||||
-- >>= relativizeUrls
|
|
||||||
|
|
||||||
-- create ["tags.html"] $ do
|
|
||||||
-- route cleanRoute
|
|
||||||
-- compile $ do
|
|
||||||
-- makeItem ""
|
|
||||||
-- >>= loadAndApplyTemplate "templates/tags.html" (defaultCtxWithTags tags)
|
|
||||||
-- >>= loadAndApplyTemplate "templates/default.html" (defaultCtxWithTags tags)
|
|
||||||
|
|
||||||
-- match "posts/*" $ do
|
|
||||||
-- route cleanRoute
|
|
||||||
-- compile $ do
|
|
||||||
-- tocCtx <- getTocCtx (postCtxWithTags tags)
|
|
||||||
-- chaoDocCompiler
|
|
||||||
-- >>= loadAndApplyTemplate "templates/post.html" tocCtx
|
|
||||||
-- >>= loadAndApplyTemplate "templates/default.html" tocCtx
|
|
||||||
-- >>= relativizeUrls
|
|
||||||
-- -- >>= katexFilter
|
|
||||||
|
|
||||||
match "notes/*" $ do
|
|
||||||
route cleanRoute
|
route cleanRoute
|
||||||
compile $ do
|
compile $ do
|
||||||
tocCtx <- getTocCtx defaultContext
|
notes <- loadNoteLinks
|
||||||
chaoDocCompiler
|
tocCtx <- getTocCtx cslPath (listField "notes" defaultContext (pure notes) <> defaultContext)
|
||||||
|
chaoDocCompiler cslPath
|
||||||
>>= loadAndApplyTemplate "templates/note.html" tocCtx
|
>>= loadAndApplyTemplate "templates/note.html" tocCtx
|
||||||
>>= relativizeUrls
|
>>= relativizeUrls
|
||||||
|
|
||||||
create ["index.html"] $ do
|
create ["index.html"] $ do
|
||||||
route idRoute
|
route idRoute
|
||||||
compile $ do
|
compile $ do
|
||||||
notes <- sortOn (toFilePath . itemIdentifier) <$> loadAll "notes/*"
|
notes <- loadNoteLinks
|
||||||
let notesCtx =
|
let notesCtx =
|
||||||
listField "posts" defaultContext (return notes)
|
listField "notes" defaultContext (pure notes)
|
||||||
`mappend` constField "title" "Notes"
|
<> constField "title" "Notes"
|
||||||
`mappend` defaultContext
|
<> defaultContext
|
||||||
makeItem ""
|
makeItem ""
|
||||||
>>= loadAndApplyTemplate "templates/notes.html" notesCtx
|
>>= loadAndApplyTemplate "templates/notes.html" notesCtx
|
||||||
>>= loadAndApplyTemplate "templates/index.html" notesCtx
|
>>= loadAndApplyTemplate "templates/index.html" notesCtx
|
||||||
>>= relativizeUrls
|
>>= relativizeUrls
|
||||||
>>= cleanIndexHtmls
|
>>= cleanIndexHtmls
|
||||||
|
|
||||||
-- create ["archive.html"] $ do
|
createBundledCss :: FilePath -> Rules ()
|
||||||
-- route cleanRoute
|
createBundledCss relPath = create [fromFilePath relPath] $ do
|
||||||
-- compile $ do
|
route idRoute
|
||||||
-- posts <- recentFirst =<< loadAll "posts/*"
|
compile bundledCssCompiler
|
||||||
-- let archiveCtx =
|
|
||||||
-- listField "posts" postCtx (return posts)
|
|
||||||
-- `mappend` constField "title" "Archives"
|
|
||||||
-- `mappend` defaultContext
|
|
||||||
-- makeItem ""
|
|
||||||
-- >>= loadAndApplyTemplate "templates/archive.html" archiveCtx
|
|
||||||
-- >>= loadAndApplyTemplate "templates/index.html" archiveCtx
|
|
||||||
-- >>= relativizeUrls
|
|
||||||
-- >>= cleanIndexHtmls
|
|
||||||
|
|
||||||
-- create ["draft.html"] $ do
|
createBundledCopy :: FilePath -> Rules ()
|
||||||
-- route cleanRoute
|
createBundledCopy relPath = create [fromFilePath relPath] $ do
|
||||||
-- compile $ do
|
route idRoute
|
||||||
-- posts <- recentFirst =<< loadAll "posts/*"
|
compile bundledCopyCompiler
|
||||||
-- let draftCtx =
|
|
||||||
-- listField "posts" postCtx (return posts)
|
|
||||||
-- `mappend` constField "title" "Drafts"
|
|
||||||
-- `mappend` defaultContext
|
|
||||||
-- makeItem ""
|
|
||||||
-- >>= loadAndApplyTemplate "templates/draft.html" draftCtx
|
|
||||||
-- >>= loadAndApplyTemplate "templates/index.html" draftCtx
|
|
||||||
-- >>= relativizeUrls
|
|
||||||
-- >>= cleanIndexHtmls
|
|
||||||
|
|
||||||
-- match "index.html" $ do
|
createBundledTemplate :: FilePath -> Rules ()
|
||||||
-- route idRoute
|
createBundledTemplate relPath =
|
||||||
-- compile $ do
|
create [fromFilePath relPath] $
|
||||||
-- posts <- fmap (take 25) . recentFirst =<< loadAll "posts/*"
|
compile bundledTemplateCompiler
|
||||||
-- let indexCtx =
|
|
||||||
-- listField "posts" postCtx (return posts)
|
|
||||||
-- `mappend` defaultContext
|
|
||||||
-- getResourceBody
|
|
||||||
-- >>= applyAsTemplate indexCtx
|
|
||||||
-- >>= loadAndApplyTemplate "templates/index.html" indexCtx
|
|
||||||
-- >>= relativizeUrls
|
|
||||||
-- >>= cleanIndexHtmls
|
|
||||||
|
|
||||||
match "templates/*" $ compile templateBodyCompiler
|
bundledAssetPath :: Compiler FilePath
|
||||||
|
bundledAssetPath = do
|
||||||
|
ident <- getUnderlying
|
||||||
|
unsafeCompiler $ Paths.getDataFileName (toFilePath ident)
|
||||||
|
|
||||||
-- https://robertwpearce.com/hakyll-pt-2-generating-a-sitemap-xml-file.html
|
bundledTextCompiler :: Compiler (Item String)
|
||||||
-- create ["sitemap.xml"] $ do
|
bundledTextCompiler = do
|
||||||
-- route idRoute
|
assetPath <- bundledAssetPath
|
||||||
-- compile $ do
|
contents <- unsafeCompiler (readFile assetPath)
|
||||||
-- posts <- recentFirst =<< loadAll "posts/*"
|
makeItem contents
|
||||||
-- singlePages <- loadAll (fromList ["about.md"])
|
|
||||||
-- let pages = posts <> singlePages
|
bundledCssCompiler :: Compiler (Item String)
|
||||||
-- sitemapCtx =
|
bundledCssCompiler = fmap compressCss <$> bundledTextCompiler
|
||||||
-- constField "root" root
|
|
||||||
-- <> listField "pages" postCtx (return pages) -- here
|
bundledCopyCompiler :: Compiler (Item CopyFile)
|
||||||
-- makeItem ""
|
bundledCopyCompiler = do
|
||||||
-- >>= loadAndApplyTemplate "templates/sitemap.xml" sitemapCtx
|
assetPath <- bundledAssetPath
|
||||||
|
makeItem (CopyFile assetPath)
|
||||||
|
|
||||||
|
bundledTemplateCompiler :: Compiler (Item Template)
|
||||||
|
bundledTemplateCompiler = do
|
||||||
|
item <- bundledTextCompiler
|
||||||
|
template <- compileTemplateItem item
|
||||||
|
pure (itemSetBody template item)
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
-- isZhField :: Context String
|
|
||||||
-- isZhField = boolFieldM "isZh" isZh
|
|
||||||
-- where
|
|
||||||
-- isZh :: Item String -> Compiler Bool
|
|
||||||
-- isZh item = do
|
|
||||||
-- maybeLang <- getMetadataField (itemIdentifier item) "lang"
|
|
||||||
-- return (maybeLang == Just "zh")
|
|
||||||
|
|
||||||
-- postCtx :: Context String
|
|
||||||
-- postCtx =
|
|
||||||
-- dateField "date" "%B %e, %Y"
|
|
||||||
-- <> dateField "date" "%Y-%m-%d"
|
|
||||||
-- <> isZhField
|
|
||||||
-- <> defaultContext
|
|
||||||
|
|
||||||
-- postCtxWithTags :: Tags -> Context String
|
|
||||||
-- postCtxWithTags tags = tagsField "tags" tags `mappend` postCtx
|
|
||||||
|
|
||||||
-- defaultCtxWithTags :: Tags -> Context String
|
|
||||||
-- defaultCtxWithTags tags = listField "tags" tagsCtx getAllTags <> defaultContext
|
|
||||||
-- where
|
|
||||||
-- getAllTags :: Compiler [Item (String, [Identifier])]
|
|
||||||
-- getAllTags = pure . map mkItem $ tagsMap tags
|
|
||||||
-- where
|
|
||||||
-- mkItem :: (String, [Identifier]) -> Item (String, [Identifier])
|
|
||||||
-- mkItem x@(t, _) = Item (tagsMakeId tags t) x
|
|
||||||
-- tagsCtx =
|
|
||||||
-- listFieldWith "posts" (postCtxWithTags tags) getPosts
|
|
||||||
-- <> metadataField
|
|
||||||
-- <> urlField "url"
|
|
||||||
-- <> pathField "path"
|
|
||||||
-- <> titleField "title"
|
|
||||||
-- <> missingField
|
|
||||||
-- where
|
|
||||||
-- getPosts ::
|
|
||||||
-- Item (String, [Identifier]) ->
|
|
||||||
-- Compiler [Item String]
|
|
||||||
-- getPosts (itemBody -> (_, is)) = mapM load is
|
|
||||||
|
|
||||||
-- toc from https://github.com/slotThe/slotThe.github.io
|
-- toc from https://github.com/slotThe/slotThe.github.io
|
||||||
getTocCtx :: Context a -> Compiler (Context a)
|
getTocCtx :: FilePath -> Context a -> Compiler (Context a)
|
||||||
getTocCtx ctx = do
|
getTocCtx cslPath ctx = do
|
||||||
noToc <- (Just "true" ==) <$> (getUnderlying >>= (`getMetadataField` "no-toc"))
|
noToc <- (Just "true" ==) <$> (getUnderlying >>= (`getMetadataField` "no-toc"))
|
||||||
writerOpts <- mkTocWriter defaultHakyllWriterOptions
|
writerOpts <- mkTocWriter defaultHakyllWriterOptions
|
||||||
toc <- writePandocWith writerOpts <$> chaoDocPandocCompiler
|
toc <- writePandocWith writerOpts <$> chaoDocPandocCompiler cslPath
|
||||||
pure $
|
pure $
|
||||||
mconcat
|
mconcat
|
||||||
[ ctx,
|
[ ctx,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="googlebot" content="noindex" />
|
<meta name="googlebot" content="noindex" />
|
||||||
<title>$title$</title>
|
<title>$title$</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="stylesheet" href="/css/fonts.css" />
|
<link rel="stylesheet" href="/css/fonts.css" />
|
||||||
<link rel="stylesheet" href="/css/default.css" />
|
<link rel="stylesheet" href="/css/default.css" />
|
||||||
<link rel="stylesheet" href="/css/pygentize.css" />
|
<link rel="stylesheet" href="/css/pygentize.css" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="googlebot" content="noindex">
|
<meta name="googlebot" content="noindex">
|
||||||
<title></title>
|
<title></title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="stylesheet" href="/css/fonts.css" />
|
<link rel="stylesheet" href="/css/fonts.css" />
|
||||||
<link rel="stylesheet" href="/css/default.css" />
|
<link rel="stylesheet" href="/css/default.css" />
|
||||||
<link rel="stylesheet" href="/css/pygentize.css" />
|
<link rel="stylesheet" href="/css/pygentize.css" />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ $partial("templates/head.html")$
|
|||||||
that are big enough -->
|
that are big enough -->
|
||||||
<div id="contents-big">
|
<div id="contents-big">
|
||||||
<p class="mini-header">Notes <a id="up-arrow" href="/">←</a></p>
|
<p class="mini-header">Notes <a id="up-arrow" href="/">←</a></p>
|
||||||
|
$partial("templates/notes-list.html")$
|
||||||
<p class="mini-header">Contents <a id="up-arrow" href="#">↑</a></p>
|
<p class="mini-header">Contents <a id="up-arrow" href="#">↑</a></p>
|
||||||
$toc$
|
$toc$
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
templates/notes-list.html
Normal file
7
templates/notes-list.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<ul class="notes-list">
|
||||||
|
$for(notes)$
|
||||||
|
<li>
|
||||||
|
<a href="$url$">$title$</a>
|
||||||
|
</li>
|
||||||
|
$endfor$
|
||||||
|
</ul>
|
||||||
@@ -1,8 +1,2 @@
|
|||||||
<h1 class="pagetitle">$title$</h1>
|
<h1 class="pagetitle">$title$</h1>
|
||||||
<ul>
|
$partial("templates/notes-list.html")$
|
||||||
$for(posts)$
|
|
||||||
<li>
|
|
||||||
<a href="$url$">$title$</a>
|
|
||||||
</li>
|
|
||||||
$endfor$
|
|
||||||
</ul>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user