From 04c1d40ff2a80141e1cbc3a298f253ebea4d2bb0 Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Thu, 20 Nov 2025 03:45:48 -0600
Subject: [PATCH 1/8] initial implementation of footnotes extension
inline references like `[^foo]`, with definitions as blocks
starting with `[^foo]:`.
---
3bmd-ext-footnotes.asd | 20 +++++
3bmd-footnote.lisp | 187 +++++++++++++++++++++++++++++++++++++++++
extensions.lisp | 16 +++-
printer.lisp | 44 ++++++++--
4 files changed, 257 insertions(+), 10 deletions(-)
create mode 100644 3bmd-ext-footnotes.asd
create mode 100644 3bmd-footnote.lisp
diff --git a/3bmd-ext-footnotes.asd b/3bmd-ext-footnotes.asd
new file mode 100644
index 0000000..b074d11
--- /dev/null
+++ b/3bmd-ext-footnotes.asd
@@ -0,0 +1,20 @@
+(defsystem 3bmd-ext-footnotes
+ :description "extension to 3bmd implementing github style ``` delimited code blocks, with support for syntax highlighting using colorize, pygments, or chroma"
+ :depends-on (3bmd alexandria)
+ :serial t
+ :components ((:file "footnotes"))
+ :in-order-to ((test-op (test-op 3bmd-ext-footnotes/tests))))
+
+
+(defsystem 3bmd-ext-footnotes/tests
+ :depends-on (#:3bmd-ext-footnotes #:3bmd-tests #:fiasco)
+ :serial t
+ :components ((:module "tests"
+ :components ((:module "extensions"
+ :components ((:file "footnotes"))))))
+ :perform (test-op (op c)
+ (declare (ignore op c))
+ (or
+ (symbol-call "FIASCO" "RUN-PACKAGE-TESTS"
+ :package '#:3bmd-footnotes-tests)
+ (error "tests failed"))))
diff --git a/3bmd-footnote.lisp b/3bmd-footnote.lisp
new file mode 100644
index 0000000..8936505
--- /dev/null
+++ b/3bmd-footnote.lisp
@@ -0,0 +1,187 @@
+(defpackage #:3bmd-footnote
+ (:use :cl :esrap :3bmd-ext)
+ (:export #:*footnotes*))
+
+(in-package #:3bmd-footnote)
+
+(defvar *footnotes*)
+
+;; fixme: add an extension API for this
+(pushnew '*footnotes* 3bmd::*footers*)
+
+(defrule footnote (and "[^" (+ (and (! (or #\[ #\]))
+ 3bmd-grammar::non-space-char))
+ "]")
+ (:destructure (o id c)
+ (declare (ignore o c))
+ (text id)))
+
+(define-extension-inline *footnotes* footnote-ref
+ (and footnote (! #\:))
+ (:destructure (id x)
+ (declare (ignore x))
+ (list 'footnote-ref id)))
+
+;;; we parse footnote definitions basically like a loose list, but
+;;; without a "next list item"
+(defrule footnote-block (and (! 3bmd-grammar::blank-line)
+ 3bmd-grammar::line
+ (* footnote-block-line))
+ (:destructure (b l block)
+ (declare (ignore b))
+ (text l block)))
+
+(defrule footnote-continuation-block (and (* 3bmd-grammar::blank-line)
+ (+ (and 3bmd-grammar::indent
+ footnote-block)))
+ (:destructure (b c)
+ (if b
+ (cons (text b) (mapcar 'second c))
+ (cons :split (mapcar 'second c)))))
+
+(defrule footnote-block-line (and (! 3bmd-grammar::blank-line)
+ (! 3bmd-grammar::horizontal-rule)
+ 3bmd-grammar::optionally-indented-line)
+ (:destructure (i1 i2 line)
+ (declare (ignore i1 i2))
+ line))
+
+
+;; hash table of ID -> (ref-name . backref-names)
+(defvar *used-footnotes*)
+(defvar *defined-footnotes*)
+(defvar *next-ref*)
+
+(define-extension-block *footnotes* footnote-def
+ (and (and footnote #\:)
+ footnote-block
+ (* footnote-continuation-block))
+ (:before 3bmd-grammar::reference)
+ (:bind *used-footnotes* (make-hash-table :test 'equalp)
+ *next-ref* 1)
+ (:destructure
+ ((id c) block cont)
+ (declare (ignore c))
+ (list* 'footnote-def
+ id
+ (loop for a in (split-sequence:split-sequence
+ :split (append (cons block (mapcan 'identity cont))
+ (list "
+
+"))
+ :remove-empty-subseqs t)
+ for p = (3bmd::parse-doc (text a))
+ ;; we append a node for the back links to last item if
+ ;; it is a paragraph, or after last item otherwise
+ if (typep (car (last p)) '(cons (eql :paragraph)))
+ do (push '(footnote-backlinks)
+ (cdr (last (car (last p)))))
+ else do (setf p (append p '((footnote-backlinks))))
+ append p))))
+
+
+(defmethod 3bmd::extract-ref ((id (eql 'footnote-def)) cdr)
+ (when *footnotes*
+ (list* (list 'footnote-def (first cdr))
+ cdr)))
+
+(defmethod print-tagged-element ((tag (eql 'footnote-ref)) stream rest)
+ (let* ((id (car rest))
+ (use (gethash id *used-footnotes*))
+ (refno (or (first use)
+ (shiftf *next-ref* (1+ *next-ref*))))
+ (backrefs (cddr use))
+ (back (if backrefs
+ (format nil "fnref-~a.~a" refno (1+ (length backrefs)))
+ (format nil "fnref-~a" refno)))
+ (fn (or (second use)
+ (second
+ (setf (gethash id *used-footnotes*)
+ (list refno (format nil "fn-~a" refno)))))))
+ (push back (cddr (gethash id *used-footnotes*)))
+ (format stream "~a"
+ fn back
+ refno)))
+
+(defmethod print-tagged-element ((tag (eql 'footnote-def)) stream rest)
+ ;; definitions will be printed in the footer, so ignore them here
+ )
+
+(defvar *backlinks* nil)
+(defmethod print-tagged-element ((tag (eql 'footnote-backlinks)) stream rest)
+ (loop for b in *backlinks*
+ do (format stream " " b)))
+
+
+(defmethod print-def (s refs (format (eql :html)))
+ (let ((def (gethash (list 'footnote-def (first refs)) 3bmd::*references*))
+ (*backlinks* (reverse (cdddr refs))))
+ (format s "
" (third refs))
+ (3bmd::padded (2 s)
+ (loop for i in (cdr def)
+ do (3bmd::print-element i s)))
+ (format s "")))
+
+(defmethod 3bmd::print-footer (stream (f (eql '*footnotes*)) format)
+ (when (plusp (hash-table-count *used-footnotes*))
+ (format stream "")))
+
+#++
+(let ((*footnotes* t)
+ (3bmd-code-blocks:*code-blocks* t)
+ (s t))
+ (format s "~&----~%")
+ (with-open-file (s "/tmp/foo.html" :if-exists :supersede
+ :direction :output)
+ (3bmd:parse-string-and-print-to-stream "a footnote[^ref] ref
+
+[^ref]: the definition
+multiline
+
+
+```
+some more body text
+
+
+
+a
+
+b
+
+c
+
+d
+
+e
+
+f
+
+f
+```
+
+Here's a simple footnote,[^1] and[^1] here[^1]'s a longer one.[^bignote]
+
+
+[^1]: This is the first footnote.
+
+[^bignote]: Here's one with multiple paragraphs and code.
+
+ Indent paragraphs to include them in the footnote.
+
+ `{ my code }`
+
+ * Add as many paragraphs as you like.
+ * liost
+
+" s)))
+
+
diff --git a/extensions.lisp b/extensions.lisp
index c044edb..9709319 100644
--- a/extensions.lisp
+++ b/extensions.lisp
@@ -47,7 +47,8 @@
(escapes (cdr (assoc :escape-char-rule options)))
(md-chars-to-escapes (cdr (assoc :md-chars-to-escape options)))
(after (cdr (assoc :after options)))
- (before (cdr (assoc :before options))))
+ (before (cdr (assoc :before options)))
+ (bind (cdr (assoc :bind options))))
`(progn
;; define the flag to make the trivial case easier
(defvar ,extension-flag nil)
@@ -60,7 +61,7 @@
(add-expression-to-list ',(first characters)
%extended-special-char-rules%))
(esrap:change-rule 'extended-special-char
- (cons 'or %extended-special-char-rules%))))
+ (cons 'or %extended-special-char-rules%))))
;; define a rule for escaped chars if any
,@ (when escapes
`((defrule ,(first escapes)
@@ -79,8 +80,15 @@
(member (car a) '(:character-rule
:escape-char-rule
:md-chars-to-escape
- :after :before)))
- options))
+ :after :before
+ :bind)))
+ options))
+ ;; add additional bindings needed by extension during printing
+ ,@(when bind
+ (loop for (v i) on bind by 'cddr
+ collect `(setf (alexandria:assoc-value
+ 3bmd::*additional-bindings* ',v)
+ (lambda () ,i))))
(setf ,var
(add-expression-to-list ',name
,var
diff --git a/printer.lisp b/printer.lisp
index 7a1bfe0..08dcb68 100644
--- a/printer.lisp
+++ b/printer.lisp
@@ -308,14 +308,21 @@
(error "unknown cons? ~s" elem)))
+;;; allow extensions to add other kinds of references, should return a
+;;; list (key . value), where key is a list (ext-key ...), and value
+;;; is whatever the extension needs
+(defmethod extract-ref (id cdr))
+(defmethod extract-ref ((id (eql :reference)) cdr)
+ (list (print-label-to-string (getf cdr :label))
+ (getf cdr :source)
+ (getf cdr :title)))
(defun extract-refs (doc)
(alexandria:alist-hash-table
(loop for i in doc
- when (and (consp i) (eq (car i) :reference))
- collect (list (print-label-to-string (getf (cdr i) :label))
- (getf (cdr i) :source)
- (getf (cdr i) :title)))
+ when (and (consp i)
+ (extract-ref (car i) (cdr i)))
+ collect it)
:test #'equalp))
(defun expand-tabs (doc &key add-newlines)
@@ -336,13 +343,38 @@
(when add-newlines
(format s "~%~%"))))
+;;; todo: add extension API, and possibly some way to specify a default order?
+;;
+;; not really expecting multiple extensions with footers to be active
+;; at once, so for now just rebind it in desired order if needed. (and
+;; file an issue with use case so it can be improved at some point)
+;;
+;; list of symbols naming extension-flags
+(defvar *footers* nil)
+
+;; extensions that print footers should define methods on this, with
+;; extension-flag EQL specialized on the name of the extension flag
+;; variable in *footers*
+(defmethod print-footer (stream extension-flag format)
+ )
+
+(defun print-footers (stream format)
+ (loop for flag in *footers*
+ when (symbol-value flag)
+ do (print-footer stream flag format)))
+
+;; alist of (var . initial-value-function)
+(defvar *additional-bindings* nil)
(defmethod print-doc-to-stream-using-format (doc stream (format (eql :html)))
(let ((*references* (extract-refs doc))
;; Protect the global value.
(*padding* *padding*))
- (loop for i in doc
- do (print-element i stream))
+ (progv (map 'list 'car *additional-bindings*)
+ (map 'list (alexandria:compose 'funcall 'cdr) *additional-bindings*)
+ (loop for i in (print doc)
+ do (print-element i stream))
+ (print-footers stream format))
(format stream "~&")))
(defun print-doc-to-stream (doc stream &key (format :html))
From c213ea395dc81ede5c8e0d811d1560df666a3065 Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Thu, 20 Nov 2025 03:59:56 -0600
Subject: [PATCH 2/8] rename file to match .asd
---
3bmd-footnote.lisp => footnotes.lisp | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename 3bmd-footnote.lisp => footnotes.lisp (100%)
diff --git a/3bmd-footnote.lisp b/footnotes.lisp
similarity index 100%
rename from 3bmd-footnote.lisp
rename to footnotes.lisp
From 36b845c1743abaf1676837d97a6f9485573d9c0e Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Thu, 20 Nov 2025 18:28:13 -0600
Subject: [PATCH 3/8] simplifications for parsing rules from @scymtym
---
footnotes.lisp | 19 ++++++++-----------
1 file changed, 8 insertions(+), 11 deletions(-)
diff --git a/footnotes.lisp b/footnotes.lisp
index 8936505..3df5b3c 100644
--- a/footnotes.lisp
+++ b/footnotes.lisp
@@ -12,23 +12,22 @@
(defrule footnote (and "[^" (+ (and (! (or #\[ #\]))
3bmd-grammar::non-space-char))
"]")
- (:destructure (o id c)
- (declare (ignore o c))
- (text id)))
+ (:function second)
+ (:text t))
(define-extension-inline *footnotes* footnote-ref
(and footnote (! #\:))
- (:destructure (id x)
- (declare (ignore x))
- (list 'footnote-ref id)))
+ (:function first)
+ (:lambda (id)
+ (list 'footnote-ref id)))
;;; we parse footnote definitions basically like a loose list, but
;;; without a "next list item"
(defrule footnote-block (and (! 3bmd-grammar::blank-line)
3bmd-grammar::line
(* footnote-block-line))
- (:destructure (b l block)
- (declare (ignore b))
+ (:function rest)
+ (:destructure (l block)
(text l block)))
(defrule footnote-continuation-block (and (* 3bmd-grammar::blank-line)
@@ -42,9 +41,7 @@
(defrule footnote-block-line (and (! 3bmd-grammar::blank-line)
(! 3bmd-grammar::horizontal-rule)
3bmd-grammar::optionally-indented-line)
- (:destructure (i1 i2 line)
- (declare (ignore i1 i2))
- line))
+ (:function third))
;; hash table of ID -> (ref-name . backref-names)
From ab11b11abb8fb6e1a0a920fde4d7d2d4dd0f340e Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Thu, 20 Nov 2025 21:24:22 -0600
Subject: [PATCH 4/8] add :markdown and :plain output formats for footnotes
Currently prints definitions where they were in original for both, not sure
if it would be better to move to the end like html?
---
footnotes.lisp | 29 ++++++++++++++++++++++++++---
printer.lisp | 13 ++++++++-----
2 files changed, 34 insertions(+), 8 deletions(-)
diff --git a/footnotes.lisp b/footnotes.lisp
index 3df5b3c..494cd02 100644
--- a/footnotes.lisp
+++ b/footnotes.lisp
@@ -132,6 +132,31 @@
(format stream ""))
(format stream "")))
+
+(defmethod 3bmd::print-md-tagged-element ((tag (eql 'footnote-ref)) stream rest)
+ (format stream "[^~a]" (first rest)))
+
+(defmethod 3bmd::print-md-tagged-element ((tag (eql 'footnote-def)) stream rest)
+ (3bmd::ensure-block stream)
+ (format stream "~&[^~a]: " (first rest))
+ (3bmd::with-md-indent (4)
+ (loop for i in (cdr rest)
+ do (3bmd::print-md-element i stream)))
+ (3bmd::end-block stream))
+
+(defmethod 3bmd::print-md-tagged-element ((tag (eql 'footnote-backlinks)) stream rest)
+ )
+
+
+(3bmd::pprinter footnote-def (s o)
+ (format s "[^~a]: ~{~a ~}" (cadr o) (cddr o)))
+
+(3bmd::pprinter footnote-ref (s o)
+ (format s "[^~a]" (cadr o)))
+
+(3bmd::pprinter footnote-backlinks (s o)
+ (declare (ignore s o)))
+
#++
(let ((*footnotes* t)
(3bmd-code-blocks:*code-blocks* t)
@@ -179,6 +204,4 @@ Here's a simple footnote,[^1] and[^1] here[^1]'s a longer one.[^bignote]
* Add as many paragraphs as you like.
* liost
-" s)))
-
-
+" s :format :html)))
diff --git a/printer.lisp b/printer.lisp
index 08dcb68..62e8b5f 100644
--- a/printer.lisp
+++ b/printer.lisp
@@ -366,15 +366,18 @@
;; alist of (var . initial-value-function)
(defvar *additional-bindings* nil)
+(defmethod print-doc-to-stream-using-format :around (doc stream format)
+ (progv (map 'list 'car *additional-bindings*)
+ (map 'list (alexandria:compose 'funcall 'cdr) *additional-bindings*)
+ (call-next-method)))
+
(defmethod print-doc-to-stream-using-format (doc stream (format (eql :html)))
(let ((*references* (extract-refs doc))
;; Protect the global value.
(*padding* *padding*))
- (progv (map 'list 'car *additional-bindings*)
- (map 'list (alexandria:compose 'funcall 'cdr) *additional-bindings*)
- (loop for i in (print doc)
- do (print-element i stream))
- (print-footers stream format))
+ (loop for i in (print doc)
+ do (print-element i stream))
+ (print-footers stream format)
(format stream "~&")))
(defun print-doc-to-stream (doc stream &key (format :html))
From 62bd8d137c0878cf3ce48b5b5303b751ba23b360 Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Fri, 21 Nov 2025 01:47:01 -0600
Subject: [PATCH 5/8] remove debug print
---
printer.lisp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/printer.lisp b/printer.lisp
index 62e8b5f..07e88fb 100644
--- a/printer.lisp
+++ b/printer.lisp
@@ -375,7 +375,7 @@
(let ((*references* (extract-refs doc))
;; Protect the global value.
(*padding* *padding*))
- (loop for i in (print doc)
+ (loop for i in doc
do (print-element i stream))
(print-footers stream format)
(format stream "~&")))
From 280b65abacc9dbc7e17abd9189d6ceb5d48c6552 Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Fri, 21 Nov 2025 01:47:54 -0600
Subject: [PATCH 6/8] start adding tests/fixing bugs
---
footnotes.lisp | 162 ++++++++++++++++----
tests/extensions/footnotes.lisp | 257 ++++++++++++++++++++++++++++++++
2 files changed, 388 insertions(+), 31 deletions(-)
create mode 100644 tests/extensions/footnotes.lisp
diff --git a/footnotes.lisp b/footnotes.lisp
index 494cd02..a5c70d3 100644
--- a/footnotes.lisp
+++ b/footnotes.lisp
@@ -15,6 +15,15 @@
(:function second)
(:text t))
+;;; ![^foo] parses as an image link, which is assumed to be incorrect
+;;; when footnotes are enabled. Add a rule to parse the ! as text in
+;;; that case so the [^foo] can be parsed as a footnote.
+(define-extension-inline *footnotes* footnote-not-an-image
+ (and "!" (& footnote))
+ (:before 3bmd-grammar::image)
+ (:function first)
+ (:text t))
+
(define-extension-inline *footnotes* footnote-ref
(and footnote (! #\:))
(:function first)
@@ -38,15 +47,16 @@
(cons (text b) (mapcar 'second c))
(cons :split (mapcar 'second c)))))
-(defrule footnote-block-line (and (! 3bmd-grammar::blank-line)
- (! 3bmd-grammar::horizontal-rule)
+(defrule footnote-block-line (and (! (or 3bmd-grammar::blank-line
+ 3bmd-grammar::horizontal-rule
+ (and footnote #\:)))
3bmd-grammar::optionally-indented-line)
- (:function third))
+ (:function second))
;; hash table of ID -> (ref-name . backref-names)
(defvar *used-footnotes*)
-(defvar *defined-footnotes*)
+(defvar *expanded-footnotes*)
(defvar *next-ref*)
(define-extension-block *footnotes* footnote-def
@@ -55,6 +65,7 @@
(* footnote-continuation-block))
(:before 3bmd-grammar::reference)
(:bind *used-footnotes* (make-hash-table :test 'equalp)
+ *expanded-footnotes* (make-hash-table :test 'equalp)
*next-ref* 1)
(:destructure
((id c) block cont)
@@ -82,36 +93,87 @@
(list* (list 'footnote-def (first cdr))
cdr)))
+(defun walk-def (def)
+ ;; getting footnotes inside footnotes right is messy:
+ ;;
+ ;; we need to include backlinks, even from footnotes we haven't
+ ;; printed yet
+ ;;
+ ;; we don't want backlinks to footnotes that get dropped
+ ;;
+ ;; footnotes only used from unused footnotes should be considered
+ ;; unused
+ ;;
+ ;; not sure if otherwise unused recursive footnotes should be
+ ;; included. I think things are easier if we only consider roots
+ ;; outside defs, so going with that for now.
+ ;;
+ ;; if a footnote has a (new) footnote, we probably want that
+ ;; footnote to show up next rather than being added to the end?
+ ;;
+ ;; so when printing, recursively partially expand the referenced
+ ;; definition if it hasn't already been expanded. We can't fully
+ ;; print it since we don't have all the backlinks yet, so just
+ ;; replace any (footnote-ref x) in the body of the definition with
+ ;; the printed representation
+ (labels ((expand-def (def)
+ (typecase def
+ ((cons (eql footnote-ref))
+ (list 'expanded-ref
+ (print-tagged-element 'footnote-ref nil (cdr def))))
+ (cons
+ ;; assuming these are small enough that some extra
+ ;; consing won't matter, other option is to walk twice
+ ;; to see if we need to expand it?
+ (mapcar #'expand-def def))
+ (t def))))
+ (let ((orig (gethash (list 'footnote-def def) 3bmd::*references*)))
+ (when (and orig (not (gethash def *expanded-footnotes*)))
+ ;; store something before walking contents so we don't get
+ ;; stuck in a loop if there is a recursive footnote
+ (setf (gethash def *expanded-footnotes*) :processing)
+ (setf (gethash def *expanded-footnotes*)
+ (expand-def orig))))))
+
(defmethod print-tagged-element ((tag (eql 'footnote-ref)) stream rest)
(let* ((id (car rest))
- (use (gethash id *used-footnotes*))
- (refno (or (first use)
- (shiftf *next-ref* (1+ *next-ref*))))
- (backrefs (cddr use))
- (back (if backrefs
- (format nil "fnref-~a.~a" refno (1+ (length backrefs)))
- (format nil "fnref-~a" refno)))
- (fn (or (second use)
- (second
- (setf (gethash id *used-footnotes*)
- (list refno (format nil "fn-~a" refno)))))))
- (push back (cddr (gethash id *used-footnotes*)))
- (format stream "~a"
- fn back
- refno)))
+ (defined (gethash (list 'footnote-def id) 3bmd::*references*)))
+ (cond
+ ((not defined)
+ (format stream "[^~a]" id))
+ (t
+ (let* ((use (gethash id *used-footnotes*))
+ (refno (or (first use)
+ (shiftf *next-ref* (1+ *next-ref*))))
+ (backrefs (cddr use))
+ (back (if backrefs
+ (format nil "fnref-~a.~a" refno (1+ (length backrefs)))
+ (format nil "fnref-~a" refno)))
+ (fn (or (second use)
+ (second
+ (setf (gethash id *used-footnotes*)
+ (list refno (format nil "fn-~a" refno)))))))
+
+ (push back (cddr (gethash id *used-footnotes*)))
+ (walk-def id)
+ (format stream "~a"
+ fn back
+ refno))))))
(defmethod print-tagged-element ((tag (eql 'footnote-def)) stream rest)
;; definitions will be printed in the footer, so ignore them here
)
+(defmethod print-tagged-element ((tag (eql 'expanded-ref)) stream rest)
+ (format stream "~a" (car rest)))
+
(defvar *backlinks* nil)
(defmethod print-tagged-element ((tag (eql 'footnote-backlinks)) stream rest)
(loop for b in *backlinks*
do (format stream " " b)))
-
(defmethod print-def (s refs (format (eql :html)))
- (let ((def (gethash (list 'footnote-def (first refs)) 3bmd::*references*))
+ (let ((def (gethash (first refs) *expanded-footnotes*))
(*backlinks* (reverse (cdddr refs))))
(format s "" (third refs))
(3bmd::padded (2 s)
@@ -160,6 +222,7 @@
#++
(let ((*footnotes* t)
(3bmd-code-blocks:*code-blocks* t)
+ (3bmd-tables:*tables* t)
(s t))
(format s "~&----~%")
(with-open-file (s "/tmp/foo.html" :if-exists :supersede
@@ -167,15 +230,21 @@
(3bmd:parse-string-and-print-to-stream "a footnote[^ref] ref
[^ref]: the definition
-multiline
+[^a] multiline
+A footnote in a paragraph[^1]
+
+| Column1 | Column2[^1] |
+| --------- | ------- |
+| foot [^a] | note |
+
+[^a]: a footnote
```
some more body text
-
-a
+a[^a]
b
@@ -190,18 +259,49 @@ f
f
```
-Here's a simple footnote,[^1] and[^1] here[^1]'s a longer one.[^bignote]
+aHere's a simple footnote,[^1][^bignote] and[^1] here[^1]'s a longer one![^bignote]
+
+
+[^1]: This is the first footnote[^indirect].
+
+[^unused]: unused footnote[^double-unused]
+
+[^indirect]: indirect footnote
+
+[^double-unused]: unused footnote with a ref from unused footnote
+
+[^unused-recursive]: unused recursive footnote[^unused-recursive]
+
+[^umr1]: unused mutually recursive footnote1[^umr2]
+
+[^umr2]: unused mutually recursive footnote2[^umr1]
+
+[^mr1]: mutually recursive footnote1[^mr2]
+
+[^mr2]: mutually recursive footnote2[^mr1]
+
+[^c1]: footnotes without separating lines1
+[^c2]: footnotes without separating lines2
+
+[url1]: http://example.com
+
+[^bignote]: Here's one with multiple paragraphs and code. missing def[^missing]
+
+ Indent paragraphs to include them in the footnote[^mr2].
+ `{ my code }` [a [^c1] b](http://example.com) [c d](http://example.com)
-[^1]: This is the first footnote.
+ * Add as many paragraphs as you like[^c1].
+ * list[^c2][^nested]
+ * [ref url][url1]
+ * [nested ref url][url2]
-[^bignote]: Here's one with multiple paragraphs and code.
+ [^nested]: probably doesn't work?
- Indent paragraphs to include them in the footnote.
+ [url1]: http://example.com
- `{ my code }`
+ more text
- * Add as many paragraphs as you like.
- * liost
+end of the body text...
" s :format :html)))
diff --git a/tests/extensions/footnotes.lisp b/tests/extensions/footnotes.lisp
new file mode 100644
index 0000000..18d70b8
--- /dev/null
+++ b/tests/extensions/footnotes.lisp
@@ -0,0 +1,257 @@
+(fiasco:define-test-package #:3bmd-footnotes-tests
+ (:use #:3bmd-tests)
+ (:import-from #:3bmd-footnote
+ #:footnote
+ #:*footnotes*
+ #:footnote-ref
+ #:footnote-def
+ #:footnote-backlinks))
+
+(in-package #:3bmd-footnotes-tests)
+
+(3bmd-tests::def-grammar-test footnote-ref1
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "a[^ref] reference
+"
+ :expected '(:plain "a" (footnote-ref "ref")
+ " " "reference"))
+
+(3bmd-tests::def-grammar-test footnote-def1
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "[^def]: definition
+"
+ :expected '(footnote-def "def"
+ (:paragraph "definition"
+ (footnote-backlinks))))
+
+(3bmd-tests::def-grammar-test footnote-def2
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "[^2]: def
+
+ with paragraphs
+
+ ```
+ and code
+ ```
+"
+ :expected '(footnote-def "2"
+ (:paragraph "def")
+ (:paragraph "with" " " "paragraphs")
+ (:paragraph (:code "
+and code
+")
+ (footnote-backlinks))))
+
+
+(3bmd-tests::def-grammar-test footnote-def3
+ ;; backlinks go after last child if it isn't a :paragraph
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "[^2]: def
+
+ with paragraphs
+
+ * 1
+ * 2
+"
+ :expected '(footnote-def "2"
+ (:paragraph "def")
+ (:paragraph "with" " " "paragraphs")
+ (:bullet-list
+ (:list-item (:plain "1"))
+ (:list-item (:plain "2")))
+ (footnote-backlinks)))
+
+(3bmd-tests::def-grammar-test footnote-def4
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "[^1 2]: not def
+"
+ :expected '(:plain
+ (:reference-link :label ("^1" " " "2")
+ :tail NIL)
+ ":" " " "not" " " "def"))
+
+
+(3bmd-tests::def-grammar-test footnote-def5
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "[^long-id]: def
+"
+ :expected '(footnote-def "long-id"
+ (:paragraph "def"
+ (footnote-backlinks))))
+
+(3bmd-tests::def-grammar-test footnote-def6
+ ;; should parse as 2 definitions
+ :enable-extensions *footnotes*
+ :text "[^1]: def1
+[^2]: def2
+"
+ :expected '((footnote-def "1"
+ (:paragraph "def1"
+ (footnote-backlinks)))
+ (footnote-def "2"
+ (:paragraph "def2"
+ (footnote-backlinks)))))
+
+
+(3bmd-tests::def-grammar-test footnote-recursive1
+ :enable-extensions *footnotes*
+ :rule 3bmd-grammar::%block
+ :text "[^def]: an inline[^def] reference in a def
+"
+ :expected '(footnote-def "def"
+ (:paragraph "an" " " "inline"
+ (footnote-ref "def")
+ " " "reference" " " "in" " " "a" " "
+ "def"
+ (footnote-backlinks))))
+
+;; should generate a footnote #1, with superscript ¹ linking to
+;; "#fn:ref" with id "fnref:ref", and ordered list of definitions
+;; in a div with class "footnotes" or similar after all body text
+;;
+;; possibly add a "see footnote" title?
+(def-print-test print-footnotes-1
+ :enable-extensions *footnotes*
+ :text "a footnote[^ref] ref
+
+[^ref]: the definition
+
+some more body text
+"
+ :expected "a footnote1 ref
+some more body text
+"
+)
+
+(def-print-test print-footnotes-2
+ :enable-extensions *footnotes*
+ :text "[^def]: a recursive[^def] footnote[^def2]
+
+[^def2]: a normal footnote
+
+body[^def] 2
+"
+ :expected "body1 2
+")
+
+;; for multiple references, include multiple backlinks (to distinguish
+;; them if keeping original ID in names, add a number after fnref, so
+;; "fnref:1" "fnref2:1" "fnref3:1". Other options are just generating
+;; sequential numbers for the links and ignoring the supplied ID, or
+(def-print-test print-footnotes-3
+ :enable-extensions *footnotes*
+ :text "multiple[^1] references[^1] to same[^1] footnote
+
+[^1]: a footnote
+"
+ :expected "multiple1 references1 to same1 footnote
+")
+
+;; not sure if this should error or merge the definitions or what?
+;; GFM takes first definition, and looks like that's what we ended up
+;; with too, so good enough.
+(def-print-test print-footnotes-4
+ :enable-extensions *footnotes*
+ :text " a reference[^def]
+
+[^def]: definition
+
+[^def]: a duplicate definition
+"
+ :expected "a reference1
+")
+
+;; drop unused footnotes
+(def-print-test print-footnotes-5
+ :enable-extensions *footnotes*
+ :text " a reference[^1]
+
+[^1]: definition
+
+[^2]: an unused definition
+"
+ :expected "a reference1
+")
+
+
+;;; footnotes should be appended and numbered in order of use
+(def-print-test print-footnotes-6
+ :enable-extensions *footnotes*
+ :text " multiple[^3] unordered[^1] footnotes[^2]
+
+[^2]: first definition (id 2) third use
+
+[^3]: second definition (id 3) first use
+
+[^1]: third definition (id 1) second use
+
+more content
+
+"
+ :expected "multiple1 unordered2 footnotes3
+
+more content
+")
+
+;;; not sure about missing def: some drop completely, some print the
+;;; original text of the ref ("[^1]") as if it were not parsed, some
+;;; add a footnote with ID as contents
+;;
+;;; probably just convert back to [^1] in text
+(def-print-test print-footnotes-7
+ :enable-extensions *footnotes*
+ :text " a bad reference[^1] a normal reference[^2]
+
+[^2]: a definition
+
+body
+"
+ :expected "a bad reference[^1] a normal reference1
+body
+")
+
+;;; make sure we don't generate nested tags. Possibly should parse
+;;; as a link with "a [^1] b" as the text instead, or try to split the
+;;; link so "a "," b" point to url, and "¹" points to footnote?
+;;
+;;; fixme: should probably auto-link the URL if not parsing whole
+;;; thing as a link?
+(def-print-test print-footnotes-8
+ :enable-extensions *footnotes*
+ :text " [a [^1] b](http://example.com)
+
+[^1]: def
+
+body
+"
+ :expected "[a 1 b](http://example.com)
+body
+")
From 8cae664dbd6bc06cf33af31bd663579a2d332497 Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Fri, 21 Nov 2025 02:10:25 -0600
Subject: [PATCH 7/8] rename footnotes package to better match system/flag and
other extensions
---
footnotes.lisp | 4 ++--
tests/extensions/footnotes.lisp | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/footnotes.lisp b/footnotes.lisp
index a5c70d3..3c8864d 100644
--- a/footnotes.lisp
+++ b/footnotes.lisp
@@ -1,8 +1,8 @@
-(defpackage #:3bmd-footnote
+(defpackage #:3bmd-footnotes
(:use :cl :esrap :3bmd-ext)
(:export #:*footnotes*))
-(in-package #:3bmd-footnote)
+(in-package #:3bmd-footnotes
(defvar *footnotes*)
diff --git a/tests/extensions/footnotes.lisp b/tests/extensions/footnotes.lisp
index 18d70b8..9eaaf42 100644
--- a/tests/extensions/footnotes.lisp
+++ b/tests/extensions/footnotes.lisp
@@ -1,6 +1,6 @@
(fiasco:define-test-package #:3bmd-footnotes-tests
(:use #:3bmd-tests)
- (:import-from #:3bmd-footnote
+ (:import-from #:3bmd-footnotes
#:footnote
#:*footnotes*
#:footnote-ref
From 7a4bd9afcdc1a74dfd6721bfaf02f00346fe8bc5 Mon Sep 17 00:00:00 2001
From: Bart Botta <00003b@gmail.com>
Date: Fri, 21 Nov 2025 02:18:43 -0600
Subject: [PATCH 8/8] add some documentation for footnotes extension
---
README.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/README.md b/README.md
index 94eeefb..d498b90 100644
--- a/README.md
+++ b/README.md
@@ -133,3 +133,14 @@ especially, without heading:
$$
\frac{\partial E}{\partial y} = \frac{\partial }{\partial y} \frac{1}{n}\sum_{i=1}^{n} (y_i - a_i)^2
$$
+
+* Loading `3bmd-footnotes.asd` adds support for footnotes. If `3bmd-footnotes:*footnotes*` is non-`NIL` while parsing, the syntax `[^id]` can be used to refer to a footnote, which should be defined by `[^id]: contents of the footnote`. For example:
+
+ A footnote[^1] reference.
+
+ [^1]: a footnote, which can have its own footnotes[^2].
+ [^3]: almost anywhere: defining footnotes inside other block elements doesn't currently work.
+ [^2]: a footnote on a footnote.
+ [^x]: footnotes without any references in main document will not be shown.
+
+ The footnote definitions can be anywhere[^3] in the document, and will be moved to the end of the generated HTML in order of reference.