├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── API.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bb.edn ├── deps.edn ├── quickdoc.edn ├── resources └── quickblog │ ├── assets │ └── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ └── templates │ ├── archive.html │ ├── base.html │ ├── favicon.html │ ├── index.html │ ├── post-links.html │ ├── post.html │ ├── style.css │ └── tags.html ├── script └── changelog.clj ├── src └── quickblog │ ├── api.clj │ ├── cli.clj │ └── internal.clj └── test └── quickblog ├── api_test.clj └── test_runner.clj /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Please answer the following questions and leave the below in as part of your PR. 2 | 3 | - [ ] This PR corresponds to an [issue with a clear problem statement](https://github.com/babashka/babashka/blob/master/doc/dev.md#start-with-an-issue-before-writing-code). 4 | 5 | - [ ] I have updated the [CHANGELOG.md](https://github.com/babashka/babashka/blob/master/CHANGELOG.md) file with a description of the addressed issue. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | test: 8 | 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, windows-latest, macos-latest] 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | 19 | # It is important to install java before installing clojure tools which needs java 20 | # exclusions: babashka, clj-kondo and cljstyle 21 | - name: Prepare java 22 | uses: actions/setup-java@v3 23 | with: 24 | distribution: 'zulu' 25 | java-version: '21' 26 | 27 | - name: Install clojure tools 28 | uses: DeLaGuardo/setup-clojure@10.1 29 | with: 30 | # Install just one or all simultaneously 31 | # The value must indicate a particular version of the tool, or use 'latest' 32 | # to always provision the latest version 33 | bb: latest 34 | 35 | # Optional step: 36 | - name: Cache clojure dependencies 37 | uses: actions/cache@v3 38 | with: 39 | path: | 40 | ~/.m2/repository 41 | ~/.gitlibs 42 | ~/.deps.clj 43 | # List all files containing dependencies: 44 | key: cljdeps-${{ hashFiles('deps.edn') }} 45 | # key: cljdeps-${{ hashFiles('deps.edn', 'bb.edn') }} 46 | # key: cljdeps-${{ hashFiles('project.clj') }} 47 | # key: cljdeps-${{ hashFiles('build.boot') }} 48 | restore-keys: cljdeps- 49 | 50 | - name: Run tests 51 | run: bb test 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .DS_Store 3 | .sass-cache 4 | .gist-cache 5 | .pygments-cache 6 | _deploy 7 | public 8 | sass.old 9 | source.old 10 | source/_stash 11 | source/stylesheets/screen.css 12 | vendor 13 | node_modules 14 | Gemfile.lock 15 | /.cache 16 | /.work 17 | /.test 18 | .clj-kondo/rewrite-clj 19 | .cpcache 20 | .shadow-cljs 21 | report.html 22 | classes 23 | /assets/ 24 | /templates/ 25 | /posts/ 26 | .cache 27 | .clj-kondo 28 | .nrepl-port 29 | .direnv 30 | .envrc 31 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # quickblog.api 2 | 3 | 4 | 5 | 6 | 7 | ## `clean` 8 | ``` clojure 9 | 10 | (clean opts) 11 | ``` 12 | 13 | 14 | Removes cache and output directories 15 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L493-L499) 16 | ## `migrate` 17 | ``` clojure 18 | 19 | (migrate opts) 20 | ``` 21 | 22 | 23 | Migrates from `posts.edn` to post-local metadata 24 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L501-L512) 25 | ## `new` 26 | ``` clojure 27 | 28 | (new opts) 29 | ``` 30 | 31 | 32 | Creates new `file` in posts dir. 33 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L466-L491) 34 | ## `quickblog` 35 | ``` clojure 36 | 37 | (quickblog opts) 38 | ``` 39 | 40 | 41 | Alias for `render` 42 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L457-L460) 43 | ## `refresh-templates` 44 | ``` clojure 45 | 46 | (refresh-templates opts) 47 | ``` 48 | 49 | 50 | Updates to latest default templates 51 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L514-L517) 52 | ## `render` 53 | ``` clojure 54 | 55 | (render opts) 56 | ``` 57 | 58 | 59 | Renders posts declared in `posts.edn` to `out-dir`. 60 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L421-L455) 61 | ## `serve` 62 | ``` clojure 63 | 64 | (serve opts) 65 | ``` 66 | 67 | 68 | Runs file-server on `port`. 69 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L519-L532) 70 | ## `watch` 71 | ``` clojure 72 | 73 | (watch opts) 74 | ``` 75 | 76 | 77 | Watches posts, templates, and assets for changes. Runs file server using 78 | `serve`. 79 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/api.clj#L536-L601) 80 | # quickblog.cli 81 | 82 | 83 | 84 | 85 | 86 | ## `-main` 87 | ``` clojure 88 | 89 | (-main & args) 90 | ``` 91 | 92 | [source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/cli.clj#L143-L144) 93 | ## `dispatch` 94 | ``` clojure 95 | 96 | (dispatch) 97 | (dispatch default-opts & args) 98 | ``` 99 | 100 | [source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/cli.clj#L127-L133) 101 | ## `run` 102 | ``` clojure 103 | 104 | (run default-opts) 105 | ``` 106 | 107 | 108 | Meant to be called using `clj -M:quickblog`; see Quickstart > Clojure in README 109 |
[source](https://github.com/borkdude/quickblog/blob/main/src/quickblog/cli.clj#L135-L141) 110 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | [Quickblog](https://github.com/borkdude/quickblog): light-weight static blog engine for Clojure and babashka 4 | 5 | Instances of quickblog can be seen here: 6 | 7 | - [Michiel Borkent's blog](https://blog.michielborkent.nl) 8 | - [Josh Glover's blog](https://jmglov.net/blog) 9 | - [Jeremy Taylor's blog](https://jdt.me/strange-reflections.html) 10 | - [JP Monetta's blog](https://jpmonettas.github.io/my-blog/public/) 11 | - [Luc Engelen's blog](https://blog.cofx.nl/) - ([source](https://github.com/cofx22/blog)) 12 | - [Rattlin.blog](https://rattlin.blog/) 13 | - [REP‘ti’L‘e’](https://kuna.us/) 14 | - [Søren Sjørup's blog](https://zoren.dk) 15 | - [Henry Widd's blog](https://widdindustries.com/blog) 16 | - [Anders means different](https://www.eknert.com/blog) - ([source](https://github.com/anderseknert/blog)) 17 | 18 | ## Unreleased 19 | 20 | - Link to previous and next posts; see "Linking to previous and next posts" in 21 | the README ([@jmglov](https://github.com/jmglov)) 22 | - Fix flaky caching tests ([@jmglov](https://github.com/jmglov)) 23 | - Fix argument passing in test runner ([@jmglov](https://github.com/jmglov)) 24 | - Add `--date` to api/new. ([@jmglov](https://github.com/jmglov)) 25 | - Support Selmer template for new posts in api/new; see [Templates > New 26 | posts](README.md#new-posts) in README. ([@jmglov](https://github.com/jmglov)) 27 | - Add 'language-xxx' to pre/code blocks 28 | - Fix README.md with working version in quickstart example 29 | - Fix [#104](https://github.com/borkdude/quickblog/issues/104): fix caching with respect to previews 30 | - Fix [#104](https://github.com/borkdude/quickblog/issues/104): document `:preview` option 31 | 32 | ## 0.3.6 (2023-12-31) 33 | 34 | - Fix caching (this is hard) 35 | 36 | ## 0.3.5 (2023-12-31) 37 | 38 | - Better caching when switching between watch and render 39 | 40 | ## 0.3.4 (2023-12-31) 41 | 42 | - Fix caching in watch mode 43 | 44 | ## 0.3.3 (2023-12-27) 45 | 46 | - [#86](https://github.com/borkdude/quickblog/issues/86): group archive page by year 47 | - [#85](https://github.com/borkdude/quickblog/issues/85): don't render discuss links when `:discuss-link` isn't set 48 | - [#84](https://github.com/borkdude/quickblog/issues/84): sort tags by post count 49 | - [#80](https://github.com/borkdude/quickblog/issues/80): Generate an `about.html` when a template exists ([@elken](https://github.com/elken)) 50 | - [#78](https://github.com/borkdude/quickblog/issues/78): Allow configurable :page-suffix to omit `.html` from page links ([@anderseknert](https://github.com/anderseknert)) 51 | - [#76](https://github.com/borkdude/quickblog/pull/76): Remove livejs script tag 52 | on render. ([@jmglov](https://github.com/jmglov)) 53 | - [#75](https://github.com/borkdude/quickblog/pull/75): Omit preview posts from 54 | index. ([@jmglov](https://github.com/jmglov)) 55 | - Support capitalization of tags 56 | - [#66](https://github.com/borkdude/quickblog/issues/66): Unambigous ordering of posts, sorting by date (descending), post title, and then file name. ([@UnwarySage](https://github.com/UnwarySage)) 57 | 58 | ## 0.2.3 (2023-01-30) 59 | 60 | - Improve visualization on mobile screens ([@MatKurianski](https://github.com/MatKurianski)) 61 | - [#51](https://github.com/borkdude/quickblog/issues/51): Enable custom default tags or no tags ([@formsandlines](https://github.com/formsandlines)) 62 | - Enable use of metadata in templates ([@ljpengelen](https://github.com/ljpengelen)) 63 | - Replace workaround that copies metadata from `api/serve` 64 | - [#61](https://github.com/borkdude/quickblog/issues/61): Add templates that allow control over layout and styling of index page, pages with tags, and archive ([@ljpengelen](https://github.com/ljpengelen)) 65 | - Preserve HTML comments ([@ljpengelen](https://github.com/ljpengelen)) 66 | - Support showing previews of posts on index page 67 | 68 | ## 0.1.0 (2022-12-11) 69 | 70 | - Add command line interface 71 | - Watch assets dir for changes in watch mode 72 | - Added `refresh-templates` task to update to latest templates 73 | - Social sharing (preview for Facebook, Twitter, LinkedIn, etc.) 74 | - Move metadata to post files and improve caching 75 | - Favicon support 76 | 77 | Thanks to Josh Glover ([@jmglov](https://github.com/jmglov)) for heavily contributing in this release! 78 | 79 | ## 0.0.1 (2022-07-17) 80 | 81 | - Initial version 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 Michiel Borkent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quickblog 2 | 3 | The blog code powering my [blog](https://blog.michielborkent.nl/). 4 | 5 | See [API.md](API.md) on how to use this. 6 | 7 | Compatible with [babashka](#babashka) and [Clojure](#clojure). 8 | 9 | Includes hot-reload. See it in action [here](https://twitter.com/borkdude/status/1547912740156583936). 10 | 11 | ## Blogs using quickblog 12 | 13 | Instances of quickblog can be seen here: 14 | 15 | - [Michiel Borkent's blog](https://blog.michielborkent.nl) 16 | - [Josh Glover's blog](https://jmglov.net/blog) 17 | - [Jeremy Taylor's blog](https://jdt.me/strange-reflections.html) 18 | - [JP Monetta's blog](https://jpmonettas.github.io/my-blog/public/) 19 | - [Luc Engelen's blog](https://blog.cofx.nl/) - ([source](https://github.com/cofx22/blog)) 20 | - [Rattlin.blog](https://rattlin.blog/) 21 | - [REP‘ti’L‘e’](https://kuna.us/) 22 | - [Søren Sjørup's blog](https://zoren.dk) 23 | - [Henry Widd's blog](https://widdindustries.com/blog) 24 | - [Anders means different](https://www.eknert.com/blog) - ([source](https://github.com/anderseknert/blog)) 25 | - [Kira McLean's programming blog](https://codewithkira.com) - ([source](https://github.com/kiramclean/kiramclean.github.io)) 26 | - [Ed Porras' blog](https://digressed.net) 27 | - [Saket Patel](https://blog.saketpatel.me/) 28 | - [Paul Butcher's blog](https://paulbutcher.com/) 29 | - [Alex Sheluchin's blog](https://fnguy.com/) - ([source](https://github.com/sheluchin/blog)) 30 | 31 | ### Articles about quickblog 32 | 33 | - [Quickblog by Anders Means Different](https://www.eknert.com/blog/quickblog) 34 | 35 | Feel free to PR yours. 36 | 37 | ## Quickstart 38 | 39 | ### Babashka 40 | 41 | quickblog is meant to be used as a library from your Babashka project. The 42 | easiest way to use it is to add a task to your project's `bb.edn`. 43 | 44 | This example assumes a basic `bb.edn` like this: 45 | 46 | ``` clojure 47 | {:deps {io.github.borkdude/quickblog 48 | #_"You use the newest SHA here:" 49 | {:git/sha "3a1d6aff07f692f6e62606317f3d9e981b1df702"}} 50 | :tasks 51 | {:requires ([quickblog.cli :as cli]) 52 | :init (def opts {:blog-title "REPL adventures" 53 | :blog-description "A blog about blogging quickly"}) 54 | quickblog {:doc "Start blogging quickly! Run `bb quickblog help` for details." 55 | :task (cli/dispatch opts)}}} 56 | ``` 57 | 58 | To create a new blog post: 59 | 60 | ``` clojure 61 | $ bb quickblog new --file "test.md" --title "Test" 62 | ``` 63 | 64 | To start an HTTP server and re-render on changes to files: 65 | 66 | ``` 67 | $ bb quickblog watch 68 | ``` 69 | 70 | ### Clojure 71 | 72 | Quickblog can be used in Clojure with the exact same API as the bb tasks. 73 | Default options can be configured in `:exec-args`. 74 | 75 | ``` clojure 76 | :quickblog 77 | {:deps {io.github.borkdude/quickblog 78 | #_"You use the newest SHA here:" 79 | {:git/sha "3a1d6aff07f692f6e62606317f3d9e981b1df702"} 80 | org.babashka/cli {:mvn/version "0.3.35"}} 81 | :main-opts ["-m" "babashka.cli.exec" "quickblog.cli" "run"] 82 | :exec-args {:blog-title "REPL adventures" 83 | :blog-description "A blog about blogging quickly"}} 84 | ``` 85 | 86 | After configuring this, you can call: 87 | 88 | ``` 89 | $ clj -M:quickblog new --file "test.md" --title "Test" 90 | ``` 91 | 92 | To watch: 93 | 94 | ``` 95 | $ clj -M:quickblog watch 96 | ``` 97 | 98 | etc. 99 | 100 | ## Features 101 | 102 | ### Markdown 103 | 104 | Posts are written in Markdown and processed by 105 | [markdown-clj](https://github.com/yogthos/markdown-clj), which implements the 106 | [MultiMarkdown](https://github.com/fletcher/MultiMarkdown/wiki/MultiMarkdown-Syntax-Guide) 107 | flavour of Markdown. 108 | 109 | ### Metadata 110 | 111 | Post metadata is specified in the post file using [MultiMarkdown's metadata 112 | tags](https://github.com/fletcher/MultiMarkdown/wiki/MultiMarkdown-Syntax-Guide#metadata). 113 | quickblog expects three pieces of metadata in each post: 114 | - `Title` - the title of the post 115 | - `Date` - the date when the post will be published (used for sorting posts, so 116 | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) datetimes are recommended) 117 | - `Tags` - a comma-separated list of tags 118 | - `Preview`: when `true`, the post won't be listed in the 119 | archive or tags page, but is still accessible via the direct link. 120 | 121 | `quickblog new` requires the title to be specified and provides sensible 122 | defaults for `Date` and `Tags`. 123 | 124 | You can add any metadata fields to posts that you want. See the [Social 125 | sharing](#social-sharing) section below for some useful suggestions. 126 | 127 | **Note: metadata may not include newlines!** 128 | 129 | ### favicon 130 | 131 | **NOTE:** when enabling or disabling a favicon, you must do a full re-render of 132 | your site by running `bb quickblog clean` and then your `bb quickblog render` 133 | command. 134 | 135 | To enable a [favicon](https://en.wikipedia.org/wiki/Favicon), add `:favicon 136 | true` to your quickblog opts (or use `--favicon true` on the command line). 137 | quickblog will render the contents of `templates/favicon.html` and insert them 138 | in the head of your pages. 139 | 140 | You will also need to create the favicon assets themselves. The easiest way is 141 | to use a favicon generator such as 142 | [RealFaviconGenerator](https://realfavicongenerator.net/), which will let you 143 | upload an image and then gives you a ZIP file containing all of the assets, 144 | which you should unzip into your `:assets-dir` (which defaults to `assets`). 145 | 146 | You can read an example of how to prepare a favicon here: 147 | https://jmglov.net/blog/2022-07-05-hacking-blog-favicon.html 148 | 149 | quickblog's default template expects the favicon files to be named as follows: 150 | - `android-chrome-192x192.png` 151 | - `android-chrome-512x512.png` 152 | - `apple-touch-icon.png` 153 | - `browserconfig.xml` 154 | - `favicon-16x16.png` 155 | - `favicon-32x32.png` 156 | - `favicon.ico` 157 | - `mstile-150x150.png` 158 | - `safari-pinned-tab.svg` 159 | - `site.webmanifest` 160 | 161 | If any of these files are not present in your `:assets-dir`, a quickblog default 162 | will be copied there from `resources/quickblog/assets`. 163 | 164 | ### Social sharing 165 | 166 | Social media sites such as Facebook, Twitter, LinkedIn, etc. display neat little 167 | preview cards when you share a link. These cards are populated from certain 168 | `` tags (as described in "[How to add a social media share card to any 169 | website](https://dev.to/mishmanners/how-to-add-a-social-media-share-card-to-any-website-ha8)", 170 | by Michelle Mannering) and typically contain a title, description / summary, and 171 | preview image. 172 | 173 | quickblog's [base 174 | template](https://github.com/borkdude/quickblog/blob/389833f393e04d4176ef3eaa5047fa307a5ff2e8/resources/quickblog/templates/base.html) 175 | adds meta tags for the page title for all pages and descriptions for the 176 | following pages: 177 | - Index: `{{blog-description}}` 178 | - Archive: Archive - `{{blog-description}}` 179 | - Tags: Tags - `{{blog-description}}` 180 | - Tag pages: Posts tagged "`{{tag}}`" - `{{blog-description}}` 181 | 182 | If you specify a `:blog-image URL` option, a preview image will be added to the 183 | index, archive, tags, and tag pages. The URL should point to an image; for best 184 | results, the image should be 1200x630 and maximum 5MB in size. It may either be 185 | an absolute URL or a URL relative to `:blog-root`. 186 | 187 | For post pages, meta tags will be populated from `Title`, `Description`, 188 | `Image`, `Image-Alt`, and `Twitter-Handle` metadata in the document. 189 | 190 | If not specified, `Twitter-Handle` defaults to the `:twitter-handle` option to 191 | quickblog. The idea is that the `:twitter-handle` option is the Twitter handle 192 | of the person owning the blog, who is likely also the author of most posts on 193 | the blog. If there's a guest post, however, the guest blogger can add their 194 | Twitter handle instead. 195 | 196 | For example, a post could look like this: 197 | 198 | ``` text 199 | Title: Sharing is caring 200 | Date: 2022-08-16 201 | Tags: demo 202 | Image: assets/2022-08-16-sharing-preview.png 203 | Image-Alt: A leather-bound notebook lies open on a writing desk 204 | Twitter-Handle: quickblog 205 | Description: quickblog now creates nifty social media sharing cards / previews. Read all about how this works and how you can maximise engagement with your posts! 206 | 207 | You may have already heard the good news: quickblog is more social than ever! 208 | ... 209 | ``` 210 | 211 | The value of the `Image` field is either an absolute URL or a URL relative to 212 | `:blog-root`. As noted above, images should be 1200x630 and maximum 5MB in size 213 | for best results. 214 | 215 | `Image-Alt` provides alt text for the preview image, which is extremely 216 | important for making pages accessible to people using screen readers. I highly 217 | recommend reading resources like "[Write good Alt Text to describe 218 | images](https://accessibility.huit.harvard.edu/describe-content-images)" to 219 | learn more. 220 | 221 | Resources for understanding and testing social sharing: 222 | - [Meta Tags debugger](https://metatags.io/) 223 | - [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) 224 | - [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) 225 | - [Twitter Card Validator](https://cards-dev.twitter.com/validator) 226 | 227 | ### Linking to previous and next posts 228 | 229 | If you set the `:link-prev-next-posts` option to `true`, quickblog adds `prev` 230 | and `next` metadata to each post (where `prev` is the previous post and `next` 231 | is the next post in date order, oldest to newest). You can make use of these by 232 | adding something similar to this to your `post.html` template: 233 | 234 | ``` html 235 | {% if any prev next %} 236 |
237 | {% if prev %} 238 |
{{prev.title}}
239 | {% endif %} 240 | {% if next %} 241 |
{{next.title}}
242 | {% endif %} 243 |
244 | {% endif %} 245 | ``` 246 | 247 | ## Templates 248 | 249 | quickblog uses the following templates in site generation: 250 | - `base.html` - All pages. Page body is provided by the `{{body}}` variable. 251 | - `post.html` - Post bodies. 252 | - `style.css` - Styles for all pages. 253 | - `favicon.html` - If `:favicon true`, used to include favicon in the `` 254 | of all pages. 255 | - `tags.html` - Tag overview page. 256 | - `post-links.html` - Used to render lists of blog posts in the archive and 257 | each page corresponding to a single tag. 258 | - `index.html` - Index page. Posts containing the marker comment 259 | `` are included on the index page up until the first 260 | occurrence of that comment. 261 | 262 | quickblog looks for these templates in your `:templates-dir`, and if it doesn't 263 | find them, will copy a default template into that directory. It is recommended 264 | to keep `:templates-dir` under revision control so that you can modify the 265 | templates to suit your needs and preferences. 266 | 267 | The default templates are occasionally modified to support new features. When 268 | this happens, you won't be able to use the new feature without making the same 269 | modifications to your local templates. The easiest way to do this is to run `bb 270 | quickblog refresh-templates`. 271 | 272 | ### New posts 273 | 274 | In addition to the HTML templates above, you can also use a template for 275 | generating new posts. Assuming you have a template `new-post.md` that looks like 276 | this: 277 | 278 | ``` markdown 279 | Title: {{title}} 280 | Date: {{date}} 281 | Tags: {{tags|join:\",\"}} 282 | Image: {% if image %}{{image}}{% else %}{{assets-dir}}/{{file|replace:.md:}}-preview.png{% endif %} 283 | Image-Alt: {{image-alt|default:FIXME}} 284 | Discuss: {{discuss|default:FIXME}} 285 | {% if preview %}Preview: true\n{% endif %} 286 | Write a blog post here! 287 | ``` 288 | 289 | you can generate a new post like this: 290 | 291 | ``` text 292 | $ bb quickblog new --file "test.md" --title "Test" --preview --template-file new-post.md 293 | ``` 294 | 295 | And the resulting `posts/test.md` will look like this: 296 | 297 | ``` markdown 298 | Title: Test 299 | Date: 2024-01-19 300 | Tags: clojure 301 | Image: assets/test-preview.png 302 | Image-Alt: FIXME 303 | Discuss: FIXME 304 | Preview: true 305 | 306 | Write a blog post here! 307 | ``` 308 | 309 | **It is not recommended to keep your new post template in your templates-dir, as 310 | any changes to the new post template will cause all of your existing posts to be 311 | re-rendered, which is probably not what you want!** 312 | 313 | ## Breaking changes 314 | 315 | ### posts.edn removed 316 | 317 | quickblog now keeps metadata for each blog post in the post file itself. It used 318 | to use a `posts.edn` file for this purpose. If you are upgrading from a version 319 | that used `posts.edn`, you should run `bb quickblog migrate` and then remove the 320 | `posts.edn` file. 321 | 322 | ## Improvements 323 | 324 | Feel free to send PRs for improvements. 325 | 326 | My wishlist: 327 | 328 | - There might be a few things hardcoded that still need to be made configurable. 329 | - Upstream improvements to [markdown-clj](https://github.com/yogthos/markdown-clj) 330 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:deps {io.github.borkdude/quickblog {:local/root "."}} 2 | :paths ["."] 3 | 4 | :bbin/bin {quickblog {:ns-default quickblog.api}} 5 | 6 | :tasks 7 | {:requires ([babashka.fs :as fs] 8 | [quickblog.cli :as cli]) 9 | :init (do 10 | (def opts {:blog-title "REPL adventures" 11 | :blog-description "A blog about blogging quickly" 12 | :about-link "https://github.com/borkdude/quickblog" 13 | :twitter-handle "quickblog"}) 14 | (defn- run-command [cmd-name opts] 15 | (apply cli/dispatch opts cmd-name *command-line-args*))) 16 | 17 | quickblog {:doc "Start blogging quickly! Run `bb quickblog help` for details." 18 | :task (cli/dispatch opts)} 19 | 20 | new {:doc "Create new blog article" 21 | :task (run-command "new" opts)} 22 | 23 | migrate {:doc "Migrate away from posts.edn to metadata in post files" 24 | :task (run-command "migrate" opts)} 25 | 26 | refresh-templates 27 | {:doc "Update to latest templates. NOTE: this is a destructive operation, as it will overwrite any templates you have in your `:templates-dir`. You should ensure that your templates are backed up or under revision control before running this command!" 28 | :task (run-command "refresh-templates" opts)} 29 | 30 | render {:doc "Render blog" 31 | :task (run-command "render" opts)} 32 | 33 | watch {:doc "Watch posts and templates and render file changes" 34 | :task (run-command "watch" opts)} 35 | 36 | clean {:doc "Remove cache and output directories" 37 | :task (run-command "clean" opts)} 38 | 39 | publish {:doc "Publish blog" 40 | :depends [render] 41 | :task (shell "rsync -a --delete public/ user@yourdomain:~/blog")} 42 | 43 | test {:doc "Run tests" 44 | :task (apply clojure "-M:test" *command-line-args*)} 45 | 46 | quickdoc {:doc "Re-generate API.md" 47 | :task (shell "bb --config quickdoc.edn quickdoc")} 48 | }} 49 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {rewrite-clj/rewrite-clj {:mvn/version "1.1.45"} 3 | babashka/fs {:mvn/version "0.1.6"} 4 | org.clojure/clojure {:mvn/version "1.11.1"} 5 | org.clojure/data.xml {:mvn/version "0.2.0-alpha6"} 6 | selmer/selmer {:mvn/version "1.12.53"} 7 | markdown-clj/markdown-clj {:mvn/version "1.12.2"} 8 | org.babashka/cli {:mvn/version "0.6.50"} 9 | org.babashka/http-server {:mvn/version "0.1.11"} 10 | babashka/babashka.pods {:git/url "https://github.com/babashka/pods" 11 | :git/sha "93081b75e66fb4c4d161f89e714c6b9e8d55c8d5"}} 12 | :aliases 13 | {:quickblog 14 | {:deps {io.github.borkdude/quickblog 15 | {:local/root "."} 16 | org.babashka/cli {:mvn/version "0.6.41"}} 17 | :main-opts ["-m" "babashka.cli.exec" "quickblog.cli" "run"] 18 | :exec-args {:blog-title "REPL adventures" 19 | :blog-description "A blog about blogging quickly" 20 | :about-link "https://github.com/borkdude/quickblog" 21 | :twitter-handle "quickblog"}} 22 | :test 23 | {:extra-paths ["test"] 24 | :extra-deps {io.github.cognitect-labs/test-runner 25 | {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 26 | :exec-args {:cmd "bb test"} 27 | :main-opts ["-m" "babashka.cli.exec"] 28 | :exec-fn quickblog.test-runner/test} 29 | 30 | :clj-1.9 {:extra-deps {org.clojure/clojure {:mvn/version "1.9.0"}}} 31 | :clj-1.10 {:extra-deps {org.clojure/clojure {:mvn/version "1.10.3"}}} 32 | :clj-1.11 {:extra-deps {org.clojure/clojure {:mvn/version "1.11.1"}}}}} 33 | -------------------------------------------------------------------------------- /quickdoc.edn: -------------------------------------------------------------------------------- 1 | {:pods {clj-kondo/clj-kondo {:version "2022.05.31"}} 2 | :deps {io.github.borkdude/quickdoc {:git/sha "a8068f1c8b13e09a2966804213fc41dd813de18e"}} 3 | 4 | :tasks 5 | {quickdoc {:doc "Invoke quickdoc" 6 | :requires ([quickdoc.api :as api]) 7 | :task (api/quickdoc {:git/branch "main" 8 | :github/repo "https://github.com/borkdude/quickblog" 9 | :source-paths ["src"]})}}} 10 | -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borkdude/quickblog/b61f8c9a51c0a1e5e310ee712e2dd45693c640cb/resources/quickblog/assets/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /resources/quickblog/assets/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /resources/quickblog/templates/archive.html: -------------------------------------------------------------------------------- 1 |
2 |

{{title}}

3 | {% for post-group in post-groups %} 4 |

{{post-group.year}}

5 | 10 | {% endfor %} 11 |
12 | -------------------------------------------------------------------------------- /resources/quickblog/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{watch | safe }} 12 | 13 | 14 | {% if favicon-tags %}{{favicon-tags | safe}}{% endif %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% if sharing.description %} 22 | 23 | 24 | 25 | {% endif %} 26 | {% if sharing.url %} 27 | 28 | 29 | {% endif %} 30 | {% if sharing.image %} 31 | 32 | 33 | 34 | 35 | {% else %} 36 | 37 | {% endif %} 38 | {% if sharing.author %} 39 | 40 | {% endif %} 41 | {% if sharing.twitter-handle %} 42 | 43 | {% endif %} 44 | 45 | 46 | 47 | 73 | 74 |
75 | 76 | {{body | safe }} 77 | 78 | {% if not skip-archive %} 79 |
80 | Archive 81 |
82 | {% endif %} 83 |
84 | 85 | 86 | -------------------------------------------------------------------------------- /resources/quickblog/templates/favicon.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/quickblog/templates/index.html: -------------------------------------------------------------------------------- 1 | {% for post in posts %} 2 |
3 |

{{post.title}}

4 | {{post.body | safe}} 5 | {% if post.truncated %} 6 |

Continue reading

7 | {% endif %} 8 | {% if post.discuss %} 9 |

Discuss this post here.

10 | {% endif %} 11 |

Published: {{post.date}}

12 | {% if post.tags %} 13 |

14 | Tagged: 15 | {% for tag in post.tags %} 16 | 17 | {{tag}} 18 | 19 | {% endfor %} 20 |

21 | {% endif %} 22 |
23 | {% endfor %} 24 | -------------------------------------------------------------------------------- /resources/quickblog/templates/post-links.html: -------------------------------------------------------------------------------- 1 |
2 |

{{title}}

3 | 8 |
9 | -------------------------------------------------------------------------------- /resources/quickblog/templates/post.html: -------------------------------------------------------------------------------- 1 |

2 | {% if post-link %} 3 | 4 | {% endif %} 5 | {{title}} 6 | {% if post-link %} 7 | 8 | {% endif %} 9 |

10 | {{body | safe}} 11 | {% if discuss-link %} 12 |

Discuss this post here.

13 | {% endif %} 14 |

Published: {{date}}

15 | {% if tags %} 16 |

17 | 18 | Tagged: 19 | {% for tag in tags %} 20 | 21 | {{tag}} 22 | 23 | {% endfor %} 24 | 25 |

26 | {% endif %} 27 | -------------------------------------------------------------------------------- /resources/quickblog/templates/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | /* borrowed from metaredux.com */ 3 | font: 400 16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; 4 | margin: 0; 5 | padding: 0; 6 | color: rgb(51, 65, 85) 7 | } 8 | 9 | h1, h2, h3 { 10 | font-weight: 800; 11 | } 12 | 13 | a { 14 | color: rgb(51, 65, 85) 15 | } 16 | 17 | .wrapper { 18 | max-width: 800px; 19 | margin: 0 auto 0 auto; 20 | padding: 0 15px; 21 | } 22 | 23 | h2 { 24 | margin-bottom: 0; 25 | margin-top: 0; 26 | } 27 | 28 | p { 29 | margin-top: 0.2em; 30 | } 31 | 32 | .site-header { 33 | border-top: 2px solid #424242; 34 | border-bottom: 1px solid #e8e8e8; 35 | min-height: 55.95px; 36 | position: relative; 37 | } 38 | 39 | .site-title { 40 | margin: 0; 41 | } 42 | 43 | .site-nav { 44 | float: right; 45 | line-height: 4rem; 46 | } 47 | 48 | .site-nav .page-link:not(:last-child) { 49 | margin-right: 20px; 50 | } 51 | 52 | .page-link { 53 | text-decoration: none; 54 | vertical-align: middle; 55 | } 56 | 57 | .page-icon { 58 | vertical-align: middle; 59 | } 60 | 61 | ul.index { 62 | list-style: none; 63 | padding-left: 0; 64 | } 65 | 66 | .index li { 67 | margin-bottom: 1em; 68 | } 69 | 70 | code { 71 | white-space: pre; 72 | } 73 | -------------------------------------------------------------------------------- /resources/quickblog/templates/tags.html: -------------------------------------------------------------------------------- 1 |
2 |

{{title}}

3 | 8 |
9 | -------------------------------------------------------------------------------- /script/changelog.clj: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (ns changelog 4 | (:require [clojure.string :as str])) 5 | 6 | (let [changelog (slurp "CHANGELOG.md") 7 | replaced (str/replace changelog 8 | #" #(\d+)" 9 | (fn [[_ issue after]] 10 | (format " [#%s](https://github.com/borkdude/quickblog/issues/%s)%s" 11 | issue issue (str after)))) 12 | replaced (str/replace replaced 13 | #"@([a-zA-Z0-9-_]+)([, \.)])" 14 | (fn [[_ name after]] 15 | (format "[@%s](https://github.com/%s)%s" 16 | name name after)))] 17 | (spit "CHANGELOG.md" replaced)) 18 | -------------------------------------------------------------------------------- /src/quickblog/api.clj: -------------------------------------------------------------------------------- 1 | (ns quickblog.api 2 | {:org.babashka/cli 3 | {:spec 4 | { 5 | ;; Blog metadata 6 | :blog-title 7 | {:desc "Title of the blog" 8 | :ref "" 9 | :default "quickblog" 10 | :require true 11 | :group :blog-metadata} 12 | 13 | :blog-author 14 | {:desc "Author's name" 15 | :ref "<name>" 16 | :default "Quick Blogger" 17 | :require true 18 | :group :blog-metadata} 19 | 20 | :blog-description 21 | {:desc "Blog description for subtitle and RSS feeds" 22 | :ref "<text>" 23 | :default "A blog about blogging quickly" 24 | :require true 25 | :group :blog-metadata} 26 | 27 | :blog-root 28 | {:desc "Base URL of the blog" 29 | :ref "<url>" 30 | :default "https://github.com/borkdude/quickblog" 31 | :require true 32 | :group :blog-metadata} 33 | 34 | ;; Optional metadata 35 | :about-link 36 | {:desc "Link to about the author page" 37 | :ref "<url>" 38 | :group :optional-metadata} 39 | 40 | :discuss-link 41 | {:desc "Link to discussion forum for posts" 42 | :ref "<url>" 43 | :group :optional-metadata} 44 | 45 | :twitter-handle 46 | {:desc "Author's Twitter handle" 47 | :ref "<handle>" 48 | :group :optional-metadata} 49 | 50 | :page-suffix 51 | {:desc "Suffix to use for page links (default .html)" 52 | :ref "<suffix>" 53 | :default ".html" 54 | :group :optional-metadata} 55 | 56 | ;; Post config 57 | :default-metadata 58 | {:desc "Default metadata to add to posts" 59 | :default {:tags ["clojure"]} 60 | :group :post-config} 61 | 62 | :num-index-posts 63 | {:desc "Number of most recent posts to show on the index page" 64 | :ref "<num>" 65 | :default 3 66 | :group :post-config} 67 | 68 | :posts-file 69 | {:desc "File containing deprecated post metadata (used only for `migrate`)" 70 | :ref "<file>" 71 | :default "posts.edn" 72 | :group :post-config} 73 | 74 | ;; Input directories 75 | :assets-dir 76 | {:desc "Directory to copy assets (images, etc.) from" 77 | :ref "<dir>" 78 | :default "assets" 79 | :require true 80 | :group :input-directories} 81 | 82 | :posts-dir 83 | {:desc "Directory to read posts from" 84 | :ref "<dir>" 85 | :default "posts" 86 | :require true 87 | :group :input-directories} 88 | 89 | :templates-dir 90 | {:desc "Directory to read templates from; see Templates section in README" 91 | :ref "<dir>" 92 | :default "templates" 93 | :require true 94 | :group :input-directories} 95 | 96 | ;; Output directories 97 | :out-dir 98 | {:desc "Base directory for outputting static site" 99 | :ref "<dir>" 100 | :default "public" 101 | :require true 102 | :group :output-directories} 103 | 104 | :assets-out-dir 105 | {:desc "Directory to write assets to (relative to :out-dir)" 106 | :ref "<dir>" 107 | :default "assets" 108 | :require true 109 | :group :output-directories} 110 | 111 | :tags-dir 112 | {:desc "Directory to write tags to (relative to :out-dir)" 113 | :ref "<dir>" 114 | :default "tags" 115 | :require true 116 | :group :output-directories} 117 | 118 | ;; Caching 119 | :force-render 120 | {:desc "If true, pages will be re-rendered regardless of cache status" 121 | :default false 122 | :group :caching} 123 | 124 | :cache-dir 125 | {:desc "Directory to use for caching" 126 | :ref "<dir>" 127 | :default ".work" 128 | :require true 129 | :group :caching} 130 | 131 | :rendering-system-files 132 | {:desc "Files involved in rendering pages (only set if you know what you're doing!)" 133 | :ref "<file1> <file2>..." 134 | :default ["bb.edn" "deps.edn"] 135 | :coerce [] 136 | :require true 137 | :group :caching} 138 | 139 | ;; Social sharing 140 | :blog-image 141 | {:desc "Blog thumbnail image URL; see Features > Social sharing in README" 142 | :ref "<url>" 143 | :group :social-sharing} 144 | 145 | ;; Favicon 146 | :favicon 147 | {:desc "If true, favicon will be added to all pages" 148 | :default false 149 | :group :favicon} 150 | 151 | :favicon-dir 152 | {:desc "Directory to read favicon assets from" 153 | :ref "<dir>" 154 | :default "assets/favicon" 155 | :group :favicon} 156 | 157 | :favicon-out-dir 158 | {:desc "Directory to write favicon assets to (relative to :out-dir)" 159 | :ref "<dir>" 160 | :default "assets/favicon" 161 | :group :favicon} 162 | 163 | ;; Command-specific opts 164 | 165 | }}} 166 | (:require 167 | [babashka.fs :as fs] 168 | [clojure.data.xml :as xml] 169 | [clojure.edn :as edn] 170 | [clojure.set :as set] 171 | [clojure.string :as str] 172 | [quickblog.internal :as lib :refer [->map]] 173 | [selmer.parser :as selmer] 174 | [selmer.filters :as filters])) 175 | 176 | ;; Add filter for tag page links; see: 177 | ;; https://github.com/yogthos/Selmer#filters 178 | (filters/add-filter! :escape-tag lib/escape-tag) 179 | 180 | (defn- update-out-dirs 181 | [{:keys [out-dir assets-out-dir favicon-out-dir] :as opts}] 182 | (let [out-dir-ify (fn [dir] 183 | (if-not (str/starts-with? (str dir) (str out-dir)) 184 | (fs/file out-dir dir) 185 | dir))] 186 | (assoc opts 187 | :assets-out-dir (out-dir-ify assets-out-dir) 188 | :favicon-out-dir (out-dir-ify favicon-out-dir)))) 189 | 190 | (defn update-cache-dir [opts] 191 | (if (:cache-dir-final opts) 192 | opts 193 | (let [cache-dir (:cache-dir opts) 194 | cache-dir (when cache-dir 195 | (if (:watch opts) 196 | (str (fs/file cache-dir "dev")) 197 | (str (fs/file cache-dir "prod"))))] 198 | (assoc opts :cache-dir cache-dir :cache-dir-final true)))) 199 | 200 | (defn- update-opts [opts] 201 | (-> opts 202 | (update :rendering-system-files #(map fs/file (cons (:templates-dir opts) %))) 203 | update-out-dirs 204 | update-cache-dir)) 205 | 206 | (defn- get-defaults [metadata] 207 | (->> (get-in metadata [:org.babashka/cli :spec]) 208 | (filter (fn [[_ m]] (contains? m :default))) 209 | (map (fn [[k m]] [k (:default m)])) 210 | (into {}))) 211 | 212 | (defn- apply-default-opts [opts] 213 | (let [defaults (get-defaults (meta (the-ns 'quickblog.api)))] 214 | (-> (->> defaults 215 | (map (fn [[k default]] [k (if (contains? opts k) (opts k) default)])) 216 | (into {})) 217 | (merge opts) 218 | update-opts))) 219 | 220 | (def ^:private favicon-assets 221 | ["android-chrome-192x192.png" 222 | "android-chrome-512x512.png" 223 | "apple-touch-icon.png" 224 | "browserconfig.xml" 225 | "favicon-16x16.png" 226 | "favicon-32x32.png" 227 | "favicon.ico" 228 | "mstile-150x150.png" 229 | "safari-pinned-tab.svg" 230 | "site.webmanifest"]) 231 | 232 | (def ^:private legacy-template " 233 | <html><head> 234 | <meta http-equiv=\"refresh\" content=\"0; URL=/{{new_url}}\" /> 235 | </head></html>") 236 | 237 | (defn- base-html [opts] 238 | (slurp (lib/ensure-template opts "base.html"))) 239 | 240 | (defn- ensure-favicon-assets [{:keys [favicon favicon-dir]}] 241 | (when favicon 242 | (doseq [asset favicon-assets] 243 | (lib/ensure-resource (fs/file favicon-dir asset) 244 | (fs/file "assets" "favicon" asset))))) 245 | 246 | (defn- gen-posts [{:keys [deleted-posts modified-posts posts 247 | cache-dir out-dir] 248 | :as opts}] 249 | (let [posts-to-write (set/union modified-posts 250 | (lib/modified-post-pages opts)) 251 | page-template (base-html opts) 252 | post-template (slurp (lib/ensure-template opts "post.html"))] 253 | (fs/create-dirs cache-dir) 254 | (fs/create-dirs out-dir) 255 | (doseq [[file post] posts 256 | :when (contains? posts-to-write file) 257 | :let [{:keys [file date legacy]} post 258 | html-file (lib/html-file file)]] 259 | (lib/write-post! (assoc opts 260 | :page-template page-template 261 | :post-template post-template) 262 | post) 263 | (let [legacy-dir (fs/file out-dir (str/replace date "-" "/") 264 | (str/replace file ".md" ""))] 265 | (when legacy 266 | (fs/create-dirs legacy-dir) 267 | (let [legacy-file (fs/file (fs/file legacy-dir "index.html")) 268 | redirect-html (selmer/render legacy-template 269 | {:new_url html-file})] 270 | (println "Writing legacy redirect:" (str legacy-file)) 271 | (spit legacy-file redirect-html))))) 272 | (doseq [file deleted-posts] 273 | (println "Post deleted; removing from cache and outdir:" (str file)) 274 | (fs/delete-if-exists (fs/file cache-dir (lib/cache-file file))) 275 | (fs/delete-if-exists (fs/file out-dir (lib/html-file file)))))) 276 | 277 | (defn debug [& xs] 278 | (binding [*out* *err*] 279 | (apply println xs))) 280 | 281 | (defn- gen-tags [{:keys [blog-title blog-description 282 | blog-image blog-image-alt twitter-handle 283 | modified-tags modified-drafts posts out-dir tags-dir] 284 | :as opts}] 285 | (let [tags-out-dir (fs/create-dirs (fs/file out-dir tags-dir)) 286 | posts-by-tag (lib/posts-by-tag posts) 287 | tags-file (fs/file tags-out-dir "index.html") 288 | template (base-html opts)] 289 | (when (or (seq modified-tags) 290 | (not (fs/exists? tags-file)) 291 | (seq modified-drafts)) 292 | (lib/write-page! opts tags-file template 293 | {:skip-archive true 294 | :title (str blog-title " - Tags") 295 | :relative-path "../" 296 | :body (lib/tag-links "Tags" posts-by-tag opts) 297 | :sharing {:description (format "Tags - %s" 298 | blog-description) 299 | :author twitter-handle 300 | :twitter-handle twitter-handle 301 | :image (lib/blog-link opts blog-image) 302 | :image-alt blog-image-alt 303 | :url (lib/blog-link opts "tags/index.html")}}) 304 | (doseq [tag-and-posts posts-by-tag] 305 | (println "Writing tags and posts" tag-and-posts) 306 | (lib/write-tag! opts tags-out-dir template tag-and-posts)) 307 | ;; Delete tags pages for removed tags 308 | (doseq [tag (remove posts-by-tag modified-tags) 309 | :let [tag-filename (fs/file tags-out-dir (lib/tag-file tag))]] 310 | (println "Deleting removed tag:" (str tag-filename)) 311 | (fs/delete-if-exists tag-filename))))) 312 | 313 | ;;;; Generate index page with last `num-index-posts` posts 314 | 315 | (defn- index [{:keys [posts page-suffix] :as opts}] 316 | (let [posts (for [{:keys [file html] :as post} posts 317 | :let [preview (first (str/split @html #"<!-- end-of-preview -->" 2))]] 318 | (assoc post 319 | :post-link (str/replace file ".md" page-suffix) 320 | :body preview 321 | :truncated (not= preview @html))) 322 | index-template (lib/ensure-template opts "index.html")] 323 | (selmer/render (slurp index-template) (merge opts {:posts posts})))) 324 | 325 | (defn- spit-index 326 | [{:keys [blog-title blog-description blog-image blog-image-alt twitter-handle 327 | posts cached-posts deleted-posts modified-posts num-index-posts 328 | out-dir] 329 | :as opts}] 330 | (let [index-posts #(->> (vals %) 331 | lib/remove-previews 332 | lib/sort-posts 333 | (map (partial lib/expand-prev-next-metadata opts)) 334 | (take num-index-posts)) 335 | posts (index-posts posts) 336 | cached-posts (index-posts cached-posts) 337 | out-file (fs/file out-dir "index.html") 338 | stale? (or (not= (map :file posts) 339 | (map :file cached-posts)) 340 | (some modified-posts (map :file posts)) 341 | (some deleted-posts (map :file cached-posts)) 342 | (not (fs/exists? out-file)))] 343 | (when stale? 344 | (let [body (index (assoc opts :posts posts))] 345 | (lib/write-page! opts out-file 346 | (base-html opts) 347 | {:title blog-title 348 | :body body 349 | :sharing {:description blog-description 350 | :author twitter-handle 351 | :twitter-handle twitter-handle 352 | :image (lib/blog-link opts blog-image) 353 | :image-alt blog-image-alt 354 | :url (lib/blog-link opts "index.html")}}))))) 355 | 356 | ;;;; Generate about page if template exists 357 | (defn- about [{:keys [templates-dir] :as opts}] 358 | (selmer/render (slurp (fs/file templates-dir "about.html")) opts)) 359 | 360 | (defn- spit-about [{:keys [blog-title blog-description 361 | blog-image blog-image-alt twitter-handle 362 | modified-metadata out-dir] 363 | :as opts}] 364 | (let [out-file (fs/file out-dir "about.html") 365 | stale? (or (some not-empty (vals modified-metadata)) 366 | (not (fs/exists? out-file)))] 367 | (when stale? 368 | (let [title (str blog-title " - About")] 369 | (lib/write-page! opts out-file 370 | (base-html opts) 371 | {:skip-archive true 372 | :title title 373 | :body (about opts) 374 | :sharing {:description (format "About - %s" 375 | blog-description) 376 | :author twitter-handle 377 | :twitter-handle twitter-handle 378 | :image (lib/blog-link opts blog-image) 379 | :image-alt blog-image-alt 380 | :url (lib/blog-link opts "about.html")}}))))) 381 | 382 | ;;;; Generate archive page with links to all posts 383 | 384 | (defn- spit-archive [{:keys [blog-title blog-description 385 | blog-image blog-image-alt twitter-handle 386 | modified-metadata posts out-dir] 387 | :as opts}] 388 | (let [out-file (fs/file out-dir "archive.html") 389 | stale? (or (some not-empty (vals modified-metadata)) 390 | (not (fs/exists? out-file)))] 391 | (when stale? 392 | (let [title (str blog-title " - Archive") 393 | posts (lib/sort-posts (vals posts))] 394 | (lib/write-page! opts out-file 395 | (base-html opts) 396 | {:skip-archive true 397 | :title title 398 | :body (lib/archive-links "Archive" posts opts) 399 | :sharing {:description (format "Archive - %s" 400 | blog-description) 401 | :author twitter-handle 402 | :twitter-handle twitter-handle 403 | :image (lib/blog-link opts blog-image) 404 | :image-alt blog-image-alt 405 | :url (lib/blog-link opts "archive.html")}}))))) 406 | 407 | ;;;; Generate atom feeds 408 | 409 | (xml/alias-uri 'atom "http://www.w3.org/2005/Atom") 410 | (import java.time.format.DateTimeFormatter) 411 | 412 | (defn- rfc-3339-now [] 413 | (let [fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH:mm:ssxxx") 414 | now (java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)] 415 | (.format now fmt))) 416 | 417 | (defn- rfc-3339 [yyyy-MM-dd] 418 | (let [in-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd") 419 | local-date (java.time.LocalDate/parse yyyy-MM-dd in-fmt) 420 | fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH:mm:ssxxx") 421 | now (java.time.ZonedDateTime/of (.atTime local-date 23 59 59) java.time.ZoneOffset/UTC)] 422 | (.format now fmt))) 423 | 424 | (defn- atom-feed 425 | ;; validate at https://validator.w3.org/feed/check.cgi 426 | [{:keys [blog-title blog-author blog-root page-suffix] :as opts} posts] 427 | (-> (xml/sexp-as-element 428 | [::atom/feed 429 | {:xmlns "http://www.w3.org/2005/Atom"} 430 | [::atom/title blog-title] 431 | [::atom/link {:href (lib/blog-link opts "atom.xml") :rel "self"}] 432 | [::atom/link {:href blog-root}] 433 | [::atom/updated (rfc-3339-now)] 434 | [::atom/id blog-root] 435 | [::atom/author 436 | [::atom/name blog-author]] 437 | (for [{:keys [title date file preview html]} posts 438 | :when (not preview) 439 | :let [html-file (str/replace file ".md" page-suffix) 440 | link (lib/blog-link opts html-file)]] 441 | [::atom/entry 442 | [::atom/id link] 443 | [::atom/link {:href link}] 444 | [::atom/title title] 445 | [::atom/updated (rfc-3339 date)] 446 | [::atom/content {:type "html"} 447 | [:-cdata @html]]])]) 448 | xml/indent-str)) 449 | 450 | (defn- clojure-post? [{:keys [tags]}] 451 | (let [clojure-tags #{"clojure" "clojurescript"} 452 | lowercase-tags (map str/lower-case tags)] 453 | (some clojure-tags lowercase-tags))) 454 | 455 | (defn- spit-feeds [{:keys [out-dir modified-posts posts] :as opts}] 456 | (let [feed-file (fs/file out-dir "atom.xml") 457 | clojure-feed-file (fs/file out-dir "planetclojure.xml") 458 | all-posts (lib/sort-posts (vals posts)) 459 | clojure-posts (->> (vals posts) 460 | (filter clojure-post?) 461 | lib/sort-posts) 462 | clojure-posts-modified? (->> modified-posts 463 | (map posts) 464 | (some clojure-post?))] 465 | (if (and (not clojure-posts-modified?) (fs/exists? clojure-feed-file)) 466 | (println "No Clojure posts modified; skipping Clojure feed") 467 | (do 468 | (println "Writing Clojure feed" (str clojure-feed-file)) 469 | (spit clojure-feed-file (atom-feed opts clojure-posts)))) 470 | (if (and (empty? modified-posts) (fs/exists? feed-file)) 471 | (println "No posts modified; skipping main feed") 472 | (do 473 | (println "Writing feed" (str feed-file)) 474 | (spit feed-file (atom-feed opts all-posts)))))) 475 | 476 | (defn render 477 | "Renders posts declared in `posts.edn` to `out-dir`." 478 | [opts] 479 | (let [{:keys [assets-dir 480 | assets-out-dir 481 | cache-dir 482 | favicon-dir 483 | favicon-out-dir 484 | out-dir 485 | posts-file 486 | templates-dir] 487 | :as opts} 488 | (-> opts apply-default-opts lib/refresh-cache)] 489 | (if (empty? (:posts opts)) 490 | (binding [*out* *err*] 491 | (println 492 | (if (fs/exists? posts-file) 493 | (format "Run `bb migrate` to move metadata from `%s` to post files" 494 | posts-file) 495 | "No posts found; run `bb new` to create one"))) 496 | (do 497 | (lib/ensure-template opts "style.css") 498 | (ensure-favicon-assets opts) 499 | (when (fs/exists? assets-dir) 500 | (lib/copy-tree-modified assets-dir assets-out-dir)) 501 | (when (fs/exists? favicon-dir) 502 | (lib/copy-tree-modified favicon-dir favicon-out-dir)) 503 | (doseq [file (fs/glob templates-dir "*.{css,svg}")] 504 | (lib/copy-modified file (fs/file out-dir (.getFileName file)))) 505 | (fs/create-dirs (fs/file cache-dir)) 506 | (gen-posts opts) 507 | (gen-tags opts) 508 | (spit-archive opts) 509 | (when (fs/exists? (fs/file templates-dir "about.html")) 510 | (spit-about opts)) 511 | (spit-index opts) 512 | (spit-feeds opts) 513 | (lib/write-cache! opts))) 514 | opts)) 515 | 516 | (defn quickblog 517 | "Alias for `render`" 518 | [opts] 519 | (render opts)) 520 | 521 | (defn- now [] 522 | (.format (java.time.LocalDate/now) 523 | (java.time.format.DateTimeFormatter/ofPattern "yyyy-MM-dd"))) 524 | 525 | (defn new 526 | "Creates new `file` in posts dir." 527 | {:org.babashka/cli 528 | {:spec 529 | {:date 530 | {:desc "Date of post (default: today; example: --date 1970-01-01)" 531 | :ref "<date>"} 532 | 533 | :file 534 | {:desc "Filename of post (relative to posts-dir)" 535 | :ref "<filename>" 536 | :require true} 537 | 538 | :preview 539 | {:desc "Create post as preview (won't be published to index, tags, or feeds)" 540 | :default false} 541 | 542 | :title 543 | {:desc "Title of post" 544 | :ref "<title>" 545 | :require true} 546 | 547 | :tags 548 | {:desc "List of tags (default: 'clojure'; example: --tags tag1 tag2 \"tag3 has spaces\")" 549 | :ref "<tags>" 550 | :coerce []} 551 | 552 | :template-file 553 | {:desc "Filename of Selmer template to use for the new post (see Templates > New posts in README)" 554 | :ref "<filename>"}}}} 555 | [opts] 556 | (let [{:keys [date file tags template-file default-metadata posts-dir] 557 | :as opts} (apply-default-opts opts) 558 | date (or date (now)) 559 | tags (cond (empty? tags) (:tags default-metadata) 560 | (= tags [true]) [] ;; `--tags` without arguments 561 | :else tags)] 562 | (doseq [k [:file :title]] 563 | (assert (contains? opts k) (format "Missing required option: %s" k))) 564 | (let [file (if (re-matches #"^.+[.][^.]+$" file) 565 | file 566 | (str file ".md")) 567 | post-file (fs/file posts-dir file) 568 | template (if template-file 569 | (slurp (fs/file template-file)) 570 | (->> ["Title: {{title}}" 571 | "Date: {{date}}" 572 | "Tags: {{tags|join:\",\"}}" 573 | "{% if preview %}Preview: true\n{% endif %}" 574 | "Write a blog post here!"] 575 | (str/join "\n")))] 576 | (when-not (fs/exists? post-file) 577 | (fs/create-dirs posts-dir) 578 | (spit (fs/file posts-dir file) 579 | (selmer/render template (merge opts (->map file date tags)))))))) 580 | 581 | (defn clean 582 | "Removes cache and output directories" 583 | [opts] 584 | (let [{:keys [cache-dir out-dir]} (apply-default-opts opts)] 585 | (doseq [dir [cache-dir out-dir]] 586 | (println "Removing dir:" dir) 587 | (fs/delete-tree dir)))) 588 | 589 | (defn migrate 590 | "Migrates from `posts.edn` to post-local metadata" 591 | [opts] 592 | (let [{:keys [posts-file] :as opts} (apply-default-opts opts)] 593 | (if (fs/exists? posts-file) 594 | (do 595 | (doseq [post (->> (slurp posts-file) (format "[%s]") edn/read-string)] 596 | (lib/migrate-post opts post)) 597 | (println "If all posts were successfully migrated, you should now delete" 598 | (str posts-file))) 599 | (println (format "Posts file %s does not exist; no posts to migrate" 600 | (str posts-file)))))) 601 | 602 | (defn refresh-templates 603 | "Updates to latest default templates" 604 | [opts] 605 | (lib/refresh-templates (apply-default-opts opts))) 606 | 607 | (defn serve 608 | "Runs file-server on `port`." 609 | {:org.babashka/cli 610 | {:spec 611 | {:port 612 | {:desc "Port for HTTP server to listen on" 613 | :ref "<port>" 614 | :default 1888}}}} 615 | ([opts] (serve opts true)) 616 | ([opts block?] 617 | (let [{:keys [port out-dir]} (merge (get-defaults (meta #'serve)) 618 | (apply-default-opts opts)) 619 | serve (requiring-resolve 'babashka.http-server/serve)] 620 | (serve {:port port 621 | :dir out-dir}) 622 | (when block? @(promise))))) 623 | 624 | (def ^:private posts-cache (atom nil)) 625 | 626 | (defn watch 627 | "Watches posts, templates, and assets for changes. Runs file server using 628 | `serve`." 629 | {:org.babashka/cli 630 | {:spec 631 | {:port 632 | {:desc "Port for HTTP server to listen on" 633 | :ref "<port>" 634 | :default 1888}}}} 635 | [opts] 636 | (let [{:keys [assets-dir assets-out-dir posts-dir templates-dir] 637 | :as opts} 638 | (-> opts 639 | (assoc :watch (format "<script type=\"text/javascript\" src=\"%s\"></script>" 640 | lib/live-reload-script)) 641 | apply-default-opts 642 | render)] 643 | (reset! posts-cache (:posts opts)) 644 | (serve opts false) 645 | (let [load-pod (requiring-resolve 'babashka.pods/load-pod)] 646 | (load-pod 'org.babashka/fswatcher "0.0.3") 647 | (let [watch (requiring-resolve 'pod.babashka.fswatcher/watch)] 648 | (watch posts-dir 649 | (fn [{:keys [path type]}] 650 | (println "Change detected:" (name type) (str path)) 651 | (when (#{:create :remove :rename :write :write|chmod} type) 652 | (let [post-filename (-> (fs/file path) fs/file-name)] 653 | ;; skip Emacs backup files and the like 654 | (when-not (str/starts-with? post-filename ".") 655 | (println "Re-rendering" post-filename) 656 | (let [post (lib/load-post opts path) 657 | posts (cond 658 | (contains? #{:remove :rename} type) 659 | (dissoc @posts-cache post-filename) 660 | 661 | (:quickblog/error post) 662 | (do 663 | (println (:quickblog/error post)) 664 | (dissoc @posts-cache post-filename)) 665 | 666 | :else 667 | (assoc @posts-cache post-filename post)) 668 | opts (-> opts 669 | (assoc :cached-posts @posts-cache 670 | :posts posts) 671 | render)] 672 | (reset! posts-cache (:posts opts)))))))) 673 | 674 | (watch templates-dir 675 | (fn [{:keys [path type]}] 676 | (println "Template change detected; re-rendering all posts:" 677 | (name type) (str path)) 678 | (let [opts (-> opts 679 | (dissoc :cached-posts :posts) 680 | render)] 681 | (reset! posts-cache (:posts opts))))) 682 | 683 | (when (fs/exists? assets-dir) 684 | (watch assets-dir 685 | (fn [{:keys [path type]}] 686 | (println "Asset change detected:" 687 | (name type) (str path)) 688 | (when (contains? #{:remove :rename} type) 689 | (let [file (fs/file assets-out-dir (fs/file-name path))] 690 | (println "Removing deleted asset:" (str file)) 691 | (fs/delete-if-exists file))) 692 | (lib/copy-tree-modified assets-dir assets-out-dir))))))) 693 | @(promise)) 694 | -------------------------------------------------------------------------------- /src/quickblog/cli.clj: -------------------------------------------------------------------------------- 1 | (ns quickblog.cli 2 | (:require [babashka.cli :as cli] 3 | [babashka.fs :as fs] 4 | [quickblog.api :as api] 5 | [clojure.set :as set] 6 | [clojure.string :as str])) 7 | 8 | (def ^:private main-cmd-name "quickblog") 9 | 10 | (def ^:private specs (get-in (meta (the-ns 'quickblog.api)) 11 | [:org.babashka/cli :spec])) 12 | 13 | (defn- apply-defaults [default-opts spec] 14 | (->> spec 15 | (map (fn [[k v]] 16 | (if-let [default-val (default-opts k)] 17 | [k (assoc v :default default-val)] 18 | [k v]))) 19 | (into {}))) 20 | 21 | (defn- ->subcommand-help [{:keys [cmds cmd-opts desc]}] 22 | (let [cmd-name (first cmds) 23 | opts-str (if (empty? cmd-opts) 24 | "" 25 | (str "\n" (cli/format-opts {:spec cmd-opts, :indent 4})))] 26 | (format " %s: %s%s" cmd-name desc opts-str))) 27 | 28 | (defn- ->group-name [group] 29 | (-> group 30 | name 31 | str/capitalize 32 | (str/replace "-" " "))) 33 | 34 | (defn- format-opts [global-specs] 35 | (->> global-specs 36 | (group-by (fn [[_opt spec]] (:group spec))) 37 | (map (fn [[group opts]] 38 | (let [spec (into {} opts)] 39 | (format "%s\n%s" 40 | (->group-name group) 41 | (cli/format-opts {:spec spec}))))) 42 | (str/join "\n\n"))) 43 | 44 | (defn- print-help [global-specs cmds] 45 | (println 46 | (format 47 | "Usage: bb %s <subcommand> <options> 48 | 49 | Subcommands: 50 | 51 | %s 52 | 53 | Options: 54 | 55 | %s 56 | " 57 | main-cmd-name 58 | (->> cmds 59 | (map ->subcommand-help) 60 | (str/join "\n")) 61 | (format-opts global-specs))) 62 | (System/exit 0)) 63 | 64 | (defn- print-command-help [cmd-name specs cmd-opts] 65 | (let [opts-str (if (empty? cmd-opts) 66 | "" 67 | (format "Options:\n%s\n\n" 68 | (cli/format-opts {:spec cmd-opts})))] 69 | (println 70 | (format "Usage: bb %s %s <options>\n\n%sGlobal options:\n\n%s" 71 | main-cmd-name cmd-name opts-str (format-opts specs))))) 72 | 73 | (defn- mk-cmd [global-specs default-opts [cmd-name desc fn-var]] 74 | (let [cmd-opts (get-in (meta fn-var) [:org.babashka/cli :spec])] 75 | {:cmds [cmd-name] 76 | :cmd-opts cmd-opts 77 | :desc desc 78 | :spec (merge global-specs (apply-defaults default-opts cmd-opts)) 79 | :error-fn 80 | (fn [{:keys [type cause msg option] :as data}] 81 | (if (= :org.babashka/cli type) 82 | (throw (ex-info 83 | (case cause 84 | :require 85 | (format "Missing required argument --%s:\n%s" 86 | (name option) 87 | (cli/format-opts {:spec cmd-opts})) 88 | msg) 89 | {:babashka/exit 1})) 90 | (throw (ex-info msg (assoc data :babashka/exit 1))))) 91 | :fn (fn [{:keys [opts]}] 92 | (when (:help opts) 93 | (print-command-help cmd-name global-specs cmd-opts) 94 | (System/exit 0)) 95 | ;; If we have a var, we need to deref it to get the function out 96 | (if (var? fn-var) 97 | (@fn-var opts) 98 | (fn-var opts)))})) 99 | 100 | (defn- mk-table [default-opts] 101 | (let [global-specs (apply-defaults default-opts specs) 102 | cmds 103 | (mapv (partial mk-cmd global-specs default-opts) 104 | [["render" 105 | "Render the blog" 106 | #'api/render] 107 | ["new" 108 | "Create new file in posts-dir" 109 | #'api/new] 110 | ["serve" 111 | "Run HTTP server for rendered site" 112 | #'api/serve] 113 | ["watch" 114 | "Run HTTP server, watching posts, templates, and assets for changes" 115 | #'api/watch] 116 | ["clean" 117 | "Remove cache and output directories" 118 | #'api/clean] 119 | ["refresh-templates" 120 | "Update to latest default templates; see the Templates section in README" 121 | #'api/refresh-templates] 122 | ["migrate" 123 | "Migrate from `posts.edn` to post-local metadata" 124 | #'api/migrate]])] 125 | (conj cmds 126 | {:cmds [], :fn (fn [m] (print-help global-specs cmds))}))) 127 | 128 | (defn dispatch 129 | ([] 130 | (dispatch {})) 131 | ([default-opts & args] 132 | (cli/dispatch (mk-table default-opts) 133 | (or args 134 | (seq *command-line-args*))))) 135 | 136 | (defn run 137 | "Meant to be called using `clj -M:quickblog`; see Quickstart > Clojure in README" 138 | [default-opts] 139 | ;; *command-line-args* will start with `(quickblog.cli run ...)`, so we need to 140 | ;; get rid of the first two items to get at what we care about 141 | (let [args (drop 2 *command-line-args*)] 142 | (apply dispatch default-opts args))) 143 | 144 | (defn -main [& args] 145 | (apply dispatch {} args)) 146 | -------------------------------------------------------------------------------- /src/quickblog/internal.clj: -------------------------------------------------------------------------------- 1 | (ns quickblog.internal 2 | {:no-doc true} 3 | (:require 4 | [babashka.fs :as fs] 5 | [clojure.data :as data] 6 | [clojure.edn :as edn] 7 | [clojure.java.io :as io] 8 | [clojure.set :as set] 9 | [clojure.string :as str] 10 | [markdown.core :as md] 11 | [selmer.parser :as selmer])) 12 | 13 | ;; Script used for live reloading in watch mode 14 | (def live-reload-script "https://livejs.com/live.js") 15 | 16 | (set! *warn-on-reflection* true) 17 | 18 | (defn- last-modified-1 19 | "Returns max last-modified of regular file f. Returns 0 if file does not exist." 20 | ^java.nio.file.attribute.FileTime [f] 21 | (if (fs/exists? f) 22 | (fs/last-modified-time f) 23 | (java.nio.file.attribute.FileTime/fromMillis 0))) 24 | 25 | (defn max-filetime [filetimes] 26 | (if (empty? filetimes) 27 | (java.nio.file.attribute.FileTime/fromMillis 0) 28 | (reduce #(if (pos? (.compareTo ^java.nio.file.attribute.FileTime %1 ^java.nio.file.attribute.FileTime %2)) 29 | %1 %2) 30 | filetimes))) 31 | 32 | (defn- last-modified 33 | "Returns max last-modified of f or of all files within f" 34 | [f] 35 | (if (fs/exists? f) 36 | (if (fs/regular-file? f) 37 | (last-modified-1 f) 38 | (max-filetime 39 | (map last-modified-1 40 | (filter fs/regular-file? (file-seq (fs/file f)))))) 41 | (java.nio.file.attribute.FileTime/fromMillis 0))) 42 | 43 | (defn- expand-file-set 44 | [file-set] 45 | (if (coll? file-set) 46 | (mapcat expand-file-set file-set) 47 | (filter fs/regular-file? (file-seq (fs/file file-set))))) 48 | 49 | (defn modified-since 50 | "Returns seq of regular files (non-directories, non-symlinks) from file-set that were modified since the anchor path. 51 | The anchor path can be a regular file or directory, in which case 52 | the recursive max last modified time stamp is used as the timestamp 53 | to compare with. The file-set may be a regular file, directory or 54 | collection of files (e.g. returned by glob). Directories are 55 | searched recursively." 56 | [anchor file-set] 57 | (let [lm (last-modified anchor)] 58 | (map fs/path (filter #(pos? (.compareTo (last-modified-1 %) lm)) (expand-file-set file-set))))) 59 | 60 | (def ^:private cache-filename "cache.edn") 61 | (def ^:private resource-path "quickblog") 62 | (def ^:private templates-resource-dir "templates") 63 | (def ^:private favicon-template "favicon.html") 64 | 65 | (def ^:private metadata-transformers 66 | {:default first 67 | :tags #(if (empty? %) #{} (-> % first (str/split #",\s*") set))}) 68 | 69 | (def ^:private required-metadata 70 | #{:date 71 | :title}) 72 | 73 | (defmacro ->map [& ks] 74 | (assert (every? symbol? ks)) 75 | (zipmap (map keyword ks) 76 | ks)) 77 | 78 | (defn rendering-modified? [target-file rendering-system-files] 79 | (seq (modified-since target-file rendering-system-files))) 80 | 81 | (defn copy-modified [src target] 82 | (when (seq (modified-since target src)) 83 | (println "Writing" (str target)) 84 | (fs/create-dirs (.getParent (fs/file target))) 85 | (fs/copy src target {:replace-existing true}))) 86 | 87 | (defn copy-tree-modified [src-dir target-dir] 88 | (let [src-dir (fs/file src-dir) 89 | target-dir (fs/file target-dir) 90 | num-dirs (->> src-dir .toPath .iterator iterator-seq count) 91 | from-src-dir (fn [path] 92 | (->> path seq (drop num-dirs) (apply fs/file))) 93 | modified-paths (modified-since target-dir src-dir) 94 | new-paths (->> (fs/glob src-dir "**") 95 | (remove #(fs/exists? (fs/file target-dir (from-src-dir %)))))] 96 | (doseq [path (concat modified-paths new-paths) 97 | :let [target-path (fs/file target-dir (from-src-dir path))]] 98 | (fs/create-dirs (.getParent target-path)) 99 | (println "Writing" (str target-path)) 100 | (fs/copy (fs/file path) target-path {:replace-existing true})))) 101 | 102 | (defn ensure-resource 103 | ([path] 104 | (ensure-resource path path)) 105 | ([target-path source-path] 106 | (let [target-file (fs/file target-path) 107 | source-file (fs/file source-path)] 108 | (when-not (fs/exists? target-file) 109 | (fs/create-dirs (fs/parent target-file)) 110 | (println "Writing default resource:" (str target-file)) 111 | (fs/copy (io/resource (str (fs/file resource-path source-file))) target-file)) 112 | target-file))) 113 | 114 | (defn ensure-template [{:keys [templates-dir]} template-name] 115 | (ensure-resource (fs/file templates-dir template-name) 116 | (fs/file templates-resource-dir template-name))) 117 | 118 | (defn refresh-templates [{:keys [templates-dir] :as opts}] 119 | (doseq [template (map fs/file (fs/glob templates-dir "*")) 120 | :let [template-name (fs/file-name template) 121 | resource (fs/file resource-path templates-resource-dir template-name)]] 122 | (if (io/resource (str resource)) 123 | (do 124 | (println "Refreshing template:" (str template)) 125 | (fs/delete template) 126 | (ensure-template opts template-name)) 127 | (println "Skipping custom template:" (str template))))) 128 | 129 | (defn blog-link [{:keys [blog-root]} relative-url] 130 | (when relative-url 131 | (format "%s%s%s" 132 | blog-root 133 | (if (str/ends-with? blog-root "/") "" "/") 134 | relative-url))) 135 | 136 | (defn html-file [file] 137 | (str/replace file ".md" ".html")) 138 | 139 | (defn cache-file [file] 140 | (str file ".pre-template.html")) 141 | 142 | (defn escape-tag [tag] 143 | (str/replace tag #"[^A-z0-9]" "-")) 144 | 145 | (defn tag-file [tag] 146 | (-> tag 147 | escape-tag 148 | (str ".html"))) 149 | 150 | (defn transform-metadata 151 | ([metadata] 152 | (transform-metadata metadata {})) 153 | ([metadata default-metadata] 154 | (->> metadata 155 | (map (fn [[k v]] 156 | (let [transformer (or (metadata-transformers k) 157 | (metadata-transformers :default))] 158 | [k (transformer v)]))) 159 | (into {}) 160 | (merge default-metadata)))) 161 | 162 | (defn pre-process-markdown [markdown] 163 | (-> markdown 164 | ;; allow multiline link title 165 | (str/replace #"\[[^\]]+\n" 166 | (fn [match] 167 | (str/replace match "\n" "$$RET$$"))))) 168 | 169 | (defn post-process-markdown [html] 170 | (-> html 171 | ;; restore comments 172 | (str/replace #"(<p>)?<!–(.*?)–>(</p>)?" "<!--$2-->") 173 | ;; restore newline in multiline link titles 174 | (str/replace "$$RET$$" "\n"))) 175 | 176 | (defn markdown->html [file] 177 | (let [markdown (slurp file)] 178 | (println "Processing markdown for file:" (str file)) 179 | (-> markdown 180 | pre-process-markdown 181 | (md/md-to-html-string-with-meta :reference-links? true 182 | :heading-anchors true 183 | :footnotes? true 184 | :code-style 185 | (fn [lang] 186 | (format "class=\"lang-%s language-%s\"" lang lang)) 187 | :pre-style 188 | (fn [lang] 189 | (format "class=\"language-%s\"" lang))) 190 | :html 191 | post-process-markdown))) 192 | 193 | (defn remove-previews [posts] 194 | (->> posts 195 | (remove (fn [{:keys [file preview]}] 196 | (let [preview? (when preview (parse-boolean preview))] 197 | (when preview? 198 | (println "Skipping preview post:" file) 199 | true)))))) 200 | 201 | (defn post-compare [a-post b-post] 202 | ;; Compare dates opposite the other values to force desending order 203 | (compare [(:date b-post) (:title a-post) (:file a-post)] 204 | [(:date a-post) (:title b-post) (:file b-post)])) 205 | 206 | (defn sort-posts [posts] 207 | (sort post-compare posts)) 208 | 209 | (defn modified-since? [target src] 210 | (seq (modified-since target src))) 211 | 212 | (defn validate-metadata [post] 213 | (if-let [missing-keys 214 | (seq (set/difference required-metadata 215 | (set (keys post))))] 216 | {:quickblog/error (format "Skipping %s due to missing required metadata: %s" 217 | (:file post) (str/join ", " (map name missing-keys)))} 218 | post)) 219 | 220 | (defn read-cached-post [{:keys [cache-dir]} file] 221 | (let [cached-file (fs/file cache-dir (cache-file file))] 222 | (delay 223 | (println "Reading post from cache:" (str file)) 224 | (slurp cached-file)))) 225 | 226 | (defn load-post [{:keys [cache-dir default-metadata 227 | force-render rendering-system-files] 228 | :as opts} 229 | path] 230 | (let [path (fs/file path) 231 | file (fs/file-name path) 232 | cached-file (fs/file cache-dir (cache-file file)) 233 | stale? (or force-render 234 | (not (fs/exists? cached-file)) 235 | (rendering-modified? cached-file (cons path rendering-system-files)))] 236 | (println "Reading metadata for post:" (str file)) 237 | (try 238 | (-> (slurp path) 239 | md/md-to-meta 240 | (transform-metadata default-metadata) 241 | (assoc :file (fs/file-name file) 242 | :html (if stale? 243 | (delay 244 | (println "Parsing Markdown for post:" (str file)) 245 | (let [html (markdown->html path)] 246 | (println "Caching post to file:" (str cached-file)) 247 | (spit cached-file html) 248 | html)) 249 | (read-cached-post opts file))) 250 | validate-metadata) 251 | (catch Exception e 252 | {:quickblog/error (format "Skipping post %s due to exception: %s" 253 | (str file) (str e))})))) 254 | 255 | (defn ->filename [path] 256 | (-> path fs/file fs/file-name)) 257 | 258 | (defn has-error? [_opts [_ {:keys [quickblog/error]}]] 259 | (when error 260 | (println error) 261 | true)) 262 | 263 | (defn debug [& xs] 264 | (binding [*out* *err*] 265 | (apply println xs))) 266 | 267 | (defn load-posts [{:keys [cache-dir cached-posts posts-dir] :as opts}] 268 | (if (fs/exists? posts-dir) 269 | (let [cache-file (fs/file cache-dir cache-filename) 270 | post-paths (set (fs/glob posts-dir "*.md")) 271 | modified-post-paths (if (empty? cached-posts) 272 | (set post-paths) 273 | (set (modified-since cache-file post-paths))) 274 | _cached-post-paths (set/difference post-paths modified-post-paths)] 275 | (merge (->> cached-posts 276 | (map (fn [[file post]] 277 | [file (assoc post :html (read-cached-post opts file))])) 278 | (into {})) 279 | (->> modified-post-paths 280 | (map (juxt ->filename (partial load-post opts))) 281 | (remove (partial has-error? opts)) 282 | (into {})))) 283 | {})) 284 | 285 | (defn only-metadata [posts] 286 | (->> posts 287 | (map (fn [[file post]] [file (dissoc post :html)])) 288 | (into {}))) 289 | 290 | (defn load-cache [{:keys [cache-dir rendering-system-files]}] 291 | (let [cache-file (fs/file cache-dir cache-filename)] 292 | ;; Invalidate the cache if the rendering system has been modified 293 | (if (or (rendering-modified? cache-file rendering-system-files) 294 | (not (fs/exists? cache-file))) 295 | {} 296 | (edn/read-string (slurp cache-file))))) 297 | 298 | (defn write-cache! [{:keys [cache-dir posts]}] 299 | (let [cache-file (fs/file cache-dir cache-filename)] 300 | (fs/create-dirs cache-dir) 301 | (spit cache-file (only-metadata posts)))) 302 | 303 | (defn deleted-posts [{:keys [cached-posts posts]}] 304 | (->> [cached-posts posts] 305 | (map (comp set keys)) 306 | (apply set/difference))) 307 | 308 | (defn modified-metadata [{:keys [cached-posts posts]}] 309 | (let [cached-posts (only-metadata cached-posts) 310 | posts (only-metadata posts) 311 | [cached current _] (data/diff cached-posts posts)] 312 | (->map cached current))) 313 | 314 | (defn modified-post-pages 315 | "Returns ids of posts which have newer cache files than post pages" 316 | [{:keys [cache-dir out-dir posts]}] 317 | (->> posts 318 | (filter (fn [[file _]] 319 | (let [cached-file (fs/file cache-dir (cache-file file)) 320 | page-file (fs/file out-dir (html-file file))] 321 | (if (fs/exists? cached-file) 322 | (modified-since? page-file cached-file) 323 | true)))) 324 | (map first) 325 | set)) 326 | 327 | (defn modified-posts 328 | [{:keys [force-render out-dir posts cached-posts posts-dir rendering-system-files]}] 329 | (->> posts 330 | (filter (fn [[file post]] 331 | (let [out-file (fs/file out-dir (html-file file)) 332 | post-file (fs/file posts-dir file)] 333 | (or force-render 334 | (rendering-modified? out-file 335 | (cons post-file rendering-system-files)) 336 | (not= (select-keys post [:prev :next]) 337 | (select-keys (cached-posts file) [:prev :next])))))) 338 | (map first) 339 | set)) 340 | 341 | (defn posts-with-modified-draft-statuses [{:keys [modified-metadata]}] 342 | (->> (vals modified-metadata) 343 | (mapcat (partial keep (fn [[post opts]] 344 | (when (contains? opts :preview) 345 | post)))))) 346 | 347 | (defn modified-tags [{:keys [posts modified-metadata modified-drafts]}] 348 | (let [tags-from-modified-drafts (map :tags (vals (select-keys posts modified-drafts)))] 349 | (->> (vals modified-metadata) 350 | (mapcat (partial map (fn [[_ {:keys [tags]}]] tags))) 351 | (concat tags-from-modified-drafts) 352 | (apply set/union)))) 353 | 354 | (defn expand-prev-next-metadata [{:keys [link-prev-next-posts posts] :as _opts} 355 | {:keys [prev next] :as post}] 356 | (if link-prev-next-posts 357 | (let [posts (if (map? posts) 358 | posts 359 | (->> posts 360 | (map (fn [{:keys [file] :as post}] [file post])) 361 | (into {})))] 362 | (merge post {:next (posts next), :prev (posts prev)})) 363 | post)) 364 | 365 | (defn assoc-prev-next 366 | "If the `:link-prev-next-posts` opt is true, adds to each post a :prev key 367 | pointing to the filename of the previous post by date and a :next key pointing 368 | to the filename of the next post by date. Preview posts are skipped unless the 369 | `:include-preview-posts-in-linking` is true." 370 | [{:keys [posts link-prev-next-posts include-preview-posts-in-linking] 371 | :as opts}] 372 | (if link-prev-next-posts 373 | (let [remove-preview-posts (if include-preview-posts-in-linking 374 | identity 375 | #(remove (comp :preview val) %)) 376 | post-keys (->> posts 377 | remove-preview-posts 378 | (sort-by (comp :date val)) 379 | (mapv first))] 380 | (assoc opts :posts 381 | ;; We need to merge the linked posts on top of the original ones 382 | ;; so that preview posts are still present even when they're 383 | ;; excluded from linking 384 | (merge posts 385 | (->> post-keys 386 | (map-indexed 387 | (fn [i k] 388 | [k (assoc (posts k) 389 | :prev (when (pos? i) 390 | (post-keys (dec i))) 391 | :next (when (< i (dec (count post-keys))) 392 | (post-keys (inc i))))])) 393 | (into {}))))) 394 | opts)) 395 | 396 | (defn refresh-cache [{:keys [cached-posts posts] :as opts}] 397 | ;; watch mode manages caching manually, so if cached-posts and posts are 398 | ;; already set, use them as is 399 | (let [cached-posts (if cached-posts 400 | cached-posts 401 | (load-cache opts)) 402 | opts (assoc opts :cached-posts cached-posts) 403 | posts (if posts 404 | posts 405 | (load-posts opts)) 406 | opts (assoc opts :posts posts) 407 | opts (assoc-prev-next opts) 408 | opts (assoc opts :modified-metadata (modified-metadata opts)) 409 | modified-drafts (distinct (posts-with-modified-draft-statuses opts)) 410 | opts (assoc opts :modified-drafts modified-drafts)] 411 | (assoc opts 412 | :deleted-posts (deleted-posts opts) 413 | :modified-posts (modified-posts opts) 414 | :modified-tags (modified-tags opts)))) 415 | 416 | (defn migrate-post [{:keys [default-metadata posts-dir] :as opts} 417 | {:keys [file title date tags categories legacy]}] 418 | (let [post-file (fs/file posts-dir file) 419 | post (load-post opts post-file)] 420 | (if (every? post required-metadata) 421 | (println (format "Post %s already contains metadata; skipping migration" 422 | (str file))) 423 | (let [contents (slurp post-file) 424 | tags (or tags categories) 425 | metadata (assoc default-metadata 426 | :title title 427 | :date date 428 | :tags (str/join "," tags)) 429 | metadata (merge metadata 430 | (when legacy 431 | {:legacy true})) 432 | metadata-str (->> metadata 433 | (map (fn [[k v]] 434 | (format "%s: %s" 435 | (str/capitalize (name k)) v))) 436 | (str/join "\n"))] 437 | (spit post-file (format "%s\n\n%s" metadata-str contents)) 438 | (println "Migrated file:" (str file)))))) 439 | 440 | (defn posts-by-tag [posts] 441 | (->> (vals posts) 442 | remove-previews 443 | (sort-by :date) 444 | (mapcat (fn [{:keys [tags] :as post}] 445 | (map (fn [tag] [tag post]) tags))) 446 | (reduce (fn [acc [tag post]] 447 | (update acc tag #(conj % post))) 448 | {}))) 449 | 450 | (defn- load-favicon [{:keys [favicon 451 | templates-dir] 452 | :as opts}] 453 | (when favicon 454 | (-> (fs/file templates-dir favicon-template) 455 | (ensure-resource (fs/file templates-resource-dir favicon-template)) 456 | slurp 457 | (selmer/render opts)))) 458 | 459 | (defn archive-links [title posts {:keys [relative-path page-suffix] :as opts}] 460 | (let [post-links-template (ensure-template opts "archive.html") 461 | post-links (for [{:keys [file title date preview]} posts 462 | :when (not preview)] 463 | {:url (str relative-path (str/replace file ".md" page-suffix)) 464 | :title title 465 | :date date}) 466 | by-year (group-by #(subs (:date %) 0 4) post-links) 467 | post-groups (for [[k v] by-year] 468 | {:year (str k) 469 | :post-links v}) 470 | post-groups (sort-by (comp parse-long :year) > post-groups)] 471 | (selmer/render (slurp post-links-template) {:title title 472 | :post-groups post-groups}))) 473 | 474 | (defn post-links [title posts {:keys [relative-path page-suffix] :as opts}] 475 | (let [post-links-template (ensure-template opts "post-links.html") 476 | post-links (for [{:keys [file title date preview]} posts 477 | :when (not preview)] 478 | {:url (str relative-path (str/replace file ".md" page-suffix)) 479 | :title title 480 | :date date})] 481 | (selmer/render (slurp post-links-template) {:title title 482 | :post-links post-links}))) 483 | 484 | (defn tag-links [title tags opts] 485 | (let [tags-template (ensure-template opts "tags.html") 486 | tags (map (fn [[tag posts]] {:url (str (escape-tag tag) (:page-suffix opts)) 487 | :tag tag 488 | :count (count posts)}) tags)] 489 | (selmer/render (slurp tags-template) {:title title 490 | :tags (sort-by (comp - :count) tags)}))) 491 | 492 | (defn render-page [opts template template-vars] 493 | (let [template-vars (merge opts template-vars) 494 | template-vars (assoc template-vars 495 | :favicon-tags (load-favicon template-vars))] 496 | (selmer/render template template-vars))) 497 | 498 | (defn write-post! [{:keys [twitter-handle 499 | discuss-link 500 | out-dir 501 | page-suffix 502 | page-template 503 | post-template] 504 | :as opts} 505 | {:keys [file html description image image-alt] 506 | :as post-metadata}] 507 | (let [out-file (fs/file out-dir (html-file file)) 508 | post-metadata (->> (assoc post-metadata :body @html) 509 | (merge {:discuss discuss-link :page-suffix page-suffix}) 510 | (expand-prev-next-metadata opts)) 511 | body (selmer/render post-template post-metadata) 512 | author (-> (:twitter-handle post-metadata) (or twitter-handle)) 513 | image (when image (if (re-matches #"^https?://.+" image) 514 | image 515 | (blog-link opts image))) 516 | url (blog-link opts (html-file file)) 517 | post-metadata (merge {:sharing (->map description 518 | author 519 | twitter-handle 520 | image 521 | image-alt 522 | url)} 523 | (assoc post-metadata :body body)) 524 | rendered-html (render-page opts page-template post-metadata)] 525 | (println "Writing post:" (str out-file)) 526 | (spit out-file rendered-html))) 527 | 528 | (defn write-page! [opts out-file 529 | template template-vars] 530 | (println "Writing page:" (str out-file)) 531 | (->> (render-page opts template template-vars) 532 | (spit out-file))) 533 | 534 | (defn write-tag! [{:keys [blog-title blog-description 535 | blog-image blog-image-alt twitter-handle 536 | modified-tags] :as opts} 537 | tags-out-dir 538 | template 539 | [tag posts]] 540 | (let [tag-filename (fs/file tags-out-dir (tag-file tag))] 541 | (when (or (contains? (set modified-tags) tag) (not (fs/exists? tag-filename))) 542 | (write-page! opts tag-filename template 543 | {:skip-archive true 544 | :title (str blog-title " - Tag - " tag) 545 | :relative-path "../" 546 | :body (post-links (str "Tag - " tag) posts 547 | (assoc opts :relative-path "../")) 548 | :sharing {:description (format "Posts tagged \"%s\" - %s" 549 | tag blog-description) 550 | :author twitter-handle 551 | :twitter-handle twitter-handle 552 | :image (blog-link opts blog-image) 553 | :image-alt blog-image-alt 554 | :url (blog-link opts "tags/index.html")}})))) 555 | -------------------------------------------------------------------------------- /test/quickblog/api_test.clj: -------------------------------------------------------------------------------- 1 | (ns quickblog.api-test 2 | (:require 3 | [babashka.fs :as fs] 4 | [clojure.data.xml :as xml] 5 | [clojure.string :as str] 6 | [clojure.test :refer [deftest is testing use-fixtures]] 7 | [quickblog.api :as api] 8 | [quickblog.internal :as lib]) 9 | (:import (java.util UUID))) 10 | 11 | (def test-dir ".test") 12 | 13 | (use-fixtures :each 14 | (fn [test-fn] 15 | (with-out-str 16 | (test-fn) 17 | (fs/delete-tree test-dir)))) 18 | 19 | (defn debug [& xs] 20 | (binding [*out* *err*] 21 | (apply println xs))) 22 | 23 | (defn- tmp-dir [dir-name] 24 | (fs/file test-dir 25 | (format "quickblog-test-%s-%s" dir-name (str (UUID/randomUUID))))) 26 | 27 | (defmacro with-dirs 28 | "dirs is a seq of directory names; e.g. [cache-dir out-dir]" 29 | [dirs & body] 30 | (let [binding-form# (mapcat (fn [dir] [dir `(tmp-dir ~(str dir))]) dirs)] 31 | `(let [~@binding-form#] 32 | ~@body))) 33 | 34 | (defn- write-test-file [dir filename content] 35 | (fs/create-dirs dir) 36 | (let [f (fs/file dir filename)] 37 | (spit f content) 38 | f)) 39 | 40 | (defn- write-test-post 41 | ([posts-dir] 42 | (write-test-post posts-dir {})) 43 | ([posts-dir {:keys [file title date tags content preview?] 44 | :or {file "test.md" 45 | title "Test post" 46 | date "2022-01-02" 47 | tags #{"clojure"} 48 | content "Write a blog post here!"}}] 49 | (let [preview-str (if preview? "Preview: true" "")] 50 | (write-test-file posts-dir file 51 | (format "Title: %s 52 | Date: %s 53 | Tags: %s 54 | %s 55 | 56 | %s" 57 | title date (str/join "," tags) preview-str content))))) 58 | 59 | (deftest new-test 60 | (testing "happy path" 61 | (with-dirs [posts-dir] 62 | (api/new {:posts-dir posts-dir 63 | :date "1970-01-01" 64 | :file "test.md" 65 | :title "Test post" 66 | :tags ["clojure" "some other tag"]}) 67 | (let [post-file (fs/file posts-dir "test.md")] 68 | (is (fs/exists? post-file)) 69 | (is (= "Title: Test post\nDate: 1970-01-01\nTags: clojure,some other tag\n\nWrite a blog post here!" 70 | (slurp post-file)))))) 71 | 72 | (testing "defaults" 73 | (with-dirs [posts-dir] 74 | (with-redefs [api/now (constantly "2022-01-02")] 75 | (api/new {:posts-dir posts-dir 76 | :file "test.md" 77 | :title "Test post"}) 78 | (let [post-file (fs/file posts-dir "test.md")] 79 | (is (fs/exists? post-file)) 80 | (is (= "Title: Test post\nDate: 2022-01-02\nTags: clojure\n\nWrite a blog post here!" 81 | (slurp post-file))))))) 82 | 83 | (testing "template" 84 | (with-dirs [assets-dir posts-dir tmp-dir] 85 | (write-test-file tmp-dir "new-post.md" 86 | (str/join "\n" 87 | ["Title: {{title}}" 88 | "Date: {{date}}" 89 | "Tags: {{tags|join:\",\"}}" 90 | "Image: {% if image %}{{image}}{% else %}{{assets-dir}}/{{file|replace:.md:.png}}{% endif %}" 91 | "Image-Alt: {{image-alt|default:FIXME}}" 92 | "Discuss: {{discuss|default:FIXME}}" 93 | "{% if preview %}Preview: true\n{% endif %}" 94 | "Write a blog post here!"])) 95 | (api/new {:assets-dir assets-dir 96 | :posts-dir posts-dir 97 | :date "1970-01-01" 98 | :file "test.md" 99 | :title "Test post" 100 | :tags ["clojure" "some other tag"] 101 | :template-file (fs/file tmp-dir "new-post.md")}) 102 | (let [post-file (fs/file posts-dir "test.md")] 103 | (is (fs/exists? post-file)) 104 | (is (= (str/join "\n" 105 | ["Title: Test post" 106 | "Date: 1970-01-01" 107 | "Tags: clojure,some other tag" 108 | (format "Image: %s/test.png" assets-dir) 109 | "Image-Alt: FIXME" 110 | "Discuss: FIXME" 111 | "" 112 | "Write a blog post here!"]) 113 | (slurp post-file))))))) 114 | 115 | (deftest migrate 116 | (with-dirs [posts-dir] 117 | (let [posts-edn (write-test-file posts-dir "posts.edn" 118 | {:file "test.md" 119 | :title "Test post" 120 | :date "2022-01-02" 121 | :tags #{"clojure"}}) 122 | post-file (write-test-file posts-dir "test.md" 123 | "Write a blog post here!") 124 | to-lines #(-> % str/split-lines set)] 125 | (api/migrate {:posts-dir posts-dir 126 | :posts-file posts-edn}) 127 | (is (= (to-lines "Title: Test post\nDate: 2022-01-02\nTags: clojure\n\nWrite a blog post here!") 128 | (to-lines (slurp post-file))))))) 129 | 130 | (deftest render 131 | (testing "happy path" 132 | (with-dirs [assets-dir 133 | posts-dir 134 | templates-dir 135 | cache-dir 136 | out-dir] 137 | (write-test-post posts-dir {:tags #{"clojure" "tag with spaces"}}) 138 | (write-test-post posts-dir {:file "preview.md" 139 | :title "This is a preview" 140 | :tags #{"preview"} 141 | :preview? true}) 142 | (write-test-file assets-dir "asset.txt" "something") 143 | (api/render {:assets-dir assets-dir 144 | :posts-dir posts-dir 145 | :templates-dir templates-dir 146 | :cache-dir cache-dir 147 | :out-dir out-dir}) 148 | (is (fs/exists? (fs/file out-dir "assets" "asset.txt"))) 149 | (doseq [filename ["base.html" "post.html" "style.css"]] 150 | (is (fs/exists? (fs/file templates-dir filename)))) 151 | (is (fs/exists? (fs/file cache-dir "prod" "test.md.pre-template.html"))) 152 | (is (fs/exists? (fs/file cache-dir "prod" "preview.md.pre-template.html"))) 153 | (doseq [filename ["test.html" "preview.html" "index.html" "archive.html" 154 | (fs/file "tags" "index.html") 155 | (fs/file "tags" "clojure.html") 156 | (fs/file "tags" "tag-with-spaces.html") 157 | "atom.xml" "planetclojure.xml"]] 158 | (is (fs/exists? (fs/file out-dir filename)))) 159 | (is (str/includes? (slurp (fs/file out-dir "test.html")) 160 | "<a href=\"tags/tag-with-spaces.html\">tag with spaces</a>")) 161 | (is (str/includes? (slurp (fs/file out-dir "tags" "index.html")) 162 | "<a href=\"tag-with-spaces.html\">tag with spaces</a>")) 163 | ;; Preview posts should be omitted from index, tags, and feeds 164 | (is (not (fs/exists? (fs/file out-dir "tags" "preview.html")))) 165 | (doseq [filename ["index.html" "atom.xml" "planetclojure.xml"]] 166 | (is (not (str/includes? (slurp (fs/file out-dir filename)) 167 | "preview.html")))))) 168 | 169 | (testing "with blank page suffix" 170 | (with-dirs [posts-dir 171 | templates-dir 172 | out-dir] 173 | (write-test-post posts-dir {:tags #{"foobar" "tag with spaces"}}) 174 | (api/render {:page-suffix "" 175 | :posts-dir posts-dir 176 | :templates-dir templates-dir 177 | :out-dir out-dir}) 178 | (doseq [filename ["test.html" "index.html" "archive.html" 179 | (fs/file "tags" "index.html") 180 | (fs/file "tags" "tag-with-spaces.html") 181 | "atom.xml" "planetclojure.xml"]] 182 | (is (fs/exists? (fs/file out-dir filename)))) 183 | (is (str/includes? (slurp (fs/file out-dir "test.html")) 184 | "<a class=\"page-link\" href=\"archive\">Archive</a>")) 185 | (is (str/includes? (slurp (fs/file out-dir "test.html")) 186 | "<a href=\"tags/foobar\">foobar</a>")) 187 | (is (str/includes? (slurp (fs/file out-dir "test.html")) 188 | "<a href=\"tags/tag-with-spaces\">tag with spaces</a>")) 189 | (is (str/includes? (slurp (fs/file out-dir "tags" "index.html")) 190 | "<a href=\"foobar\">foobar</a>")) 191 | (is (str/includes? (slurp (fs/file out-dir "tags" "index.html")) 192 | "<a href=\"tag-with-spaces\">tag with spaces</a>")))) 193 | 194 | (testing "with favicon" 195 | (with-dirs [favicon-dir 196 | posts-dir 197 | templates-dir 198 | cache-dir 199 | out-dir] 200 | (let [favicon-out-dir (fs/file out-dir "favicon")] 201 | (write-test-post posts-dir) 202 | (api/render {:favicon true 203 | :favicon-dir favicon-dir 204 | :favicon-out-dir favicon-out-dir 205 | :posts-dir posts-dir 206 | :templates-dir templates-dir 207 | :cache-dir cache-dir 208 | :out-dir out-dir}) 209 | (is (fs/exists? (fs/file templates-dir "favicon.html"))) 210 | (doseq [filename (var-get #'api/favicon-assets)] 211 | (is (fs/exists? (fs/file favicon-dir filename))) 212 | (is (fs/exists? (fs/file favicon-out-dir filename)))) 213 | (is (str/includes? (slurp (fs/file out-dir "index.html")) 214 | "favicon-16x16.png"))))) 215 | 216 | (testing "preview" 217 | (with-dirs [posts-dir 218 | templates-dir 219 | cache-dir 220 | out-dir] 221 | (write-test-post posts-dir {:file "preview.md" 222 | :content (str "always included\n\n" 223 | "<!-- end-of-preview -->\n\n" 224 | "only part of full post")}) 225 | (api/render {:posts-dir posts-dir 226 | :templates-dir templates-dir 227 | :cache-dir cache-dir 228 | :out-dir out-dir}) 229 | (is (str/includes? (slurp (fs/file out-dir "preview.html")) "<p>always included</p>")) 230 | (is (str/includes? (slurp (fs/file out-dir "preview.html")) "<p>only part of full post</p>")) 231 | (is (str/includes? (slurp (fs/file out-dir "index.html")) "<p>always included</p>")) 232 | (is (not (str/includes? (slurp (fs/file out-dir "index.html")) "<p>only part of full post</p>"))))) 233 | 234 | (testing "multiline links" 235 | (with-dirs [posts-dir 236 | templates-dir 237 | cache-dir 238 | out-dir] 239 | (write-test-post posts-dir {:file "multiline.md" 240 | :content "[a \n\n multiline \n\n link](www.example.org)"}) 241 | (api/render {:posts-dir posts-dir 242 | :templates-dir templates-dir 243 | :cache-dir cache-dir 244 | :out-dir out-dir}) 245 | (is (str/includes? (slurp (fs/file out-dir "multiline.html")) 246 | "<a href='www.example.org'>a \n\n multiline \n\n link</a>")))) 247 | 248 | (testing "tag with capitals" 249 | (with-dirs [assets-dir 250 | posts-dir 251 | templates-dir 252 | cache-dir 253 | out-dir] 254 | (write-test-post posts-dir {:content "Post about ClojureScript" 255 | :tags #{"ClojureScript"}}) 256 | (api/render {:assets-dir assets-dir 257 | :posts-dir posts-dir 258 | :templates-dir templates-dir 259 | :cache-dir cache-dir 260 | :out-dir out-dir}) 261 | (is (str/includes? (slurp (fs/file out-dir "planetclojure.xml")) "Post about ClojureScript")))) 262 | 263 | (testing "non-Clojure tag" 264 | (with-dirs [assets-dir 265 | posts-dir 266 | templates-dir 267 | cache-dir 268 | out-dir] 269 | (write-test-post posts-dir {:content "Post about Elixir" 270 | :tags #{"elixir"}}) 271 | (api/render {:assets-dir assets-dir 272 | :posts-dir posts-dir 273 | :templates-dir templates-dir 274 | :cache-dir cache-dir 275 | :out-dir out-dir}) 276 | (is (fs/exists? (fs/file out-dir "planetclojure.xml"))) 277 | (is (not (str/includes? (slurp (fs/file out-dir "planetclojure.xml")) "Post about Elixir"))))) 278 | 279 | (testing "comments" 280 | (with-dirs [posts-dir 281 | templates-dir 282 | cache-dir 283 | out-dir] 284 | (write-test-post posts-dir {:file "comments.md" 285 | :content "<!-- a comment -->"}) 286 | (api/render {:posts-dir posts-dir 287 | :templates-dir templates-dir 288 | :cache-dir cache-dir 289 | :out-dir out-dir}) 290 | (is (str/includes? (slurp (fs/file out-dir "comments.html")) "<!-- a comment -->")))) 291 | 292 | (testing "remove live reloading on render" 293 | (with-dirs [posts-dir 294 | templates-dir 295 | cache-dir 296 | out-dir] 297 | (write-test-post posts-dir) 298 | (api/render {:posts-dir posts-dir 299 | :templates-dir templates-dir 300 | :cache-dir cache-dir 301 | :out-dir out-dir 302 | :watch lib/live-reload-script}) 303 | (is (str/includes? (slurp (fs/file out-dir "test.html")) lib/live-reload-script)) 304 | (api/render {:posts-dir posts-dir 305 | :templates-dir templates-dir 306 | :cache-dir cache-dir 307 | :out-dir out-dir}) 308 | (is (not (str/includes? (slurp (fs/file out-dir "test.html")) lib/live-reload-script)))))) 309 | 310 | (deftest caching 311 | (testing "assets" 312 | (with-dirs [assets-dir 313 | posts-dir 314 | templates-dir 315 | cache-dir 316 | out-dir] 317 | (let [render #(api/render {:assets-dir assets-dir 318 | :posts-dir posts-dir 319 | :templates-dir templates-dir 320 | :cache-dir cache-dir 321 | :out-dir out-dir})] 322 | (Thread/sleep 5) 323 | (write-test-post posts-dir) 324 | (write-test-file assets-dir "asset.txt" "something") 325 | (render) 326 | (let [asset-file (fs/file out-dir "assets" "asset.txt") 327 | mtime (fs/last-modified-time asset-file)] 328 | ;; Shouldn't copy unmodified file 329 | (render) 330 | (is (= mtime (fs/last-modified-time asset-file))) 331 | ;; Should copy modified file 332 | (write-test-file assets-dir "asset.txt" "something else") 333 | (render) 334 | (is (not= mtime (fs/last-modified-time asset-file))))))) 335 | 336 | (testing "posts" 337 | (with-dirs [posts-dir 338 | templates-dir 339 | cache-dir 340 | out-dir] 341 | (let [render #(api/render {:posts-dir posts-dir 342 | :templates-dir templates-dir 343 | :cache-dir cache-dir 344 | :out-dir out-dir}) 345 | cache-dir (fs/file cache-dir "prod")] 346 | (write-test-post posts-dir) 347 | (render) 348 | ;; We need to render again, since the first render will have written 349 | ;; default templates for pages, post links, archive, and index after the 350 | ;; post, which means the post will be considered modified relative to 351 | ;; the templates dir 352 | (render) 353 | (let [->mtimes (fn [dir filenames] 354 | (->> filenames 355 | (map #(let [filename (fs/file dir %)] 356 | [filename (fs/last-modified-time filename)])) 357 | (into {}))) 358 | content-cached (merge (->mtimes cache-dir ["test.md.pre-template.html"]) 359 | (->mtimes out-dir ["test.html" "index.html" 360 | "atom.xml" "planetclojure.xml"])) 361 | metadata-cached (merge (->mtimes out-dir ["archive.html"]) 362 | (->mtimes (fs/file out-dir "tags") 363 | ["index.html"])) 364 | clojure-metadata-cached (merge metadata-cached 365 | (->mtimes (fs/file out-dir "tags") 366 | ["clojure.html"]))] 367 | ;; Shouldn't rewrite anything when post unmodified 368 | (render) 369 | (doseq [[filename mtime] (merge content-cached clojure-metadata-cached)] 370 | (is (= (map str [filename mtime]) 371 | (map str [filename (fs/last-modified-time filename)])))) 372 | ;; Should rewrite all but metadata-cached files when post modified 373 | (Thread/sleep 5) 374 | (write-test-post posts-dir) 375 | (render) 376 | (doseq [[filename mtime] content-cached] 377 | (is (not= (map str [filename mtime]) 378 | (map str [filename (fs/last-modified-time filename)])))) 379 | (doseq [[filename mtime] clojure-metadata-cached] 380 | (is (= (map str [filename mtime]) 381 | (map str [filename (fs/last-modified-time filename)])))) 382 | ;; Should rewrite everything when metadata modified 383 | (Thread/sleep 5) 384 | (write-test-post posts-dir {:title "Changed", :tags #{"not-clojure"}}) 385 | (render) 386 | (doseq [[filename mtime] (merge content-cached metadata-cached)] 387 | (is (not= (map str [filename mtime]) 388 | (map str [filename (fs/last-modified-time filename)])))) 389 | (is (fs/exists? (fs/file out-dir "tags" "not-clojure.html"))) 390 | (is (not (fs/exists? (fs/file out-dir "tags" "clojure.html")))))))) 391 | 392 | (testing "feeds" 393 | (with-dirs [assets-dir 394 | posts-dir 395 | templates-dir 396 | cache-dir 397 | out-dir] 398 | (let [render #(api/render {:assets-dir assets-dir 399 | :posts-dir posts-dir 400 | :templates-dir templates-dir 401 | :cache-dir cache-dir 402 | :out-dir out-dir}) 403 | ->mtimes (fn [dir filenames] 404 | (->> filenames 405 | (map #(let [filename (fs/file dir %)] 406 | [filename (fs/last-modified-time filename)])) 407 | (into {}))) 408 | elem-tagged? (fn [tag el] 409 | (let [tag (keyword (str "xmlns.http%3A%2F%2Fwww.w3.org%2F2005%2FAtom/" (name tag)))] 410 | (and (instance? clojure.data.xml.node.Element el) 411 | (= tag (:tag el))))) 412 | post-ids (fn [filename] 413 | (->> (xml/parse-str (slurp filename)) 414 | :content 415 | (filter (partial elem-tagged? :entry)) 416 | (mapcat (fn [el] 417 | (->> (:content el) 418 | (filter (partial elem-tagged? :id)) 419 | (map (comp #(str/replace % #".+/" "") 420 | first 421 | :content))))) 422 | set))] 423 | (write-test-post posts-dir {:file "clojure1.md" 424 | :tags #{"clojure" "something"}}) 425 | (write-test-post posts-dir {:file "clojurescript1.md" 426 | :tags #{"clojurescript" "something-else"}}) 427 | (write-test-post posts-dir {:file "random1.md" 428 | :tags #{"something-else"}}) 429 | (render) 430 | (is (= #{"clojure1.html" 431 | "clojurescript1.html" 432 | "random1.html"} 433 | (post-ids (fs/file out-dir "atom.xml")))) 434 | (is (= #{"clojure1.html" 435 | "clojurescript1.html"} 436 | (post-ids (fs/file out-dir "planetclojure.xml")))) 437 | (Thread/sleep 5) 438 | (write-test-post posts-dir {:file "clojure2.md" 439 | :tags #{"clojure"}}) 440 | (write-test-post posts-dir {:file "random2.md" 441 | :tags #{"something"}}) 442 | (let [mtimes (->mtimes out-dir ["atom.xml" "planetclojure.xml"]) 443 | _ (render) 444 | mtimes-after (->mtimes out-dir ["atom.xml" "planetclojure.xml"])] 445 | (doseq [[filename mtime] mtimes-after] 446 | (is (not= [filename mtime] [filename (mtimes filename)])))) 447 | (is (= #{"clojure1.html" 448 | "clojure2.html" 449 | "clojurescript1.html" 450 | "random1.html" 451 | "random2.html"} 452 | (post-ids (fs/file out-dir "atom.xml")))) 453 | (is (= #{"clojure1.html" 454 | "clojure2.html" 455 | "clojurescript1.html"} 456 | (post-ids (fs/file out-dir "planetclojure.xml")))) 457 | (let [mtimes (->mtimes out-dir ["atom.xml" "planetclojure.xml"]) 458 | _ (render) 459 | mtimes-after (->mtimes out-dir ["atom.xml" "planetclojure.xml"])] 460 | (doseq [[filename mtime] mtimes-after] 461 | (is (= [filename mtime] [filename (mtimes filename)])))) 462 | (is (= #{"clojure1.html" 463 | "clojure2.html" 464 | "clojurescript1.html" 465 | "random1.html" 466 | "random2.html"} 467 | (post-ids (fs/file out-dir "atom.xml")))) 468 | (is (= #{"clojure1.html" 469 | "clojure2.html" 470 | "clojurescript1.html"} 471 | (post-ids (fs/file out-dir "planetclojure.xml")))))))) 472 | 473 | (defn- test-sharing [filename {:keys [title description image image-alt 474 | author twitter-handle]}] 475 | (let [meta (->> (slurp filename) 476 | (re-seq #"<meta (?:name|property)=\"([^\"]+)\" content=\"([^\"]+)\">") 477 | (map (fn [[_ k v]] [k v])) 478 | (into {}))] 479 | (is (= "website" (meta "og:type"))) 480 | (is (= "summary_large_image" (meta "twitter:card"))) 481 | (is (= title (meta "title"))) 482 | (is (= title (meta "og:title"))) 483 | (is (= title (meta "twitter:title"))) 484 | (is (= description (meta "description"))) 485 | (is (= description (meta "og:description"))) 486 | (is (= description (meta "twitter:description"))) 487 | (is (= image (meta "og:image"))) 488 | (is (= image (meta "twitter:image"))) 489 | (is (= image-alt (meta "og:image:alt"))) 490 | (is (= author (meta "twitter:creator"))) 491 | (is (= twitter-handle (meta "twitter:site"))))) 492 | 493 | (deftest social-sharing 494 | (with-dirs [assets-dir 495 | posts-dir 496 | templates-dir 497 | cache-dir 498 | out-dir] 499 | (let [blog-title "quickblog" 500 | blog-description "A blog about blogging quickly" 501 | blog-root "http://localhost:1888" 502 | blog-image "assets/blog-preview.png" 503 | blog-image-alt "A shimmering sunset" 504 | twitter-handle "quickblogger"] 505 | (write-test-file posts-dir "test.md" 506 | (str "Title: Test post\n" 507 | "Date: 2022-01-02\n" 508 | "Tags: clojure\n" 509 | "Twitter-Handle: guestblogger\n" 510 | "Description: Something or other\n" 511 | "Image: assets/post-preview.png\n" 512 | "Image-Alt: A leather-bound notebook lies open on a writing desk\n" 513 | "\n" 514 | "This is a test post")) 515 | (api/render {:blog-title blog-title 516 | :blog-description blog-description 517 | :blog-root blog-root 518 | :blog-image blog-image 519 | :blog-image-alt blog-image-alt 520 | :twitter-handle twitter-handle 521 | :assets-dir assets-dir 522 | :posts-dir posts-dir 523 | :templates-dir templates-dir 524 | :cache-dir cache-dir 525 | :out-dir out-dir}) 526 | (test-sharing (fs/file out-dir "test.html") 527 | {:title "Test post" 528 | :description "Something or other" 529 | :image "http://localhost:1888/assets/post-preview.png" 530 | :image-alt "A leather-bound notebook lies open on a writing desk" 531 | :author "guestblogger" 532 | :twitter-handle "quickblogger"}) 533 | (test-sharing (fs/file out-dir "index.html") 534 | {:title blog-title 535 | :description blog-description 536 | :image (format "%s/%s" blog-root blog-image) 537 | :image-alt blog-image-alt 538 | :author twitter-handle 539 | :twitter-handle twitter-handle}) 540 | (test-sharing (fs/file out-dir "archive.html") 541 | {:title (str blog-title " - Archive") 542 | :description (str "Archive - " blog-description) 543 | :image (format "%s/%s" blog-root blog-image) 544 | :image-alt blog-image-alt 545 | :author twitter-handle 546 | :twitter-handle twitter-handle}) 547 | (test-sharing (fs/file out-dir "tags" "index.html") 548 | {:title (str blog-title " - Tags") 549 | :description (str "Tags - " blog-description) 550 | :image (format "%s/%s" blog-root blog-image) 551 | :image-alt blog-image-alt 552 | :author twitter-handle 553 | :twitter-handle twitter-handle}) 554 | (test-sharing (fs/file out-dir "tags" "clojure.html") 555 | {:title (str blog-title " - Tag - clojure") 556 | :description (str "Posts tagged "clojure" - " blog-description) 557 | :image (format "%s/%s" blog-root blog-image) 558 | :image-alt blog-image-alt 559 | :author twitter-handle 560 | :twitter-handle twitter-handle})))) 561 | 562 | (deftest refresh-templates 563 | ;; This fails in CI, why? /cc @jmglov 564 | #_(with-dirs [templates-dir] 565 | (fs/create-dirs templates-dir) 566 | (let [default-templates ["base.html" "post.html" "favicon.html" "style.css"] 567 | custom-templates ["template1.html" "some-file.txt"] 568 | mtimes (->> (concat default-templates custom-templates) 569 | (map #(let [filename % 570 | file (fs/file templates-dir filename)] 571 | (spit file filename) 572 | [filename (str (fs/last-modified-time file))])) 573 | (into {}))] 574 | (api/refresh-templates {:templates-dir templates-dir}) 575 | (doseq [filename default-templates 576 | :let [file (fs/file templates-dir filename) 577 | mtime (str (fs/last-modified-time file))]] 578 | (is (not= [filename (mtimes filename)] 579 | [filename mtime]))) 580 | (doseq [filename custom-templates 581 | :let [file (fs/file templates-dir filename) 582 | mtime (str (fs/last-modified-time file))]] 583 | (is (= [filename (mtimes filename)] 584 | [filename mtime])))))) 585 | 586 | (deftest link-prev-next-posts 587 | (let [posts (->> (range 1 5) 588 | (map (fn [i] 589 | {:file (format "post%s.md" i) 590 | :title (format "post%s" i) 591 | :date (format "2024-02-0%s" i) 592 | :preview? (= 3 i)}))) 593 | write-templates! (fn [templates-dir] 594 | (fs/create-dirs templates-dir) 595 | (spit (fs/file templates-dir "base.html") 596 | "{{body | safe }}") 597 | (spit (fs/file templates-dir "index.html") 598 | (str "{% for post in posts %}" 599 | "{{post.title}}\n" 600 | "{% if all forloop.last post.prev %}" 601 | "prev: {{post.prev.title}}" 602 | "{% endif %}" 603 | "{% endfor %}")) 604 | (spit (fs/file templates-dir "post.html") 605 | (str "{% if prev %}prev: {{prev.title}}{% endif %}" 606 | "\n" 607 | "{% if next %}next: {{next.title}}{% endif %}")))] 608 | 609 | (testing "add prev and next posts to post metadata" 610 | (with-dirs [posts-dir 611 | templates-dir 612 | cache-dir 613 | out-dir] 614 | (doseq [post posts] 615 | (write-test-post posts-dir post)) 616 | (write-templates! templates-dir) 617 | (api/render {:posts-dir posts-dir 618 | :templates-dir templates-dir 619 | :cache-dir cache-dir 620 | :out-dir out-dir 621 | :link-prev-next-posts true 622 | :num-index-posts 2}) 623 | (is (= "\nnext: post2" 624 | (slurp (fs/file out-dir "post1.html")))) 625 | (is (= "prev: post1\nnext: post4" 626 | (slurp (fs/file out-dir "post2.html")))) 627 | (is (= "\n" 628 | (slurp (fs/file out-dir "post3.html")))) 629 | (is (= "prev: post2\n" 630 | (slurp (fs/file out-dir "post4.html")))) 631 | (is (= "post4\npost2\nprev: post1" 632 | (slurp (fs/file out-dir "index.html")))))) 633 | 634 | (testing "include preview posts" 635 | (with-dirs [posts-dir 636 | templates-dir 637 | cache-dir 638 | out-dir] 639 | (doseq [post posts] 640 | (write-test-post posts-dir post)) 641 | (write-templates! templates-dir) 642 | (api/render {:posts-dir posts-dir 643 | :templates-dir templates-dir 644 | :cache-dir cache-dir 645 | :out-dir out-dir 646 | :link-prev-next-posts true 647 | :include-preview-posts-in-linking true}) 648 | (is (= "\nnext: post2" 649 | (slurp (fs/file out-dir "post1.html")))) 650 | (is (= "prev: post1\nnext: post3" 651 | (slurp (fs/file out-dir "post2.html")))) 652 | (is (= "prev: post2\nnext: post4" 653 | (slurp (fs/file out-dir "post3.html")))) 654 | (is (= "prev: post3\n" 655 | (slurp (fs/file out-dir "post4.html")))))))) 656 | 657 | (deftest preview-tag-caching-test 658 | (testing "Tag pages are regenerated when preview status changes" 659 | (with-dirs [tmp-dir cache-dir] 660 | (let [opts {:blog-title "Test" 661 | :blog-author "Test Author" 662 | :blog-root "https://example.com" 663 | :blog-description "Test blog" 664 | :posts-dir (fs/file tmp-dir "posts") 665 | :out-dir (fs/file tmp-dir "out") 666 | :cache-dir cache-dir 667 | :force-render false}] 668 | (write-test-post (:posts-dir opts) 669 | {:file "post1.md" 670 | :title "Post 1" 671 | :date "2023-01-01" 672 | :tags #{"clojure" "blog"} 673 | :preview? false}) 674 | 675 | ;; First render 676 | (testing "Initial render creates tag pages" 677 | (api/render opts) 678 | (is (fs/exists? (fs/file (:out-dir opts) "tags" "clojure.html"))) 679 | (is (fs/exists? (fs/file (:out-dir opts) "tags" "blog.html"))) 680 | (let [clojure-tag-content (slurp (fs/file (:out-dir opts) "tags" "clojure.html"))] 681 | (is (str/includes? clojure-tag-content "Post 1") 682 | "Non-preview post should appear in tag page"))) 683 | 684 | ;; Change preview to true 685 | (testing "Tag pages regenerate when preview status changes" 686 | (Thread/sleep 500) 687 | (write-test-post (:posts-dir opts) 688 | {:file "post1.md" 689 | :title "Post 1" 690 | :date "2023-01-01" 691 | :tags #{"clojure" "blog"} 692 | :preview? true}) 693 | (Thread/sleep 500) 694 | (api/render opts) 695 | (is (not (fs/exists? (fs/file (:out-dir opts) "tags" "clojure.html")))) 696 | (is (not (fs/exists? (fs/file (:out-dir opts) "tags" "blog.html")))) 697 | (Thread/sleep 5) 698 | (write-test-post (:posts-dir opts) 699 | {:file "post2.md" 700 | :title "Post 2" 701 | :date "2023-01-01" 702 | :tags #{"clojure" "blog"} 703 | :preview? false}) 704 | (Thread/sleep 5) 705 | (api/render opts) 706 | (Thread/sleep 5) 707 | (is (fs/exists? (fs/file (:out-dir opts) "tags" "clojure.html"))) 708 | (is (fs/exists? (fs/file (:out-dir opts) "tags" "blog.html")))))))) 709 | -------------------------------------------------------------------------------- /test/quickblog/test_runner.clj: -------------------------------------------------------------------------------- 1 | (ns quickblog.test-runner 2 | {:org.babashka/cli {:coerce {:dirs [:string] 3 | :nses [:symbol] 4 | :patterns [:string] 5 | :vars [:symbol] 6 | :includes [:keyword] 7 | :excludes [:keyword] 8 | :only :symbol}}} 9 | (:refer-clojure :exclude [test]) 10 | (:require 11 | [clojure.test :as test] 12 | [cognitect.test-runner.api :as api])) 13 | 14 | (def fail-meth (get-method test/report :fail)) 15 | (def err-meth (get-method test/report :error)) 16 | 17 | (def test-var (atom {})) 18 | 19 | (def cmd (atom nil)) 20 | 21 | (defn print-only [] 22 | (println) 23 | (println (str @cmd) ":only" (let [v (:var @test-var) 24 | v (meta v)] 25 | (symbol (str (ns-name (:ns v))) (str (:name v)))))) 26 | 27 | (defmethod test/report :fail [m] 28 | (print-only) 29 | (fail-meth m)) 30 | 31 | (defmethod test/report :error [m] 32 | (print-only) 33 | (err-meth m)) 34 | 35 | (defmethod test/report :begin-test-var [m] 36 | (reset! test-var m)) 37 | 38 | (defn test [opts] 39 | (let [_ (reset! cmd (:cmd opts)) 40 | only (:only opts) 41 | opts 42 | (if only 43 | (if (qualified-symbol? only) 44 | (update opts :vars (fnil conj []) only) 45 | (update opts :nses (fnil conj []) only)) 46 | opts)] 47 | (api/test opts))) 48 | --------------------------------------------------------------------------------