init commit (base extensions: notes)
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
-- glossary.lua — clickable/hoverable glossary for Quarto (HTML + PDF)
|
||||
--
|
||||
-- Entries can be defined two ways:
|
||||
--
|
||||
-- (1) in metadata (document or _quarto.yml), rendered by the glossary list:
|
||||
--
|
||||
-- glossary:
|
||||
-- laplacian:
|
||||
-- symbol: "$\\nabla^2$"
|
||||
-- term: "Laplacian"
|
||||
-- def: "Divergence of the gradient."
|
||||
-- props: # optional sub-entries
|
||||
-- linearity:
|
||||
-- term: "linearity"
|
||||
-- def: "$\\nabla^2(af+bg) = a\\nabla^2 f + b\\nabla^2 g$"
|
||||
--
|
||||
-- (2) inline, at the natural place in the document flow:
|
||||
--
|
||||
-- :::: {.gls-def key=laplacian symbol="$\nabla^2$" term="Laplacian"}
|
||||
-- Divergence of the gradient: $\nabla^2 f = \nabla\cdot\nabla f$.
|
||||
--
|
||||
-- ::: {.gls-prop key=linearity term="linearity"}
|
||||
-- $\nabla^2(af + bg) = a\,\nabla^2 f + b\,\nabla^2 g$.
|
||||
-- :::
|
||||
-- ::::
|
||||
--
|
||||
-- The div renders in place as the definition card AND registers the
|
||||
-- entry; usages link back to this spot.
|
||||
--
|
||||
-- Usage (works for entries and properties):
|
||||
-- in math: \gls{laplacian} \gls{laplacian.linearity}
|
||||
-- in prose: [laplacian]{.gls} [by linearity]{.gls key=laplacian.linearity}
|
||||
-- the list: ::: {.glossary}\n:::
|
||||
--
|
||||
-- HTML: usages are links with hover popups. PDF: hyperref links.
|
||||
|
||||
local glossary = {} -- key -> entry
|
||||
local keys = {} -- sorted list of top-level keys
|
||||
local glossary_page = nil -- e.g. "glossary.html": where metadata entries live
|
||||
-- entry = { sym, term = Inlines, def = Blocks, inline = bool,
|
||||
-- props = { pkey -> {sym, term = Inlines, def = Blocks} },
|
||||
-- porder = { pkey... } }
|
||||
|
||||
local function fmt_is(name)
|
||||
if quarto and quarto.doc and quarto.doc.is_format then
|
||||
return quarto.doc.is_format(name)
|
||||
end
|
||||
return FORMAT ~= nil and FORMAT:match(name) ~= nil
|
||||
end
|
||||
|
||||
------------------------------------------------------------------ helpers
|
||||
|
||||
-- first Math element's text within a metadata value / inline list
|
||||
local function math_of(v)
|
||||
if v == nil then return nil end
|
||||
local inlines = v
|
||||
if pandoc.utils.type(v) == "Blocks" then
|
||||
inlines = pandoc.utils.blocks_to_inlines(v)
|
||||
end
|
||||
for _, it in ipairs(inlines) do
|
||||
if it.t == "Math" then return it.text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- math text out of an attribute string like "$\nabla^2$" (or bare "\nabla^2")
|
||||
local function math_of_attr(s)
|
||||
if s == nil then return nil end
|
||||
local inner = s:match("^%s*%$(.-)%$%s*$")
|
||||
return inner or s
|
||||
end
|
||||
|
||||
local function to_inlines(v)
|
||||
if v == nil then return pandoc.Inlines({}) end
|
||||
if pandoc.utils.type(v) == "Blocks" then
|
||||
return pandoc.utils.blocks_to_inlines(v)
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
local function to_blocks(v)
|
||||
if v == nil then return pandoc.Blocks({}) end
|
||||
if pandoc.utils.type(v) == "Blocks" then return v end
|
||||
return pandoc.Blocks({ pandoc.Plain(v) })
|
||||
end
|
||||
|
||||
local function anchor(key) return "gls-" .. key end
|
||||
|
||||
-- resolve "key" or "key.prop"; returns display info + anchor or nil
|
||||
-- Entries defined inline live on this page; entries that come from
|
||||
-- metadata live wherever the ::: {.glossary} list is rendered. In a book
|
||||
-- each chapter is a separate HTML page, so those need a page-qualified
|
||||
-- link (set `glossary-page:` in metadata).
|
||||
local function href_of(base, id)
|
||||
local e = glossary[base]
|
||||
if e ~= nil and e.inline then return "#" .. id end
|
||||
if glossary_page ~= nil and fmt_is("html") then
|
||||
return glossary_page .. "#" .. id
|
||||
end
|
||||
return "#" .. id
|
||||
end
|
||||
|
||||
local function resolve(full)
|
||||
local base, prop = full:match("^([^%.]+)%.(.+)$")
|
||||
base = base or full
|
||||
local e = glossary[base]
|
||||
if e == nil then return nil end
|
||||
local id = anchor(full)
|
||||
if prop ~= nil then
|
||||
local p = e.props[prop]
|
||||
if p == nil then return nil end
|
||||
return { sym = p.sym, term = p.term, id = id, href = href_of(base, id) }
|
||||
end
|
||||
return { sym = e.sym, term = e.term, id = id, href = href_of(base, id) }
|
||||
end
|
||||
|
||||
-- what \gls{...} displays in math mode
|
||||
local function math_display(r)
|
||||
return r.sym or ("\\text{" .. pandoc.utils.stringify(r.term) .. "}")
|
||||
end
|
||||
|
||||
local function warn_key(key)
|
||||
io.stderr:write("[glossary] unknown key: " .. key
|
||||
.. " (if it is defined with ::: {.gls-def} in another chapter, note that"
|
||||
.. " HTML book chapters render separately - define shared terms in"
|
||||
.. " metadata / glossary.yml instead)\n")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------ pass 1: metadata
|
||||
|
||||
local function parse_meta_entry(v)
|
||||
local entry = {
|
||||
sym = math_of(v.symbol),
|
||||
term = to_inlines(v.term),
|
||||
def = to_blocks(v.def),
|
||||
inline = false,
|
||||
props = {},
|
||||
porder = {},
|
||||
}
|
||||
if v.props ~= nil then
|
||||
for pk, pv in pairs(v.props) do
|
||||
entry.props[pk] = {
|
||||
sym = math_of(pv.symbol),
|
||||
term = #to_inlines(pv.term) > 0 and to_inlines(pv.term)
|
||||
or pandoc.Inlines({ pandoc.Str(pk) }),
|
||||
def = to_blocks(pv.def),
|
||||
}
|
||||
entry.porder[#entry.porder + 1] = pk
|
||||
end
|
||||
table.sort(entry.porder)
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
local function read_meta(meta)
|
||||
if meta["glossary-page"] ~= nil then
|
||||
glossary_page = pandoc.utils.stringify(meta["glossary-page"])
|
||||
end
|
||||
if meta.glossary ~= nil then
|
||||
for k, v in pairs(meta.glossary) do
|
||||
local entry = parse_meta_entry(v)
|
||||
if #entry.term == 0 then
|
||||
entry.term = pandoc.Inlines({ pandoc.Str(k) })
|
||||
end
|
||||
glossary[k] = entry
|
||||
keys[#keys + 1] = k
|
||||
end
|
||||
end
|
||||
if quarto ~= nil and quarto.doc ~= nil and fmt_is("html") then
|
||||
quarto.doc.add_html_dependency({
|
||||
name = "calc-glossary",
|
||||
version = "2.0.0",
|
||||
stylesheets = { "glossary.css" },
|
||||
scripts = { "glossary.js" },
|
||||
})
|
||||
end
|
||||
return meta
|
||||
end
|
||||
|
||||
------------------------------------------------------------------ pass 2: inline defs
|
||||
|
||||
local pending_props = {}
|
||||
|
||||
-- standalone property: ::: {.gls-prop key=div.linearity} (or key=linearity of=div)
|
||||
local function register_standalone_prop(el)
|
||||
local key = el.attributes.key
|
||||
local base, prop
|
||||
if el.attributes.of ~= nil then
|
||||
base, prop = el.attributes.of, key
|
||||
elseif key ~= nil then
|
||||
base, prop = key:match("^([^%.]+)%.(.+)$")
|
||||
end
|
||||
if base == nil then return false end -- plain key: nested style, parent scans it
|
||||
pending_props[#pending_props + 1] = {
|
||||
base = base,
|
||||
prop = prop,
|
||||
sym = math_of_attr(el.attributes.symbol),
|
||||
term = el.attributes.term ~= nil
|
||||
and pandoc.Inlines({ pandoc.Str(el.attributes.term) })
|
||||
or pandoc.Inlines({ pandoc.Str(prop) }),
|
||||
def = el.content:clone(),
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
-- attach standalone props once every .gls-def has been registered
|
||||
local function attach_pending(doc)
|
||||
for _, p in ipairs(pending_props) do
|
||||
local e = glossary[p.base]
|
||||
if e == nil then
|
||||
io.stderr:write("[glossary] property " .. p.base .. "." .. p.prop
|
||||
.. " has no matching .gls-def for '" .. p.base .. "'\n")
|
||||
e = { sym = nil, term = pandoc.Inlines({ pandoc.Str(p.base) }),
|
||||
def = pandoc.Blocks({}), inline = true, props = {}, porder = {} }
|
||||
glossary[p.base] = e
|
||||
keys[#keys + 1] = p.base
|
||||
end
|
||||
if e.props[p.prop] ~= nil then
|
||||
io.stderr:write("[glossary] duplicate property: " .. p.base .. "." .. p.prop .. "\n")
|
||||
else
|
||||
e.props[p.prop] = { sym = p.sym, term = p.term, def = p.def,
|
||||
standalone = true }
|
||||
e.porder[#e.porder + 1] = p.prop
|
||||
end
|
||||
end
|
||||
return doc
|
||||
end
|
||||
|
||||
local function register_inline(el)
|
||||
if el.classes:includes("gls-prop") then
|
||||
register_standalone_prop(el)
|
||||
return nil
|
||||
end
|
||||
if not el.classes:includes("gls-def") then return nil end
|
||||
local key = el.attributes.key
|
||||
if key == nil then
|
||||
io.stderr:write("[glossary] .gls-def without key= attribute\n")
|
||||
return nil
|
||||
end
|
||||
local entry = {
|
||||
sym = math_of_attr(el.attributes.symbol),
|
||||
term = el.attributes.term ~= nil
|
||||
and pandoc.Inlines({ pandoc.Str(el.attributes.term) })
|
||||
or pandoc.Inlines({ pandoc.Str(key) }),
|
||||
def = pandoc.Blocks({}),
|
||||
inline = true,
|
||||
props = {},
|
||||
porder = {},
|
||||
}
|
||||
for _, b in ipairs(el.content) do
|
||||
if b.t == "Div" and b.classes:includes("gls-prop") then
|
||||
local pk = b.attributes.key
|
||||
if pk == nil then
|
||||
io.stderr:write("[glossary] .gls-prop without key= (in " .. key .. ")\n")
|
||||
else
|
||||
entry.props[pk] = {
|
||||
sym = math_of_attr(b.attributes.symbol),
|
||||
term = b.attributes.term ~= nil
|
||||
and pandoc.Inlines({ pandoc.Str(b.attributes.term) })
|
||||
or pandoc.Inlines({ pandoc.Str(pk) }),
|
||||
def = b.content:clone(),
|
||||
}
|
||||
entry.porder[#entry.porder + 1] = pk
|
||||
end
|
||||
else
|
||||
entry.def:insert(b)
|
||||
end
|
||||
end
|
||||
if glossary[key] ~= nil then
|
||||
io.stderr:write("[glossary] duplicate key: " .. key .. "\n")
|
||||
else
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
glossary[key] = entry
|
||||
return nil -- registration only; pass 3 transforms
|
||||
end
|
||||
|
||||
------------------------------------------------------------------ pass 3: usages & rendering
|
||||
|
||||
-- rewrite \gls{...} occurrences inside a TeX/math string
|
||||
local function replace_gls(txt, target)
|
||||
return (txt:gsub("\\gls(%b{})", function(braced)
|
||||
local key = braced:sub(2, -2)
|
||||
local r = resolve(key)
|
||||
if r == nil then
|
||||
warn_key(key)
|
||||
return "\\text{??" .. key .. "??}"
|
||||
end
|
||||
local disp = math_display(r)
|
||||
if target == "latex" then
|
||||
return "\\hyperlink{" .. r.id .. "}{" .. disp .. "}"
|
||||
else
|
||||
return "\\href{" .. r.href .. "}{\\class{gls-sym gls-key-" .. key .. "}{" .. disp .. "}}"
|
||||
end
|
||||
end))
|
||||
end
|
||||
|
||||
local function Math(el)
|
||||
if not el.text:find("\\gls") then return nil end
|
||||
el.text = replace_gls(el.text, fmt_is("latex") and "latex" or "html")
|
||||
return el
|
||||
end
|
||||
|
||||
local function RawBlock(el)
|
||||
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
||||
if not el.text:find("\\gls") then return nil end
|
||||
if fmt_is("latex") then
|
||||
return pandoc.RawBlock(el.format, replace_gls(el.text, "latex"))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function usage(key, display)
|
||||
local r = resolve(key)
|
||||
if r == nil then
|
||||
warn_key(key)
|
||||
return nil
|
||||
end
|
||||
local out = pandoc.Inlines({})
|
||||
if fmt_is("latex") then
|
||||
out:insert(pandoc.RawInline("latex", "\\hyperlink{" .. r.id .. "}{"))
|
||||
out:extend(display)
|
||||
out:insert(pandoc.RawInline("latex", "}"))
|
||||
elseif fmt_is("html") then
|
||||
out:insert(pandoc.RawInline("html",
|
||||
'<a class="gls-sym" data-gls-key="' .. key .. '" href="' .. r.href .. '">'))
|
||||
out:extend(display)
|
||||
out:insert(pandoc.RawInline("html", "</a>"))
|
||||
else
|
||||
return display
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function Span(el)
|
||||
-- pandoc's LaTeX reader turns \gls{key} into this span (glossaries pkg)
|
||||
local acr = el.attributes["acronym-label"]
|
||||
if acr ~= nil then
|
||||
local r = resolve(acr)
|
||||
if r == nil then
|
||||
warn_key(acr)
|
||||
return nil
|
||||
end
|
||||
return usage(acr, pandoc.Inlines({ pandoc.Math("InlineMath", math_display(r)) }))
|
||||
end
|
||||
if not el.classes:includes("gls") then return nil end
|
||||
local key = el.attributes.key or pandoc.utils.stringify(el.content)
|
||||
local display
|
||||
if el.attributes.key ~= nil then
|
||||
display = el.content
|
||||
else
|
||||
local r = resolve(key)
|
||||
display = r and r.term:clone() or el.content
|
||||
end
|
||||
return usage(key, display)
|
||||
end
|
||||
|
||||
local function RawInline(el)
|
||||
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
||||
local key = el.text:match("^\\gls%s*{(.-)}$")
|
||||
if key == nil then return nil end
|
||||
local r = resolve(key)
|
||||
if r == nil then return nil end
|
||||
return usage(key, pandoc.Inlines({ pandoc.Math("InlineMath", math_display(r)) }))
|
||||
end
|
||||
|
||||
-- head line "sym — **term**" (+ latex hypertarget when with_anchor)
|
||||
local function head_line(id, sym, term, with_anchor)
|
||||
local head = pandoc.Inlines({})
|
||||
if with_anchor and fmt_is("latex") then
|
||||
head:insert(pandoc.RawInline("latex", "\\hypertarget{" .. id .. "}{}"))
|
||||
end
|
||||
if sym ~= nil then
|
||||
head:insert(pandoc.Math("InlineMath", sym))
|
||||
head:insert(pandoc.Str("\u{2002}—\u{2002}"))
|
||||
end
|
||||
head:insert(pandoc.Strong(term:clone()))
|
||||
return pandoc.Para(head)
|
||||
end
|
||||
|
||||
local function prop_div(base, pk, p, with_anchor)
|
||||
local id = anchor(base .. "." .. pk)
|
||||
local blocks = pandoc.Blocks({ head_line(id, p.sym, p.term, with_anchor) })
|
||||
blocks:extend(p.def:clone())
|
||||
return pandoc.Div(blocks,
|
||||
pandoc.Attr(with_anchor and id or "", { "gls-entry", "gls-prop-entry" }))
|
||||
end
|
||||
|
||||
-- transform an in-place .gls-def into its rendered definition card
|
||||
local function render_inline_def(el)
|
||||
local key = el.attributes.key
|
||||
if key == nil then return nil end
|
||||
local e = glossary[key]
|
||||
if e == nil or not e.inline then return nil end
|
||||
local id = anchor(key)
|
||||
local blocks = pandoc.Blocks({ head_line(id, e.sym, e.term, true) })
|
||||
blocks:extend(e.def:clone())
|
||||
for _, pk in ipairs(e.porder) do
|
||||
if not e.props[pk].standalone then -- standalone props render at their own spot
|
||||
blocks:insert(prop_div(key, pk, e.props[pk], true))
|
||||
end
|
||||
end
|
||||
return pandoc.Div(blocks, pandoc.Attr(id, { "gls-entry", "gls-inline" }))
|
||||
end
|
||||
|
||||
-- ::: {.glossary} ::: -> rendered list
|
||||
local function render_list(el)
|
||||
table.sort(keys)
|
||||
local blocks = pandoc.Blocks({})
|
||||
for _, key in ipairs(keys) do
|
||||
local e = glossary[key]
|
||||
local id = anchor(key)
|
||||
-- inline-defined entries keep their canonical anchor in the flow;
|
||||
-- the list clone links back instead of re-anchoring
|
||||
local with_anchor = not e.inline
|
||||
local entry = pandoc.Blocks({ head_line(id, e.sym, e.term, with_anchor) })
|
||||
entry:extend(e.def:clone())
|
||||
if e.inline then
|
||||
for _, pk in ipairs(e.porder) do
|
||||
local p = e.props[pk]
|
||||
local line = pandoc.Inlines({})
|
||||
local pid = anchor(key .. "." .. pk)
|
||||
if fmt_is("latex") then
|
||||
line:insert(pandoc.RawInline("latex", "\\hyperlink{" .. pid .. "}{"))
|
||||
elseif fmt_is("html") then
|
||||
line:insert(pandoc.RawInline("html",
|
||||
'<a class="gls-back" href="#' .. pid .. '">'))
|
||||
end
|
||||
if p.sym ~= nil then
|
||||
line:insert(pandoc.Math("InlineMath", p.sym))
|
||||
line:insert(pandoc.Str("\u{2002}"))
|
||||
end
|
||||
line:extend(p.term:clone())
|
||||
if fmt_is("latex") then
|
||||
line:insert(pandoc.RawInline("latex", "}"))
|
||||
elseif fmt_is("html") then
|
||||
line:insert(pandoc.RawInline("html", "</a>"))
|
||||
end
|
||||
entry:insert(pandoc.Div(pandoc.Blocks({ pandoc.Plain(line) }),
|
||||
pandoc.Attr("", { "gls-prop-line" })))
|
||||
end
|
||||
local back = pandoc.Inlines({})
|
||||
if fmt_is("latex") then
|
||||
back:insert(pandoc.RawInline("latex", "\\hyperlink{" .. id .. "}{"))
|
||||
back:insert(pandoc.Str("→ definition in context"))
|
||||
back:insert(pandoc.RawInline("latex", "}"))
|
||||
else
|
||||
back:insert(pandoc.RawInline("html",
|
||||
'<a class="gls-back" href="#' .. id .. '">'))
|
||||
back:insert(pandoc.Str("→ definition in context"))
|
||||
back:insert(pandoc.RawInline("html", "</a>"))
|
||||
end
|
||||
entry:insert(pandoc.Para(back))
|
||||
else
|
||||
for _, pk in ipairs(e.porder) do
|
||||
entry:insert(prop_div(key, pk, e.props[pk], true))
|
||||
end
|
||||
end
|
||||
blocks:insert(pandoc.Div(entry,
|
||||
pandoc.Attr(with_anchor and id or "", { "gls-entry" })))
|
||||
end
|
||||
return pandoc.Div(blocks, pandoc.Attr(el.identifier, { "glossary" }))
|
||||
end
|
||||
|
||||
-- transform a standalone .gls-prop into its rendered card
|
||||
local function render_standalone_prop(el)
|
||||
local key = el.attributes.key
|
||||
local base, prop
|
||||
if el.attributes.of ~= nil then
|
||||
base, prop = el.attributes.of, el.attributes.key
|
||||
elseif key ~= nil then
|
||||
base, prop = key:match("^([^%.]+)%.(.+)$")
|
||||
end
|
||||
if base == nil then return nil end -- nested style: parent handles it
|
||||
local e = glossary[base]
|
||||
local p = e and e.props[prop]
|
||||
if p == nil then return nil end
|
||||
local d = prop_div(base, prop, p, true)
|
||||
d.classes:insert("gls-standalone")
|
||||
return d
|
||||
end
|
||||
|
||||
|
||||
------------------------------------------------------------------ tooltip data
|
||||
|
||||
local function js_escape(s)
|
||||
s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
|
||||
s = s:gsub("\n", "\\n"):gsub("\r", ""):gsub("<%/", "<\\/")
|
||||
return s
|
||||
end
|
||||
|
||||
local function blocks_to_html(blocks)
|
||||
if blocks == nil or #blocks == 0 then return "" end
|
||||
local ok, html = pcall(pandoc.write, pandoc.Pandoc(blocks), "html")
|
||||
if not ok then return "" end
|
||||
return html
|
||||
end
|
||||
|
||||
local function head_html(sym, term)
|
||||
local ins = pandoc.Inlines({})
|
||||
if sym ~= nil then
|
||||
ins:insert(pandoc.Math("InlineMath", sym))
|
||||
ins:insert(pandoc.Str("\u{2002}\u{2014}\u{2002}"))
|
||||
end
|
||||
ins:extend(term:clone())
|
||||
return blocks_to_html(pandoc.Blocks({ pandoc.Para(ins) }))
|
||||
end
|
||||
|
||||
-- make every entry's definition available to the tooltip on any page
|
||||
local function inject_data(doc)
|
||||
if not fmt_is("html") then return doc end
|
||||
if quarto == nil or quarto.doc == nil then return doc end
|
||||
local parts = {}
|
||||
for key, e in pairs(glossary) do
|
||||
parts[#parts + 1] = '"' .. js_escape(key) .. '":"'
|
||||
.. js_escape(head_html(e.sym, e.term) .. blocks_to_html(e.def)) .. '"'
|
||||
for pk, p in pairs(e.props) do
|
||||
parts[#parts + 1] = '"' .. js_escape(key .. "." .. pk) .. '":"'
|
||||
.. js_escape(head_html(p.sym, p.term) .. blocks_to_html(p.def)) .. '"'
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return doc end
|
||||
quarto.doc.include_text("after-body",
|
||||
"<script>window.__glsData = {" .. table.concat(parts, ",") .. "};</script>")
|
||||
return doc
|
||||
end
|
||||
|
||||
local function Div(el)
|
||||
if el.classes:includes("gls-def") then
|
||||
return render_inline_def(el)
|
||||
elseif el.classes:includes("gls-prop") then
|
||||
return render_standalone_prop(el)
|
||||
elseif el.classes:includes("glossary") then
|
||||
return render_list(el)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
{ Meta = read_meta },
|
||||
{ Div = register_inline, Pandoc = attach_pending },
|
||||
{
|
||||
Math = Math,
|
||||
RawBlock = RawBlock,
|
||||
RawInline = RawInline,
|
||||
Span = Span,
|
||||
Div = Div,
|
||||
Pandoc = inject_data,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user