diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2332f0e..97ecea0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,25 @@ 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. The + # release is pinned, so a new q2 changes the job only through a commit. + - 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 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 + 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/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. diff --git a/modules/content-extraction.lua b/modules/content-extraction.lua index fd7b55b..59f3554 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 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) + 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..d947a3c 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 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) + 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..79a3a6e 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 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) + 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..5213f8e 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 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) + 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..3012c7b 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 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) + 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..f16da4e 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 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) + 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..735c5c1 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -109,6 +109,86 @@ 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 + -- 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 + 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, 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 + -- 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') + 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 function read(path) + local handle = io.open(path, 'r') + if not handle then + return '' + end + local text = handle:read('a') + handle:close() + 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 + 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 + end + end) +end + io.stdout:write('# exports\n') --- Checked both ways. `EXPECTED` is hand-maintained on purpose, because these