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}}))))))))