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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- feat: Add `get_project_metadata`, which returns the project's `format.metadata` table from the `QUARTO_EXECUTE_INFO` file. When the file is set but can't be used, it returns `nil` and a short reason, so each extension can stay silent or warn in its own words. (#19)
- feat: `get_project_repo_url` takes an optional `format_meta` table. An extension that has already read the project metadata no longer reads the file a second time. (#19)
- fix: `get_project_repo_url` returns `nil` when `QUARTO_EXECUTE_INFO` holds JSON that isn't an object. It used to raise an error and stop the filter. (#19)

## 2.6.0 (2026-10-26)

- feat: Add `Checker:cells`, which checks the comment-pipe options of one code cell against the `cells` section and returns what they resolve to. It needs Quarto Wizard schema 2.3.0 or newer, whose validator provides `validate_cells`. (#16)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ They are meant to be vendored. Quarto ships no package manager for Lua, so an ex
| `html.lua` | Builds raw HTML and registers HTML dependencies. |
| `logging.lua` | Writes messages prefixed with the extension name. |
| `lookup.lua` | Membership tests and keyword mapping. |
| `metadata.lua` | Reads extension configuration out of document metadata. |
| `metadata.lua` | Reads extension configuration out of document metadata, and project metadata out of `QUARTO_EXECUTE_INFO`. |
| `pandoc-helpers.lua` | Builds Pandoc elements and detects the output format. |
| `paths.lua` | Resolves a path relative to the project and checks a URI's file type. |
| `schema-check.lua` | Checks a document and a shortcode call against the extension's `_schema.yml`. |
Expand Down
62 changes: 45 additions & 17 deletions modules/metadata.lua
Original file line number Diff line number Diff line change
Expand Up @@ -188,34 +188,62 @@ end
-- PROJECT METADATA UTILITIES
-- ============================================================================

--- Get repo-url from Quarto project metadata via QUARTO_EXECUTE_INFO.
--- Reads the JSON file pointed to by the QUARTO_EXECUTE_INFO environment variable
--- and extracts repo-url from website or book metadata.
--- @return string|nil The repo-url value, or nil if not available
function M.get_project_repo_url()
--- Get project-level metadata from Quarto via QUARTO_EXECUTE_INFO.
--- Reads and decodes the JSON file pointed to by the QUARTO_EXECUTE_INFO
--- environment variable and returns its format.metadata table, which holds
--- the website and book configuration that document metadata lacks.
--- The file is read on every call; pass the result on rather than calling twice.
--- @return table|nil format_meta The format.metadata table, or nil if unavailable
--- @return string|nil reason Why the file could not be used when
--- QUARTO_EXECUTE_INFO is set; nil when it is unset or empty
--- @usage local format_meta, reason = M.get_project_metadata()
--- @note quarto-cli's decoder raises on bad JSON, while q2's (pandoc.json)
--- returns nil, so a nil result is also reported as invalid JSON.
function M.get_project_metadata()
local path = os.getenv("QUARTO_EXECUTE_INFO")
if not path then return nil end
if str.is_empty(path) then return nil end

local file = io.open(path, "r")
if not file then return nil end
if not file then return nil, "file is unreadable" end

local content = file:read("*a")
file:close()

if str.is_empty(content) then return nil end
if str.is_empty(content) then return nil, "file is empty" end

local ok, info = pcall(quarto.json.decode, content)
if not ok or not info then return nil end
if not ok or info == nil then return nil, "invalid JSON" end

local format_meta = info["format"] and info["format"]["metadata"]
if not format_meta then return nil end
if type(info) ~= "table" or type(info["format"]) ~= "table"
or type(info["format"]["metadata"]) ~= "table" then
return nil, "no format.metadata table"
end

return info["format"]["metadata"]
end

--- Get repo-url from Quarto project metadata via QUARTO_EXECUTE_INFO.
--- Extracts repo-url from website or book metadata, website first.
--- The QUARTO_EXECUTE_INFO file is read only when no table is given.
--- @param format_meta table|nil The project format.metadata table, as returned
--- by get_project_metadata; read from QUARTO_EXECUTE_INFO when nil
--- @return string|nil The repo-url value, or nil if not available
--- @usage local repo_url = M.get_project_repo_url(format_meta)
function M.get_project_repo_url(format_meta)
if format_meta == nil then
format_meta = M.get_project_metadata()
if not format_meta then return nil end
elseif type(format_meta) ~= "table" then
error("get_project_repo_url: format_meta must be a table or nil, got " .. type(format_meta), 2)
end

-- Try website.repo-url first, then book.repo-url
local repo_url = nil
if format_meta["website"] and format_meta["website"]["repo-url"] then
repo_url = format_meta["website"]["repo-url"]
elseif format_meta["book"] and format_meta["book"]["repo-url"] then
repo_url = format_meta["book"]["repo-url"]
local website = format_meta["website"]
local book = format_meta["book"]
local repo_url
if type(website) == "table" and website["repo-url"] then
repo_url = website["repo-url"]
elseif type(book) == "table" and book["repo-url"] then
repo_url = book["repo-url"]
end

if str.is_empty(repo_url) then return nil end
Expand Down
167 changes: 161 additions & 6 deletions tests/run.lua
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ local function returns(description, expected, fn, ...)
string.format('expected %s, got %s', tostring(expected), tostring(actual)))
end

local function write(path, text)
local handle = assert(io.open(path, 'w'))
handle:write(text)
handle:close()
end

--- Every function a module exports, so a new one cannot be added untested
--- without this list disagreeing with the module.
local EXPECTED = {
Expand All @@ -84,7 +90,7 @@ local EXPECTED = {
logging = { 'log_error', 'log_warning', 'log_output', 'log_debug' },
lookup = { 'is_valid_value', 'in_array', 'keyword_to_value', 'size_to_css' },
metadata = { 'get_extension_config', 'get_metadata_value', 'check_deprecated_config',
'get_option_with_fallbacks', 'get_options', 'get_project_repo_url' },
'get_option_with_fallbacks', 'get_options', 'get_project_metadata', 'get_project_repo_url' },
['pandoc-helpers'] = { 'create_link', 'attr', 'has_class', 'add_class', 'get_quarto_format',
'is_object_empty', 'is_type_simple', 'is_function_userdata', 'get_value',
'attributes_to_table' },
Expand Down Expand Up @@ -148,11 +154,6 @@ do
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'
Expand Down Expand Up @@ -351,6 +352,160 @@ do
_G.quarto = previous
end

io.stdout:write('# metadata\n')

do
-- Lua cannot set an environment variable, and `quarto pandoc lua` has no
-- `quarto` global, so `os.getenv` and `quarto.json` are stood in for while
-- the project metadata reader runs against fixture files. The decoder is
-- `pandoc.json`, which is what q2 provides as `quarto.json`: it returns nil
-- on bad JSON where quarto-cli's decoder raises, and both must be covered.
local metadata = modules['metadata']
local previous_getenv = os.getenv
local previous_quarto = _G.quarto
local function decode(text)
return pandoc.json.decode(text, false)
end
_G.quarto = { json = { decode = decode } }

local WEBSITE = '{"format":{"metadata":{"website":{"title":"W"}}}}'
local BOOK = '{"format":{"metadata":{"book":{"title":"B"}}}}'
local BOTH = '{"format":{"metadata":{"website":{"title":"W"},"book":{"title":"B"}}}}'

local function with_env(value, fn)
os.getenv = function(name)
if name == 'QUARTO_EXECUTE_INFO' then
return value
end
return previous_getenv(name)
end
fn()
end

--- Point QUARTO_EXECUTE_INFO at a file holding `content`, or at a path that
--- does not exist when `content` is nil.
local function with_execute_info(content, fn)
pandoc.system.with_temporary_directory('metadata', function(tmp)
local path = tmp .. '/info.json'
if content ~= nil then
write(path, content)
end
with_env(path, fn)
end)
end

local function reports(description, expected_reason)
local ok, format_meta, reason = pcall(metadata.get_project_metadata)
check(ok and format_meta == nil and reason == expected_reason, description,
ok and string.format('expected nil, %s; got %s, %s',
tostring(expected_reason), tostring(format_meta), tostring(reason))
or ('raised: ' .. tostring(format_meta)))
end

local function reports_for(content, description, expected_reason)
with_execute_info(content, function() reports(description, expected_reason) end)
end

local function returns_blocks(content, description, predicate)
with_execute_info(content, function()
local ok, format_meta, reason = pcall(metadata.get_project_metadata)
check(ok and reason == nil and type(format_meta) == 'table' and predicate(format_meta),
description,
ok and string.format('got %s, %s', tostring(format_meta), tostring(reason))
or ('raised: ' .. tostring(format_meta)))
end)
end

local ok, err = pcall(function()
with_env(nil, function()
reports('get_project_metadata is silent when QUARTO_EXECUTE_INFO is unset', nil)
end)
with_env('', function()
reports('get_project_metadata is silent when QUARTO_EXECUTE_INFO is empty', nil)
end)
reports_for(nil, 'get_project_metadata reports a file that cannot be opened', 'file is unreadable')
reports_for('', 'get_project_metadata reports an empty file', 'file is empty')
reports_for('{bad', 'get_project_metadata reports invalid JSON', 'invalid JSON')
reports_for(' \n', 'get_project_metadata reports whitespace-only content as invalid JSON',
'invalid JSON')
_G.quarto.json.decode = function() error('boom') end
reports_for('{}', 'get_project_metadata reports a decoder that raises as invalid JSON',
'invalid JSON')
_G.quarto.json.decode = decode
reports_for('"x"', 'get_project_metadata rejects a JSON string', 'no format.metadata table')
reports_for('42', 'get_project_metadata rejects a JSON number', 'no format.metadata table')
reports_for('{}', 'get_project_metadata rejects JSON with no format', 'no format.metadata table')
reports_for('{"format":{"metadata":"x"}}', 'get_project_metadata rejects a non-table format.metadata',
'no format.metadata table')

returns_blocks(WEBSITE, 'get_project_metadata returns a website-only block', function(m)
return m.website.title == 'W' and m.book == nil
end)
returns_blocks(BOOK, 'get_project_metadata returns a book-only block', function(m)
return m.book.title == 'B' and m.website == nil
end)
returns_blocks(BOTH, 'get_project_metadata returns both blocks', function(m)
return m.website.title == 'W' and m.book.title == 'B'
end)
with_execute_info(WEBSITE, function()
local first_ok, first = pcall(metadata.get_project_metadata)
local second_ok, second = pcall(metadata.get_project_metadata)
check(first_ok and second_ok and type(first) == 'table' and type(second) == 'table'
and first.website.title == 'W' and second.website.title == 'W',
'get_project_metadata gives the same result on a second call',
string.format('got %s, %s', tostring(first), tostring(second)))
end)

local function repo_url_for(content, description, expected)
with_execute_info(content, function()
returns(description, expected, metadata.get_project_repo_url)
end)
end

repo_url_for('{"format":{"metadata":{"website":{"repo-url":"https://w"}}}}',
'get_project_repo_url reads website.repo-url', 'https://w')
repo_url_for('{"format":{"metadata":{"book":{"repo-url":"https://b"}}}}',
'get_project_repo_url falls back to book.repo-url', 'https://b')
repo_url_for('{"format":{"metadata":{"website":{"repo-url":"https://w"},"book":{"repo-url":"https://b"}}}}',
'get_project_repo_url prefers website over book', 'https://w')
repo_url_for('{"format":{"metadata":{}}}', 'get_project_repo_url returns nil with neither', nil)
repo_url_for('{"format":{"metadata":{"website":{"repo-url":""},"book":{"repo-url":"https://b"}}}}',
'get_project_repo_url returns nil for an empty repo-url', nil)
repo_url_for('"x"', 'get_project_repo_url tolerates a JSON string', nil)
repo_url_for('{"format":{"metadata":{"website":"x","book":{"repo-url":"https://b"}}}}',
'get_project_repo_url tolerates a non-table website', 'https://b')
repo_url_for('{"format":{"metadata":{"book":42}}}', 'get_project_repo_url tolerates a numeric book', nil)

--- Count the reads of QUARTO_EXECUTE_INFO, which points at a file whose
--- repo-url differs from the one passed in.
local function counts_reads(format_meta, description, expected)
with_execute_info('{"format":{"metadata":{"website":{"repo-url":"https://file"}}}}', function()
local reads = 0
local stubbed = os.getenv
os.getenv = function(name)
if name == 'QUARTO_EXECUTE_INFO' then
reads = reads + 1
end
return stubbed(name)
end
returns(description, expected, metadata.get_project_repo_url, format_meta)
equal(reads, 0, description .. ' without reading QUARTO_EXECUTE_INFO')
end)
end

counts_reads({ website = { ['repo-url'] = 'https://arg' } },
'get_project_repo_url uses a given table', 'https://arg')
counts_reads({}, 'get_project_repo_url reads nothing for an empty table', nil)

local rejected = pcall(metadata.get_project_repo_url, 'x')
check(not rejected, 'get_project_repo_url rejects a non-table argument')
end)

os.getenv = previous_getenv
_G.quarto = previous_quarto
check(ok, 'the metadata checks ran without raising', err)
end

io.stdout:write('# schema-check\n')

do
Expand Down
Loading