-- 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", '')) out:extend(display) out:insert(pandoc.RawInline("html", "")) 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", '')) 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", "")) 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", '')) back:insert(pandoc.Str("→ definition in context")) back:insert(pandoc.RawInline("html", "")) 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", "") 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, }, }