diff --git a/README.md b/README.md
index bddb575..0d5605a 100644
--- a/README.md
+++ b/README.md
@@ -217,6 +217,26 @@ Resources for understanding and testing social sharing:
- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/)
- [Twitter Card Validator](https://cards-dev.twitter.com/validator)
+### Post links
+
+quickblog adds `prev` and `next` metadata to each post (where `prev` is the
+previous post and `next` is the next post in date order, oldest to newest). You
+can make use of these by adding something similar to this to your `post.html`
+template:
+
+``` html
+{% if any prev next %}
+
+{% if prev %}
+
+{% endif %}
+{% if next %}
+
+{% endif %}
+
+{% endif %}
+```
+
## Templates
quickblog uses the following templates in site generation:
diff --git a/src/quickblog/api.clj b/src/quickblog/api.clj
index 6cf563e..824c747 100644
--- a/src/quickblog/api.clj
+++ b/src/quickblog/api.clj
@@ -630,7 +630,9 @@
:as opts}
(-> opts
(assoc :watch (format ""
- lib/live-reload-script))
+ lib/live-reload-script)
+ ;; Include preview posts in post linking (prev and next)
+ :include-preview-posts? true)
apply-default-opts
render)]
(reset! posts-cache (:posts opts))
diff --git a/src/quickblog/internal.clj b/src/quickblog/internal.clj
index 4c8022b..08596e0 100644
--- a/src/quickblog/internal.clj
+++ b/src/quickblog/internal.clj
@@ -217,6 +217,45 @@
(println error)
true))
+(defn- delink-prev
+ "Make sure we don't have a chain of previous posts"
+ [post]
+ (update post :prev dissoc :prev))
+
+(defn link-posts-reduce-f [{:keys [posts prev] :as acc} post]
+ (let [cur (when prev
+ (-> prev (assoc :next post) delink-prev))
+ post' (if prev
+ (assoc post :prev prev)
+ post)
+ posts' (if cur
+ (conj posts cur)
+ posts)]
+ {:posts posts', :prev post'}))
+
+(defn link-posts
+ "For a map of files to posts, sorts them adds to each post a :prev key pointing
+ to the previous post and a :next key pointing to the next post, where previous
+ and next are defined by the order imposed by `sort-posts`. Preview posts are
+ skipped unless `:include-preview-posts?` is set true in `opts`."
+ [{:keys [include-preview-posts?]} posts]
+ (->> (let [maybe-remove-previews (if include-preview-posts? identity remove-previews)
+ ps (->> posts
+ vals
+ maybe-remove-previews
+ sort-posts
+ reverse
+ (reduce link-posts-reduce-f {:posts [], :prev nil}))]
+ (-> ps :posts (conj (-> ps :prev delink-prev))))
+ (map (fn [{:keys [file] :as post}]
+ [file post]))
+ (into {})
+ ;; We need to merge this with the original posts map because that
+ ;; map might contain preview posts, whereas the linking process
+ ;; intentionally skips preview posts unless the `include-preview-posts?`
+ ;; param is true
+ (merge posts)))
+
(defn load-posts [{:keys [cache-dir cached-posts posts-dir] :as opts}]
(if (fs/exists? posts-dir)
(let [cache-file (fs/file cache-dir cache-filename)
@@ -225,19 +264,20 @@
(set post-paths)
(set (fs/modified-since cache-file post-paths)))
_cached-post-paths (set/difference post-paths modified-post-paths)]
- (merge (->> cached-posts
- (map (fn [[file post]]
- [file (assoc post :html (read-cached-post opts file))]))
- (into {}))
- (->> modified-post-paths
- (map (juxt ->filename (partial load-post opts)))
- (remove (partial has-error? opts))
- (into {}))))
+ (->> (merge (->> cached-posts
+ (map (fn [[file post]]
+ [file (assoc post :html (read-cached-post opts file))]))
+ (into {}))
+ (->> modified-post-paths
+ (map (juxt ->filename (partial load-post opts)))
+ (remove (partial has-error? opts))
+ (into {})))
+ (link-posts opts)))
{}))
(defn only-metadata [posts]
(->> posts
- (map (fn [[file post]] [file (dissoc post :html)]))
+ (map (fn [[file post]] [file (dissoc post :html :prev :next)]))
(into {})))
(defn load-cache [{:keys [cache-dir rendering-system-files]}]
diff --git a/test/quickblog/api_test.clj b/test/quickblog/api_test.clj
index 344bc9e..25eec3d 100644
--- a/test/quickblog/api_test.clj
+++ b/test/quickblog/api_test.clj
@@ -160,12 +160,14 @@
(testing "with blank page suffix"
(with-dirs [posts-dir
templates-dir
- out-dir]
+ out-dir
+ cache-dir]
(write-test-post posts-dir {:tags #{"foobar" "tag with spaces"}})
(api/render {:page-suffix ""
:posts-dir posts-dir
:templates-dir templates-dir
- :out-dir out-dir})
+ :out-dir out-dir
+ :cache-dir cache-dir})
(doseq [filename ["test.html" "index.html" "archive.html"
(fs/file "tags" "index.html")
(fs/file "tags" "tag-with-spaces.html")
@@ -298,6 +300,74 @@
:out-dir out-dir})
(is (not (str/includes? (slurp (fs/file out-dir "test.html")) lib/live-reload-script))))))
+(deftest link-posts
+ (let [posts (->> (range 1 5)
+ (map (fn [i]
+ {:file (format "post%s.md" i)
+ :title (format "post%s" i)
+ :date (format "2024-02-0%s" i)
+ :preview? (= 3 i)})))
+ write-templates! (fn [templates-dir]
+ (fs/create-dirs templates-dir)
+ (spit (fs/file templates-dir "base.html")
+ "{{body | safe }}")
+ (spit (fs/file templates-dir "post.html")
+ (str "{% if prev %}prev: {{prev.title}}{% endif %}"
+ "\n"
+ "{% if next %}next: {{next.title}}{% endif %}")))]
+
+ (testing "add prev and next posts to post metadata"
+ (with-dirs [posts-dir
+ templates-dir
+ cache-dir
+ out-dir]
+ (doseq [post posts]
+ (write-test-post posts-dir post))
+ (write-templates! templates-dir)
+ (api/render {:posts-dir posts-dir
+ :templates-dir templates-dir
+ :cache-dir cache-dir
+ :out-dir out-dir})
+ (is (= (slurp (fs/file out-dir "post1.html"))
+ "\nnext: post2"))
+ (is (= (slurp (fs/file out-dir "post2.html"))
+ "prev: post1\nnext: post4"))
+ (is (= (slurp (fs/file out-dir "post3.html"))
+ "\n"))
+ (is (= (slurp (fs/file out-dir "post4.html"))
+ "prev: post2\n"))
+ (let [cache (lib/load-cache {:cache-dir cache-dir})]
+ (is (nil? (get-in cache ["post1.md" :prev])))
+ (is (= (get-in cache ["post1.md" :next :title "post2"])))
+ (is (= (get-in cache ["post2.md" :prev :title "post1"])))
+ (is (= (get-in cache ["post2.md" :next :title "post4"])))
+ (is (nil? (get-in cache ["post3.md" :prev])))
+ (is (nil? (get-in cache ["post3.md" :next])))
+ (is (= (get-in cache ["post4.md" :prev :title "post2"])))
+ (is (nil? (get-in cache ["post4.md" :next]))))))
+
+ (testing "include preview posts"
+ (with-dirs [posts-dir
+ templates-dir
+ cache-dir
+ out-dir]
+ (doseq [post posts]
+ (write-test-post posts-dir post))
+ (write-templates! templates-dir)
+ (api/render {:posts-dir posts-dir
+ :templates-dir templates-dir
+ :cache-dir cache-dir
+ :out-dir out-dir
+ :include-preview-posts? true})
+ (is (= (slurp (fs/file out-dir "post1.html"))
+ "\nnext: post2"))
+ (is (= (slurp (fs/file out-dir "post2.html"))
+ "prev: post1\nnext: post3"))
+ (is (= (slurp (fs/file out-dir "post3.html"))
+ "prev: post2\nnext: post4"))
+ (is (= (slurp (fs/file out-dir "post4.html"))
+ "prev: post3\n"))))))
+
;; disabled, flaky in CI, cc @jmglov
#_(deftest caching
(testing "assets"
diff --git a/test/quickblog/test_runner.clj b/test/quickblog/test_runner.clj
index c5e32d4..d051630 100644
--- a/test/quickblog/test_runner.clj
+++ b/test/quickblog/test_runner.clj
@@ -1,11 +1,11 @@
(ns quickblog.test-runner
- {:org.babashka/cli {:coerce {:dirs :strings
- :nses :symbols
- :patterns :strings
- :vars :symbols
- :includes :keywords
- :excludes :keywords
- :only :symbols}}}
+ {:org.babashka/cli {:coerce {:dirs [:string]
+ :nses [:symbol]
+ :patterns [:string]
+ :vars [:symbol]
+ :includes [:keyword]
+ :excludes [:keyword]
+ :only :symbol}}}
(:refer-clojure :exclude [test])
(:require
[clojure.test :as test]