Counting blog posts in 50 simple steps

An abacus

As I was writing yesterday's post, I wanted to count the number of posts I had written during my last involuntary vacation in the summer of 2022. There is of course a very simple way to do this:

  1. Navigate to the archive page of my blog
  2. Realise that whilst there are only (as of the writing of yesterday's post) two posts in 2024 (three as of the writing of this post, since yesterday's is now there, and in fact four as of the reading of this post, because now this post is also there) and three in 2023, there were enough in 2022 that counting them by hand smacks of effort
  3. Decide to use Babashka to count the posts, since this blog is powered by quickblog and thus the posts are right at hand
  4. Assert that it should be as easy as dropping a user.clj on the blog's classpath
  5. Open up the blog's bb.edn to figure out how to do this:
       {:deps {jmglov/jmglov {:local/root "."}
               io.github.borkdude/quickblog {:local/root "../clojure/quickblog"}
               #_"You use the newest SHA here:"
               #_{:git/sha "b69c11f4292702f78a8ac0a9f32379603bebf2af"}
              }
        ;; ...
       }
       
  6. Realise that the classpath is set up in deps.edn, because of this incantation:
       {jmglov/jmglov {:local/root "."}
       
  7. Open up deps.edn instead:
       {:paths ["." "classes"]
        :deps {markdown-clj/markdown-clj {:mvn/version "1.10.7"}
               org.babashka/cli {:mvn/version "0.8.55"}
               babashka/fs {:mvn/version "0.1.6"}
               org.clojure/data.xml {:mvn/version "0.2.0-alpha6"}
               hiccup/hiccup {:mvn/version "2.0.0-alpha2"}
               babashka/pods {:git/url "https://github.com/babashka/pods"
                              :git/sha "93081b75e66fb4c4d161f89e714c6b9e8d55c8d5"}
               rewrite-clj/rewrite-clj {:mvn/version "1.1.45"}
               selmer/selmer {:mvn/version "1.12.53"}}}
       
  8. Armed with the knowledge that base directory is on the path, drop a user.clj there:
       (ns user)
       
  9. Poise your hands elegantly above your keyboard like a concert pianist, then, imagining the swells of the string section as they invite you in and the upraised faces of the expecting crowd, attack the keyboard with a C-c M-j (cider-jack-in-clj) and select babashka from the REPL options! 🎉
  10. Refer back to bb.edn to see how the quickblog opts are defined:
        {:deps { ; ...
                }
         :tasks
         {:init (def opts {:blog-title "jmglov's blog"
                           :blog-author "Josh Glover"
                           :blog-description "A blog about stuff but also things."
                           :blog-root "https://jmglov.net/blog/"
                           :about-link "https://jmglov.net/"
                           :twitter-handle "jmglov"
                           :assets-dir "blog/assets"
                           :num-index-posts 3
                           :cache-dir ".cache"
                           :favicon true
                           :favicon-dir "favicon"
                           :out-dir "public/blog"
                           :posts-dir "blog/posts"
                           :templates-dir "blog/templates"})
       
          :requires ([babashka.cli]
                     [babashka.fs :as fs]
                     [clojure.string :as str]
                     [quickblog.api :as qb]
                     [quickblog.cli :as cli])
          ;; ...
         }}
        
  11. Start to copy the (def opts {...}) bit into user.clj, but then, struck by a blinding flash of insight that bb.edn is just EDN, decide to Not Repeat Yourself (NRY, obv) and just read in bb.edn and set opts from what's defined there:
        (ns user
          (:require [clojure.edn :as edn]))
    
        (comment
       
          (-> (slurp "bb.edn")
              edn/read-string
              :tasks
              :init)
          ;; (def opts {:blog-title "jmglov's blog"
                        :blog-author "Josh Glover"
                        :blog-description "A blog about stuff but also things."
                        :blog-root "https://jmglov.net/blog/"
                        :about-link "https://jmglov.net/"
                        :twitter-handle "jmglov"
                        :assets-dir "blog/assets"
                        :num-index-posts 3
                        :cache-dir ".cache"
                        :favicon true
                        :favicon-dir "favicon"
                        :out-dir "public/blog"
                        :posts-dir "blog/posts"
                        :templates-dir "blog/templates"})
        )
       
  12. Celebrate your genius and eval that string (making sure of course that you're only eval'ing it if it's (def opts ...) and nothing untoward and/or sinister)!
        (comment
       
          (let [[form & params :as expr] (-> (slurp "bb.edn")
                                             edn/read-string
                                             :tasks
                                             :init)]
            (when (and (= 'def form) (= 'opts (first params)))
              (eval expr)))
          ;; => #'user/opts
        
          opts
          ;; => {:blog-description "A blog about stuff but also things.",
          ;;     :blog-author "Josh Glover",
          ;;     :num-index-posts 3,
          ;;     :favicon-dir "favicon",
          ;;     :posts-dir "blog/posts",
          ;;     :assets-dir "blog/assets",
          ;;     :templates-dir "blog/templates",
          ;;     :favicon true,
          ;;     :out-dir "public/blog",
          ;;     :blog-root "https://jmglov.net/blog/",
          ;;     :link-posts true,
          ;;     :cache-dir ".cache",
          ;;     :about-link "https://jmglov.net/",
          ;;     :blog-title "jmglov's blog"}
        
        )
        
  13. Revel in the power of Lisp: verily code is data and data is code!
  14. Realise that you are a silly silly person and that there's a much less ridiculous way to do this: move the opts to a file named opts.edn...
            {:blog-title "jmglov's blog"
         :blog-author "Josh Glover"
         :blog-description "A blog about stuff but also things."
         :blog-root "https://jmglov.net/blog/"
         :about-link "https://jmglov.net/"
         :assets-dir "blog/assets"
         :num-index-posts 3
         :cache-dir ".cache"
         :favicon true
         :favicon-dir "favicon"
         :out-dir "public/blog"
         :posts-dir "blog/posts"
         :templates-dir "blog/templates"
         :link-posts true}
        
  15. ...and read them in bb.edn:
        { ; ...
         :tasks
         {:requires ([babashka.cli]
                     [babashka.fs :as fs]
                     [clojure.edn :as edn]
                     [clojure.string :as str]
                     [quickblog.api :as qb]
                     [quickblog.cli :as cli])
          :init (def opts (slurp "opts.edn"))
          ;; ...
         }}
        
  16. ...and also in user.clj:
        (ns user
          (:require [clojure.edn :as edn]))
          
        (comment
        
          (def opts (-> (slurp "opts.edn") edn/read-string))
          ;; => #'user/opts
        
          opts
          ;; => {:blog-description "A blog about stuff but also things.",
          ;;     :blog-author "Josh Glover",
          ;;     :num-index-posts 3,
          ;;     :favicon-dir "favicon",
          ;;     :posts-dir "blog/posts",
          ;;     :assets-dir "blog/assets",
          ;;     :templates-dir "blog/templates",
          ;;     :favicon true,
          ;;     :out-dir "public/blog",
          ;;     :blog-root "https://jmglov.net/blog/",
          ;;     :link-posts true,
          ;;     :cache-dir ".cache",
          ;;     :about-link "https://jmglov.net/",
          ;;     :blog-title "jmglov's blog"}
        
         )
        
  17. Now try and remember how to load posts in quickblog. Maybe the API documentation has a clue?
        (render opts)
        
        Renders posts declared in posts.edn to out-dir.
        
  18. Conclude that whilst rendering the blog shouldn't be necessary to count the posts, the source code for render must contain incantations of great power that load posts before rendering them:
        (defn render
          "Renders posts declared in `posts.edn` to `out-dir`."
          [opts]
          (let [{:keys [assets-dir
                        assets-out-dir
                        cache-dir
                        favicon-dir
                        favicon-out-dir
                        out-dir
                        posts-file
                        templates-dir]
                 :as opts}
                (-> opts apply-default-opts lib/refresh-cache)]
          ;; ...
          ))
        
  19. Armed with this arcane knowledge, return to user.clj:
        (ns user
          (:require ; ...
                    [quickblog.api :as qb]
                    [quickblog.internal :as lib]))
        
        (defn load-opts [base-opts]
          (-> base-opts
              #'qb/apply-default-opts
              lib/refresh-cache))
        
        (comment
        
          (def base-opts (-> (slurp "opts.edn") edn/read-string))
          ;; => #'user/base-opts
        
          (def opts (load-opts base-opts))
          ;; => #'user/opts
        
          (keys opts)
          ;; => (:blog-description
          ;;     :blog-author
          ;;     :num-index-posts
          ;;     :favicon-dir
          ;;     :posts-dir
          ;;     :assets-dir
          ;;     :modified-posts
          ;;     :cached-posts
          ;;     :templates-dir
          ;;     :deleted-posts
          ;;     :favicon
          ;;     :modified-metadata
          ;;     :out-dir
          ;;     :blog-root
          ;;     :modified-tags
          ;;     :link-posts
          ;;     :cache-dir
          ;;     :about-link
          ;;     :blog-title
          ;;     :posts)
        
         )
        
  20. Count all the posts!
        (comment
        
          (->> opts
               :posts
               count)
          ;; => 71
        
         )
        
  21. Break out the champagne! 🍾
  22. Remember that the goal wasn't to count all of the posts, but rather the ones from the summer of 2022 before I started my old new job 😢
  23. See what a post looks like so you can figure out this whole date thing:
        (comment
        
          ;; => (["2022-08-26-doing-software-wrong.md"
          ;;      {:description
          ;;       "In which I make a bold statement, but then rather than explaining it or providing any evidence whatsoever, go on to talk about something completely different.",
          ;;       :tags #{"waffle"},
          ;;       :date "2022-08-26",
          ;;       :file "2022-08-26-doing-software-wrong.md",
          ;;       :title "We're doing software wrong",
          ;;       :image-alt
          ;;       "A man on a mobile phone stands in front of a wall with the word \"productivity\" written on it - Photo by Andreas Klassen on Unsplash",
          ;;       :image "assets/2022-08-26-preview.jpg",
          ;;       :html #<Delay@6b4fc6d6: :not-delivered>}])
        
         )
        
  24. Realise that :posts is a map of filename to post, but no matter!
    (comment
    
      (->> opts
           :posts
           vals
           (map :date))
      ;; => ("2022-08-26"
      ;;     "2022-06-22"
      ;;     "2023-11-12"
      ;;     "2022-07-01"
      ;;     "2022-07-31"
      ;;     "2022-07-09"
      ;;     "2022-06-21"
      ;;     ...
      ;;     "2022-07-02")
    
     )
    
  25. Sigh as you come to terms with the fact that you're going to need to do some date parsing and you never remember how to use java.time and argh!
  26. Smile as you remember about tick, which is a library from our good friends at JUXT that provides a nicer API for this sort of stuff
  27. Since you hate restarting your REPL, use the power of Babashka to hotload the dependency right in user.clj:
    (ns user
      (:require [babashka.deps :as deps]
                ;; ...
                ))
    
    (comment
    
      (deps/add-deps '{:deps {tick/tick {:mvn/version "0.7.5"}}})
      ;; => nil
    
      (require '[tick.core :as t])
      ;; => clojure.lang.ExceptionInfo: Could not resolve symbol: java.time.temporal.TemporalQuery user cljc/java_time/format/date_time_formatter.clj:33:444
    
     )
    
  28. Swear as you realise that tick must not work with Babashka 🤬
  29. Scratch your head as you read that tick is "a Clojure(Script) & babashka library for dealing with time" and explicitly mentions Babashka so 😕
  30. Search #babashka on Clojurians Slack for tick and find borkdude mentioning a message in #announcements that claims that "the new version works with babashka". Note the date of this message as 2023-04-03.
  31. Suspect that you may be running an older version of Babashka that doesn't ship with java.time.temporal.TemporalQuery and have a look at src/babashka/impl/classes.clj in the Babashka codebase to see when it was added
  32. Track down commit ead237e and note that it made it into Babashka v1.2.174
  33. Figure out what version of Babashka you're running:
        $ bb --version
        babashka v1.1.173
        
  34. Realise that you've found the issue and need to upgrade Babashka
  35. Since you're using Home Manager on NixOS like a normal person who is totally normal, open up your home.nix
  36. Note that apparently you're installing the binary version of Babashka in your own package that you wrote like a normal person who is totally normal and open up pkgs/babashka-bin/default.nix:
        { stdenv, ... }:
        
        let
          arch = if stdenv.isAarch64 then "aarch64" else "amd64";
          osName = if stdenv.isDarwin then
            "macos"
          else if stdenv.isLinux then
            "linux"
          else
            null;
          sha256 = assert !isNull osName;
            {
              linux = {
                aarch64 =
                  "bc7e733863486b334b8bff83ba13b416800e0ce45050153cb413906b46090d68";
                amd64 =
                  "25975d5424e7dea9fbaef5a6551ce7d3834631b5e28bdc4caf037bf45af57dfd";
              };
              macos = {
                # No MacOS builds for ARM at the moment
                # aarch64 =
                #   "11c4b4bd0b534db1ecd732b03bc376f8b21bbda0d88cacb4bbe15b8469029123";
                amd64 =
                  "792ade86e61703170f3de3082183173db66a9a98b11d01c95ace0235f0a5e345";
              };
            }.${osName}.${arch};
        in stdenv.mkDerivation rec {
          pname = "babashka";
          version = "1.1.173";
          filename = if osName == "macos" then
          # No static builds for MacOS
            "babashka-${version}-${osName}-${arch}.tar.gz"
          else
            "babashka-${version}-${osName}-${arch}-static.tar.gz";
        
          src = builtins.fetchurl {
            inherit sha256;
            url =
              "https://github.com/babashka/babashka/releases/download/v${version}/${filename}";
          };
        
          dontFixup = true;
          dontUnpack = true;
        
          installPhase = ''
            mkdir -p $out/bin
            cd $out/bin && tar xvzf $src
          '';
        }
        
  37. Avoid the urge to celebrate your own genius and instead pop over to the Babashka releases page on Github and find that the latest release is v1.3.188
  38. Grab the SHA256 hashes you're going to need to plug into your Nix package:
        $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-linux-aarch64-static.tar.gz.sha256
    417280537b20754b675b7552d560c4c2817a93fbcaa0d51e426a1bff385e3e47
        $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-linux-amd64-static.tar.gz.sha256
        89431b0659e84a468da05ad78daf2982cbc8ea9e17f315fa2e51fecc78af7cc0
        $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-macos-aarch64.tar.gz.sha256
        77eb9ec502260fa94008e1e43edc5678fab8dc1a5082b7eb3d28ae594ea54e09
        $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-macos-amd64.tar.gz.sha256
        d8854833a052bb578360294d6975b85ed917b9f86da0068fb3c263f8cbcc9e15
        
  39. Update the SHAs and Babashka version in your Nix package:
        let
          # ...
          sha256 = {
            linux = {
              aarch64 =
                "417280537b20754b675b7552d560c4c2817a93fbcaa0d51e426a1bff385e3e47";
              amd64 =
                "89431b0659e84a468da05ad78daf2982cbc8ea9e17f315fa2e51fecc78af7cc0";
            };
            macos = {
              aarch64 =
                "77eb9ec502260fa94008e1e43edc5678fab8dc1a5082b7eb3d28ae594ea54e09";
              amd64 =
                "d8854833a052bb578360294d6975b85ed917b9f86da0068fb3c263f8cbcc9e15";
            };
          }.${osName}.${arch};
        in stdenv.mkDerivation rec {
          pname = "babashka";
          version = "1.3.188";
          # ...
        }
        
  40. Update Babashka:
        $ sudo nixos-rebuild switch
        building Nix...
        building the system configuration...
        these 8 derivations will be built:
          /nix/store/x9c0ip7xchwzhkhznvjz5r57krcqjm3r-babashka-1.3.188.drv
          /nix/store/lsq07jvqmk5kywbdrj55vh3ndjrw2vwm-home-manager-path.drv
        [...]
        building '/nix/store/x9c0ip7xchwzhkhznvjz5r57krcqjm3r-babashka-1.3.188.drv'...
        patching sources
        updateAutotoolsGnuConfigScriptsPhase
        configuring
        no configure script, doing nothing
        building
        no Makefile or custom buildPhase, doing nothing
        installing
        bb
        [...]
        activating the configuration...
        setting up /etc...
        reloading user units for jmglov...
        setting up tmpfiles
        restarting the following units: home-manager-jmglov.service
        
  41. Trust but verify:
        $ bb --version
        babashka v1.3.188
        
  42. Hang your head in shame as you prepare to restart your REPL, but take the opportunity to add tick to your deps.edn so you won't have to hotload it in user.clj:
        {:paths ["." "classes"]
         :deps { ; ...
                tick/tick {:mvn/version "0.7.5"}}}
        
  43. Drop back into user.clj, then C-c C-z to hop to your REPL buffer, C-c C-q to quit it, then C-c M-j to start a new REPL, then require tick in the ns form:
        (ns user
          (:require ; ...
                    [tick.core :as t]))
        
  44. Evaluate the buffer with C-c C-k and get to ticking!
  45. Figure out how to parse a date string like "2022-07-02" by looking at the tick cheatsheet, a sheet that lets you cheat, apparently:
        (comment
        
          (t/date "2022-07-02")
          ;; => #time/date "2022-07-02"
        
         )
        
  46. Turn the date strings into date dates:
        (comment
        
          (def opts (-> (slurp "opts.edn") edn/read-string load-opts))
          ;; => #'user/opts
        
          (->> opts
               :posts
               vals
               (map (comp t/date :date)))
          ;; => (#time/date "2022-08-26"
          ;;     #time/date "2022-06-22"
          ;;     #time/date "2023-11-12"
          ;;     #time/date "2022-07-01"
          ;;     ...
          ;;     #time/date "2022-07-02")
        
         )
        
  47. Ask yourself what you were doing again?
  48. Oh yeah, counting posts before September 1, 2022, which was when I started my old new job
  49. Say this in Clojure, not English!
        (comment
        
          (->> opts
               :posts
               vals
               (remove #(= "FIXME" (:date %)))
               (map (comp t/date :date))
               (filter #(t/< % (t/date "2022-09-01")))
               count)
          ;; => 55
        
         )
        
  50. Sit back and reflect on just how easy that was and how it took less time than just counting those 55 things with your finger
🏷 clojure 50-simple-steps nix
📝 Published: 2024-02-09
📣 Discuss this post here