refactoring
This commit is contained in:
parent
24927f7028
commit
b6a011218c
|
@ -0,0 +1,10 @@
|
||||||
|
## todo
|
||||||
|
|
||||||
|
- RSS feed(s)
|
||||||
|
- dark theme
|
||||||
|
- faster thumbnail generation with openCV
|
||||||
|
- better gallery (albums, webzines, media types, layouts, etc)
|
||||||
|
- tag/category/search system
|
||||||
|
- parallelization
|
||||||
|
- draft builds + live server
|
||||||
|
- font subsetting?
|
|
@ -66,7 +66,8 @@ a.footnote-ref sup::after { content: ']' }
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--red);
|
color: var(--blacker);
|
||||||
|
font-weight: 500;
|
||||||
text-decoration: none
|
text-decoration: none
|
||||||
}
|
}
|
||||||
a:hover {text-decoration: underline}
|
a:hover {text-decoration: underline}
|
||||||
|
@ -77,8 +78,9 @@ hr {
|
||||||
margin: 2.5em 0;
|
margin: 2.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
main > ul { list-style: none }
|
#pidx { list-style: none }
|
||||||
main > ul li > span {
|
#pidx li {line-height: 1.6em}
|
||||||
|
#pidx li > span {
|
||||||
font: 13px monospace;
|
font: 13px monospace;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
padding: .1em .5em;
|
padding: .1em .5em;
|
||||||
|
@ -151,21 +153,29 @@ main ul { padding: 0 0 0 1.5em }
|
||||||
main ul.projects {
|
main ul.projects {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 2.5em 0 0;
|
margin: 2.5em 0 0;
|
||||||
background: #fafafb;
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
border-radius: 2px;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
grid-gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
main ul.projects li {
|
||||||
|
background: #fafafb;
|
||||||
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
main ul.projects > li > a {
|
main ul.projects > li > a {
|
||||||
display: flex;
|
display: flex;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: .5em 2em 1em;
|
padding: .5em 1em 1em;
|
||||||
transition: .2s background
|
transition: .2s background
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.projects li a p { font-weight: 400 }
|
||||||
|
|
||||||
main ul.projects > li img {
|
main ul.projects > li img {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
margin: .5em 1.5em 0 0;
|
margin: .5em 1em 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header.project { display: flex }
|
header.project { display: flex }
|
||||||
|
@ -182,11 +192,11 @@ header.project ul li {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: #eceff4;
|
background: #eceff4;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
margin: 0 1em 0 0;
|
||||||
}
|
}
|
||||||
header.project ul li + li {margin: 0 0 0 1em;}
|
|
||||||
|
|
||||||
main ul.projects > li a:hover { background: #f2f4f7; text-decoration: none }
|
main ul.projects > li a:hover { background: #f2f4f7; text-decoration: none }
|
||||||
main ul.projects > li h2 { margin: 0; font-size: 1.4em; color: var(--black) }
|
main ul.projects > li h2 { margin: 0; font-size: 1.3em; color: var(--black) }
|
||||||
main ul.projects > li p { margin: 0 }
|
main ul.projects > li p { margin: 0 }
|
||||||
main ul.projects li ul { padding: .5em 0 }
|
main ul.projects li ul { padding: .5em 0 }
|
||||||
main ul.projects li ul li {
|
main ul.projects li ul li {
|
||||||
|
@ -207,8 +217,9 @@ main blockquote {
|
||||||
}
|
}
|
||||||
|
|
||||||
main pre { padding: 0 0 0 1em }
|
main pre { padding: 0 0 0 1em }
|
||||||
main h2 { font-size: 1.8em; margin: 1em 0 .5em }
|
main h2 { font-size: 1.6em; margin: 1em 0 .5em }
|
||||||
main h3 { font-size: 1.5em; margin: 1em 0 .5em }
|
main h1 + h2 {margin-top: 0}
|
||||||
|
main h3 { font-size: 1.3em; margin: 1em 0 .5em }
|
||||||
|
|
||||||
#citations {margin: 2em 0 0}
|
#citations {margin: 2em 0 0}
|
||||||
dl {display:grid; gap: 1em; grid-template-columns: auto 1fr}
|
dl {display:grid; gap: 1em; grid-template-columns: auto 1fr}
|
||||||
|
@ -257,21 +268,19 @@ figure {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.pages {
|
ol.pages {
|
||||||
padding: 0;
|
padding: .5em 1em .5em 3em;
|
||||||
margin: 2em 0 2.5em;
|
margin: 2em 0 2.5em;
|
||||||
list-style:none;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
ul.pages li a {
|
ol.pages li::marker {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.pages li a {
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 2.4em;
|
line-height: 2em;
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
background: #eceff4;
|
|
||||||
}
|
|
||||||
ul.pages li a:hover {
|
|
||||||
background: #f2f4f7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
figure img {
|
figure img {
|
||||||
|
@ -279,6 +288,21 @@ figure img {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admonition {
|
||||||
|
border-left: 3px solid var(--yellow);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1px 1em;
|
||||||
|
background: #f8c32520;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: #67510f
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition p {
|
||||||
|
margin: .5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
.al {color: #f00; font-weight: bold; }
|
.al {color: #f00; font-weight: bold; }
|
||||||
.an {color: #60a0b0; font-weight: bold; font-style: italic; }
|
.an {color: #60a0b0; font-weight: bold; font-style: italic; }
|
||||||
.at {color:#7d9029}
|
.at {color:#7d9029}
|
||||||
|
@ -305,3 +329,19 @@ figure img {
|
||||||
.va {color:#19177c}
|
.va {color:#19177c}
|
||||||
.vs {color:#4070a0}
|
.vs {color:#4070a0}
|
||||||
.wa {color:#60a0b0; font-weight: bold; font-style: italic; }
|
.wa {color:#60a0b0; font-weight: bold; font-style: italic; }
|
||||||
|
|
||||||
|
table.books {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
table.books tr td {
|
||||||
|
padding: .1em 1em
|
||||||
|
}
|
||||||
|
table.books tr td:first-child {
|
||||||
|
font-style: italic;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--blacker);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
table.books tr td:nth-child(3),
|
||||||
|
table.books tr td:last-child {text-align: center;color: var(--yellow)}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
Oh god why
|
|
|
@ -0,0 +1,195 @@
|
||||||
|
---
|
||||||
|
title: Motivation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Static site generators (SSG) have proven to be very useful tools for easily
|
||||||
|
generating static websites from neatly organised content files. Most of them
|
||||||
|
support using **markup languages** like markdown for writing content, and offer
|
||||||
|
**incremental compilation** so that updating a website stays **fast**,
|
||||||
|
regardless of its size. However, most SSGs are very opinionated about how you
|
||||||
|
should manage your content. As soon as your specific needs deviate slightly
|
||||||
|
from what your SSG supports, it becomes a lot more tedious.
|
||||||
|
|
||||||
|
This leads to many people writing their own personal static site generators
|
||||||
|
from scratch. This results in a completely personalised workflow, but without
|
||||||
|
good libraries it is a time-consuming endeavor, and incremental compilation is often
|
||||||
|
out of the equation as it is hard to get right.
|
||||||
|
|
||||||
|
This is where **achille** and [Hakyll][Hakyll] come in: they provide a *domain
|
||||||
|
specific language* embedded in Haskell to easily yet very precisely describe
|
||||||
|
how to build your site. Compile this description and **you get a full-fledged
|
||||||
|
static site generator with incremental compilation**, tailored specifically to
|
||||||
|
your needs.
|
||||||
|
|
||||||
|
[Hakyll]: https://jaspervdj.be/hakyll
|
||||||
|
|
||||||
|
### Why Hakyll is not enough
|
||||||
|
|
||||||
|
To provide incremental compilation, Hakyll relies on a global store, in which
|
||||||
|
all your *intermediate values* are stored. It is *your* responsibility to
|
||||||
|
populate it with *snapshots*. There are some severe limitations to this
|
||||||
|
approach:
|
||||||
|
|
||||||
|
- The store is **fundamentally untyped**, so **retrieving snapshots may fail at
|
||||||
|
runtime** if you're not careful when writing your build rules. You may
|
||||||
|
argue that's not very critical --- I think it shouldn't be possible in the
|
||||||
|
first place. We are using a strongly typed language, so we shouldn't have
|
||||||
|
to rely on flaky coercions at runtime to manipulate intermediate values.
|
||||||
|
|
||||||
|
- **Loading snapshots with glob patterns is awkward**. With Hakyll, *the*
|
||||||
|
way to retrieve intermediate values is by querying the store,
|
||||||
|
using glob patterns. This indirect way of managing values is very
|
||||||
|
clumsy. In Haskell, the very purpose of variables is to store intermediate
|
||||||
|
values, so we should only have to deal with plain old variables.
|
||||||
|
|
||||||
|
- **Dependencies are not explicit**. Because it relies on a global store for
|
||||||
|
handling intermediate values, Hakyll has to make sure that the snaphots you
|
||||||
|
want to load have been generated already. And because rules have no imposed
|
||||||
|
order despite implicit inter-dependencies, Hakyll has to evaluate very
|
||||||
|
carefully each rule, eventually pausing them to compute missing dependencies.
|
||||||
|
This is very complex and quite frankly impressive, yet I believe we can strive
|
||||||
|
for a simpler model of evaluation. If we used plain old variables to hold
|
||||||
|
intermediate values, we simply would not be allowed to refer to an undefined
|
||||||
|
variable.
|
||||||
|
|
||||||
|
There are other somewhat debatable design decisions:
|
||||||
|
|
||||||
|
- In Hakyll, every rule will produce an output file, and only one, if you're
|
||||||
|
restricting yourself to the API they provide. I argue
|
||||||
|
such a library should not care whether a rule produces any output on the
|
||||||
|
filesystem. Its role is merely to know *if the rule must be executed*. Because of
|
||||||
|
this requirement, producing multiple outputs from the same file is a tad
|
||||||
|
cumbersome.
|
||||||
|
- Because Hakyll stores many content files directly in the store, the resulting
|
||||||
|
cache is *huge*. This is unnecessary, the files are right here in the content
|
||||||
|
directory.
|
||||||
|
- Hakyll uses a *lot* of abstractions --- `Compiler`, `Item`, `Rule`, `RuleSet`
|
||||||
|
--- whose purpose is not obvious to a newcomer.
|
||||||
|
- It defines monads to allow the convenient `do` notation to be used, but
|
||||||
|
disregards completely the very benefit of using monads --- it composes!
|
||||||
|
|
||||||
|
### Other tools
|
||||||
|
|
||||||
|
As always when thinking I am onto something, I jumped straight into code
|
||||||
|
and forgot to check whether there were alternatives. By fixating on Hakyll, I did not
|
||||||
|
realize many people have had the same comments about the shortcomings of Hakyll
|
||||||
|
and improved upon it. Therefore, it's only after building most of **achille**
|
||||||
|
in a week that I realized there were many
|
||||||
|
other similar tools available, namely: [rib][rib], [slick][slick], [Pencil][pencil] &
|
||||||
|
[Lykah][lykah].
|
||||||
|
|
||||||
|
[rib]: https://rib.srid.ca/
|
||||||
|
[slick]: https://hackage.haskell.org/package/slick
|
||||||
|
[pencil]: http://elbenshira.com/pencil/
|
||||||
|
[lykah]: https://hackage.haskell.org/package/Lykah
|
||||||
|
|
||||||
|
Fortunately, I still believe **achille** is a significant improvement over these libraries.
|
||||||
|
|
||||||
|
- As far as I can tell, **pencil** does not provide incremental generation.
|
||||||
|
It also relies on a global store, no longer untyped but very
|
||||||
|
restrictive about what you can store. It implements its own templating language.
|
||||||
|
- Likewise, no incremental generation in **Lykah**.
|
||||||
|
Reimplements its own HTML DSL rather than use *lucid*.
|
||||||
|
Very opinionated, undocumented and unmaintained.
|
||||||
|
- **rib** and **slick** are the most feature-complete of the lot.
|
||||||
|
They both provide a minimalist web-focused interface over the very powerful build system
|
||||||
|
[Shake][Shake]. Shake is in itself a very complicated beast. The forward
|
||||||
|
module that both libraries utilize is marked as *"experimental"* and depends
|
||||||
|
on yet another tool to track file accesses: *fsatrace*.
|
||||||
|
I'm not actually opposed to using Shake for dependency tracking at some point
|
||||||
|
in **achille**, but ultimately I didn't find **rib** and **slick** to offer
|
||||||
|
much more on top of it.
|
||||||
|
|
||||||
|
[Shake]: https://shakebuild.com/
|
||||||
|
|
||||||
|
## How achille works
|
||||||
|
|
||||||
|
In **achille** there is a single abstraction for reasoning about build rules:
|
||||||
|
`Recipe m a b`. A **recipe** of type `Recipe m a b` will produce a value of type
|
||||||
|
`m b` given some input of type `a`.
|
||||||
|
Conveniently, if `m` is a monad then **`Recipe m a` is a monad** too, so
|
||||||
|
you can retrieve the output of a recipe to reuse it in another recipe.
|
||||||
|
|
||||||
|
*(Because of caching, a recipe is almost but not quite a Kleisli arrow)*
|
||||||
|
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
-- the (>>=) operator, restricted to recipes
|
||||||
|
(>>=) :: Monad m => Recipe m a b -> (b -> Recipe m a c) -> Recipe m a c
|
||||||
|
```
|
||||||
|
|
||||||
|
With only this, **achille** tackles every single one of the limitations highlighted above.
|
||||||
|
|
||||||
|
- Intermediate values are plain old Haskell variables.
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
renderPost :: Recipe IO FilePath Post
|
||||||
|
buildPostIndex :: [Post] -> Recipe a ()
|
||||||
|
|
||||||
|
renderPosts :: Task IO ()
|
||||||
|
renderPosts = do
|
||||||
|
posts <- match "posts/*" renderPost
|
||||||
|
buildPostIndex posts
|
||||||
|
```
|
||||||
|
|
||||||
|
See how a correct ordering of build rules is enforced by design: you can only
|
||||||
|
use an intermediate value once the recipe it is originating from has been
|
||||||
|
executed.
|
||||||
|
|
||||||
|
Note: a **task** is a recipe that takes no input.
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
type Task m = Recipe m ()
|
||||||
|
```
|
||||||
|
|
||||||
|
- **achille** does not care what happens during the execution of a recipe.
|
||||||
|
It only cares about the input and return type of the recipe --- that is, the
|
||||||
|
type of intermediate values.
|
||||||
|
In particullar, **achille** does not expect every recipe to produce a file,
|
||||||
|
and lets you decide when to actually write on the filesystem.
|
||||||
|
|
||||||
|
For example, it is very easy to produce multiple versions of a same source file:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
renderPage :: Recipe IO FilePath FilePath
|
||||||
|
renderPage = do
|
||||||
|
-- Copy the input file as is to the output directory
|
||||||
|
copyFile
|
||||||
|
|
||||||
|
-- Render the input file with pandoc,
|
||||||
|
-- then save it to the output dir with extension ".html"
|
||||||
|
compilePandoc >>= saveTo (-<.> "html")
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you have defined the recipe for building your site, you forward
|
||||||
|
this description to **achille** in order to get a command-line interface for
|
||||||
|
your generator, just as you would using Hakyll:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
buildSite :: Task IO ()
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = achille buildSite
|
||||||
|
```
|
||||||
|
|
||||||
|
Assuming we compiled the file above into an executable called `site`, running
|
||||||
|
it gives the following output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ site
|
||||||
|
A static site generator for fun and profit
|
||||||
|
|
||||||
|
Usage: site COMMAND
|
||||||
|
|
||||||
|
Available options:
|
||||||
|
-h,--help Show this help text
|
||||||
|
|
||||||
|
Available commands:
|
||||||
|
build Build the site once
|
||||||
|
deploy Server go brrr
|
||||||
|
clean Delete all artefacts
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it, you now have your very own static site generator!
|
|
@ -0,0 +1,244 @@
|
||||||
|
---
|
||||||
|
title: How achille works
|
||||||
|
---
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
So far we haven't talked about caching and incremental builds.
|
||||||
|
Rest assured: **achille produces generators with robust incremental
|
||||||
|
builds** for free. To understand how this is done, we can simply look at the
|
||||||
|
definition of `Recipe m a b`:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
-- the cache is simply a lazy bytestring
|
||||||
|
type Cache = ByteString
|
||||||
|
|
||||||
|
newtype Recipe m a b = Recipe (Context a -> m (b, Cache))
|
||||||
|
```
|
||||||
|
|
||||||
|
In other words, when a recipe is run, it is provided a **context** containing
|
||||||
|
the input value, **a current cache** *local* to the recipe, and some more
|
||||||
|
information. The IO action is executed, and we update the local cache with the
|
||||||
|
new cache returned by the recipe. We say *local* because of how composition of
|
||||||
|
recipes is handled internally. When the *composition* of two recipes (made with
|
||||||
|
`>>=` or `>>`) is being run, we retrieve two bytestrings from the local cache
|
||||||
|
and feed them as local cache to both recipes respectively. Then we gather the two updated
|
||||||
|
caches, join them and make it the new cache of the composition.
|
||||||
|
|
||||||
|
This way, a recipe is guaranteed to receive the same local cache it returned
|
||||||
|
during the last run, *untouched by other recipes*. And every recipe is free to
|
||||||
|
dispose of this local cache however it wants.
|
||||||
|
|
||||||
|
As a friend noted, **achille** is "just a library for composing memoized
|
||||||
|
computations".
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
#### High-level interface
|
||||||
|
|
||||||
|
Because we do not want the user to carry the burden of updating the cache
|
||||||
|
manually, **achille** comes with many utilies for common operations, managing
|
||||||
|
the cache for us under the hood. Here is an exemple highlighting how we keep
|
||||||
|
fine-grained control over the cache at all times, while never having to
|
||||||
|
manipulate it directly.
|
||||||
|
|
||||||
|
Say you want to run a recipe for every file maching a glob pattern, *but do
|
||||||
|
not care about the output of the recipe*. A typical exemple would be to copy
|
||||||
|
every static asset of your site to the output directory. **achille** provides
|
||||||
|
the `match_` function for this very purpose:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
match_ :: Glob.Pattern -> Recipe FilePath b -> Recipe a ()
|
||||||
|
```
|
||||||
|
|
||||||
|
We would use it in this way:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
copyAssets :: Recipe a ()
|
||||||
|
copyAssets = match_ "assets/*" copyFile
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = achille copyAssets
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood, `match_ p r` will cache every filepath for which the recipe was
|
||||||
|
run. During the next run, for every filepath matching the pattern, `match_ p r` will
|
||||||
|
lookup the path in its cache. If it is found and hasn't been modified since,
|
||||||
|
then we do nothing for this path. Otherwise, the task is run and the filepath
|
||||||
|
added to the cache.
|
||||||
|
|
||||||
|
Now assume we do care about the output of the recipe we want to run on every filepath.
|
||||||
|
For example if we compile every blogpost, we want to retrieve each blogpost's title and
|
||||||
|
the filepath of the compiled `.html` file. In that case, we can use the
|
||||||
|
built-in `match` function:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
match :: Binary b
|
||||||
|
=> Glob.Pattern -> Recipe FilePath b -> Recipe a [b]
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice the difference here: we expect the type of the recipe output `b` to have
|
||||||
|
an instance of `Binary`, **so that we can encode it in the cache**. Fortunately,
|
||||||
|
many of the usual Haskell types have an instance available. Then we can do:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data PostMeta = PostMeta { title :: Text }
|
||||||
|
renderPost :: Text -> Text -> Text
|
||||||
|
renderIndex :: [(Text, FilePath)] -> Text
|
||||||
|
|
||||||
|
buildPost :: Recipe FilePath (Text, FilePath)
|
||||||
|
buildPost = do
|
||||||
|
(PostMeta title, pandoc) <- compilePandocMeta
|
||||||
|
renderPost title pdc & saveAs (-<.> "html")
|
||||||
|
<&> (title,)
|
||||||
|
|
||||||
|
buildPost :: Recipe a [(Text, FilePath)]
|
||||||
|
buildPosts = match "posts/*.md" buildPost
|
||||||
|
|
||||||
|
buildIndex :: [(Text, FilePath)] -> Recipe
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shortcomings
|
||||||
|
|
||||||
|
The assertion *"A recipe will always receive the same cache between two runs"*
|
||||||
|
can only violated in the two following situations:
|
||||||
|
|
||||||
|
- There is **conditional branching in your recipes**, and more specifically,
|
||||||
|
**branching for which the branch taken can differ between runs**.
|
||||||
|
|
||||||
|
For example, it is **not** problematic to do branching on the extension of a file,
|
||||||
|
as the same path will be taken each execution.
|
||||||
|
|
||||||
|
But assuming you want to parametrize by some boolean value for whatever reason,
|
||||||
|
whose value you may change between runs, then because the two branches will
|
||||||
|
share the same cache, every time the boolean changes, the recipe will start
|
||||||
|
from an inconsistent cache so it will recompute from scratch, and overwrite
|
||||||
|
the existing cache.
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
buildSection :: Bool -> Task IO ()
|
||||||
|
buildSection isProductionBuild =
|
||||||
|
if isProductionBuild then
|
||||||
|
someRecipe
|
||||||
|
else
|
||||||
|
someOtherRecipe
|
||||||
|
```
|
||||||
|
|
||||||
|
Although I expect few people ever do this kind of conditional branching for
|
||||||
|
generating a static site, **achille** still comes with combinators for branching.
|
||||||
|
You can use `if` in order to keep two separate caches for the two branches:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
if :: Bool -> Recipe m a b -> Recipe m a b -> Recipe m a b
|
||||||
|
```
|
||||||
|
|
||||||
|
The previous example becomes:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
buildSection :: Bool -> Task IO ()
|
||||||
|
buildSection isProductionBuild =
|
||||||
|
Achille.if isProductionBuild
|
||||||
|
someRecipe
|
||||||
|
someOtherRecipe
|
||||||
|
```
|
||||||
|
|
||||||
|
### No runtime failures
|
||||||
|
|
||||||
|
All the built-in cached recipes **achille** provides are implemented carefully
|
||||||
|
so that **they never fail in case of cache corruption**. That is, in the
|
||||||
|
eventuality of failing to retrieve the desired values from the cache, our
|
||||||
|
recipes will automatically recompute the result from the input, ignoring the
|
||||||
|
cache entirely. To make sure this is indeed what happens, every cached recipe
|
||||||
|
in **achille** has been tested carefully (not yet really, but it is on the todo
|
||||||
|
list).
|
||||||
|
|
||||||
|
This means the only failures possible are those related to poor content
|
||||||
|
formatting from the user part: missing frontmatter fields, watching files
|
||||||
|
that do not exist, etc. All of those are errors are gracefully reported to the
|
||||||
|
user.
|
||||||
|
|
||||||
|
### Parallelism
|
||||||
|
|
||||||
|
**achille** could very easily support parallelism for free, I just didn't take
|
||||||
|
the time to make it a reality.
|
||||||
|
|
||||||
|
## Recursive recipes
|
||||||
|
|
||||||
|
It is very easy to define recursive recipes in **achille**. This allows us to
|
||||||
|
traverse and build tree-like structures, such as wikis.
|
||||||
|
|
||||||
|
For example, given the following structure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
content
|
||||||
|
├── index.md
|
||||||
|
├── folder1
|
||||||
|
│ └── index.md
|
||||||
|
└── folder2
|
||||||
|
├── index.md
|
||||||
|
├── folder21
|
||||||
|
│ └── index.md
|
||||||
|
├── folder22
|
||||||
|
│ └── index.md
|
||||||
|
└── folder23
|
||||||
|
├── index.md
|
||||||
|
├── folder231
|
||||||
|
│ └── index.md
|
||||||
|
├── folder222
|
||||||
|
│ └── index.md
|
||||||
|
└── folder233
|
||||||
|
└── index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
We can generate a site with the same structure and in which each index page has
|
||||||
|
links to its children:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
renderIndex :: PageMeta -> [(PageMeta, FilePath)] -> Text -> Html
|
||||||
|
|
||||||
|
buildIndex :: Recipe IO a (PageMeta, FilePath)
|
||||||
|
buildIndex = do
|
||||||
|
children <- walkDir
|
||||||
|
|
||||||
|
matchFile "index.*" do
|
||||||
|
(meta, text) <- compilePandoc
|
||||||
|
renderIndex meta children text >>= save (-<.> "html")
|
||||||
|
return $ (meta,) <$> getInput
|
||||||
|
|
||||||
|
walkDir :: Recipe IO a [(PageMeta, FilePath)]
|
||||||
|
walkDir = matchDir "*/" buildIndex
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = achille buildIndex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forcing the regeneration of output
|
||||||
|
|
||||||
|
Currently, **achille** doesn't track what files a recipe produces in the output
|
||||||
|
dir. This means you cannot ask for things like *"Please rebuild
|
||||||
|
output/index.html"*.
|
||||||
|
|
||||||
|
That's because we make the assumption that the output dir is untouched between
|
||||||
|
builds. The only reason I can think of for wanting to rebuild a specific page
|
||||||
|
is if the template used to generate it has changed.
|
||||||
|
But in that case, the template is *just another input*.
|
||||||
|
So you can treat it as such by putting it in your content directory and doing
|
||||||
|
the following:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
import Templates.Index (renderIndex)
|
||||||
|
|
||||||
|
buildIndex :: Task IO ()
|
||||||
|
buildIndex =
|
||||||
|
watchFile "Templates/Index.hs" $ match_ "index.*" do
|
||||||
|
compilePandoc <&> renderIndex >>= write "index.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
This way, **achille** will automatically rebuild your index if the template has
|
||||||
|
changed!
|
||||||
|
|
||||||
|
While writing these lines, I realized it would be very easy for **achille**
|
||||||
|
to know which recipe produced which output file,
|
||||||
|
so I might just add that. Still, it would still require you to ask for an output
|
||||||
|
file to be rebuilt if a template has changed. With the above pattern, it is
|
||||||
|
handled automatically!
|
|
@ -0,0 +1,103 @@
|
||||||
|
---
|
||||||
|
title: Making a blog from scratch
|
||||||
|
---
|
||||||
|
|
||||||
|
# Making a blog from scratch
|
||||||
|
|
||||||
|
Let's see how to use **achille** for making a static site generator for a blog.
|
||||||
|
First we decide what will be the structure of our source directory.
|
||||||
|
We choose the following:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
content
|
||||||
|
└── posts
|
||||||
|
├── 2020-04-13-hello-world.md
|
||||||
|
├── 2020-04-14-another-article.md
|
||||||
|
└── 2020-05-21-some-more.md
|
||||||
|
```
|
||||||
|
|
||||||
|
We define the kind of metadata we want to allow in the frontmatter header
|
||||||
|
of our markdown files:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
|
||||||
|
import GHC.Generics
|
||||||
|
import Data.Aeson
|
||||||
|
import Data.Text (Text)
|
||||||
|
|
||||||
|
data Meta = Meta
|
||||||
|
{ title :: Text
|
||||||
|
} deriving (Generic)
|
||||||
|
|
||||||
|
instance FromJSON Meta
|
||||||
|
```
|
||||||
|
|
||||||
|
This way we enfore correct metadata when retrieving the content of our files.
|
||||||
|
Every markdown file will have to begin with the following header for our
|
||||||
|
generator to proceed:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Something about efficiency
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Then we create a generic template for displaying a page, thanks to lucid:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE BlockArguments #-}
|
||||||
|
|
||||||
|
import Lucid.Html5
|
||||||
|
|
||||||
|
renderPost :: Text -> Text -> Html a
|
||||||
|
renderPost title content = wrapContent do
|
||||||
|
h1_ $ toHtml title
|
||||||
|
toHtmlRaw content
|
||||||
|
|
||||||
|
renderIndex :: [(Text, FilePath)] -> Html a
|
||||||
|
renderIndex = wrapContent .
|
||||||
|
ul_ . mconcat . map \(title, path) ->
|
||||||
|
li_ $ a_ [href_ path] $ toHtml title
|
||||||
|
|
||||||
|
wrapContent :: Html a -> Html a
|
||||||
|
wrapContent content = doctypehtml_ do
|
||||||
|
head_ do
|
||||||
|
meta_ [charset_ "utf-8"]
|
||||||
|
title_ "my very first blog"
|
||||||
|
|
||||||
|
body_ do
|
||||||
|
header_ $ h1_ "BLOG"
|
||||||
|
content_
|
||||||
|
```
|
||||||
|
|
||||||
|
We define a recipe for rendering every post:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
buildPosts :: Task IO [(String, FilePath)]
|
||||||
|
buildPosts =
|
||||||
|
match "posts/*.md" do
|
||||||
|
(Meta title, text) <- compilePandocMetadata
|
||||||
|
saveFileAs (-<.> "html") (renderPost title text)
|
||||||
|
<&> (title,)
|
||||||
|
```
|
||||||
|
|
||||||
|
We can define a simple recipe for rendering the index, given a list of posts:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
buildIndex :: [(Text, FilePath)] -> Task IO FilePath
|
||||||
|
buildIndex posts =
|
||||||
|
save (renderIndex posts) "index.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, it's only a matter of composing the recipes and giving them to **achille**:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
main :: IO ()
|
||||||
|
main = achille do
|
||||||
|
posts <- buildPosts
|
||||||
|
buildIndex posts
|
||||||
|
```
|
||||||
|
|
||||||
|
And that's it, you now have a very minimalist incremental blog generator!
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
title: Examples
|
||||||
|
---
|
||||||
|
|
||||||
|
## Achille in the wild
|
||||||
|
|
||||||
|
**achille** is very new and has not been advertised much.
|
||||||
|
Therefore there are currently few concrete examples to show how people use it.
|
||||||
|
|
||||||
|
Using **achille** on your own site? Make a PR to add it to the list!
|
||||||
|
|
||||||
|
- https://acatalepsie.fr ([source](https://github.com/flupe/site))
|
|
@ -15,13 +15,10 @@ animations and interactive demos.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Currently, this implementation has **no support for audio**. At the time I remember
|
- Currently, this implementation **does not support audio**.
|
||||||
the WebAudio API to be very poorly designed, and I did not understand how it was
|
- WebGL is used for color conversion only. Ideally I would like to get rid of it
|
||||||
implemented in the official IBNIZ VM.
|
and find an exact integer only formula.
|
||||||
- I use WebGL for color conversion because I never figured out how to actually
|
|
||||||
reliably convert YUV to RGB. The original C implementation uses SDL2
|
|
||||||
Overlays or something, and I was not able to reverse engineer the conversion.
|
|
||||||
I found floating-point formulas, hence the GLSL shader, etc.
|
|
||||||
- At some point I wanted to compile the entire IBNIZ programs to WASM, rather
|
- At some point I wanted to compile the entire IBNIZ programs to WASM, rather
|
||||||
than build an interpreter. The problem is that IBNIZ programs are *unstructured*.
|
than build an interpreter. The problem is that IBNIZ programs are
|
||||||
the `J` instruction allows you to jump *anywhere* in the program.
|
*unstructured*. the `J` instruction allows you to jump *anywhere* in the
|
||||||
|
program. This makes WASM a poor target for the language.
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
- title: Ayoade on Ayoade
|
||||||
|
author: Richard Ayoade
|
||||||
|
- title: Antkind
|
||||||
|
author: Charlie Kaufman
|
||||||
|
- title: Huis Clos
|
||||||
|
author: Jean-Paul Sartre
|
||||||
|
- title: The Secret History
|
||||||
|
author: Donna Tartt
|
||||||
|
rating: 1
|
||||||
|
- title: Le Joueur d'échecs
|
||||||
|
author: Stefan Zweig
|
|
@ -0,0 +1 @@
|
||||||
|
../../.git/annex/objects/fX/Qw/SHA256E-s2483080--965ae6b60293cd8ef7e56bec079da103e3798def95e53635e489b3f7abff305c.png/SHA256E-s2483080--965ae6b60293cd8ef7e56bec079da103e3798def95e53635e489b3f7abff305c.png
|
|
@ -0,0 +1 @@
|
||||||
|
../../.git/annex/objects/zz/5K/SHA256E-s124243--638cf5e6b669ab03d26831bb346988b72cc8a88d3aee977c4cd62d8b1680fc42.jpg/SHA256E-s124243--638cf5e6b669ab03d26831bb346988b72cc8a88d3aee977c4cd62d8b1680fc42.jpg
|
17
site.cabal
17
site.cabal
|
@ -4,7 +4,6 @@ version: 0.1.0.0
|
||||||
author: flupe
|
author: flupe
|
||||||
maintainer: lucas@escot.me
|
maintainer: lucas@escot.me
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
optimization: 2
|
|
||||||
|
|
||||||
executable site
|
executable site
|
||||||
main-is: Main.hs
|
main-is: Main.hs
|
||||||
|
@ -12,6 +11,12 @@ executable site
|
||||||
other-modules: Templates
|
other-modules: Templates
|
||||||
, Types
|
, Types
|
||||||
, Page
|
, Page
|
||||||
|
, Feed
|
||||||
|
, Posts
|
||||||
|
, Projects
|
||||||
|
, Common
|
||||||
|
, Config
|
||||||
|
, Visual
|
||||||
build-depends: base >=4.12 && <4.13
|
build-depends: base >=4.12 && <4.13
|
||||||
, filepath
|
, filepath
|
||||||
, achille
|
, achille
|
||||||
|
@ -29,8 +34,16 @@ executable site
|
||||||
, binary
|
, binary
|
||||||
, containers
|
, containers
|
||||||
, dates
|
, dates
|
||||||
|
, sort
|
||||||
|
, feed
|
||||||
|
, time
|
||||||
|
, xml-types
|
||||||
|
, binary-instances
|
||||||
|
extensions: BlockArguments
|
||||||
|
, TupleSections
|
||||||
|
, OverloadedStrings
|
||||||
|
, ScopedTypeVariables
|
||||||
other-modules: Templates
|
other-modules: Templates
|
||||||
ghc-options: -threaded
|
ghc-options: -threaded
|
||||||
-Wunused-imports
|
|
||||||
-j8
|
-j8
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
module Common
|
||||||
|
( module Data.Functor
|
||||||
|
, module Data.Sort
|
||||||
|
, module Data.String
|
||||||
|
, module System.FilePath
|
||||||
|
, module Achille
|
||||||
|
, module Achille.Recipe.Pandoc
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Achille
|
||||||
|
import Achille.Recipe.Pandoc
|
||||||
|
|
||||||
|
import Data.Functor ((<&>))
|
||||||
|
import Data.Sort (sort)
|
||||||
|
import Data.String (fromString)
|
||||||
|
import System.FilePath
|
|
@ -0,0 +1,36 @@
|
||||||
|
module Config (config, ropts, wopts, SiteConfig(..), def) where
|
||||||
|
|
||||||
|
import Data.Default
|
||||||
|
import Data.Text (Text)
|
||||||
|
import Text.Pandoc.Options as Pandoc
|
||||||
|
import Achille (Config(..))
|
||||||
|
|
||||||
|
|
||||||
|
config :: Achille.Config
|
||||||
|
config = def
|
||||||
|
{ deployCmd = Just "rsync -avzzr _site/ --chmod=755 acatalepsie:/var/www/html"
|
||||||
|
, contentDir = root <> "content"
|
||||||
|
, outputDir = root <> "_site"
|
||||||
|
, cacheFile = root <> ".cache"
|
||||||
|
} where root = "/home/flupe/dev/acatalepsie/"
|
||||||
|
|
||||||
|
|
||||||
|
ropts :: Pandoc.ReaderOptions
|
||||||
|
ropts = def { readerExtensions = pandocExtensions }
|
||||||
|
|
||||||
|
wopts :: Pandoc.WriterOptions
|
||||||
|
wopts = def { writerHTMLMathMethod = KaTeX "" }
|
||||||
|
|
||||||
|
|
||||||
|
data SiteConfig = SiteConfig
|
||||||
|
{ title :: Text
|
||||||
|
, description :: Text
|
||||||
|
, image :: Text
|
||||||
|
}
|
||||||
|
|
||||||
|
instance Default SiteConfig where
|
||||||
|
def = SiteConfig
|
||||||
|
{ title = "sbbls"
|
||||||
|
, description = "my personal web space, for your enjoyment"
|
||||||
|
, image = "https://acatalepsie.fr/assets/card.png"
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE DisambiguateRecordFields #-}
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
{-# LANGUAGE TypeApplications #-}
|
||||||
|
{-# LANGUAGE InstanceSigs #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
|
||||||
|
module Feed where
|
||||||
|
|
||||||
|
import Data.String (fromString)
|
||||||
|
import Data.Text hiding (map)
|
||||||
|
import Data.XML.Types as XML
|
||||||
|
import qualified Data.Text.Lazy as Lazy
|
||||||
|
import qualified Text.Atom.Feed as Atom
|
||||||
|
import qualified Text.Atom.Feed.Export as Export (textFeed)
|
||||||
|
|
||||||
|
import Types
|
||||||
|
|
||||||
|
class Reifiable a where
|
||||||
|
toEntry :: a -> Atom.Entry
|
||||||
|
|
||||||
|
instance Reifiable Project where
|
||||||
|
toEntry :: Project -> Atom.Entry
|
||||||
|
toEntry (Project {title, subtitle}) =
|
||||||
|
( Atom.nullEntry
|
||||||
|
"https://acatalepsie.fr/"
|
||||||
|
(Atom.TextString $ fromString title)
|
||||||
|
"2020-09-02"
|
||||||
|
)
|
||||||
|
{ Atom.entryContent = Just (Atom.TextContent $ fromString subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
toFeed :: Reifiable a => [a] -> Atom.Feed
|
||||||
|
toFeed items =
|
||||||
|
( Atom.nullFeed
|
||||||
|
"https://acatalepsie.fr/atom.xml"
|
||||||
|
(Atom.TextString "acatalepsie")
|
||||||
|
"2017-08-01"
|
||||||
|
)
|
||||||
|
{ Atom.feedEntries = map toEntry items
|
||||||
|
}
|
131
src/Main.hs
131
src/Main.hs
|
@ -1,126 +1,31 @@
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
module Main where
|
||||||
{-# LANGUAGE BlockArguments #-}
|
|
||||||
{-# LANGUAGE TupleSections #-}
|
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
|
||||||
{-# LANGUAGE DisambiguateRecordFields #-}
|
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
|
||||||
|
|
||||||
import Data.Functor
|
import qualified Data.Yaml as Yaml
|
||||||
import Data.Maybe (fromMaybe, mapMaybe)
|
|
||||||
import Control.Monad (when)
|
|
||||||
import System.FilePath
|
|
||||||
import Text.Pandoc.Options
|
|
||||||
|
|
||||||
import Achille
|
import Common
|
||||||
import Achille.Recipe.Pandoc
|
|
||||||
|
|
||||||
import Page
|
|
||||||
import Templates
|
import Templates
|
||||||
import Types
|
import qualified Posts
|
||||||
|
import qualified Projects
|
||||||
|
import qualified Visual
|
||||||
config :: Config
|
import Config (config, ropts, wopts, SiteConfig(title))
|
||||||
config = def
|
|
||||||
{ deployCmd = Just "rsync -avzzr _site/ --chmod=755 pi@192.168.0.45:/var/www/html"
|
|
||||||
|
|
||||||
-- by making everything absolute you can run the command from anywhere
|
|
||||||
, contentDir = "/home/flupe/dev/acatalepsie/content"
|
|
||||||
, outputDir = "/home/flupe/dev/acatalepsie/_site"
|
|
||||||
, cacheFile = "/home/flupe/dev/acatalepsie/.cache"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
-- pandoc options
|
|
||||||
ropts :: ReaderOptions
|
|
||||||
ropts = def
|
|
||||||
{ readerExtensions =
|
|
||||||
enableExtension Ext_smart githubMarkdownExtensions
|
|
||||||
}
|
|
||||||
|
|
||||||
wopts :: WriterOptions
|
|
||||||
wopts = def
|
|
||||||
{ writerHTMLMathMethod = KaTeX ""
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
buildProject :: Recipe IO a (Project, FilePath)
|
|
||||||
buildProject = do
|
|
||||||
name <- takeBaseName <$> getCurrentDir
|
|
||||||
|
|
||||||
-- task $ match_ "*" copyFile
|
|
||||||
match "*" copyFile
|
|
||||||
|
|
||||||
children <- buildChildren
|
|
||||||
|
|
||||||
watch children $ matchFile "index.*" do
|
|
||||||
(meta, doc) <- readPandocMetadataWith ropts
|
|
||||||
renderPandocWith wopts doc <&> renderProject meta children
|
|
||||||
>>= saveFileAs (-<.> "html")
|
|
||||||
>> (meta,) <$> getCurrentDir
|
|
||||||
where
|
|
||||||
buildChildren :: Recipe IO a [(String, FilePath)]
|
|
||||||
buildChildren = match "pages/*" do
|
|
||||||
(TitledPage title _, doc) <- readPandocMetadataWith ropts
|
|
||||||
renderPandocWith wopts doc
|
|
||||||
<&> outerWith (def {title = title})
|
|
||||||
>>= saveFileAs ((-<.> "html") . takeFileName)
|
|
||||||
<&> (title,)
|
|
||||||
|
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = achilleWith config do
|
main = achilleWith config do
|
||||||
|
-- static assets
|
||||||
match_ "assets/*" copyFile
|
match_ "assets/*" copyFile
|
||||||
|
|
||||||
-----------
|
-- quid page
|
||||||
-- QUID
|
|
||||||
|
|
||||||
match_ "./quid.rst" $
|
match_ "./quid.rst" $
|
||||||
compilePandoc <&> outerWith def {title = "quid"}
|
compilePandoc <&> outerWith def {Config.title = "quid"}
|
||||||
>>= saveFileAs (-<.> "html")
|
>>= saveFileAs (-<.> "html")
|
||||||
|
|
||||||
-----------
|
Visual.build
|
||||||
-- VISUAL
|
Projects.build
|
||||||
|
Posts.build
|
||||||
|
|
||||||
pictures <- match "visual/*" do
|
-- reading list
|
||||||
copyFile
|
matchFile "readings.yaml" $ readBS
|
||||||
runCommandWith (-<.> "thumb.png")
|
>>= (liftIO . Yaml.decodeThrow)
|
||||||
(\a b -> "convert -resize 740x " <> a <> " " <> b)
|
<&> renderReadings
|
||||||
<&> timestamped
|
>>= saveFileAs (-<.> "html")
|
||||||
|
|
||||||
watch pictures $ match_ "./visual.rst" do
|
|
||||||
txt <- compilePandoc
|
|
||||||
write "visual.html" $ renderVisual txt (recentFirst pictures)
|
|
||||||
|
|
||||||
-------------
|
|
||||||
-- PROJECTS
|
|
||||||
|
|
||||||
projects <- matchDir "projects/*/" buildProject
|
|
||||||
|
|
||||||
watch projects $ match_ "./projects.rst" do
|
|
||||||
debug "rendering project index"
|
|
||||||
txt <- compilePandocWith def wopts
|
|
||||||
write "projects.html" $ renderProjects txt projects
|
|
||||||
|
|
||||||
------------------
|
|
||||||
-- POSTS & INDEX
|
|
||||||
|
|
||||||
posts <- match "posts/*" do
|
|
||||||
src <- copyFile
|
|
||||||
(Page title d, pdc) <- readPandocMetadataWith ropts
|
|
||||||
|
|
||||||
renderPandocWith wopts pdc
|
|
||||||
<&> renderPost title src
|
|
||||||
>>= saveFileAs (-<.> "html")
|
|
||||||
<&> (d,) . (title,)
|
|
||||||
<&> timestampedWith (timestamp . snd . snd)
|
|
||||||
|
|
||||||
let visible = mapMaybe
|
|
||||||
(\(Timestamped d (dr, p)) ->
|
|
||||||
if fromMaybe False dr then Nothing
|
|
||||||
else Just $ Timestamped d p) posts
|
|
||||||
|
|
||||||
watch (take 10 visible) $ match_ "./index.rst" do
|
|
||||||
debug "rendering index"
|
|
||||||
compilePandoc
|
|
||||||
<&> renderIndex visible
|
|
||||||
>>= saveFileAs (-<.> "html")
|
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
{-# LANGUAGE DisambiguateRecordFields #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
|
||||||
|
module Posts (build) where
|
||||||
|
|
||||||
|
import Data.Maybe (fromMaybe, mapMaybe)
|
||||||
|
|
||||||
|
import Common
|
||||||
|
import Page
|
||||||
|
import Config
|
||||||
|
import Templates
|
||||||
|
|
||||||
|
|
||||||
|
build :: Task IO ()
|
||||||
|
build = do
|
||||||
|
posts <- reverse <$> sort <$> match "posts/*" do
|
||||||
|
src <- copyFile
|
||||||
|
(Page title d, pdc) <- readPandocMetadataWith ropts
|
||||||
|
|
||||||
|
renderPandocWith wopts pdc
|
||||||
|
<&> renderPost title src
|
||||||
|
>>= saveFileAs (-<.> "html")
|
||||||
|
<&> (d,) . (title,)
|
||||||
|
<&> timestampedWith (timestamp . snd . snd)
|
||||||
|
|
||||||
|
let visible = mapMaybe
|
||||||
|
(\(Timestamped d (dr, p)) ->
|
||||||
|
if fromMaybe False dr then Nothing
|
||||||
|
else Just $ Timestamped d p) posts
|
||||||
|
|
||||||
|
watch visible $ match_ "index.rst" do
|
||||||
|
-- render index
|
||||||
|
compilePandoc
|
||||||
|
<&> renderIndex visible
|
||||||
|
>>= saveFileAs (-<.> "html")
|
||||||
|
|
||||||
|
-- build atom feed
|
||||||
|
--
|
|
@ -0,0 +1,61 @@
|
||||||
|
module Projects (build) where
|
||||||
|
|
||||||
|
import Data.Char (digitToInt)
|
||||||
|
|
||||||
|
import Common
|
||||||
|
import Types
|
||||||
|
import Page
|
||||||
|
import Config
|
||||||
|
import Templates
|
||||||
|
|
||||||
|
|
||||||
|
getKey :: String -> (Int, String)
|
||||||
|
getKey xs = getKey' 0 xs
|
||||||
|
where
|
||||||
|
getKey' :: Int -> String -> (Int, String)
|
||||||
|
getKey' k (x : xs) | x >= '0' && x <= '9' =
|
||||||
|
getKey' (k * 10 + digitToInt x) xs
|
||||||
|
getKey' k ('-' : xs) = (k, xs)
|
||||||
|
getKey' k xs = (k, xs)
|
||||||
|
|
||||||
|
|
||||||
|
buildProject :: Recipe IO a (Project, FilePath)
|
||||||
|
buildProject = do
|
||||||
|
match "*" copyFile
|
||||||
|
|
||||||
|
name <- takeBaseName <$> getCurrentDir
|
||||||
|
children <- buildChildren name
|
||||||
|
|
||||||
|
watch children $ matchFile "index.*" do
|
||||||
|
(meta, doc) <- readPandocMetadataWith ropts
|
||||||
|
renderPandocWith wopts doc <&> renderProject meta children
|
||||||
|
>>= saveFileAs (-<.> "html")
|
||||||
|
>> (meta,) <$> getCurrentDir
|
||||||
|
where
|
||||||
|
buildChildren :: String -> Recipe IO a [(String, FilePath)]
|
||||||
|
buildChildren name = match "pages/*" do
|
||||||
|
filepath <- getInput
|
||||||
|
let (key, file) = getKey $ takeFileName filepath
|
||||||
|
(TitledPage title _, doc) <- readPandocMetadataWith ropts
|
||||||
|
renderPandocWith wopts doc
|
||||||
|
<&> outerWith (def {Config.title = fromString title})
|
||||||
|
>>= saveFileAs (const $ file -<.> "html")
|
||||||
|
<&> (title,)
|
||||||
|
|
||||||
|
-- sorted = sortBy (\(_, x, _, _, _) (_, y, _, _, _) -> compare x y) children
|
||||||
|
|
||||||
|
-- match "pages/*" do
|
||||||
|
-- renderPandocWith wopts doc
|
||||||
|
-- <&> outerWith (def {title = name})
|
||||||
|
-- >>= saveFileAs (const $ file -<.> "html")
|
||||||
|
-- <&> (name,)
|
||||||
|
-- -}
|
||||||
|
|
||||||
|
|
||||||
|
build :: Task IO ()
|
||||||
|
build = do
|
||||||
|
projects <- matchDir "projects/*/" buildProject
|
||||||
|
|
||||||
|
watch projects $ match_ "./projects.rst" do
|
||||||
|
txt <- compilePandocWith def wopts
|
||||||
|
write "projects.html" $ renderProjects txt projects
|
|
@ -1,5 +1,3 @@
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
{-# LANGUAGE BlockArguments #-}
|
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
{-# LANGUAGE DisambiguateRecordFields #-}
|
{-# LANGUAGE DisambiguateRecordFields #-}
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
@ -7,18 +5,18 @@
|
||||||
|
|
||||||
module Templates where
|
module Templates where
|
||||||
|
|
||||||
import Data.String (fromString)
|
|
||||||
import Control.Monad (forM_, when)
|
import Control.Monad (forM_, when)
|
||||||
|
|
||||||
import System.FilePath
|
|
||||||
import Text.Blaze.Internal as I
|
import Text.Blaze.Internal as I
|
||||||
import Text.Blaze.Html5 as H
|
import Text.Blaze.Html5 as H
|
||||||
import Text.Blaze.Html5.Attributes as A
|
import Text.Blaze.Html5.Attributes as A
|
||||||
import Data.Dates.Types (DateTime(..), months, capitalize)
|
import Data.Dates.Types (DateTime(..), months, capitalize)
|
||||||
|
import Data.Time.Format (formatTime, defaultTimeLocale)
|
||||||
|
import Data.Time.LocalTime (zonedTimeToUTC)
|
||||||
|
|
||||||
import Achille
|
|
||||||
import Types
|
import Types
|
||||||
import qualified Types
|
import Common
|
||||||
|
import Config
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
|
|
||||||
showDate :: DateTime -> String
|
showDate :: DateTime -> String
|
||||||
|
@ -47,7 +45,7 @@ renderIndex posts content =
|
||||||
|
|
||||||
renderPost :: String -> FilePath -> Html -> Html
|
renderPost :: String -> FilePath -> Html -> Html
|
||||||
renderPost title source content =
|
renderPost title source content =
|
||||||
outerWith def {Types.title = title} do
|
outerWith def { Config.title = fromString title } do
|
||||||
H.h1 $ fromString title
|
H.h1 $ fromString title
|
||||||
toLink source "View source"
|
toLink source "View source"
|
||||||
content
|
content
|
||||||
|
@ -63,7 +61,9 @@ renderVisual txt imgs =
|
||||||
|
|
||||||
renderProject :: Project -> [(String, FilePath)] -> Html -> Html
|
renderProject :: Project -> [(String, FilePath)] -> Html -> Html
|
||||||
renderProject (project@Project{title,..}) children content =
|
renderProject (project@Project{title,..}) children content =
|
||||||
outerWith def {Types.title = title} do
|
outerWith def { Config.title = fromString title
|
||||||
|
, Config.description = fromString subtitle
|
||||||
|
} do
|
||||||
H.header ! A.class_ "project" $ do
|
H.header ! A.class_ "project" $ do
|
||||||
H.div $ H.img ! A.src "logo.svg"
|
H.div $ H.img ! A.src "logo.svg"
|
||||||
H.div do
|
H.div do
|
||||||
|
@ -76,16 +76,34 @@ renderProject (project@Project{title,..}) children content =
|
||||||
$ fromString v
|
$ fromString v
|
||||||
else fromString v
|
else fromString v
|
||||||
when (length children > 0) $
|
when (length children > 0) $
|
||||||
H.ul ! A.class_ "pages" $ forM_ children \(t,l) ->
|
H.ol ! A.class_ "pages" $ forM_ children \(t,l) ->
|
||||||
H.li $ H.a ! A.href (fromString l) $ (fromString t)
|
H.li $ H.a ! A.href (fromString l) $ (fromString t)
|
||||||
content
|
content
|
||||||
|
|
||||||
|
renderReadings :: [Book] -> Html
|
||||||
|
renderReadings books =
|
||||||
|
outerWith def { Config.title = "readings"
|
||||||
|
, Config.description = "books I've read"
|
||||||
|
} do
|
||||||
|
H.table ! A.class_ "books" $
|
||||||
|
forM_ books \ Book {title,author,rating,completed} ->
|
||||||
|
H.tr do
|
||||||
|
H.td $ toHtml title
|
||||||
|
H.td $ toHtml author
|
||||||
|
H.td $ fromString $ case rating of
|
||||||
|
Just r -> replicate r '★'
|
||||||
|
Nothing -> "·"
|
||||||
|
H.td $ fromString $ case completed of
|
||||||
|
Just d -> formatTime defaultTimeLocale "%m/%0Y"
|
||||||
|
$ zonedTimeToUTC d
|
||||||
|
Nothing -> "·"
|
||||||
|
|
||||||
renderProjects :: Html -> [(Project, FilePath)] -> Html
|
renderProjects :: Html -> [(Project, FilePath)] -> Html
|
||||||
renderProjects txt paths =
|
renderProjects txt paths =
|
||||||
outer do
|
outer do
|
||||||
txt
|
txt
|
||||||
H.ul ! A.class_ "projects" $ do
|
H.ul ! A.class_ "projects" $ do
|
||||||
forM_ paths \(Project{title,..}, link) -> H.li $ H.a ! A.href (fromString link) $ do
|
forM_ paths \(Project {title,..}, link) -> H.li $ H.a ! A.href (fromString link) $ do
|
||||||
H.div $ H.img ! A.src (fromString $ link </> "logo.svg")
|
H.div $ H.img ! A.src (fromString $ link </> "logo.svg")
|
||||||
H.div do
|
H.div do
|
||||||
H.h2 $ fromString title
|
H.h2 $ fromString title
|
||||||
|
@ -111,18 +129,18 @@ outerWith SiteConfig{title,..} content = H.docTypeHtml do
|
||||||
|
|
||||||
-- OpenGraph
|
-- OpenGraph
|
||||||
H.meta ! property "og:title"
|
H.meta ! property "og:title"
|
||||||
! A.content (fromString title)
|
! A.content (textValue title)
|
||||||
|
|
||||||
H.meta ! property "og:type"
|
H.meta ! property "og:type"
|
||||||
! A.content "website"
|
! A.content "website"
|
||||||
|
|
||||||
H.meta ! property "og:image"
|
H.meta ! property "og:image"
|
||||||
! A.content (fromString image)
|
! A.content (textValue image)
|
||||||
|
|
||||||
H.meta ! property "og:description"
|
H.meta ! property "og:description"
|
||||||
! A.content (fromString description)
|
! A.content (textValue description)
|
||||||
|
|
||||||
H.title (fromString title)
|
H.title $ toHtml title
|
||||||
|
|
||||||
H.body do
|
H.body do
|
||||||
H.header ! A.id "hd" $ H.section do
|
H.header ! A.id "hd" $ H.section do
|
||||||
|
@ -130,6 +148,7 @@ outerWith SiteConfig{title,..} content = H.docTypeHtml do
|
||||||
H.section $ H.nav do
|
H.section $ H.nav do
|
||||||
H.a ! A.href "/projects.html" $ "Projects"
|
H.a ! A.href "/projects.html" $ "Projects"
|
||||||
H.a ! A.href "/visual.html" $ "Visual"
|
H.a ! A.href "/visual.html" $ "Visual"
|
||||||
|
H.a ! A.href "/readings.html" $ "Readings"
|
||||||
H.a ! A.href "/quid.html" $ "Quid"
|
H.a ! A.href "/quid.html" $ "Quid"
|
||||||
|
|
||||||
H.main content
|
H.main content
|
||||||
|
|
33
src/Types.hs
33
src/Types.hs
|
@ -6,9 +6,13 @@ module Types where
|
||||||
import GHC.Generics
|
import GHC.Generics
|
||||||
import Data.Aeson.Types (FromJSON)
|
import Data.Aeson.Types (FromJSON)
|
||||||
import Data.Binary (Binary, put, get)
|
import Data.Binary (Binary, put, get)
|
||||||
import Data.Default
|
import Data.Time.LocalTime (ZonedTime)
|
||||||
|
import Data.Binary.Instances.Time ()
|
||||||
|
import Data.Text (Text)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
|
|
||||||
|
|
||||||
|
-- | Full project description
|
||||||
data Project = Project
|
data Project = Project
|
||||||
{ title :: String
|
{ title :: String
|
||||||
, subtitle :: String
|
, subtitle :: String
|
||||||
|
@ -17,28 +21,29 @@ data Project = Project
|
||||||
, gallery :: Maybe Bool
|
, gallery :: Maybe Bool
|
||||||
} deriving (Generic, Eq, Show)
|
} deriving (Generic, Eq, Show)
|
||||||
|
|
||||||
|
|
||||||
data TitledPage = TitledPage
|
data TitledPage = TitledPage
|
||||||
{ title :: String
|
{ title :: String
|
||||||
, description :: Maybe String
|
, description :: Maybe String
|
||||||
} deriving (Generic, Eq, Show)
|
} deriving (Generic, Eq, Show)
|
||||||
|
|
||||||
|
|
||||||
|
-- | Book description for the readings page
|
||||||
|
data Book = Book
|
||||||
|
{ title :: Text
|
||||||
|
, author :: Text
|
||||||
|
, rating :: Maybe Int
|
||||||
|
, completed :: Maybe ZonedTime
|
||||||
|
} deriving (Generic, Show)
|
||||||
|
|
||||||
instance FromJSON Project
|
instance FromJSON Project
|
||||||
instance FromJSON TitledPage
|
instance FromJSON TitledPage
|
||||||
|
instance FromJSON Book
|
||||||
|
|
||||||
instance Binary Project where
|
instance Binary Project where
|
||||||
put (Project t s y l g) = put t >> put s >> put y >> put l >> put g
|
put (Project t s y l g) = put t >> put s >> put y >> put l >> put g
|
||||||
get = Project <$> get <*> get <*> get <*> get <*> get
|
get = Project <$> get <*> get <*> get <*> get <*> get
|
||||||
|
|
||||||
|
instance Binary Book where
|
||||||
data SiteConfig = SiteConfig
|
put (Book t a r c) = put t >> put a >> put r >> put c
|
||||||
{ title :: String
|
get = Book <$> get <*> get <*> get <*> get
|
||||||
, description :: String
|
|
||||||
, image :: String
|
|
||||||
}
|
|
||||||
|
|
||||||
instance Default SiteConfig where
|
|
||||||
def = SiteConfig
|
|
||||||
{ title = "sbbls"
|
|
||||||
, description = "my personal web space, for your enjoyment"
|
|
||||||
, image = "https://acatalepsie.fr/assets/card.png"
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
module Visual (build) where
|
||||||
|
|
||||||
|
import Common
|
||||||
|
import Templates
|
||||||
|
|
||||||
|
build :: Task IO ()
|
||||||
|
build = do
|
||||||
|
pictures <- match "visual/*" do
|
||||||
|
copyFile
|
||||||
|
runCommandWith (-<.> "thumb.png")
|
||||||
|
(\a b -> "convert -resize 740x " <> a <> " " <> b)
|
||||||
|
<&> timestamped
|
||||||
|
|
||||||
|
watch pictures $ match_ "./visual.rst" do
|
||||||
|
intro <- compilePandoc
|
||||||
|
write "visual.html" $ renderVisual intro (recentFirst pictures)
|
Loading…
Reference in New Issue