├── .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 |
{{blog-description}}
70 |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 |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 |)?(
)?" "") 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 | "tag with spaces")) 161 | (is (str/includes? (slurp (fs/file out-dir "tags" "index.html")) 162 | "tag with spaces")) 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 | "Archive")) 185 | (is (str/includes? (slurp (fs/file out-dir "test.html")) 186 | "foobar")) 187 | (is (str/includes? (slurp (fs/file out-dir "test.html")) 188 | "tag with spaces")) 189 | (is (str/includes? (slurp (fs/file out-dir "tags" "index.html")) 190 | "foobar")) 191 | (is (str/includes? (slurp (fs/file out-dir "tags" "index.html")) 192 | "tag with spaces")))) 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 | "\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")) "always included
")) 230 | (is (str/includes? (slurp (fs/file out-dir "preview.html")) "only part of full post
")) 231 | (is (str/includes? (slurp (fs/file out-dir "index.html")) "always included
")) 232 | (is (not (str/includes? (slurp (fs/file out-dir "index.html")) "only part of full post
"))))) 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 \n\n multiline \n\n link")))) 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 ""}) 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")) "")))) 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 #"") 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 | --------------------------------------------------------------------------------