From d286f603a697b3617c795bae91e533a0511c4aac Mon Sep 17 00:00:00 2001 From: flupe Date: Mon, 28 Sep 2020 02:58:01 +0200 Subject: [PATCH] improved blog tutorial --- .../pages/3-a-blog-from-scratch.markdown | 218 ++++++++++++------ 1 file changed, 150 insertions(+), 68 deletions(-) diff --git a/content/projects/achille/pages/3-a-blog-from-scratch.markdown b/content/projects/achille/pages/3-a-blog-from-scratch.markdown index 2dffcd5..025a7d7 100644 --- a/content/projects/achille/pages/3-a-blog-from-scratch.markdown +++ b/content/projects/achille/pages/3-a-blog-from-scratch.markdown @@ -4,9 +4,13 @@ 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: +In this tutorial we'll see how to use **achille** for a simple blog generator. + +## Content structure + +The first step is to settle on the content structure. +For a blog, we will simply store each article in a separate markdown file, +inside the `posts/` folder. ```bash content @@ -16,87 +20,165 @@ content    └── 2020-05-21-some-more.md ``` -We define the kind of metadata we want to allow in the frontmatter header -of our markdown files: +We require every article to have a title in their Front Matter header. +We also give them the possibility to specify a summary. -```haskell -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE DeriveAny #-} +```md +--- +title: Hello World! +draft: true +--- -import GHC.Generics -import Data.Aeson +This is my first article on *this* blog. +It is powered by [achille](https://acatalepsie.fr/projects/achille). +``` + + +## HTML template + +Then we figure out how we want to render our blog posts. We need to produce +HTML, and achille doesn't care how we do it, so we are free to use any library +that suits us. My personal favorite is `lucid` so we'll use that here. + +```hs +{-# LANGUAGE BlockArguments, OverloadedStrings #-} + +import Lucid import Data.Text (Text) -data Meta = Meta - { title :: Text - } deriving (Generic, FromJSON) -``` - -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: My first blogpost! ---- -``` - -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 () -renderPost title content = wrapContent do - h1_ $ toHtml title - toHtmlRaw content - -renderIndex :: [(Text, FilePath)] -> Html () -renderIndex = wrapContent . - ul_ . mconcat . map \(title, path) -> - li_ $ a_ [href_ path] $ toHtml title - -wrapContent :: Html () -> Html () -wrapContent content = doctypehtml_ do +template :: Html () -> Html () +template content = doctypehtml_ do head_ do meta_ [charset_ "utf-8"] - title_ "my very first blog" - + title_ "Jenna's Weblog" body_ do - header_ $ h1_ "BLOG" - content_ + header_ "Jenna's Weblog" + content + footer_ "© Jenna 2020" ``` -We define a recipe for rendering every post: +We also tell achille how `Html ()` should be written on disk. -```haskell -buildPosts :: Task IO [(String, FilePath)] -buildPosts = - match "posts/*.md" do - (Meta title, text) <- compilePandocMetadata - saveFileAs (-<.> "html") (renderPost title text) - <&> (title,) +```hs +import Achille.Writable as Writable (Writable) + +instance Writable (Html ()) where + write to = Writable.write to . renderBS ``` -We can define a simple recipe for rendering the index, given a list of posts: +## Processing articles -```haskell -buildIndex :: [(Text, FilePath)] -> Task IO FilePath -buildIndex posts = - save (renderIndex posts) "index.html" +Now let's define what kind of information is associated with an article. + +```hs +{-# LANGUAGE DeriveGeneric, DeriveAny #-} + +import GHC.Generics (Generic) +import Data.Aeson (FromJSON) +import Data.Binary (Binary) +import Data.Time (UTCTime) + +data PostMeta = PostMeta + { title :: Text + , summary :: Maybe Text + , draft :: Maybe Bool + } deriving (Generic, Eq, FromJSON) + +data Post = Post + { postTitle :: Text + , postSummary :: Maybe Text + , postIsDraft :: Bool + , postPath :: FilePath + } deriving (Generic, Eq, Binary) + +renderPost :: Post -> Text -> Html () +renderPost post content = template $ + article_ do + header_ $ h1 (toHtml $ postTitle post) + toHtmlRaw content ``` -Then, it's only a matter of composing the recipes and giving them to **achille**: +Because we derive `FromJSON` for `PostMeta`, we are now able to load a Front +Matter header and convert it directly to a value of type `PostMeta`. **Correct +metadata is thus enforced**. `Post` is a datatype containing all the processed +information about an article, and we derive `Binary` so that we can cache it later. +Notice how we do not store the article content here, there's no use. -```haskell +Now we simply need a recipe for reading an article, processing it and rendering +the appropriate HTML output file: + +```hs +import System.FilePath ((-<.>)) +import Data.Maybe (fromMaybe) + +processPost :: Recipe IO FilePath Post +processPost = do + outputPath <- (-<.> "html") <$> getInput + (meta, doc) <- readPandoc + + let post = Post { postTitle = title meta + , postSummary = summary meta + , postIsDraft = fromMaybe False (draft meta) + , postPath = outputPath + } + + renderPandoc doc <&> renderPost post >>= write outputPath + + return post +``` + +## Rendering the index + +Ok, we now have a recipe for building individual articles, however we want to +be able to display them on the index. We need to filter out drafts. And we need +to sort them from most recent to oldest. + +```hs +import Control.Monad (forM_, mapM_) + +processPosts :: Task IO () +processPosts = do + posts <- match "posts/*.md" processPost + let visible = filter (not postIsDraft) posts + watch visible $ write "index.html" (renderIndex visible) + +renderIndex :: [Post] -> Html () +renderIndex posts = do + h2_ "Latest articles" + ul_ $ forM_ posts \ post -> li_ do + a_ [href_ (fromString $ postPath post)] (toHtml $ postTitle post) + forM_ (postSummary post) \summary -> p_ $ toHtml summary +``` + +## Wrapping things up + +Finally, we can forward this task to the top-level achille runner: + +```hs main :: IO () -main = achille do - posts <- buildPosts - buildIndex posts +main = achille processPosts ``` -And that's it, you now have a very minimalist incremental blog generator! +Compile into an executable `blog` and run the command `blog build`. Hold and +behold, if you look into the `_site/` folder, there should be: + +```bash +_site +├── index.html +└── posts +    ├── 2020-04-13-hello-world.html +    ├── 2020-04-14-another-article.html +    └── 2020-05-21-some-more.html +``` + +What's more, your generator is **incremental**. Modify a single article, and +trigger a rebuild. + +```bash +$ touch -m content/posts/2020-04-13-hello-world.md +$ blog build +``` + +You should see that only this article is rebuilt. And because we haven't +actually changed neither the title nor the summary, the index hasn't been rebuilt. +Magic!