Sidenotes: emit the section.footnotes fallback the CSS expects

The filter consumes every Pandoc Note, so the "standard Pandoc-
generated section.footnotes" its doc claimed as the no-JS fallback
never existed — below 1500px with JS disabled, footnote content was
simply invisible (AUDIT §2.3). The filter now collects consumed notes
and appends the section itself: letter labels, jump targets for the
in-text refs (which now point at the visible fallback item), and
doc-backlink returns. sidenotes.js pairs ref/note by element id and
preventDefaults clicks, so behavior with JS is unchanged.

Verified in output: per-page item count matches inline sidenote count;
refs target #fn-<label>; backlinks target #snref-<label>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 10:37:28 -04:00
parent 4e28c82e4c
commit 8ca22a45d2
2 changed files with 109 additions and 22 deletions

View File

@ -4,12 +4,23 @@
--
-- Each footnote becomes:
-- * A @<sup class="sidenote-ref">@ anchor in the body text.
-- * An @<aside class="sidenote">@ immediately following it, containing
-- * A @<span class="sidenote">@ immediately following it, containing
-- the rendered note content.
--
-- On wide viewports, sidenotes.css floats asides into the right margin.
-- On narrow viewports they are hidden; the standard Pandoc-generated
-- @<section class="footnotes">@ at the document end serves as fallback.
-- Additionally, every consumed note is re-emitted in a
-- @<section class="footnotes">@ appended at the document end. The
-- filter swallows Pandoc's own @Note@ inlines, so Pandoc's writer
-- never produces that section itself — without this re-emission,
-- narrow viewports with JavaScript disabled (where sidenotes.css
-- hides @.sidenote@ and sidenotes.js's bottom sheet never runs)
-- would lose footnote content entirely.
--
-- On wide viewports, sidenotes.css floats the spans into the right
-- margin and hides @section.footnotes@; on narrow viewports the
-- spans are hidden and the section is shown. The in-text anchor
-- targets the footnotes item (the only target visible on narrow
-- no-JS viewports); sidenotes.js intercepts clicks and pairs
-- ref\/note by element id, so the href is purely the no-JS path.
module Filters.Sidenotes (apply) where
import Control.Monad.State.Strict
@ -23,17 +34,53 @@ import Text.Pandoc.Options (WriterOptions (..),
import Text.Pandoc.Walk (walkM)
import Text.Pandoc.Writers.HTML (writeHtml5String)
-- | Transform all @Note@ inlines in the document to inline sidenote HTML.
apply :: Pandoc -> Pandoc
apply doc = evalState (walkM convertNote doc) (1 :: Int)
-- | Accumulator: next label counter plus collected notes
-- (newest-first; reversed before rendering the fallback section).
type NoteState = (Int, [(Text, [Block])])
convertNote :: Inline -> State Int Inline
-- | Transform all @Note@ inlines in the document to inline sidenote
-- HTML, and append the collected notes as a @section.footnotes@
-- fallback block.
apply :: Pandoc -> Pandoc
apply doc =
let (Pandoc m blocks, (_, collected)) =
runState (walkM convertNote doc) (1, [])
notes = reverse collected
in Pandoc m $
if null notes
then blocks
else blocks ++ [footnotesSection notes]
convertNote :: Inline -> State NoteState Inline
convertNote (Note blocks) = do
n <- get
put (n + 1)
(n, acc) <- get
put (n + 1, (toLabel n, blocks) : acc)
return $ RawInline "html" (renderNote n blocks)
convertNote x = return x
-- | The end-of-document fallback list. Letter labels are rendered
-- explicitly (an @<ol>@'s automatic numbering would disagree with
-- the in-text letters), so the list itself is unstyled.
footnotesSection :: [(Text, [Block])] -> Block
footnotesSection notes = RawBlock "html" $ T.concat $
[ "<section class=\"footnotes\" role=\"doc-endnotes\">"
, "<ol class=\"footnotes-list\">"
]
++ map item notes ++
[ "</ol>"
, "</section>"
]
where
item (lbl, blocks) = T.concat
[ "<li id=\"fn-", lbl, "\" class=\"footnote-item\">"
, "<span class=\"footnote-label\" aria-hidden=\"true\">", lbl, "</span>"
, blocksToHtml blocks
, "<a href=\"#snref-", lbl
, "\" class=\"footnote-back\" role=\"doc-backlink\""
, " aria-label=\"Back to reference ", lbl, "\">\x21a9\xfe0e</a>"
, "</li>"
]
-- | Convert a 1-based counter to a letter label using base-26 expansion
-- (Excel-column style): 1→a, 2→b, … 26→z, 27→aa, 28→ab, … 52→az,
-- 53→ba, … 702→zz, 703→aaa. Guarantees a unique label per counter so
@ -54,8 +101,14 @@ renderNote n blocks =
let inner = blocksToInlineHtml blocks
lbl = toLabel n
in T.concat
-- href targets the footnotes-section item: on narrow no-JS
-- viewports that is the only visible rendering of the note
-- (the adjacent .sidenote span is display:none there, and on
-- wide viewports the note is already visible in the margin).
-- sidenotes.js pairs ref/note by id and preventDefaults the
-- click, so the href only ever navigates without JS.
[ "<sup class=\"sidenote-ref\" id=\"snref-", lbl, "\">"
, "<a href=\"#sn-", lbl, "\">", lbl, "</a>"
, "<a href=\"#fn-", lbl, "\">", lbl, "</a>"
, "</sup>"
, "<span class=\"sidenote\" id=\"sn-", lbl, "\">"
, "<sup class=\"sidenote-num\">", lbl, "</sup>\x00a0"

View File

@ -16,8 +16,10 @@
For an inline <span> inside a <p>, this is roughly the line containing
the sidenote reference, giving correct vertical alignment without JS.
On narrow viewports the <span> is hidden and the Pandoc-generated
<section class="footnotes"> at document end is shown instead.
On narrow viewports the <span> is hidden and the
<section class="footnotes"> the Sidenotes filter appends at document
end is shown instead (Pandoc's own footnote section never exists
the filter consumes every Note, and re-emits this fallback itself).
*/
/* ============================================================
@ -137,22 +139,54 @@
/* ============================================================
FOOTNOTE REFERENCES shown on narrow viewports alongside
section.footnotes
FOOTNOTES FALLBACK LIST the section the Sidenotes filter
appends at document end; visible on narrow viewports only
(see the media queries above). Letter labels are rendered
explicitly because an <ol>'s automatic numbers would disagree
with the in-text letter refs.
============================================================ */
a.footnote-ref {
text-decoration: none;
color: var(--text-faint);
font-size: 0.75em;
line-height: 0;
section.footnotes .footnotes-list {
list-style: none;
margin: 0;
padding: 0;
}
.footnote-item {
position: relative;
top: -0.4em;
padding-left: 1.5rem;
margin-bottom: 0.85rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text-muted);
}
.footnote-label {
position: absolute;
left: 0;
top: 0.15em;
font-family: var(--font-sans);
font-size: 0.75em;
color: var(--text-faint);
}
/* First paragraph flows on the label's line; later ones stack. */
.footnote-item > p {
margin: 0 0 0.5em;
}
.footnote-item > p:first-of-type {
display: inline;
}
.footnote-back {
margin-left: 0.35em;
text-decoration: none;
font-family: var(--font-sans);
color: var(--text-faint);
transition: color var(--transition-fast);
}
a.footnote-ref:hover {
.footnote-back:hover {
color: var(--text-muted);
}