diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f2dce..61a0ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 4a6c01a..add69f1 100644 --- a/README.md +++ b/README.md @@ -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`. | diff --git a/modules/metadata.lua b/modules/metadata.lua index 79a3a6e..cedfa95 100644 --- a/modules/metadata.lua +++ b/modules/metadata.lua @@ -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 diff --git a/tests/run.lua b/tests/run.lua index da01657..098d51f 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -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 = { @@ -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' }, @@ -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' @@ -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