├── .gitignore
├── ChangeLog.md
├── LICENSE
├── README.md
├── Setup.hs
├── app
└── Main.hs
├── docs
├── atom.xml
├── css
│ ├── style.css
│ └── syntax.css
├── images
│ ├── code.jpg
│ ├── github-logo.png
│ └── twitter-logo.png
├── index.html
├── js
│ └── main.js
└── posts
│ └── sample-post.html
├── package.yaml
├── site-example.png
├── site
├── css
│ ├── style.css
│ └── syntax.css
├── images
│ ├── code.jpg
│ ├── github-logo.png
│ └── twitter-logo.png
├── js
│ └── main.js
├── posts
│ └── sample-post.md
└── templates
│ ├── atom.xml
│ ├── footer.html
│ ├── header.html
│ ├── index.html
│ ├── meta-data.html
│ ├── post-list.html
│ └── post.html
├── slick-template.cabal
├── stack.yaml
└── stack.yaml.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .stack-work/
2 | *~
3 | .shake
4 |
--------------------------------------------------------------------------------
/ChangeLog.md:
--------------------------------------------------------------------------------
1 | # Changelog for my site
2 |
3 | ## Unreleased changes
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright AUTHOR (c) 2018
2 |
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 |
11 | * Redistributions in binary form must reproduce the above
12 | copyright notice, this list of conditions and the following
13 | disclaimer in the documentation and/or other materials provided
14 | with the distribution.
15 |
16 | * Neither the name of AUTHOR nor the names of other
17 | contributors may be used to endorse or promote products derived
18 | from this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Slick Template
2 |
3 | A cloneable template for building a static site with [slick](https://github.com/ChrisPenner/slick)!
4 |
5 | For an example of what the site will look like, check out [my blog](https://chrispenner.ca)!
6 |
7 | 
8 |
9 | Here's [the configuration](https://github.com/ChrisPenner/ChrisPenner.github.io/blob/site/app/Main.hs) for my site if you're curious.
10 |
11 | Get up and running with slick in no time!
12 |
13 | Here's all you need to do to have your own site:
14 |
15 | 1. Click the green `Use this template` button at the top of this page (next to "clone")
16 | 2. Clone your new site repo
17 | 3. Edit your `siteMeta` inside `Main.hs`
18 | 4. Add some awesome blog posts in `site/posts/` by copying the sample post there
19 | 4. run `stack build`; `stack exec build-site`
20 | 5. Serve your `docs` directory by enabling Github Pages in your repository's settings
21 | 6. ...?
22 | 7. Profit!
23 |
24 | If you want a quick tool for serving your file system during development I recommend using `serve`:
25 |
26 | ```shell
27 | $ npm install -g serve
28 | $ serve docs
29 | ```
30 |
31 | Then navigate to the port which is serving (usually http://localhost:3000 or http://localhost:5000 )
32 |
33 |
34 | ---
35 |
36 | # Tips
37 |
38 | * Everything is just html, css, and javascript! Edit things to your heart's content!
39 | * Templates are in `site/tempates`; they use the [Mustache](https://mustache.github.io/) template language.
40 | * You'll need to delete your `.shake` directory when you edit `Main.hs` to avoid stale build caches.
41 | * Slick is good at **updating** and **creating** files, but it doesn't delete stale files. When in doubt you can delete your whole output directory.
42 |
43 | # Caching guide
44 |
45 | Shake takes care of most of the tricky parts, but there're still a few things you need to know.
46 |
47 | Cache-busting in Slick works using [`Development.Shake.Forward`](https://hackage.haskell.org/package/shake/docs/Development-Shake-Forward.html). The idea is that you can wrap actions with [`cacheAction`](https://hackage.haskell.org/package/shake-0.18.3/docs/Development-Shake-Forward.html#v:cacheAction), providing an unique identifier for each time it runs. Shake will track any dependencies which are triggered during the first run of that action and can use them to detect when that particular action must be re-run. Typically you'll want to cache an action for each "thing" you have to load, e.g. when you load a post, or when you build a page. You can also nest these caches if you like.
48 |
49 | When using `cacheAction` Shake will automatically serialize and store the results of that action to disk so that on a later build it can simply 'hydrate' that asset without running the command. For this reason, your data models should probably implement `Binary`. Here's an example data model:
50 |
51 | ```haskell
52 | {-# LANGUAGE DeriveGeneric #-}
53 | {-# LANGUAGE DeriveAnyClass #-}
54 |
55 | import Data.Aeson (ToJSON, FromJSON)
56 | import Development.Shake.Classes (Binary)
57 | import GHC.Generics (Generic)
58 |
59 | -- | Data for a blog post
60 | data Post =
61 | Post { title :: String
62 | , author :: String
63 | , content :: String
64 | , url :: String
65 | , date :: String
66 | , image :: Maybe String
67 | }
68 | deriving (Generic, Eq, Ord, Show, FromJSON, ToJSON, Binary)
69 | ```
70 |
71 | If you need to run arbitrary shell commands you can use [`cache`](https://hackage.haskell.org/package/shake-0.18.3/docs/Development-Shake-Forward.html#v:cache); it will do its best to track file use during the run of the command and cache-bust on that; results may vary. It's likely better to use explicit tracking commands like `readFile'` when possible, (or even just use `readFile'` on the files you depend on, then throw away the results. It's equivalent to explicitly depending on the file contents).
72 |
73 | Shake has many dependency tracking combinators available; whenever possible you should use the shake variants of these (e.g. `copyFileChanged`, `readFile'`, `writeFile'`, etc.). This will allow shake to detect when and what it needs to rebuild.
74 |
75 | Note: You'll likely need to delete `.shake` in your working directory after editing your `Main.hs` file as shake can get confused if rules change without it noticing.
76 |
77 | ## Example
78 |
79 | Here's a FULL static website! This is the [`Main.hs`](app/Main.hs) for this template repo.
80 |
81 | ```haskell
82 | {-# LANGUAGE DeriveGeneric #-}
83 | {-# LANGUAGE DeriveAnyClass #-}
84 | {-# LANGUAGE OverloadedStrings #-}
85 |
86 | module Main where
87 |
88 | import Control.Lens
89 | import Control.Monad
90 | import Data.Aeson as A
91 | import Data.Aeson.Lens
92 | import Development.Shake
93 | import Development.Shake.Classes (Binary)
94 | import Development.Shake.Forward
95 | import Development.Shake.FilePath
96 | import GHC.Generics (Generic)
97 | import Slick
98 |
99 | import qualified Data.Text as T
100 |
101 |
102 | outputFolder :: FilePath
103 | outputFolder = "docs/"
104 |
105 | -- | Data for the index page
106 | data IndexInfo =
107 | IndexInfo
108 | { posts :: [Post]
109 | } deriving (Generic, Show, FromJSON, ToJSON)
110 |
111 | -- | Data for a blog post
112 | data Post =
113 | Post { title :: String
114 | , author :: String
115 | , content :: String
116 | , url :: String
117 | , date :: String
118 | , image :: Maybe String
119 | }
120 | deriving (Generic, Eq, Ord, Show, FromJSON, ToJSON, Binary)
121 |
122 | -- | given a list of posts this will build a table of contents
123 | buildIndex :: [Post] -> Action ()
124 | buildIndex posts' = do
125 | indexT <- compileTemplate' "site/templates/index.html"
126 | let indexInfo = IndexInfo {posts = posts'}
127 | indexHTML = T.unpack $ substitute indexT (toJSON indexInfo)
128 | writeFile' (outputFolder > "index.html") indexHTML
129 |
130 | -- | Find and build all posts
131 | buildPosts :: Action [Post]
132 | buildPosts = do
133 | pPaths <- getDirectoryFiles "." ["site/posts//*.md"]
134 | forP pPaths buildPost
135 |
136 | -- | Load a post, process metadata, write it to output, then return the post object
137 | -- Detects changes to either post content or template
138 | buildPost :: FilePath -> Action Post
139 | buildPost srcPath = cacheAction ("build" :: T.Text, srcPath) $ do
140 | liftIO . putStrLn $ "Rebuilding post: " <> srcPath
141 | postContent <- readFile' srcPath
142 | -- load post content and metadata as JSON blob
143 | postData <- markdownToHTML . T.pack $ postContent
144 | let postUrl = T.pack . dropDirectory1 $ srcPath -<.> "html"
145 | withPostUrl = _Object . at "url" ?~ String postUrl
146 | -- Add additional metadata we've been able to compute
147 | let fullPostData = withPostUrl $ postData
148 | template <- compileTemplate' "site/templates/post.html"
149 | writeFile' (outputFolder > T.unpack postUrl) . T.unpack $ substitute template fullPostData
150 | -- Convert the metadata into a Post object
151 | convert fullPostData
152 |
153 | -- | Copy all static files from the listed folders to their destination
154 | copyStaticFiles :: Action ()
155 | copyStaticFiles = do
156 | filepaths <- getDirectoryFiles "./site/" ["images//*", "css//*", "js//*"]
157 | void $ forP filepaths $ \filepath ->
158 | copyFileChanged ("site" > filepath) (outputFolder > filepath)
159 |
160 | -- | Specific build rules for the Shake system
161 | -- defines workflow to build the website
162 | buildRules :: Action ()
163 | buildRules = do
164 | allPosts <- buildPosts
165 | buildIndex allPosts
166 | copyStaticFiles
167 |
168 | main :: IO ()
169 | main = slick buildRules
170 | ```
171 |
--------------------------------------------------------------------------------
/Setup.hs:
--------------------------------------------------------------------------------
1 | import Distribution.Simple
2 | main = defaultMain
3 |
--------------------------------------------------------------------------------
/app/Main.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE DeriveGeneric #-}
2 | {-# LANGUAGE DeriveAnyClass #-}
3 | {-# LANGUAGE OverloadedStrings #-}
4 | {-# LANGUAGE DuplicateRecordFields #-}
5 |
6 | module Main where
7 |
8 | import Control.Lens ((?~), at)
9 | import Control.Monad (void)
10 | import Data.Aeson (FromJSON, ToJSON, Value (Object, String), toJSON)
11 | import Data.Aeson.KeyMap (union)
12 | import Data.Aeson.Lens (_Object)
13 | import qualified Data.Text as T
14 | import Data.Time
15 | ( UTCTime, defaultTimeLocale, formatTime, getCurrentTime
16 | , iso8601DateFormat, parseTimeOrError)
17 | import Development.Shake
18 | ( Action, Verbosity(Verbose), copyFileChanged, forP, getDirectoryFiles
19 | , liftIO, readFile', shakeLintInside, shakeOptions, shakeVerbosity
20 | , writeFile')
21 | import Development.Shake.Classes (Binary)
22 | import Development.Shake.FilePath ((>), (-<.>), dropDirectory1)
23 | import Development.Shake.Forward (cacheAction, shakeArgsForward)
24 | import GHC.Generics (Generic)
25 | import Slick (compileTemplate', convert, markdownToHTML, substitute)
26 |
27 |
28 | ---Config-----------------------------------------------------------------------
29 |
30 | siteMeta :: SiteMeta
31 | siteMeta =
32 | SiteMeta { siteAuthor = "Me"
33 | , baseUrl = "https://example.com"
34 | , siteTitle = "My Slick Site"
35 | , twitterHandle = Just "myslickhandle"
36 | , githubUser = Just "myslickgithubuser"
37 | }
38 |
39 | outputFolder :: FilePath
40 | outputFolder = "docs/"
41 |
42 | --Data models-------------------------------------------------------------------
43 |
44 | withSiteMeta :: Value -> Value
45 | withSiteMeta (Object obj) = Object $ union obj siteMetaObj
46 | where
47 | Object siteMetaObj = toJSON siteMeta
48 | withSiteMeta _ = error "only add site meta to objects"
49 |
50 | data SiteMeta =
51 | SiteMeta { siteAuthor :: String
52 | , baseUrl :: String -- e.g. https://example.ca
53 | , siteTitle :: String
54 | , twitterHandle :: Maybe String -- Without @
55 | , githubUser :: Maybe String
56 | }
57 | deriving (Generic, Eq, Ord, Show, ToJSON)
58 |
59 | -- | Data for the index page
60 | data IndexInfo =
61 | IndexInfo
62 | { posts :: [Post]
63 | } deriving (Generic, Show, FromJSON, ToJSON)
64 |
65 | type Tag = String
66 |
67 | -- | Data for a blog post
68 | data Post =
69 | Post { title :: String
70 | , author :: String
71 | , content :: String
72 | , url :: String
73 | , date :: String
74 | , tags :: [Tag]
75 | , description :: String
76 | , image :: Maybe String
77 | }
78 | deriving (Generic, Eq, Ord, Show, FromJSON, ToJSON, Binary)
79 |
80 | data AtomData =
81 | AtomData { title :: String
82 | , domain :: String
83 | , author :: String
84 | , posts :: [Post]
85 | , currentTime :: String
86 | , atomUrl :: String } deriving (Generic, ToJSON, Eq, Ord, Show)
87 |
88 | -- | given a list of posts this will build a table of contents
89 | buildIndex :: [Post] -> Action ()
90 | buildIndex posts' = do
91 | indexT <- compileTemplate' "site/templates/index.html"
92 | let indexInfo = IndexInfo {posts = posts'}
93 | indexHTML = T.unpack $ substitute indexT (withSiteMeta $ toJSON indexInfo)
94 | writeFile' (outputFolder > "index.html") indexHTML
95 |
96 | -- | Find and build all posts
97 | buildPosts :: Action [Post]
98 | buildPosts = do
99 | pPaths <- getDirectoryFiles "." ["site/posts//*.md"]
100 | forP pPaths buildPost
101 |
102 | -- | Load a post, process metadata, write it to output, then return the post object
103 | -- Detects changes to either post content or template
104 | buildPost :: FilePath -> Action Post
105 | buildPost srcPath = cacheAction ("build" :: T.Text, srcPath) $ do
106 | liftIO . putStrLn $ "Rebuilding post: " <> srcPath
107 | postContent <- readFile' srcPath
108 | -- load post content and metadata as JSON blob
109 | postData <- markdownToHTML . T.pack $ postContent
110 | let postUrl = T.pack . dropDirectory1 $ srcPath -<.> "html"
111 | withPostUrl = _Object . at "url" ?~ String postUrl
112 | -- Add additional metadata we've been able to compute
113 | let fullPostData = withSiteMeta . withPostUrl $ postData
114 | template <- compileTemplate' "site/templates/post.html"
115 | writeFile' (outputFolder > T.unpack postUrl) . T.unpack $ substitute template fullPostData
116 | convert fullPostData
117 |
118 | -- | Copy all static files from the listed folders to their destination
119 | copyStaticFiles :: Action ()
120 | copyStaticFiles = do
121 | filepaths <- getDirectoryFiles "./site/" ["images//*", "css//*", "js//*"]
122 | void $ forP filepaths $ \filepath ->
123 | copyFileChanged ("site" > filepath) (outputFolder > filepath)
124 |
125 | formatDate :: String -> String
126 | formatDate humanDate = toIsoDate parsedTime
127 | where
128 | parsedTime =
129 | parseTimeOrError True defaultTimeLocale "%b %e, %Y" humanDate :: UTCTime
130 |
131 | rfc3339 :: Maybe String
132 | rfc3339 = Just "%H:%M:%SZ"
133 |
134 | toIsoDate :: UTCTime -> String
135 | toIsoDate = formatTime defaultTimeLocale (iso8601DateFormat rfc3339)
136 |
137 | buildFeed :: [Post] -> Action ()
138 | buildFeed posts' = do
139 | now <- liftIO getCurrentTime
140 | let atomData =
141 | AtomData
142 | { title = siteTitle siteMeta
143 | , domain = baseUrl siteMeta
144 | , author = siteAuthor siteMeta
145 | , posts = mkAtomPost <$> posts'
146 | , currentTime = toIsoDate now
147 | , atomUrl = "/atom.xml"
148 | }
149 | atomTempl <- compileTemplate' "site/templates/atom.xml"
150 | writeFile' (outputFolder > "atom.xml") . T.unpack $ substitute atomTempl (toJSON atomData)
151 | where
152 | mkAtomPost :: Post -> Post
153 | mkAtomPost p = p { date = formatDate $ date p }
154 |
155 | -- | Specific build rules for the Shake system
156 | -- defines workflow to build the website
157 | buildRules :: Action ()
158 | buildRules = do
159 | allPosts <- buildPosts
160 | buildIndex allPosts
161 | buildFeed allPosts
162 | copyStaticFiles
163 |
164 | main :: IO ()
165 | main = do
166 | let shOpts = shakeOptions { shakeVerbosity = Verbose, shakeLintInside = ["\\"]}
167 | shakeArgsForward shOpts buildRules
168 |
--------------------------------------------------------------------------------
/docs/atom.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | <site-title-here>
4 |
5 | 2020-04-08T20:00:SZ
6 |
7 | <author-name-here>
8 |
9 | https://<your-domain-here>/
10 |
11 |
12 | Sample Post
13 |
14 | https://<your-domain-here>posts/sample-post.html
15 | 2019-01-01T00:00:SZ
16 |
17 |
18 | My first blog post using slick
19 | Welcome to your first blog post!