the personal playground of evan louie; developer, designer, photographer, and breaker of the web.
   (  )   /\   _                 (     
    \ |  (  \ ( \.(               )                      _____
  \  \ \  `  `   ) \             (  ___                 / _   \
 (_`    \+   . x  ( .\            \/   \____-----------/ (o)   \_
- .-               \+  ;          (  O                           \____
                          )        \_____________  `              \  /
(__                +- .( -'.- <. - _  VVVVVVV VV V\                 \/
(_____            ._._: <_ - <- _  (--  _AAAAAAA__A_/                  |
  .    /./.+-  . .- /  +--  - .     \______________//_              \_______
  (__ ' /x  / x _/ (                                  \___'          \     /
 , x / ( '  . / .  /                                      |           \   /
    /  /  _/ /    +                                      /              \/
   '  (__/                                             /                  \

markdown-clj is an excellent markdown parser for Clojure(Script), but sadly its footnote parser doesn't support multi-line definitions and it looks as though no wants to work on it.

Hopefully I'll find the time to make a PR to add the functionality, but in the in-term I wrote a pre-processor to render footnotes in-place of their usage in a markdown string. The idea being you run the com.evanlouie.markdown.footnote/render-footnotes function prior to passing it to markdown-clj's markdown.core/md-to-html-string (without the :footnotes? option).

Warning: the render-footnotes functions isn't pretty and can definitely be cleaned up, but it works well and gave me the functionality I needed in a pinch.

(ns com.evanlouie.markdown.footnote
  "Markdown footnote pre-processing library."
  {:author "Evan Louie"}
  (:require
   [clojure.spec.alpha :as s]
   [clojure.string :as string]
   [markdown.core :as md]))

(set! *warn-on-reflection* true)

(s/def ::footnote-declaration
  (s/and string? #(re-find #"^\[\^[a-zA-Z0-9_-]+\]:" %)))

(s/def ::footnote-declaration-2-indent
  (s/and ::footnote-declaration
         #(re-matches #"^\[\^[a-zA-Z0-9_-]+\]:\s+\w+.+$" %)))

(s/def ::footnote-body-line-2-indent
  (s/and string? #(or (empty? %)
                      (string/starts-with? % (string/join (repeat 2 \space))))))

(s/def ::footnote-declaration-4-indent
  (s/and ::footnote-declaration
         #(re-matches #"^\[\^[a-zA-Z0-9_-]+\]:\s*$" %)))

(s/def ::footnote-body-line-4-indent
  (s/and string?
         #(or (empty? %)
              (string/starts-with? % (string/join (repeat 4 \space))))))

(s/def ::footnote
  (s/alt :indent-2 (s/cat :declaration ::footnote-declaration-2-indent
                          :body (s/* ::footnote-body-line-2-indent))
         :indent-4 (s/cat :declaration ::footnote-declaration-4-indent
                          :body (s/+ ::footnote-body-line-4-indent))))

(s/def ::markdown
  (s/* (s/alt :footnote ::footnote
              :other string?)))

(defn render-footnotes
  "Renders the references to and declarations of footnotes in the provided
  markdown string and returns a the input markdown string with the rendered
  html footnotes inline."
  [markdown-string]
  (let [markdown-lines (string/split-lines markdown-string)
        conformed      (s/conform ::markdown markdown-lines)]
    (when-some [explanation (s/explain-data ::markdown markdown-lines)]
      (throw (ex-info "Invalid markdown provided"
                      {:markdown markdown-string
                       :reason   explanation})))
    (let [footnotes
          (->> conformed
               (keep
                (fn [[tag [indent-tag {:keys [declaration body] :as footnote}]]]
                  ;; filter out non footnotes
                  (when (= tag :footnote)
                    ;; remove markdown indentation
                    (let [declaration-rgx  (case indent-tag
                                             :indent-2 #"^\[\^([a-zA-Z0-9_-]+)\]:\s+(\w+.+)$"
                                             :indent-4 #"^\[\^([a-zA-Z0-9_-]+)\]:\s*$"
                                             (throw (ex-info "Invalid footnote tag"
                                                             {:tag indent-tag})))
                          [_ fn-k fn-line] (re-matches declaration-rgx declaration)]
                      ;; create a map containing the footnote key and markdown
                      (assoc footnote
                             :key fn-k
                             :markdown
                             (case indent-tag
                               :indent-2
                               (->> (into [fn-line]
                                          (->> body
                                               (map #(string/replace-first % (string/join (repeat 2 \space)) ""))))
                                    (string/join \newline))
                               :indent-4
                               (->> body
                                    (map #(string/replace-first % (string/join (repeat 4 \space)) ""))
                                    (string/join \newline))
                               (throw (ex-info "Invalid footnote tag"
                                               {:tag indent-tag}))))))))
               ;; convert markdown to html
               (map (fn [{:keys [markdown] :as footnote}]
                      (assoc footnote :html (md/md-to-html-string markdown)))))
          ;; aggregate the footnote declaration lines for removal
          strings-to-remove
          (->> footnotes
               (map (fn [{:keys [declaration body]}]
                      (string/join \newline (into [declaration] body)))))]

      ;; loop over the input markdown string and replace all references and
      ;; declarations for footnotes with their respective html.
      (loop [md-str         markdown-string
             declarations   strings-to-remove
             references     (re-matcher #"(?m)\[\^([a-zA-Z0-9_-]+)\][^:]" markdown-string)
             times-replaced 0]
        (let [reference (re-find references)]
          (cond
            ;; base case - append our footnote declarations <ol> to the end
            (and (empty? declarations)
                 (nil? reference))
            (let [li-str-list (->> footnotes
                                   (map (fn [{:keys [key html]}]
                                          (format "<li id=\"ref-%s\">%s</li>"
                                                  key
                                                  html)))
                                   (string/join \newline))
                  ol-str      (format "<ol class=\"footnotes\">%s</ol>" li-str-list)]
              (str md-str ol-str))

            ;; footnote reference found - replace it with an <a>
            reference
            (let [replacement-count (inc times-replaced)]
              (recur (string/replace md-str
                                     (first reference)
                                     (format "<a href=\"#ref-%s\"><sup>%s</sup></a>"
                                             (second reference)
                                             (inc times-replaced)))
                     declarations
                     references
                     replacement-count))

            ;; footnote declaration found - remove it
            (not-empty declarations)
            (recur (string/replace md-str (first declarations) "")
                   (rest declarations)
                   references
                   times-replaced)

            :else
            (throw (ex-info "Invalid state"
                            {:input      markdown-string
                             :loop-state {:md-str         md-str
                                          :declarations   declarations
                                          :references     references
                                          :times-replaced times-replaced}}))))))))