From 198b27600f8afdd201c01b60c127e06c34de21ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:50:23 +0200 Subject: [PATCH 1/6] fix: load sibling modules without the debug library q2 has no debug library, so every module that loads a sibling failed to load there, and an extension that used one stopped with a shortcode script error. Without debug, load_sibling now passes the bare name to require, which q2 resolves in the directory of the module being loaded. quarto-cli keeps the absolute path, because a bare require('string') there returns the standard library. --- modules/content-extraction.lua | 8 ++++- modules/html.lua | 8 ++++- modules/metadata.lua | 8 ++++- modules/pandoc-helpers.lua | 8 ++++- modules/paths.lua | 8 ++++- modules/schema-check.lua | 8 ++++- tests/run.lua | 63 ++++++++++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 6 deletions(-) diff --git a/modules/content-extraction.lua b/modules/content-extraction.lua index fd7b55b..290e62b 100644 --- a/modules/content-extraction.lua +++ b/modules/content-extraction.lua @@ -10,9 +10,15 @@ local M = {} --- @param filename string The sibling module filename (e.g., 'string.lua') --- @return table The loaded module local function load_sibling(filename) + local name = filename:gsub('%.lua$', '') + if not debug then + -- q2 has no `debug` library, and its `require` looks in the directory of + -- the module being loaded first. + return require(name) + end local source = debug.getinfo(1, 'S').source:sub(2) local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) + return require(dir .. name) end --- Load required modules diff --git a/modules/html.lua b/modules/html.lua index ecb4870..eba6b98 100644 --- a/modules/html.lua +++ b/modules/html.lua @@ -10,9 +10,15 @@ local M = {} --- @param filename string The sibling module filename (e.g., 'string.lua') --- @return table The loaded module local function load_sibling(filename) + local name = filename:gsub('%.lua$', '') + if not debug then + -- q2 has no `debug` library, and its `require` looks in the directory of + -- the module being loaded first. + return require(name) + end local source = debug.getinfo(1, 'S').source:sub(2) local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) + return require(dir .. name) end --- Load string module for escape_html and escape_attribute diff --git a/modules/metadata.lua b/modules/metadata.lua index 7bc55c1..abe45a6 100644 --- a/modules/metadata.lua +++ b/modules/metadata.lua @@ -10,9 +10,15 @@ local M = {} --- @param filename string The sibling module filename (e.g., 'string.lua') --- @return table The loaded module local function load_sibling(filename) + local name = filename:gsub('%.lua$', '') + if not debug then + -- q2 has no `debug` library, and its `require` looks in the directory of + -- the module being loaded first. + return require(name) + end local source = debug.getinfo(1, 'S').source:sub(2) local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) + return require(dir .. name) end --- Load required modules diff --git a/modules/pandoc-helpers.lua b/modules/pandoc-helpers.lua index f90919b..9525273 100644 --- a/modules/pandoc-helpers.lua +++ b/modules/pandoc-helpers.lua @@ -10,9 +10,15 @@ local M = {} --- @param filename string The sibling module filename (e.g., 'string.lua') --- @return table The loaded module local function load_sibling(filename) + local name = filename:gsub('%.lua$', '') + if not debug then + -- q2 has no `debug` library, and its `require` looks in the directory of + -- the module being loaded first. + return require(name) + end local source = debug.getinfo(1, 'S').source:sub(2) local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) + return require(dir .. name) end --- Load string module for is_empty diff --git a/modules/paths.lua b/modules/paths.lua index e00670d..4407e82 100644 --- a/modules/paths.lua +++ b/modules/paths.lua @@ -10,9 +10,15 @@ local M = {} --- @param filename string The sibling module filename (e.g., 'string.lua') --- @return table The loaded module local function load_sibling(filename) + local name = filename:gsub('%.lua$', '') + if not debug then + -- q2 has no `debug` library, and its `require` looks in the directory of + -- the module being loaded first. + return require(name) + end local source = debug.getinfo(1, 'S').source:sub(2) local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) + return require(dir .. name) end --- Load string module for is_empty diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 9fe2c17..6a40f97 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -36,9 +36,15 @@ local M = {} --- @param filename string The sibling module filename (e.g., 'string.lua') --- @return table The loaded module local function load_sibling(filename) + local name = filename:gsub('%.lua$', '') + if not debug then + -- q2 has no `debug` library, and its `require` looks in the directory of + -- the module being loaded first. + return require(name) + end local source = debug.getinfo(1, 'S').source:sub(2) local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) + return require(dir .. name) end --- Load required modules diff --git a/tests/run.lua b/tests/run.lua index 032ddd0..be0603f 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -109,6 +109,69 @@ for _, name in ipairs(names) do end end +io.stdout:write('# loading from an extension\n') + +--- The modules as an extension vendors them, in a directory whose path has a +--- space. A shortcode loads every module that has siblings and prints what +--- each one returned. quarto-cli has the `debug` library and q2 does not, so +--- each renders the project with its own way to find the siblings. +do + local with_siblings = { 'content-extraction', 'html', 'metadata', 'pandoc-helpers', 'paths', 'schema-check' } + local function quote(text) + return "'" .. text:gsub("'", "'\\''") .. "'" + end + local function write(path, text) + local handle = assert(io.open(path, 'w')) + handle:write(text) + handle:close() + end + pandoc.system.with_temporary_directory('lua-modules', function(tmp) + local project = tmp .. '/with space' + local vendor = project .. '/_extensions/probe/_vendor/quarto-lua-modules' + pandoc.system.make_directory(vendor, true) + os.execute('cp ' .. quote(here .. 'modules') .. '/*.lua ' .. quote(vendor)) + local lines = { 'local loaded = {}' } + for _, name in ipairs(with_siblings) do + lines[#lines + 1] = string.format( + "loaded[#loaded + 1] = '%s=' .. type(require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/%s.lua'):gsub('%%.lua$', '')))", + name, name) + end + lines[#lines + 1] = "return { probe = function() return pandoc.Str(table.concat(loaded, ',')) end }" + write(project .. '/_extensions/probe/probe.lua', table.concat(lines, '\n') .. '\n') + write(project .. '/_extensions/probe/_extension.yml', + 'title: Probe\nversion: 1.0.0\ncontributes:\n shortcodes:\n - probe.lua\n') + write(project .. '/_quarto.yml', 'project:\n type: default\n') + write(project .. '/doc.qmd', '---\ntitle: Probe\nformat: html\n---\n\n{{< probe >}}\n') + + local expected = {} + for _, name in ipairs(with_siblings) do + expected[#expected + 1] = name .. '=table' + end + expected = table.concat(expected, ',') + + local function render(command) + os.remove(project .. '/doc.html') + os.execute('cd ' .. quote(project) .. ' && ' .. command .. ' render doc.qmd > /dev/null 2>&1') + local handle = io.open(project .. '/doc.html', 'r') + if not handle then + return '' + end + local html = handle:read('a') + handle:close() + return html + end + + check(render('quarto'):find(expected, 1, true) ~= nil, + 'quarto-cli loads every module and its siblings from a vendored path with a space') + if os.execute('q2 --version > /dev/null 2>&1') then + check(render('q2'):find(expected, 1, true) ~= nil, + 'q2, which has no `debug` library, loads every module and its siblings from a vendored path with a space') + else + io.stdout:write('skip q2 loads every module: q2 is not on the PATH\n') + end + end) +end + io.stdout:write('# exports\n') --- Checked both ways. `EXPECTED` is hand-maintained on purpose, because these From 21ec15a1c682e24db57547fa32e3f105fc32bef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:51:21 +0200 Subject: [PATCH 2/6] test: render the loading check with each engine in one loop --- tests/run.lua | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/run.lua b/tests/run.lua index be0603f..220e36a 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -130,12 +130,14 @@ do local vendor = project .. '/_extensions/probe/_vendor/quarto-lua-modules' pandoc.system.make_directory(vendor, true) os.execute('cp ' .. quote(here .. 'modules') .. '/*.lua ' .. quote(vendor)) - local lines = { 'local loaded = {}' } + local lines, expected = { 'local loaded = {}' }, {} for _, name in ipairs(with_siblings) do lines[#lines + 1] = string.format( "loaded[#loaded + 1] = '%s=' .. type(require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/%s.lua'):gsub('%%.lua$', '')))", name, name) + expected[#expected + 1] = name .. '=table' end + expected = table.concat(expected, ',') lines[#lines + 1] = "return { probe = function() return pandoc.Str(table.concat(loaded, ',')) end }" write(project .. '/_extensions/probe/probe.lua', table.concat(lines, '\n') .. '\n') write(project .. '/_extensions/probe/_extension.yml', @@ -143,12 +145,6 @@ do write(project .. '/_quarto.yml', 'project:\n type: default\n') write(project .. '/doc.qmd', '---\ntitle: Probe\nformat: html\n---\n\n{{< probe >}}\n') - local expected = {} - for _, name in ipairs(with_siblings) do - expected[#expected + 1] = name .. '=table' - end - expected = table.concat(expected, ',') - local function render(command) os.remove(project .. '/doc.html') os.execute('cd ' .. quote(project) .. ' && ' .. command .. ' render doc.qmd > /dev/null 2>&1') @@ -161,13 +157,13 @@ do return html end - check(render('quarto'):find(expected, 1, true) ~= nil, - 'quarto-cli loads every module and its siblings from a vendored path with a space') - if os.execute('q2 --version > /dev/null 2>&1') then - check(render('q2'):find(expected, 1, true) ~= nil, - 'q2, which has no `debug` library, loads every module and its siblings from a vendored path with a space') - else - io.stdout:write('skip q2 loads every module: q2 is not on the PATH\n') + for _, engine in ipairs({ 'quarto', 'q2' }) do + local description = engine .. ' loads every module and its siblings from a vendored path with a space' + if os.execute(engine .. ' --version > /dev/null 2>&1') then + check(render(engine):find(expected, 1, true) ~= nil, description) + else + io.stdout:write('skip ', description, ': ', engine, ' is not on the PATH\n') + end end end) end From 90d10093d4d2787118c6d9d1bf094e26c1fbc3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:55:40 +0200 Subject: [PATCH 3/6] test: prove each engine binds the sibling string module --- modules/content-extraction.lua | 2 +- modules/html.lua | 2 +- modules/metadata.lua | 2 +- modules/pandoc-helpers.lua | 2 +- modules/paths.lua | 2 +- modules/schema-check.lua | 2 +- tests/run.lua | 26 +++++++++++++++++++------- 7 files changed, 25 insertions(+), 13 deletions(-) diff --git a/modules/content-extraction.lua b/modules/content-extraction.lua index 290e62b..59f3554 100644 --- a/modules/content-extraction.lua +++ b/modules/content-extraction.lua @@ -11,7 +11,7 @@ local M = {} --- @return table The loaded module local function load_sibling(filename) local name = filename:gsub('%.lua$', '') - if not debug then + if not (debug and debug.getinfo) then -- q2 has no `debug` library, and its `require` looks in the directory of -- the module being loaded first. return require(name) diff --git a/modules/html.lua b/modules/html.lua index eba6b98..d947a3c 100644 --- a/modules/html.lua +++ b/modules/html.lua @@ -11,7 +11,7 @@ local M = {} --- @return table The loaded module local function load_sibling(filename) local name = filename:gsub('%.lua$', '') - if not debug then + if not (debug and debug.getinfo) then -- q2 has no `debug` library, and its `require` looks in the directory of -- the module being loaded first. return require(name) diff --git a/modules/metadata.lua b/modules/metadata.lua index abe45a6..79a3a6e 100644 --- a/modules/metadata.lua +++ b/modules/metadata.lua @@ -11,7 +11,7 @@ local M = {} --- @return table The loaded module local function load_sibling(filename) local name = filename:gsub('%.lua$', '') - if not debug then + if not (debug and debug.getinfo) then -- q2 has no `debug` library, and its `require` looks in the directory of -- the module being loaded first. return require(name) diff --git a/modules/pandoc-helpers.lua b/modules/pandoc-helpers.lua index 9525273..5213f8e 100644 --- a/modules/pandoc-helpers.lua +++ b/modules/pandoc-helpers.lua @@ -11,7 +11,7 @@ local M = {} --- @return table The loaded module local function load_sibling(filename) local name = filename:gsub('%.lua$', '') - if not debug then + if not (debug and debug.getinfo) then -- q2 has no `debug` library, and its `require` looks in the directory of -- the module being loaded first. return require(name) diff --git a/modules/paths.lua b/modules/paths.lua index 4407e82..3012c7b 100644 --- a/modules/paths.lua +++ b/modules/paths.lua @@ -11,7 +11,7 @@ local M = {} --- @return table The loaded module local function load_sibling(filename) local name = filename:gsub('%.lua$', '') - if not debug then + if not (debug and debug.getinfo) then -- q2 has no `debug` library, and its `require` looks in the directory of -- the module being loaded first. return require(name) diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 6a40f97..f16da4e 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -37,7 +37,7 @@ local M = {} --- @return table The loaded module local function load_sibling(filename) local name = filename:gsub('%.lua$', '') - if not debug then + if not (debug and debug.getinfo) then -- q2 has no `debug` library, and its `require` looks in the directory of -- the module being loaded first. return require(name) diff --git a/tests/run.lua b/tests/run.lua index 220e36a..9d91805 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -137,6 +137,12 @@ do name, name) expected[#expected + 1] = name .. '=table' end + -- A sibling bound to the wrong table still loads, so one call goes through + -- `string.lua`, which the standard library of the same name cannot answer. + lines[#lines + 1] = "local html = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/html.lua'):gsub('%.lua$', ''))" + lines[#lines + 1] = "local ok, header = pcall(html.raw_header, 2, 'T', 'a&b')" + lines[#lines + 1] = "loaded[#loaded + 1] = 'escaped=' .. tostring(ok and header:find('a&b', 1, true) ~= nil)" + expected[#expected + 1] = 'escaped=true' expected = table.concat(expected, ',') lines[#lines + 1] = "return { probe = function() return pandoc.Str(table.concat(loaded, ',')) end }" write(project .. '/_extensions/probe/probe.lua', table.concat(lines, '\n') .. '\n') @@ -145,22 +151,28 @@ do write(project .. '/_quarto.yml', 'project:\n type: default\n') write(project .. '/doc.qmd', '---\ntitle: Probe\nformat: html\n---\n\n{{< probe >}}\n') - local function render(command) - os.remove(project .. '/doc.html') - os.execute('cd ' .. quote(project) .. ' && ' .. command .. ' render doc.qmd > /dev/null 2>&1') - local handle = io.open(project .. '/doc.html', 'r') + local function read(path) + local handle = io.open(path, 'r') if not handle then return '' end - local html = handle:read('a') + local text = handle:read('a') handle:close() - return html + return text + end + + --- The rendered page, and the engine's output for the failure detail. + local function render(command) + os.remove(project .. '/doc.html') + os.execute('cd ' .. quote(project) .. ' && ' .. command .. ' render doc.qmd > render.log 2>&1') + return read(project .. '/doc.html'), read(project .. '/render.log') end for _, engine in ipairs({ 'quarto', 'q2' }) do local description = engine .. ' loads every module and its siblings from a vendored path with a space' if os.execute(engine .. ' --version > /dev/null 2>&1') then - check(render(engine):find(expected, 1, true) ~= nil, description) + local page, output = render(engine) + check(page:find(expected, 1, true) ~= nil, description, output:sub(-600)) else io.stdout:write('skip ', description, ': ', engine, ' is not on the PATH\n') end From baae6c03839e9df356683850aaa3deeb39ab69e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:59:30 +0200 Subject: [PATCH 4/6] ci: run the loading check with q2 --- .github/workflows/test.yml | 18 ++++++++++++++++++ tests/run.lua | 11 ++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2332f0e..6cd2d3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,24 @@ jobs: - name: Setup Quarto uses: quarto-dev/quarto-actions/setup@v2 + # q2 has no `debug` library, so the modules find their siblings another + # way there. The suite renders a vendored copy with q2 when it is on the + # PATH, which this step provides. HTML needs no pandoc of its own. + - name: Set up q2 + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + q2_dir="${RUNNER_TEMP}/q2" + mkdir -p -- "${q2_dir}/bin" + gh release download -R quarto-dev/q2 \ + -p 'q2-*-linux_amd64.tar.gz' -p 'checksums.sha256' -D "${q2_dir}" + cd -- "${q2_dir}" + sha256sum --check --ignore-missing --strict checksums.sha256 + tar -xzf q2-*-linux_amd64.tar.gz -C "${q2_dir}/bin" + printf '%s\n' "${q2_dir}/bin" >> "${GITHUB_PATH}" + "${q2_dir}/bin/q2" --version + # The suite loads and executes every module it finds on disk, which # subsumes a parse check, so there is no separate one. - name: Run the tests diff --git a/tests/run.lua b/tests/run.lua index 9d91805..735c5c1 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -116,7 +116,16 @@ io.stdout:write('# loading from an extension\n') --- each one returned. quarto-cli has the `debug` library and q2 does not, so --- each renders the project with its own way to find the siblings. do - local with_siblings = { 'content-extraction', 'html', 'metadata', 'pandoc-helpers', 'paths', 'schema-check' } + -- Read from disk, so a module that starts to load a sibling is probed too. + local with_siblings = {} + for _, name in ipairs(names) do + local handle = assert(io.open(here .. 'modules/' .. name .. '.lua', 'r')) + if handle:read('a'):find('load_sibling(', 1, true) then + with_siblings[#with_siblings + 1] = name + end + handle:close() + end + check(#with_siblings == 6, 'six modules load a sibling', table.concat(with_siblings, ', ')) local function quote(text) return "'" .. text:gsub("'", "'\\''") .. "'" end From 304664c6c05c57705b3a1b619af4a0d9bc0edc4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:08:24 +0200 Subject: [PATCH 5/6] ci: pin the q2 release of the loading check --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6cd2d3e..97ecea0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,8 @@ jobs: # q2 has no `debug` library, so the modules find their siblings another # way there. The suite renders a vendored copy with q2 when it is on the - # PATH, which this step provides. HTML needs no pandoc of its own. + # PATH, which this step provides. HTML needs no pandoc of its own. The + # release is pinned, so a new q2 changes the job only through a commit. - name: Set up q2 env: GH_TOKEN: ${{ github.token }} @@ -54,7 +55,7 @@ jobs: set -euo pipefail q2_dir="${RUNNER_TEMP}/q2" mkdir -p -- "${q2_dir}/bin" - gh release download -R quarto-dev/q2 \ + gh release download v0.34.0 -R quarto-dev/q2 \ -p 'q2-*-linux_amd64.tar.gz' -p 'checksums.sha256' -D "${q2_dir}" cd -- "${q2_dir}" sha256sum --check --ignore-missing --strict checksums.sha256 From 84b64fce445698b0c0b03195ce5c27e4e75511cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:08:56 +0200 Subject: [PATCH 6/6] docs: add changelog entry for sibling loading on q2 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b86fa0f..63a33b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- fix: Load sibling modules on q2, which has no `debug` library. An extension that vendors the modules no longer stops there with a shortcode script error. (#12) + ## 2.5.0 (2026-09-12) - feat: Add `Checker:format`, which checks one output format's options against the `formats` section and returns what they resolve to. It needs Quarto Wizard 3.6.0 or newer, whose `validate_format` reads the top level of the metadata where Quarto puts them.