diff --git a/src/resources/filters/ast/customnodes.lua b/src/resources/filters/ast/customnodes.lua index a36bf6a3a09..45967e060f8 100644 --- a/src/resources/filters/ast/customnodes.lua +++ b/src/resources/filters/ast/customnodes.lua @@ -41,7 +41,7 @@ function is_regular_node(node, name) return node end -function run_emulated_filter(doc, filter) +function run_emulated_filter(doc, filter, traverse) if doc == nil then return nil end @@ -73,7 +73,17 @@ function run_emulated_filter(doc, filter) -- luacov: enable end end - return node:walk(filter_param) + local old_use_walk = _QUARTO_USE_WALK + if traverse == nil or traverse == 'walk' then + _QUARTO_USE_WALK = true + elseif traverse == 'jog' then + _QUARTO_USE_WALK = false + else + warn('Unknown traverse method: ' .. tostring(traverse)) + end + local result = _quarto.modules.jog(node, filter_param) + _QUARTO_USE_WALK = old_use_walk + return result end -- performance: if filter is empty, do nothing @@ -336,22 +346,21 @@ _quarto.ast = { return end local node = node_accessor(table) - local t = pandoc.utils.type(value) - -- FIXME this is broken; that can only be "Block", "Inline", etc - if t == "Div" or t == "Span" then - local custom_data, t, kind = _quarto.ast.resolve_custom_data(value) - if custom_data ~= nil then - value = custom_data - end - end + local valtype = pandoc.utils.type(value) + quarto_assert(valtype ~= 'Div' and valtype ~= 'Span', "") if index > #node.content then _quarto.ast.grow_scaffold(node, index) end - local pt = pandoc.utils.type(value) - if pt == "Block" or pt == "Inline" then - node.content[index].content = {value} + local inner_node = node.content[index] + local innertype = pandoc.utils.type(inner_node) + if innertype == 'Block' then + inner_node.content = quarto.utils.as_blocks(value) + elseif innertype == 'Inline' then + inner_node.content = quarto.utils.as_inlines(value) else - node.content[index].content = value + warn(debug.traceback( + 'Cannot find the right content type for value ' .. valtype)) + inner_node.content = value end end } @@ -422,13 +431,15 @@ _quarto.ast = { -- luacov: enable end - local forwarder = { } + local forwarder if tisarray(handler.slots) then + forwarder = pandoc.List{} for i, slot in ipairs(handler.slots) do forwarder[slot] = i end - else - forwarder = handler.slots + elseif handler.slots ~= nil then + warn('Expected `slots` to be either an array or nil, got ' .. + tostring(handler.slots)) end quarto[handler.ast_name] = function(params) diff --git a/src/resources/filters/ast/emulatedfilter.lua b/src/resources/filters/ast/emulatedfilter.lua index 34d05278f82..99e3df938db 100644 --- a/src/resources/filters/ast/emulatedfilter.lua +++ b/src/resources/filters/ast/emulatedfilter.lua @@ -68,6 +68,9 @@ inject_user_filters_at_entry_points = function(filter_list) end local filter = { name = entry_point .. "-user-" .. tostring(entry_point_counts[entry_point]), + -- The filter might not work as expected when doing a non-lazy jog, so + -- make sure it is processed with the default 'walk' function. + traverse = 'walk', } if is_many_filters then filter.filters = wrapped diff --git a/src/resources/filters/ast/runemulation.lua b/src/resources/filters/ast/runemulation.lua index 39b193a8f36..763b32ed6ae 100644 --- a/src/resources/filters/ast/runemulation.lua +++ b/src/resources/filters/ast/runemulation.lua @@ -79,7 +79,7 @@ local function run_emulated_filter_chain(doc, filters, afterFilterPass, profilin print(pandoc.write(doc, "native")) else _quarto.ast._current_doc = doc - doc = run_emulated_filter(doc, v.filter) + doc = run_emulated_filter(doc, v.filter, v.traverse) ensure_vault(doc) add_trace(doc, v.name) diff --git a/src/resources/filters/common/error.lua b/src/resources/filters/common/error.lua index 930af18b78d..e27f9c0893a 100644 --- a/src/resources/filters/common/error.lua +++ b/src/resources/filters/common/error.lua @@ -15,8 +15,15 @@ function fail(message, level) end end -function internal_error() - fail("This is an internal error. Please file a bug report at https://github.com/quarto-dev/quarto-cli/", 5) +function internal_error(msg, level) + fail((msg and (msg .. '\n') or '') .. + "This is an internal error. Please file a bug report at https://github.com/quarto-dev/quarto-cli/", level or 5) +end + +function quarto_assert (test, msg, level) + if not test then + internal_error(msg, level or 6) + end end function currentFile() diff --git a/src/resources/filters/common/layout.lua b/src/resources/filters/common/layout.lua index 0f0d7672c55..55bd175afda 100644 --- a/src/resources/filters/common/layout.lua +++ b/src/resources/filters/common/layout.lua @@ -56,7 +56,7 @@ end -- we often wrap a table in a div, unwrap it function tableFromLayoutCell(cell) local tbl - cell:walk({ + _quarto.modules.jog(cell, { Table = function(t) tbl = t end diff --git a/src/resources/filters/common/log.lua b/src/resources/filters/common/log.lua index 584358be1fe..67a736bbe93 100644 --- a/src/resources/filters/common/log.lua +++ b/src/resources/filters/common/log.lua @@ -5,6 +5,10 @@ -- could write to named filed (e.g. .filter.log) and client could read warnings and delete (also delete before run) -- always append b/c multiple filters +--- The default, built-in error function. +-- The `error` global is redefined below. +local builtin_error_function = error + -- luacov: disable local function caller_info(offset) offset = offset or 3 @@ -27,6 +31,6 @@ end function fatal(message, offset) io.stderr:write(lunacolors.red("FATAL (" .. caller_info(offset) .. ") " ..message .. "\n")) -- TODO write stack trace into log, and then exit. - crash_with_stack_trace() + builtin_error_function('FATAL QUARTO ERROR', offset) end --- luacov: enable \ No newline at end of file +-- luacov: enable diff --git a/src/resources/filters/common/pandoc.lua b/src/resources/filters/common/pandoc.lua index d4b69ae8591..b1bc7ceda3d 100644 --- a/src/resources/filters/common/pandoc.lua +++ b/src/resources/filters/common/pandoc.lua @@ -84,12 +84,14 @@ function inlinesToString(inlines) return pandoc.utils.stringify(pandoc.Span(inlines)) end +local InlinesMT = getmetatable(pandoc.Inlines{}) + -- lua string to pandoc inlines function stringToInlines(str) if str then - return pandoc.Inlines({pandoc.Str(str)}) + return setmetatable({pandoc.Str(str)}, InlinesMT) else - return pandoc.Inlines({}) + return setmetatable({}, InlinesMT) end end @@ -98,27 +100,24 @@ end function markdownToInlines(str) if str then local doc = pandoc.read(str) - if #doc.blocks == 0 then - return pandoc.List({}) - else - return doc.blocks[1].content - end + return pandoc.utils.blocks_to_inlines(doc.blocks) else - return pandoc.List() + return setmetatable({}, InlinesMT) end end + function stripTrailingSpace(inlines) - -- we always convert to pandoc.List to ensure a uniform + -- we always convert to pandoc.Inlines to ensure a uniform -- return type (and its associated methods) if #inlines > 0 then if inlines[#inlines].t == "Space" then - return pandoc.List(tslice(inlines, 1, #inlines - 1)) + return setmetatable(tslice(inlines, 1, #inlines - 1), InlinesMT) else - return pandoc.List(inlines) + return setmetatable(inlines, InlinesMT) end else - return pandoc.List(inlines) + return setmetatable(inlines, InlinesMT) end end @@ -217,14 +216,14 @@ function string_to_quarto_ast_blocks(text, opts) -- run the whole normalization pipeline here to get extended AST nodes, etc. for _, filter in ipairs(quarto_ast_pipeline()) do - doc = doc:walk(filter.filter) + doc = _quarto.modules.jog(doc, filter.filter) end -- compute flags so we don't skip filters that depend on them - doc:walk(compute_flags()) + _quarto.modules.jog(doc, compute_flags()) return doc.blocks end function string_to_quarto_ast_inlines(text, sep) return pandoc.utils.blocks_to_inlines(string_to_quarto_ast_blocks(text), sep) -end \ No newline at end of file +end diff --git a/src/resources/filters/common/wrapped-filter.lua b/src/resources/filters/common/wrapped-filter.lua index 7bb7e1f189d..4fc139ee3e1 100644 --- a/src/resources/filters/common/wrapped-filter.lua +++ b/src/resources/filters/common/wrapped-filter.lua @@ -97,7 +97,7 @@ function makeWrappedJsonFilter(scriptFile, filterHandler) path = quarto.utils.resolve_path_relative_to_document(scriptFile) local custom_node_map = {} local has_custom_nodes = false - doc = doc:walk({ + doc = _quarto.modules.jog(doc, { -- FIXME: This is broken with new AST. Needs to go through Custom node instead. RawInline = function(raw) local custom_node, t, kind = _quarto.ast.resolve_custom_data(raw) @@ -130,7 +130,7 @@ function makeWrappedJsonFilter(scriptFile, filterHandler) return nil end if has_custom_nodes then - doc:walk({ + _quarto.modules.jog(doc, { Meta = function(meta) _quarto.ast.reset_custom_tbl(meta["quarto-custom-nodes"]) end diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index f8a12e64f28..0ecb0e430b7 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -20,7 +20,7 @@ function process_equations(blockEl) end local mathInlines = nil - local targetInlines = pandoc.List() + local targetInlines = pandoc.Inlines{} for i, el in ipairs(inlines) do diff --git a/src/resources/filters/crossref/index.lua b/src/resources/filters/crossref/index.lua index 16ad656d1cc..3e11fe7caa5 100644 --- a/src/resources/filters/crossref/index.lua +++ b/src/resources/filters/crossref/index.lua @@ -65,9 +65,9 @@ end -- add an entry to the index function indexAddEntry(label, parent, order, caption, appendix) if caption ~= nil then - caption = pandoc.List(caption) + caption = _quarto.utils.as_blocks(caption) else - caption = pandoc.List({}) + caption = pandoc.Blocks({}) end crossref.index.entries[label] = { parent = parent, diff --git a/src/resources/filters/crossref/preprocess.lua b/src/resources/filters/crossref/preprocess.lua index 1b995b78a6c..6a7c113fe2d 100644 --- a/src/resources/filters/crossref/preprocess.lua +++ b/src/resources/filters/crossref/preprocess.lua @@ -7,7 +7,7 @@ function crossref_mark_subfloats() return { traverse = "topdown", FloatRefTarget = function(float) - float.content = _quarto.ast.walk(float.content, { + float.content = _quarto.ast.walk(float.content or pandoc.Blocks{}, { FloatRefTarget = function(subfloat) float.has_subfloats = true crossref.subfloats[subfloat.identifier] = { diff --git a/src/resources/filters/customnodes/callout.lua b/src/resources/filters/customnodes/callout.lua index 8a9f4c17dee..0ec2889b27a 100644 --- a/src/resources/filters/customnodes/callout.lua +++ b/src/resources/filters/customnodes/callout.lua @@ -264,7 +264,8 @@ function _callout_main() return _quarto.format.typst.function_call("callout", { { "body", _quarto.format.typst.as_typst_content(callout.content) }, { "title", _quarto.format.typst.as_typst_content( - callout.title or pandoc.Plain(_quarto.modules.callouts.displayName(callout.type)) + (not _quarto.utils.is_empty_node(callout.title) and callout.title) or + pandoc.Plain(_quarto.modules.callouts.displayName(callout.type)) )}, { "background_color", pandoc.RawInline("typst", background_color) }, { "icon_color", pandoc.RawInline("typst", icon_color) }, @@ -406,4 +407,4 @@ function crossref_callouts() return callout end } -end \ No newline at end of file +end diff --git a/src/resources/filters/customnodes/content-hidden.lua b/src/resources/filters/customnodes/content-hidden.lua index f27af5a5418..9117085009a 100644 --- a/src/resources/filters/customnodes/content-hidden.lua +++ b/src/resources/filters/customnodes/content-hidden.lua @@ -105,7 +105,8 @@ local _content_hidden_meta = nil function content_hidden_meta(meta) -- return { -- Meta = function(meta) - _content_hidden_meta = meta + -- The call to `pandoc.Meta` ensures that we hold a copy. + _content_hidden_meta = pandoc.Meta(meta) -- end -- } end diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index 03c7d35cc0d..9f75512fc0a 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -767,7 +767,7 @@ function float_reftarget_render_html_figure(float) local float_content = pandoc.Div(_quarto.ast.walk(float.content, { -- strip image captions Image = function(image) - image.caption = {} + image.caption = pandoc.Inlines{} return image end }) or pandoc.Div({})) -- this should never happen but the lua analyzer doesn't know it @@ -1098,4 +1098,4 @@ end, function(float) return pandoc.Para({im}) end) -global_table_guid_id = 0 \ No newline at end of file +global_table_guid_id = 0 diff --git a/src/resources/filters/customnodes/shortcodes.lua b/src/resources/filters/customnodes/shortcodes.lua index ffb5cc1c7a2..32ad1128e69 100644 --- a/src/resources/filters/customnodes/shortcodes.lua +++ b/src/resources/filters/customnodes/shortcodes.lua @@ -11,7 +11,7 @@ _quarto.ast.add_handler({ kind = "Inline", parse = function(span) - local inner_content = pandoc.List({}) + local inner_content = pandoc.Inlines({}) span.content = span.content:filter(function(el) return el.t == "Span" @@ -78,9 +78,9 @@ _quarto.ast.add_handler({ end local node = _quarto.ast.create_custom_node_scaffold("Shortcode", "Inline") - node.content = inner_content:map(function(el) - return pandoc.Span({el}) - end) + node.content = pandoc.Inlines(inner_content:map(function(el) + return pandoc.Span({el}) + end)) local tbl = { __quarto_custom_node = node, name = name, diff --git a/src/resources/filters/layout/html.lua b/src/resources/filters/layout/html.lua index dc387110211..317cd855656 100644 --- a/src/resources/filters/layout/html.lua +++ b/src/resources/filters/layout/html.lua @@ -190,7 +190,7 @@ function renderHtmlFigure(el, render) end) -- remove identifier (it is now on the div) - el.identifier = "" + el.attr.identifier = "" if not figureDiv.classes:find_if(function(str) return str:match("quarto%-figure%-.+") end) then -- apply standalone figure css if not already set diff --git a/src/resources/filters/layout/lightbox.lua b/src/resources/filters/layout/lightbox.lua index 15203c120fd..d2569bd68d5 100644 --- a/src/resources/filters/layout/lightbox.lua +++ b/src/resources/filters/layout/lightbox.lua @@ -176,10 +176,11 @@ function lightbox() return {{ traverse = "topdown", - Meta = function(meta) + Meta = function(meta) -- Set auto lightbox mode, if need be auto = lightbox_module.automatic(meta) == true - end, + imgCount = 0 + end, -- Find images that are already within links -- we'll use this to filter out these images if -- the most is auto diff --git a/src/resources/filters/layout/typst.lua b/src/resources/filters/layout/typst.lua index 4c875f9b4fa..4a6258e5e16 100644 --- a/src/resources/filters/layout/typst.lua +++ b/src/resources/filters/layout/typst.lua @@ -11,7 +11,7 @@ function make_typst_figure(tbl) local identifier = tbl.identifier local separator = tbl.separator - if (not caption or #caption.content == 0) and tbl.separator == nil then + if _quarto.utils.is_empty_node(caption) and tbl.separator == nil then separator = "" end diff --git a/src/resources/filters/modules/import_all.lua b/src/resources/filters/modules/import_all.lua index 1369a183c9a..2b97fdef94e 100644 --- a/src/resources/filters/modules/import_all.lua +++ b/src/resources/filters/modules/import_all.lua @@ -11,6 +11,7 @@ _quarto.modules = { dashboard = require("modules/dashboard"), filenames = require("modules/filenames"), filters = require("modules/filters"), + jog = require("modules/jog"), license = require("modules/license"), lightbox = require("modules/lightbox"), mediabag = require("modules/mediabag"), @@ -20,4 +21,4 @@ _quarto.modules = { string = require("modules/string"), tablecolwidths = require("modules/tablecolwidths"), typst = require("modules/typst") -} \ No newline at end of file +} diff --git a/src/resources/filters/modules/jog.lua b/src/resources/filters/modules/jog.lua new file mode 100644 index 00000000000..590acbe4844 --- /dev/null +++ b/src/resources/filters/modules/jog.lua @@ -0,0 +1,323 @@ +--- jog.lua – walk the pandoc AST with context, and with inplace modification. +--- +--- Copyright: © 2024 Albert Krewinkel, Carlos Scheidegger +--- License: MIT – see LICENSE for details + +local pandoc = require 'pandoc' +local List = require 'pandoc.List' + +local debug_getmetatable = debug.getmetatable + +--- Get the element type; like pandoc.utils.type, but faster. +local function ptype (x) + local mt = debug_getmetatable(x) + if mt then + local name = mt.__name + return name or type(x) + else + return type(x) + end +end + +--- Checks whether the object is a list type. +local listy_type = { + Blocks = true, + Inlines = true, + List = true, +} + +local function run_filter_function (fn, element, context) + if fn == nil then + return element + end + + local result, continue = fn(element, context) + if result == nil then + return element, continue + else + return result, continue + end +end + +--- Set of Block and Inline tags that are leaf nodes. +local leaf_node_tags = { + Code = true, + CodeBlock = true, + HorizontalRule = true, + LineBreak = true, + Math = true, + RawBlock = true, + RawInline = true, + Space = true, + SoftBreak = true, + Str = true, +} + +--- Set of Block and Inline tags that have nested items in `.contents` only. +local content_only_node_tags = { + -- Blocks with Blocks content + BlockQuote = true, + Div = true, + Header = true, + -- Blocks with Inlines content + Para = true, + Plain = true, + -- Blocks with List content + LineBlock = true, + BulletList = true, + OrderedList = true, + DefinitionList = true, + -- Inlines with Inlines content + Cite = true, + Emph = true, + Link = true, + Quoted = true, + SmallCaps = true, + Span = true, + Strikeout = true, + Strong = true, + Subscript = true, + Superscript = true, + Underline = true, + -- Inline with Blocks content + Note = true, +} + +--- Apply the filter on the nodes below the given element. +local function recurse (element, tp, jogger) + tp = tp or ptype(element) + local tag = element.tag + if leaf_node_tags[tag] then + -- do nothing, cannot traverse any deeper + elseif tp == 'table' then + for key, value in pairs(element) do + element[key] = jogger(value) + end + elseif content_only_node_tags[tag] or + tp == 'Cell' or tp == 'pandoc Cell' then + element.content = jogger(element.content) + elseif tag == 'Image' then + element.caption = jogger(element.caption) + elseif tag == 'Table' then + element.caption = jogger(element.caption) + element.head = jogger(element.head) + element.bodies = jogger(element.bodies) + element.foot = jogger(element.foot) + elseif tag == 'Caption' then + element.long = jogger(element.long) + element.short = element.short and jogger(element.short) + elseif tag == 'Figure' then + element.caption = jogger(element.caption) + element.content = jogger(element.content) + elseif tp == 'Meta' then + for key, value in pairs(element) do + element[key] = jogger(value) + end + elseif tp == 'Row' or tp == 'pandoc Row' then + element.cells = jogger(element.cells) + elseif tp == 'pandoc TableHead' or tp == 'pandoc TableFoot' or + tp == 'TableHead' or tp == 'TableFoot' then + element.rows = jogger(element.rows) + elseif tp == 'Blocks' or tp == 'Inlines' then + local expected_itemtype = tp == 'Inlines' and 'Inline' or 'Block' + local pos = 0 + local filtered_index = 1 + local filtered_items = element:map(function (x) + return jogger(x) + end) + local item = filtered_items[filtered_index] + local itemtype + while item ~= nil do + itemtype = ptype(item) + if itemtype ~= tp and itemtype ~= expected_itemtype then + -- neither the list type nor the list's item type. Try to convert. + item = pandoc[tp](item) + itemtype = tp + end + if itemtype == tp then + local sublist_index = 1 + local sublistitem = item[sublist_index] + while sublistitem ~= nil do + pos = pos + 1 + element[pos] = sublistitem + sublist_index = sublist_index + 1 + sublistitem = item[sublist_index] + end + else + -- not actually a sublist, just an element + pos = pos + 1 + element[pos] = item + end + filtered_index = filtered_index + 1 + item = filtered_items[filtered_index] + end + -- unset remaining indices if the new list is shorter than the old + pos = pos + 1 + while element[pos] do + element[pos] = nil + pos = pos + 1 + end + elseif tp == 'List' then + local i, item = 1, element[1] + while item do + element[i] = jogger(item) + i, item = i+1, element[i+1] + end + elseif tp == 'Caption' then + element.long = jogger(element.long) + element.short = element.short and jogger(element.short) + elseif tp == 'Pandoc' then + element.meta = jogger(element.meta) + element.blocks = jogger(element.blocks) + else + error("Don't know how to traverse " .. (element.t or tp)) + end + return element +end + +local non_joggable_types = { + ['Attr'] = true, + ['boolean'] = true, + ['nil'] = true, + ['number'] = true, + ['string'] = true, +} + +local function get_filter_function(element, filter, tp) + if non_joggable_types[tp] or tp == 'table' then + return nil + elseif tp == 'Block' then + return filter[element.tag] or filter.Block + elseif tp == 'Inline' then + return filter[element.tag] or filter.Inline + else + return filter[tp] + end +end + +local function make_jogger (filter, context) + local is_topdown = filter.traverse == 'topdown' + local jogger + + jogger = function (element) + if context then + context:insert(element) + end + local tp = ptype(element) + local result, continue = nil, true + if non_joggable_types[tp] then + result = element + elseif tp == 'table' then + result = recurse(element, tp, jogger) + else + local fn = get_filter_function(element, filter, tp) + if is_topdown then + result, continue = run_filter_function(fn, element, context) + if continue ~= false then + -- the result might have a different type, so use nil + result = recurse(result, nil, jogger) + end + else + element = recurse(element, tp, jogger) + result = run_filter_function(fn, element, context) + end + end + + if context then + context:remove() -- remove this element from the context + end + return result + end + return jogger +end + +local element_name_map = { + Cell = 'pandoc Cell', + Row = 'pandoc Row', + TableHead = 'pandoc TableHead', + TableFoot = 'pandoc TableFoot', +} + +--- Function to traverse the pandoc AST with context. +local function jog(element, filter) + if _QUARTO_USE_WALK then + return element:walk(filter) + end + + local context = filter.context and List{} or nil + + -- Table elements have a `pandoc ` prefix in the name + for from, to in pairs(element_name_map) do + filter[to] = filter[from] + end + + -- Check if we can just call Pandoc and Meta and be done + if ptype(element) == 'Pandoc' then + local must_recurse = false + for name in pairs(filter) do + if name:match'^[A-Z]' and name ~= 'Pandoc' and name ~= 'Meta' then + must_recurse = true + break + end + end + if not must_recurse then + element.meta = run_filter_function(filter.Meta, element.meta, context) + element = run_filter_function(filter.Pandoc, element, context) + return element + end + end + + + -- Create and call traversal function + local jog_internal = make_jogger(filter, context) + return jog_internal(element) +end + +--- Add `jog` as a method to all pandoc AST elements +-- This uses undocumented features and might break! +local function add_method(funname) + funname = funname or 'jog' + pandoc.Space() -- init metatable 'Inline' + pandoc.HorizontalRule() -- init metatable 'Block' + pandoc.Meta{} -- init metatable 'Meta' + pandoc.Pandoc{} -- init metatable 'Pandoc' + pandoc.Blocks{} -- init metatable 'Blocks' + pandoc.Inlines{} -- init metatable 'Inlines' + pandoc.Caption{} -- init metatable 'Caption' + pandoc.Cell{} -- init metatable 'Cell' + pandoc.Row{} -- init metatable 'Row' + pandoc.TableHead{} -- init metatable 'TableHead' + pandoc.TableFoot{} -- init metatable 'TableFoot' + local reg = debug.getregistry() + List{ + 'Block', 'Inline', 'Pandoc', + 'pandoc Cell', 'pandoc Row', 'pandoc TableHead', 'pandoc TableFoot', + 'Caption', 'Cell', 'Row', 'TableHead', 'TableFoot', + }:map( + function (name) + if reg[name] then + reg[name].methods[funname] = jog + end + end + ) + for name in pairs(listy_type) do + if reg[name] then + reg[name][funname] = jog + end + end + if reg['Meta'] then + reg['Meta'][funname] = jog + end +end + +local mt = { + __call = function (_, ...) + return jog(...) + end +} + +local M = setmetatable({}, mt) +M.jog = jog +M.add_method = add_method + +return M diff --git a/src/resources/filters/normalize/draft.lua b/src/resources/filters/normalize/draft.lua index 7b9c718a18d..d3f043e6438 100644 --- a/src/resources/filters/normalize/draft.lua +++ b/src/resources/filters/normalize/draft.lua @@ -23,17 +23,17 @@ function normalize_draft() end is_draft = meta[kDraft] == true or tcontains(drafts, quarto.doc.input_file); end, - Pandoc = function(pandoc) + Pandoc = function(doc) if _quarto.format.isHtmlOutput() and not _quarto.format.isHtmlSlideOutput() then if is_draft and draft_mode == kDraftModeGone then - pandoc.blocks = {} + doc.blocks = pandoc.Blocks{} quarto.doc.includeText("in-header", '') - return pandoc + return doc elseif is_draft and draft_mode ~= kDraftModeGone then quarto.doc.includeText("in-header", '') - return pandoc + return doc end end end } -end \ No newline at end of file +end diff --git a/src/resources/filters/normalize/flags.lua b/src/resources/filters/normalize/flags.lua index 8815da0a2fa..d695d39b452 100644 --- a/src/resources/filters/normalize/flags.lua +++ b/src/resources/filters/normalize/flags.lua @@ -107,8 +107,7 @@ function compute_flags() -- FIXME: are we actually triggering this with FloatRefTargets? -- table captions local kTblCap = "tbl-cap" - local tblCap = extractTblCapAttrib(node,kTblCap) - if hasTableRef(node) or tblCap then + if hasTableRef(node) or node.attr.attributes[kTblCap] then flags.has_table_captions = true end diff --git a/src/resources/filters/quarto-init/metainit.lua b/src/resources/filters/quarto-init/metainit.lua index 9c3a22407db..9b63df41219 100644 --- a/src/resources/filters/quarto-init/metainit.lua +++ b/src/resources/filters/quarto-init/metainit.lua @@ -8,6 +8,7 @@ function quarto_meta_init() read_includes(meta) init_crossref_options(meta) initialize_custom_crossref_categories(meta) + return meta end } -end \ No newline at end of file +end diff --git a/src/resources/filters/quarto-post/book.lua b/src/resources/filters/quarto-post/book.lua index ff354ec200c..4a0d923a23d 100644 --- a/src/resources/filters/quarto-post/book.lua +++ b/src/resources/filters/quarto-post/book.lua @@ -8,10 +8,11 @@ local license = require 'modules/license' local function clean (inlines) -- this is in post, so it's after render, so we don't need to worry about -- custom ast nodes - return inlines:walk { - Note = function (_) return {} end, + return _quarto.modules.jog(inlines, { + traverse = 'topdown', + Note = function (_) return {}, false end, Link = function (link) return link.content end, - } + }) end --- Creates an Inlines singleton containing the raw LaTeX. diff --git a/src/resources/filters/quarto-post/delink.lua b/src/resources/filters/quarto-post/delink.lua index f391781c29d..d91610e428d 100644 --- a/src/resources/filters/quarto-post/delink.lua +++ b/src/resources/filters/quarto-post/delink.lua @@ -19,7 +19,7 @@ function delink() -- find links and transform them to spans -- this is in post, so it's after render, so we don't need to worry about -- custom ast nodes - return pandoc.walk_block(div, { + return _quarto.modules.jog(div, { Link = function(link) return pandoc.Span(link.content) end diff --git a/src/resources/filters/quarto-post/foldcode.lua b/src/resources/filters/quarto-post/foldcode.lua index c5884a3da57..ab68552c298 100644 --- a/src/resources/filters/quarto-post/foldcode.lua +++ b/src/resources/filters/quarto-post/foldcode.lua @@ -65,7 +65,7 @@ function fold_code_and_lift_codeblocks() local prev_annotated_code_block_scaffold = nil local prev_annotated_code_block = nil -- ok to lift codeblocks - float.content = _quarto.ast.walk(float.content, { + float.content = _quarto.ast.walk(float.content or pandoc.Blocks{}, { traverse = "topdown", DecoratedCodeBlock = function(block) -- defer the folding of code blocks to the DecoratedCodeBlock renderer diff --git a/src/resources/filters/quarto-post/latex.lua b/src/resources/filters/quarto-post/latex.lua index 132252a6528..a78d159d653 100644 --- a/src/resources/filters/quarto-post/latex.lua +++ b/src/resources/filters/quarto-post/latex.lua @@ -407,7 +407,7 @@ function render_latex() end, Note = function(el) tappend(noteContents, {el.content}) - el.content:walk({ + _quarto.modules.jog(el.content, { CodeBlock = function(el) hasVerbatimInNotes = true end diff --git a/src/resources/filters/quarto-post/render-asciidoc.lua b/src/resources/filters/quarto-post/render-asciidoc.lua index 10ad3603eed..5121d550994 100644 --- a/src/resources/filters/quarto-post/render-asciidoc.lua +++ b/src/resources/filters/quarto-post/render-asciidoc.lua @@ -89,7 +89,7 @@ function render_asciidoc() local noteEl = el[i+1] -- if the note contains a code inline, we need to add a space local hasCode = false - pandoc.walk_inline(noteEl, { + _quarto.module.jog(noteEl, { Code = function(_el) hasCode = true end diff --git a/src/resources/filters/quarto-post/typst.lua b/src/resources/filters/quarto-post/typst.lua index 4a2e7f8d6f5..8ebd9c87596 100644 --- a/src/resources/filters/quarto-post/typst.lua +++ b/src/resources/filters/quarto-post/typst.lua @@ -19,7 +19,9 @@ function render_typst() return { { Meta = function(m) - m["toc-depth"] = PANDOC_WRITER_OPTIONS["toc_depth"] + -- This should be a number, but we must represent it as a string, + -- as numbers are disallowed as metadata values. + m["toc-depth"] = tostring(PANDOC_WRITER_OPTIONS["toc_depth"]) m["toc-indent"] = option("toc-indent") if m["number-depth"] then number_depth = tonumber(pandoc.utils.stringify(m["number-depth"])) @@ -138,7 +140,7 @@ function render_typst_fixups() end img.attributes["fig-align"] = nil - return pandoc.Inlines({ + return pandoc.Plain({ pandoc.RawInline("typst", "#align(" .. align .. ")["), img, pandoc.RawInline("typst", "]"), diff --git a/src/resources/filters/quarto-pre/code-annotation.lua b/src/resources/filters/quarto-pre/code-annotation.lua index 4fa26cf24a6..077a10077e6 100644 --- a/src/resources/filters/quarto-pre/code-annotation.lua +++ b/src/resources/filters/quarto-pre/code-annotation.lua @@ -310,7 +310,7 @@ function code_annotations() -- if code annotations is false, then shut it down if codeAnnotations ~= false then - local outputs = pandoc.List() + local outputs = pandoc.Blocks{} -- annotations[annotation-number] = {list of line numbers} local pendingAnnotations = nil diff --git a/src/resources/filters/quarto-pre/output-location.lua b/src/resources/filters/quarto-pre/output-location.lua index eebe8bc2121..c50d388248a 100644 --- a/src/resources/filters/quarto-pre/output-location.lua +++ b/src/resources/filters/quarto-pre/output-location.lua @@ -71,7 +71,7 @@ function output_location() if _quarto.format.isRevealJsOutput() then return { Blocks = function(blocks) - local newBlocks = pandoc.List() + local newBlocks = pandoc.Blocks{} for _,block in pairs(blocks) do local outputLoc = collectCellOutputLocation(block) if outputLoc then diff --git a/src/resources/filters/quarto-pre/parsefiguredivs.lua b/src/resources/filters/quarto-pre/parsefiguredivs.lua index ca075b0a5a9..868643cd308 100644 --- a/src/resources/filters/quarto-pre/parsefiguredivs.lua +++ b/src/resources/filters/quarto-pre/parsefiguredivs.lua @@ -236,7 +236,7 @@ function parse_floatreftargets() end local caption = refCaptionFromDiv(div) if caption ~= nil then - div.content:remove(#div.content) + div.content:remove() -- drop the last element elseif div.attributes[caption_attr_key] ~= nil then caption = pandoc.Plain(string_to_quarto_ast_inlines(div.attributes[caption_attr_key])) div.attributes[caption_attr_key] = nil @@ -246,10 +246,10 @@ function parse_floatreftargets() local found_caption = false content = _quarto.ast.walk(content, { Table = function(table) - if table.caption.long ~= nil then + -- check if caption is non-empty + if table.caption.long and next(table.caption.long) then found_caption = true caption = table.caption.long[1] -- what if there's more than one entry here? - table.caption.long = nil return table end end @@ -458,7 +458,7 @@ function parse_floatreftargets() fig_attr.classes:insert(v) end end - image.caption = {} + image.caption = pandoc.Inlines{} return image end }) or fig.content[1] -- this shouldn't be needed but the lua analyzer doesn't know it @@ -494,7 +494,7 @@ function parse_floatreftargets() end -- we've parsed the caption, so we can remove it from the table - el.caption.long = pandoc.List({}) + el.caption.long = pandoc.Blocks({}) if label == "" then return nil @@ -602,7 +602,7 @@ function parse_floatreftargets() if img.identifier == "" then local caption = img.caption if #caption > 0 then - img.caption = nil + img.caption = pandoc.Inlines{} return pandoc.Figure(link, { long = { caption } }) else return nil @@ -819,4 +819,4 @@ function forward_cell_subcaps() return div end } -end \ No newline at end of file +end diff --git a/src/resources/filters/quarto-pre/shiny.lua b/src/resources/filters/quarto-pre/shiny.lua index 75dbdd9197b..4ce3cdf5af6 100644 --- a/src/resources/filters/quarto-pre/shiny.lua +++ b/src/resources/filters/quarto-pre/shiny.lua @@ -67,7 +67,7 @@ function server_shiny() -- blocks.) local context = nil - local res = pandoc.walk_block(divEl, { + local res = _quarto.modules.jog(divEl, { CodeBlock = function(el) if el.attr.classes:includes("python") and el.attr.classes:includes("cell-code") then diff --git a/src/resources/filters/quarto-pre/table-captions.lua b/src/resources/filters/quarto-pre/table-captions.lua index ede527f83e0..67a3917f75a 100644 --- a/src/resources/filters/quarto-pre/table-captions.lua +++ b/src/resources/filters/quarto-pre/table-captions.lua @@ -140,7 +140,7 @@ function applyTableCaptions(el, tblCaptions, tblLabels) cap:insert(pandoc.Str("{#" .. tblLabels[idx] .. "}")) end idx = idx + 1 - el.caption.long = pandoc.Plain(cap) + el.caption.long = pandoc.Blocks{pandoc.Plain(cap)} return el end end, @@ -231,7 +231,7 @@ function extractTblCapAttrib(el, name, subcap) else value = pandoc.List({ value }) end - el.attr.attributes[name] = nil + -- el.attr.attributes[name] = nil return value end return nil diff --git a/src/resources/filters/quarto-pre/table-rawhtml.lua b/src/resources/filters/quarto-pre/table-rawhtml.lua index 41708cd51b0..e36e946c31b 100644 --- a/src/resources/filters/quarto-pre/table-rawhtml.lua +++ b/src/resources/filters/quarto-pre/table-rawhtml.lua @@ -14,23 +14,32 @@ function table_merge_raw_html() return { Blocks = function(blocks) - local pendingRaw = pandoc.List() - local merged = pandoc.List() - for i,el in ipairs(blocks) do - if _quarto.format.isRawHtml(el) and el.text:find(patterns.html_table_tag_name) then - pendingRaw:insert(el.text) + local pending_raw = pandoc.List() + local next_element_idx = 1 + for _, el in ipairs(blocks) do + if _quarto.format.isRawHtml(el) and + el.text:find(patterns.html_table_tag_name) then + pending_raw:insert(el.text) else - if #pendingRaw > 0 then - merged:insert(pandoc.RawBlock("html", table.concat(pendingRaw, "\n"))) - pendingRaw = pandoc.List() + if next(pending_raw) then + blocks[next_element_idx] = + pandoc.RawBlock("html", table.concat(pending_raw, "\n")) + pending_raw = pandoc.List() + next_element_idx = next_element_idx + 1 end - merged:insert(el) + blocks[next_element_idx] = el + next_element_idx = next_element_idx + 1 end end - if #pendingRaw > 0 then - merged:insert(pandoc.RawBlock("html", table.concat(pendingRaw, "\n"))) + if #pending_raw > 0 then + blocks[next_element_idx] = + pandoc.RawBlock("html", table.concat(pending_raw, "\n")) + next_element_idx = next_element_idx + 1 end - return merged + for i = next_element_idx, #blocks do + blocks[i] = nil + end + return blocks end } end @@ -54,4 +63,4 @@ function table_respecify_gt_css() return el end } -end \ No newline at end of file +end diff --git a/src/resources/pandoc/datadir/_utils.lua b/src/resources/pandoc/datadir/_utils.lua index e2d986646e2..37befbb8e2f 100644 --- a/src/resources/pandoc/datadir/_utils.lua +++ b/src/resources/pandoc/datadir/_utils.lua @@ -1,6 +1,10 @@ -- _utils.lua -- Copyright (C) 2020-2022 Posit Software, PBC +--- The pandoc module. Imported to avoid 'no such variable' +--- warnings in some editors. +local pandoc = require 'pandoc' + -- improved formatting for dumping tables and quarto's emulated pandoc nodes function tdump (tbl, raw) @@ -265,59 +269,75 @@ local function get_type(v) return pandoc_type end -local function as_inlines(v) - if v == nil then - return pandoc.Inlines({}) - end - local t = pandoc.utils.type(v) - if t == "Inlines" then - ---@cast v pandoc.Inlines - return v - elseif t == "Blocks" then - return pandoc.utils.blocks_to_inlines(v) - elseif t == "Inline" then - return pandoc.Inlines({v}) - elseif t == "Block" then - return pandoc.utils.blocks_to_inlines({v}) - end +--- Blocks metatable +local BlocksMT = getmetatable(pandoc.Blocks{}) +--- Inlines metatable +local InlinesMT = getmetatable(pandoc.Inlines{}) - if type(v) == "table" then - local result = pandoc.Inlines({}) - for i, v in ipairs(v) do - tappend(result, as_inlines(v)) +--- Turns the given object into a `Inlines` list. +-- +-- Works mostly like `pandoc.Inlines`, but doesn't a do a full +-- unmarshal/marshal roundtrip. This buys performance, at the cost of +-- less thorough type checks. +-- +-- NOTE: The input object might be modified *destructively*! +local function as_inlines(obj) + local pt = pandoc.utils.type(obj) + if pt == 'Inlines' then + return obj + elseif pt == "Inline" then + -- Faster than calling pandoc.Inlines + return setmetatable({obj}, InlinesMT) + elseif pt == 'List' or pt == 'table' then + if obj[1] and pandoc.utils.type(obj[1]) == 'Block' then + return pandoc.utils.blocks_to_inlines(obj) end - return result + -- Faster than calling pandoc.Inlines + return setmetatable(obj, InlinesMT) + elseif pt == "Block" then + return pandoc.utils.blocks_to_inlines({obj}) + elseif pt == "Blocks" then + return pandoc.utils.blocks_to_inlines(obj) + else + return pandoc.Inlines(obj or {}) end - - -- luacov: disable - fatal("as_inlines: invalid type " .. t) - return pandoc.Inlines({}) - -- luacov: enable end -local function as_blocks(v) - if v == nil then - return pandoc.Blocks({}) - end - local t = pandoc.utils.type(v) - if t == "Blocks" then - return v - elseif t == "Inlines" then - return pandoc.Blocks({pandoc.Plain(v)}) - elseif t == "Block" then - return pandoc.Blocks({v}) - elseif t == "Inline" then - return pandoc.Blocks({pandoc.Plain(v)}) - end - - if type(v) == "table" then - return pandoc.Blocks(v) +--- Turns the given object into a `Blocks` list. +-- +-- Works mostly like `pandoc.Blocks`, but doesn't a do a full +-- unmarshal/marshal roundtrip. This buys performance, at the cost of +-- less thorough type checks. +-- +-- NOTE: The input object might be modified *destructively*! +-- +-- This might need some benchmarking. +local function as_blocks(obj) + local pt = pandoc.utils.type(obj) + if pt == 'Blocks' then + return obj + elseif pt == 'Block' then + -- Assigning a metatable directly is faster than calling + -- `pandoc.Blocks`. + return setmetatable({obj}, BlocksMT) + elseif pt == 'Inline' then + return setmetatable({pandoc.Plain{obj}}, BlocksMT) + elseif pt == 'Inlines' then + if next(obj) then + return setmetatable({pandoc.Plain(obj)}, BlocksMT) + end + return setmetatable({}, BlocksMT) + elseif pt == 'List' or (pt == 'table' and obj[1]) then + if pandoc.utils.type(obj[1]) == 'Inline' then + obj = {pandoc.Plain(obj)} + end + return setmetatable(obj, BlocksMT) + elseif (pt == 'table' and obj.long) or pt == 'Caption' then + -- Looks like a Caption + return as_blocks(obj.long) + else + return pandoc.Blocks(obj or {}) end - - -- luacov: disable - fatal("as_blocks: invalid type " .. t) - return pandoc.Blocks({}) - -- luacov: enable end local function match_fun(reset, ...) @@ -557,6 +577,32 @@ local function match(...) return match_fun(reset, table.unpack(result)) end +--- Returns `true` iff the given AST node is empty. +-- A node is considered "empty" if it's an empty list, table, or a node +-- without any text or nested AST nodes. +local function is_empty_node (node) + if not node then + return true + elseif type(node) == 'table' then + -- tables are considered empty if they don't have any fields. + return not next(node) + elseif node.content then + return not next(node.content) + elseif node.caption then + -- looks like an image, figure, or table + if node.caption.long then + return not next(node.caption.long) + end + return not next(node.caption) + elseif node.text then + -- looks like a code node or text node + return node.text ~= '' + else + -- Not sure what this is, but it's probably not empty. + return false + end +end + return { dump = dump, type = get_type, @@ -567,6 +613,7 @@ return { }, as_inlines = as_inlines, as_blocks = as_blocks, + is_empty_node = is_empty_node, match = match, add_to_blocks = function(blocks, block) if pandoc.utils.type(blocks) ~= "Blocks" then diff --git a/src/resources/pandoc/datadir/init.lua b/src/resources/pandoc/datadir/init.lua index 0cde1cccedd..da5a625a539 100644 --- a/src/resources/pandoc/datadir/init.lua +++ b/src/resources/pandoc/datadir/init.lua @@ -1546,9 +1546,9 @@ local function processTextDependency(dependency, meta) local textLoc = rawText.location if meta[textLoc] == nil then - meta[textLoc] = {} + meta[textLoc] = pandoc.List{} end - table.insert(meta[textLoc], pandoc.RawBlock(FORMAT, rawText.text)) + meta[textLoc]:insert(pandoc.Blocks{pandoc.RawBlock(FORMAT, rawText.text)}) end -- make the usePackage statement @@ -1568,9 +1568,9 @@ local function usePackage(package, option) local headerLoc = resolveLocation(kInHeader) if meta[headerLoc] == nil then - meta[headerLoc] = {} + meta[headerLoc] = pandoc.List{} end - table.insert(meta[headerLoc], usePackage(rawPackage.package, rawPackage.options)) + meta[headerLoc]:insert(usePackage(rawPackage.package, rawPackage.options)) end @@ -1584,28 +1584,28 @@ local function processDependencies(meta) -- holds a list of hashes for dependencies that -- have been processed. Process each dependency -- only once - local injectedText = {} - local injectedFile = {} - local injectedPackage = {} + local injectedText = pandoc.List{} + local injectedFile = pandoc.List{} + local injectedPackage = pandoc.List{} -- each line was written as a dependency. -- process them and contribute the appropriate headers - for line in io.lines(dependenciesFile) do + for line in io.lines(dependenciesFile) do local dependency = json.decode(line) if dependency.type == 'text' then if not utils.table.contains(injectedText, dependency.content) then processTextDependency(dependency, meta) - injectedText[#injectedText + 1] = dependency.content + injectedText:insert(dependency.content) end elseif dependency.type == "file" then if not utils.table.contains(injectedFile, dependency.content.path) then processFileDependency(dependency, meta) - injectedFile[#injectedFile + 1] = dependency.content.path + injectedFile:insert(dependency.content.path) end elseif dependency.type == "usepackage" then if not utils.table.contains(injectedPackage, dependency.content.package) then processUsePackageDependency(dependency, meta) - injectedPackage[#injectedPackage + 1] = dependency.content.package + injectedPackage:insert(dependency.content.package) end end end