--
-- pandoc-sidenote.lua
--
-- Author: Jacob Zimmerman (@jez)
-- Version: 0.26.0
-- Modified: 2026-01-04
-- URL: https://github.com/jez/pandoc-sidenote
--
--------------------------------------------------------------------------------
-- The MIT License (MIT)
--
-- Copyright (c) 2025 Jacob Zimmerman
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the "Software"), to
-- deal in the Software without restriction, including without limitation the
-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-- sell copies of the Software, and to permit persons to whom the Software is
-- furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--------------------------------------------------------------------------------
local function startsWithStrSpace(inlines, canOmitSpace)
if not inlines[1] or inlines[1].tag ~= "Str" then
return false
end
if not inlines[2] then
return canOmitSpace
end
if inlines[2].tag ~= "Space" then
return false
end
return true
end
local function stripNoteAttribute(inlines, canOmitSpace)
if startsWithStrSpace(inlines, canOmitSpace) then
-- The '{-}' symbol differentiates between margin note and side note
if inlines[1].text == "{-}" then
inlines:remove(1)
if inlines[1] and inlines[1].tag == "Space" then
inlines:remove(1)
end
return "marginnote"
end
-- '{^-}' indicates that it should be a margin note, but using the
-- hoisted block markup, instead of remaining inline.
if inlines[1].text == "{^-}" then
inlines:remove(1)
if inlines[1] and inlines[1].tag == "Space" then
inlines:remove(1)
end
return "marginnote-block"
end
-- '{^}' indicates that it should be a side note, but using the
-- hoisted block markup, instead of remaining inline.
if inlines[1].text == "{^}" then
inlines:remove(1)
if inlines[1] and inlines[1].tag == "Space" then
inlines:remove(1)
end
return "sidenote-block"
end
-- '{.}' indicates whether to leave the footnote untouched (a footnote)
if inlines[1].text == "{.}" then
inlines:remove(1)
if inlines[1] and inlines[1].tag == "Space" then
inlines:remove(1)
end
return "footnote"
end
end
return "sidenote"
end
local function mungeBlocks(blocks)
if #blocks == 0 then
return "sidenote"
end
local block = blocks[1]
if block.tag == "Plain" or block.tag == "Para" then
return stripNoteAttribute(block.content, #blocks > 1)
elseif block.tag == "LineBlock" then
local firstInlines = block.content[1]
if firstInlines then
return stripNoteAttribute(firstInlines, #block.content > 1 or #blocks > 1)
end
return "sidenote"
else
return "sidenote"
end
end
local function append(xs, ys)
for i = 1, #ys do
xs[#xs + 1] = ys[i]
end
end
-- TODO(jez) Can you rewrite this with a walk?
local function accumulateInlines(inlines, block)
if block.tag == "Plain" then
append(inlines, block.content)
elseif block.tag == "Para" then
-- Simulate paragraphs with double LineBreak
append(inlines, block.content)
inlines[#inlines + 1] = pandoc.LineBreak()
inlines[#inlines + 1] = pandoc.LineBreak()
elseif block.tag == "LineBlock" then
-- See extension: line_blocks
for i = 1, #block.content do
append(inlines, block.content[i])
end
elseif block.tag == "RawBlock" then
-- Pretend RawBlock is RawInline (might not work!)
-- Consider: raw
now inside RawInline... what happens?
inlines[#inlines + 1] = pandoc.RawInline(block.format, block.text)
end
-- lists, blockquotes, headers, hrs, and tables are all omitted.
-- Think they shouldn't be? I'm open to sensible PR's.
end
-- Extract inlines from blocks. Note has Blocks, but Span needs Inlines
local function coerceToInline(blocks)
blocks = blocks:walk({
Note = function(note)
return pandoc.Str("")
end,
})
local inlines = {}
for i = 1, #blocks do
accumulateInlines(inlines, blocks[i])
end
return inlines
end
local function makeLabel(snIdx, noteKind)
local labelCls = "margin-toggle"
if noteKind == "sidenote" or noteKind == "sidenote-block" then
labelCls = labelCls .. " sidenote-number"
end
local labelSym
if noteKind == "marginnote" or noteKind == "marginnote-block" then
labelSym = "⊕"
else
labelSym = ""
end
local labelFormatStr = ''
local labelHTML = labelFormatStr:format(snIdx, labelCls, labelSym)
return pandoc.RawInline("html", labelHTML)
end
local function makeInputHTML(snIdx)
local inputFormatStr = ''
return inputFormatStr:format(snIdx)
end
notesStack = {}
snIdx = -1
noteVisitor = {
Note = function(note)
local noteKind = mungeBlocks(note.content)
if noteKind == "footnote" then
return note
end
-- Generate a unique number for the `for=` attribute
snIdx = snIdx + 1
if noteKind ~= "marginnote-block" and noteKind ~= "sidenote-block" then
local inlines = coerceToInline(note.content)
return pandoc.Span({
makeLabel(snIdx, noteKind),
pandoc.RawInline("html", makeInputHTML(snIdx)),
pandoc.Span(inlines, { class = noteKind }),
}, { class = "sidenote-wrapper" })
end
local notes = notesStack[#notesStack]
notes[#notes + 1] = {
snIdx = snIdx,
kind = noteKind,
content = note.content,
}
return makeLabel(snIdx, noteKind)
end,
}
function translateNotes(notes)
local translatedNotes = {}
for i = 1, #notes do
note = notes[i]
local contentClass = note.kind
-- Use original classes for backwards compatibility.
-- If people really want to care about the distinction,
-- they can write `div.marginnote` or `span.marginnote`
if contentClass == "marginnote-block" then
contentClass = "marginnote"
elseif contentClass == "sidenote-block" then
contentClass = "sidenote"
end
translatedNotes[#translatedNotes + 1] = pandoc.Div(
pandoc.Blocks({
pandoc.RawBlock("html", makeInputHTML(note.snIdx)),
pandoc.Div(note.content, { class = contentClass }),
}),
{ class = "sidenote-wrapper" }
)
end
return translatedNotes
end
function collectNotes(node)
notesStack[#notesStack + 1] = {}
node.content = node.content:walk(noteVisitor)
local notes = table.remove(notesStack, #notesStack)
return translateNotes(notes)
end
function visitListOfBlocks(blockss)
for i = 1, #blockss do
local blocks = blockss[i]
visitBlocks(blocks)
end
return {}
end
blockVisitor = {
BlockQuote = function(node)
visitBlocks(node.content)
return {}, nil
end,
BulletList = function(node)
return visitListOfBlocks(node.content), nil
end,
CodeBlock = function(node)
return {}, nil
end,
DefinitionList = function(node)
notesStack[#notesStack + 1] = {}
for i = 1, #node.content do
local item = node.content[i]
local term = item[1]
local definition = item[2]
node:walk(noteVisitor)
visitListOfBlocks(definition)
end
-- Unfortunately, the best we can do is either hoist any notes in the terms
-- to before the entire list, or to push the note into the start of the
-- definition. This implementation chooses the former. Realistically, notes
-- in the definitions should probably use inline side notes instead of
-- block-based side notes.
local notes = table.remove(notesStack, #notesStack)
return translateNotes(notes), nil
end,
Div = function(node)
visitBlocks(node.content)
return {}, nil
end,
Figure = function(node)
visitBlocks(node.content)
return {}, nil
end,
Header = function(node)
return collectNotes(node), nil
end,
HorizontalRule = function(node)
return {}, nil
end,
LineBlock = function(node)
return collectNotes(node), nil
end,
OrderedList = function(node)
return visitListOfBlocks(node.content), nil
end,
Para = function(node)
return collectNotes(node), nil
end,
Plain = function(node)
return collectNotes(node), nil
end,
RawBlock = function(node)
return {}, nil
end,
Table = function(node)
notesStack[#notesStack + 1] = {}
newTable = node:walk(noteVisitor)
local notes = table.remove(notesStack, #notesStack)
return translateNotes(notes), newTable
end,
}
function visitBlocks(blocks)
local result = {}
for i = 1, #blocks do
local block = blocks[i]
local newBlocks, replacedElem = blockVisitor[block.tag](block)
for j = 1, #newBlocks do
result[#result + 1] = newBlocks[j]
end
if replacedElem then
result[#result + 1] = replacedElem
else
result[#result + 1] = block
end
end
for i = 1, #result do
blocks[i] = result[i]
end
end
function Pandoc(doc)
visitBlocks(doc.blocks)
return doc
end