diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4985f..e870c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- feat: Report a key nested inside an option that the schema does not declare, such as a typo in a nested setting. Only top-level keys were checked before. (#60) + ### Documentation - docs: Serve the extension's social card as the Open Graph image, so a shared link shows the card rather than the first image on the page. (#58) @@ -9,6 +13,7 @@ ### Refactoring - build: Update the vendored Lua modules to 2.3.0. A module no longer carries a version line in its header, so its checksum changes only when its code changes. (#59) +- build: Update the vendored Lua modules to 2.5.0, which adds the accessors that read what the schema resolves an option, an element's attributes and a format's options to. The schema validator moves to its own release train and is pinned at `schema-v2.1.0`, which accepts only `true` and `false` as a boolean. (#60) ## 1.5.0 (2026-09-06) diff --git a/_extensions/code-window/_dependencies.yml b/_extensions/code-window/_dependencies.yml index 51191e8..17820fc 100644 --- a/_extensions/code-window/_dependencies.yml +++ b/_extensions/code-window/_dependencies.yml @@ -3,7 +3,7 @@ sources: quarto-lua-modules: origin: "https://github.com/mcanouil/quarto-lua-modules" fetch: "{origin}/releases/download/{version}/{file}" - version: "2.3.0" + version: "2.5.0" licence: MIT files: html.lua: @@ -19,7 +19,7 @@ sources: sha256: "519cdfa87e0026cfaf754639c8f07faefa4e0d21819f463f8b202448fe0f4d7e" runtime: any schema-check.lua: - sha256: "7c4dc34f31b85ff7bc4e2511f73e2301377e964e57ea5374ad97e7817a10af4b" + sha256: "343c4846d4bced229de2b09f84b756051cb4061099787f8001384bce680e2987" runtime: any string.lua: sha256: "7801d2f217e803951313ef86f1f899cb6350a44cbb468b2d7c28dd6911a0fadc" @@ -27,9 +27,10 @@ sources: quarto-wizard: origin: "https://github.com/mcanouil/quarto-wizard" fetch: "{origin}/releases/download/{version}/{file}" - version: "3.5.0" + version: "2.1.0" licence: MIT + tag-prefix: schema-v files: schema.lua: - sha256: "30b830304ddeb410e507edf176ce4e056cdc242d51161f5f8a8d65a2e01ecd5a" + sha256: "fa5a02ef4320c800a9b681a44843f4971f0537a9e82abe8ea1a03e2cc76003a8" runtime: any diff --git a/_extensions/code-window/_schema.yml b/_extensions/code-window/_schema.yml index 41bbfb4..39349e4 100644 --- a/_extensions/code-window/_schema.yml +++ b/_extensions/code-window/_schema.yml @@ -34,11 +34,13 @@ options: hotfix: type: object description: "Temporary hot-fixes for Typst output. These will be removed when Quarto natively supports the corresponding features (see quarto-dev/quarto-cli#14170)." + additionalProperties: false properties: code-annotations: type: [boolean, object] default: true description: "Enable the code-annotations hot-fix for Typst output. Use a boolean or a map with 'enabled' and 'quarto-version' keys." + additionalProperties: false properties: enabled: type: boolean @@ -50,6 +52,7 @@ options: type: [boolean, object] default: true description: "Enable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background). Use a boolean or a map with 'enabled' and 'quarto-version' keys." + additionalProperties: false properties: enabled: type: boolean @@ -61,6 +64,7 @@ options: type: [boolean, object] default: true description: "Enable the Typst title hot-fix (evaluates theorem title strings as markup). Use a boolean or a map with 'enabled' and 'quarto-version' keys." + additionalProperties: false properties: enabled: type: boolean diff --git a/_extensions/code-window/_vendor/quarto-lua-modules/schema-check.lua b/_extensions/code-window/_vendor/quarto-lua-modules/schema-check.lua index e6204c8..9fe2c17 100644 --- a/_extensions/code-window/_vendor/quarto-lua-modules/schema-check.lua +++ b/_extensions/code-window/_vendor/quarto-lua-modules/schema-check.lua @@ -15,8 +15,17 @@ --- The validator arrives as an argument rather than through `require`. A --- vendored copy of this module then knows nothing about where the validator --- was vendored, so the two sources stay independent. The validator must ---- provide `load_schema`, `validate`, `validate_shortcode` and ---- `extract_meta_options`. +--- provide `load_schema`, `validate`, `validate_shortcode`, +--- `extract_meta_options`, `validate_attributes` and `validate_format`. +--- +--- The last two are newer than the rest of that list, so a validator vendored +--- before either exists is reported rather than called. The others are required +--- outright and a validator without one of them raises. +--- +--- `validate_format` must also be the one that reads the top level of the +--- metadata, which is Quarto Wizard 3.6.0 or newer. An older one looks for the +--- format name as a metadata key, which a document never has, so it would +--- report nothing whatever the document wrote. --- --- Nothing here stops a render. A schema is configuration, and a fault in the --- configuration must not remove the document. @@ -51,7 +60,18 @@ local str = load_sibling('string.lua') --- document. --- --- A rejected document option keeps the error level it has today: it names a ---- value the extension cannot use, and the author has to correct it. +--- value the extension cannot use, and the author has to correct it. A rejected +--- format option is the same finding about the same document, one section over, +--- so it takes the same level. +--- +--- A finding about an element's attribute is a warning instead. The attribute +--- stays on the element whatever the schema says, so the rendered output does +--- not change because of it, which is the reason a shortcode attribute is a +--- warning too. +--- +--- `misuse` is the one kind that is not about the document. It reports a fault +--- in the extension calling this module, and it is an error because the caller +--- is handed no value and would otherwise carry on with nil. --- @type table local SEVERITY = { schema = 'error', @@ -60,6 +80,11 @@ local SEVERITY = { call_error = 'warning', call_warning = 'warning', missing_argument = 'error', + misuse = 'error', + attribute_error = 'warning', + attribute_warning = 'warning', + format_error = 'error', + format_warning = 'warning', } --- The reporting function for each level. @@ -161,6 +186,9 @@ end --- @field defaults table The defaults the schema declares --- @field resolved table|nil The three tables the configuration resolves to --- @field options_checked boolean Whether the configuration was already checked +--- @field meta table|nil The metadata `options` was given, which `format` reads +--- @field formats table What each format checked so far resolved to +--- @field unavailable table Validator functions already reported missing local Checker = {} Checker.__index = Checker @@ -182,6 +210,31 @@ function Checker:_report(kind, message) REPORTERS[level](self.extension, message) end +--- Whether the validator provides one function, reporting it once if not. +--- +--- `validate_attributes` and `validate_format` are newer than the rest of the +--- contract, so a validator vendored before either exists satisfies everything +--- else and still lacks them. Calling one raises, and a raise removes the +--- document, which is the one thing this module promises not to do. +--- +--- It is reported once for the render rather than once for each caller. A +--- filter reaches these for every element and every format it handles, and the +--- condition is a fact about the vendored pair, so the second message says +--- nothing the first did not. +--- @param name string The function the caller is about to use +--- @return boolean available +function Checker:_provides(name) + if type(self.validator[name]) == 'function' then + return true + end + if not self.unavailable[name] then + self.unavailable[name] = true + self:_report('misuse', string.format( + 'schema-check: the validator provides no `%s`, so nothing was checked with it', name)) + end + return false +end + --- Check the document configuration and return what it resolves to. The check --- runs once, so an extension can ask on every shortcode without repeating the --- messages. @@ -202,7 +255,16 @@ end --- The defaults are a fresh copy on every call, so a caller may treat them as --- its own. The tables inside the second return are the checker's, and every --- later reader of the same checker sees them, so they must not be written to. ---- @param meta table Document metadata +--- +--- Only the first call reads `meta`. A later call returns what the first one +--- resolved, whatever it is handed. A caller that needs a second document +--- checked builds a second checker. +--- +--- The argument cannot be read on a later call without losing the single check +--- this function promises. Quarto hands a shortcode a new metadata table on +--- every call, and the content is the same each time. A checker that read it +--- again repeats every finding once per shortcode. +--- @param meta table Document metadata, read on the first call only --- @return table defaults A copy of the defaults, empty when there is no schema --- @return table|nil resolved {provided, merged, defaults}, nil when there is no schema function Checker:options(meta) @@ -210,6 +272,10 @@ function Checker:options(meta) return deep_copy(self.defaults), self.resolved end self.options_checked = true + -- Kept for `format`, which reads the same document. Quarto merges the options + -- of the selected format into the top level of this table, so the format + -- check has nowhere else to read them from. + self.meta = meta --- @type table|nil local loaded = self.schema @@ -240,6 +306,156 @@ function Checker:options(meta) return deep_copy(self.defaults), self.resolved end +--- Read what one option resolves to, after `options` has run. +--- +--- This is the value the schema decides, not the text the document holds. An +--- extension that reads the metadata itself has to decide what counts as true, +--- and each one that did decided something different, so `enabled: no` turned +--- one filter off and left another on. Here the schema is the only answer: the +--- validator coerces the written value toward the declared type, and a key the +--- document never set resolves to its declared default. +--- +--- It answers nil when there is no schema, which the checker has already +--- reported once. A key the schema does not declare answers nil as well, +--- because a schema that omits an option is the author's statement that the +--- extension does not have it. +--- @param key string The option name, as the schema declares it +--- @return any value The resolved value, nil when there is nothing to resolve +function Checker:option(key) + if type(key) ~= 'string' then + self:_report('misuse', string.format( + 'schema-check: the key given to `option` must be a string, got %s', type(key))) + return nil + end + if not self.options_checked then + self:_report('misuse', string.format( + 'schema-check: `option("%s")` was called before `options`', key)) + return nil + end + if self.resolved == nil then + return nil + end + return self.resolved.merged[key] +end + +--- Check one element's attributes against the `attributes` section, and return +--- what they resolve to. +--- +--- The section declares a map of groups. A group is named after the element it +--- describes, such as `Header` or `CodeBlock`, or after the class the extension +--- gives it, such as `modal`. `_any` is the group every element takes, and it +--- is additive rather than an alternative: quarto-revealjs-tabset declares +--- `panel-tabset` for a tabset's own attributes and `_any` for one that any +--- slide can carry, and an element can meet both. +--- +--- So both apply, and the named group runs last, over what `_any` resolved. The +--- validator hands an undeclared attribute straight back, so chaining the two +--- passes is the whole of the merge and there is no rule here about which group +--- wins. The one that declares the attribute decides it. +--- +--- This reports only. The attribute stays on the element whatever the schema +--- says, so nothing about the rendered output changes, which is why a finding +--- here is a warning as it is for a shortcode attribute. +--- The attributes may arrive as Pandoc's `AttributeList` rather than as a +--- table, which is what an element filter holds, so nothing here assumes a +--- plain table. The validator reads them with `pairs`, which both answer. +--- @param attributes table The element's attributes +--- @param group string|nil The element's own group, nil when it has none +--- @return table|nil resolved The attributes with the schema applied +function Checker:attributes(attributes, group) + if group ~= nil and type(group) ~= 'string' then + self:_report('misuse', string.format( + 'schema-check: the group given to `attributes` must be a string, got %s', type(group))) + return nil + end + + attributes = attributes or {} + + --- @type table|nil + local loaded = self.schema + -- The validator is injected from an independent source, so the shape of what + -- it returns is not this module's to assume, as `options` and `call` allow + -- for `options` and `shortcodes`. + if loaded == nil or next(loaded.attributes or {}) == nil then + return attributes + end + + if not self:_provides('validate_attributes') then + return attributes + end + + --- @type table + local resolved = attributes + -- `_any` is the group every element takes, so a caller that names it has + -- already asked for the only pass there is. Running the list would report + -- each of its findings twice for every element handed over. + local groups = (group == nil or group == '_any') and { '_any' } or { '_any', group } + for _, name in ipairs(groups) do + local _, errors, warnings, merged = + self.validator.validate_attributes(resolved, name, loaded) + for _, message in ipairs(errors) do + self:_report('attribute_error', message) + end + for _, message in ipairs(warnings) do + self:_report('attribute_warning', message) + end + resolved = merged + end + return resolved +end + +--- Check one output format's options against the `formats` section, and return +--- what they resolve to. +--- +--- Quarto merges the options of the selected format into the top level of the +--- document metadata, and the format name is never a key there. So this reads +--- the metadata `options` was given, and it must be called after `options`. +--- +--- The extension names its own format, as it names an attribute group. A format +--- name such as `letter-pdf` is a name the extension contributes, and nothing +--- here can work out which of several declared formats a render selected. +--- +--- The answer is kept, so a filter that asks again in the same render gets the +--- same table and the findings are reported once. +--- @param name string The format name, as the schema declares it +--- @return table|nil resolved The format's options, nil when there is nothing to resolve +function Checker:format(name) + if type(name) ~= 'string' then + self:_report('misuse', string.format( + 'schema-check: the name given to `format` must be a string, got %s', type(name))) + return nil + end + if not self.options_checked then + self:_report('misuse', string.format( + 'schema-check: `format("%s")` was called before `options`', name)) + return nil + end + if self.formats[name] ~= nil then + return self.formats[name] + end + + --- @type table|nil + local loaded = self.schema + if loaded == nil or next(loaded.formats or {}) == nil then + return nil + end + if not self:_provides('validate_format') then + return nil + end + + local _, errors, warnings, merged = + self.validator.validate_format(self.meta, name, loaded) + for _, message in ipairs(errors) do + self:_report('format_error', message) + end + for _, message in ipairs(warnings) do + self:_report('format_warning', message) + end + + self.formats[name] = merged + return merged +end + --- Check one shortcode call against its entry in the schema. --- This reports only. Nothing about the rendered output changes, so an --- unrecognised attribute is surfaced rather than dropped. @@ -323,7 +539,8 @@ end --- schema is, and the checker it builds belongs at file scope, so that the --- schema is read once for the render and not once for each call. --- @param validator table The validator, with `load_schema`, `validate`, ---- `validate_shortcode` and `extract_meta_options` +--- `validate_shortcode`, `extract_meta_options`, `validate_attributes` and +--- `validate_format` --- @param extension_name string The extension name every message carries --- @param schema_path string|nil The schema to read, relative to the entry --- point that is running. Defaults to `_schema.yml`. @@ -338,6 +555,9 @@ function M.new(validator, extension_name, schema_path) defaults = {}, resolved = nil, options_checked = false, + meta = nil, + formats = {}, + unavailable = {}, }, Checker) -- The default is chosen here rather than in the signature, so a caller that diff --git a/_extensions/code-window/_vendor/quarto-wizard/schema.lua b/_extensions/code-window/_vendor/quarto-wizard/schema.lua index a7e0c56..1dc2244 100644 --- a/_extensions/code-window/_vendor/quarto-wizard/schema.lua +++ b/_extensions/code-window/_vendor/quarto-wizard/schema.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 2.0.0 +--- @version 2.1.0 --- --- Implements the v2 extension schema vocabulary published at --- . @@ -313,11 +313,18 @@ local function _coerce_scalar(value, name) return number, true end elseif name == 'boolean' then + -- Only `true` and `false`, which is what YAML 1.2 calls a boolean and what + -- Pandoc reads. `yes` and `no` are booleans in YAML 1.1 alone, and Pandoc + -- hands them over as strings, so accepting them here made this validator + -- disagree with the YAML the documents are written in. + -- + -- The coercion is still needed for the words that do name a boolean, + -- because an attribute and a shortcode argument always arrive as strings. local lowered = value:lower() - if lowered == 'true' or lowered == 'yes' then + if lowered == 'true' then return true, true end - if lowered == 'false' or lowered == 'no' then + if lowered == 'false' then return false, true end end @@ -2382,6 +2389,12 @@ function M.validate_attributes(attributes, group, schema, options) end --- Validate the options of one output format against the `formats` section. +--- Quarto merges the options of the selected format into the top level of the +--- metadata, so the values are read from there. The format name is never a key. +--- A value is collected only when a descriptor declares its name, which keeps +--- the document's own keys, such as `title`, out of the merge. The `unknown` +--- option therefore has nothing to report here, and is kept so that the entry +--- points keep one signature. --- @param meta table Document metadata --- @param format string Format name, such as 'html' or 'typst' --- @param schema table Loaded schema @@ -2401,11 +2414,54 @@ function M.validate_format(meta, format, schema, options) return _finish(context, {}) end + -- Each value is stored under the spelling the document wrote, never under the + -- declared name. `_validate_map` is what moves an alias or the other spelling + -- to the declared name, and it needs the original key to say which two + -- spellings a document supplied for one field. + -- + -- `_lookup` returns the first spelling it finds, so a document that wrote one + -- name in both spellings loses the other. Every other entry point reports the + -- loser through the `unknown` option, which has nothing to report here, so the + -- collection reports it in the same words `_validate_map` uses. local values = {} - local format_meta = meta and _lookup(meta, format) - if format_meta ~= nil then - for key, value in pairs(format_meta) do - values[tostring(key)] = _convert_pandoc_value(value) + for field, raw_spec in pairs(descriptors) do + local spec = _compile(raw_spec) + + -- One entry per name, and not one per spelling. `_lookup` reads both + -- spellings of whichever entry is kept, so an alias that is only the other + -- spelling of a name already listed adds nothing. Probing it as well would + -- collect the two spellings under separate keys, and one mistake would then + -- be reported three times, once by each probe and once by `_validate_map`. + local names = { field } + local listed = { [(field:gsub('_', '-'))] = true } + if type(spec) == 'table' and type(spec.aliases) == 'table' then + for _, alias in ipairs(spec.aliases) do + -- A schema file is not read against the meta-schema, so an unquoted + -- `no` reaches here as a boolean. `_lookup` skips a key that is not a + -- string everywhere else, and so does this. + if type(alias) == 'string' then + local normalised = (alias:gsub('_', '-')) + if not listed[normalised] then + listed[normalised] = true + names[#names + 1] = alias + end + end + end + end + + for _, name in ipairs(names) do + local value, found_key = _lookup(meta, name) + if value ~= nil then + values[found_key] = _convert_pandoc_value(value) + + for _, other in ipairs({ (name:gsub('%-', '_')), (name:gsub('_', '-')) }) do + if other ~= found_key and meta[other] ~= nil then + _report(context, 'warning', format .. '.' .. field, 'aliases', + string.format('was given as both "%s" and "%s"; "%s" was used.', + found_key, other, found_key)) + end + end + end end end