Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion modules/content-extraction.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion modules/html.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion modules/metadata.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion modules/pandoc-helpers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion modules/paths.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion modules/schema-check.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/run.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down