diff --git a/docs/_extensions/mcanouil/atelier/_dependencies.yml b/docs/_extensions/mcanouil/atelier/_dependencies.yml new file mode 100644 index 0000000..a4cb7df --- /dev/null +++ b/docs/_extensions/mcanouil/atelier/_dependencies.yml @@ -0,0 +1,17 @@ +schema: 1 +sources: + quarto-lua-modules: + origin: "https://github.com/mcanouil/quarto-lua-modules" + fetch: "{origin}/releases/download/{version}/{file}" + version: "2.2.0" + licence: MIT + files: + logging.lua: + sha256: "1a8339db434d4cc8f794a2e2526ffe002124b3f71df3e3ef47eb9db3a3b24296" + runtime: any + metadata.lua: + sha256: "83fc3757c1ca7d7d63fcf5bf6a8d4ac60144adfc95af2a90559d52d203e153fb" + runtime: any + string.lua: + sha256: "f14a487f61d027e38c9cb32f280bd5266e73ef76e31bd61fdff184c2e588698d" + runtime: any diff --git a/docs/_extensions/mcanouil/atelier/_extension.yml b/docs/_extensions/mcanouil/atelier/_extension.yml index f48ed37..5150714 100644 --- a/docs/_extensions/mcanouil/atelier/_extension.yml +++ b/docs/_extensions/mcanouil/atelier/_extension.yml @@ -1,6 +1,6 @@ title: Atelier author: Mickaël Canouil -version: 0.10.3 +version: 0.10.4 quarto-required: ">=1.9.36" contributes: project: @@ -84,4 +84,5 @@ contributes: - file: html/scripts/ordinal-dates.html - file: html/scripts/a11y-fixes.html - file: html/scripts/navbar-tooltips.html -source: mcanouil/quarto-atelier@0.10.3 +source: mcanouil/quarto-atelier@0.10.4 +source-type: github diff --git a/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/LICENSE b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/LICENSE new file mode 100644 index 0000000..4b53bb8 --- /dev/null +++ b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mickaël Canouil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/_extensions/mcanouil/atelier/_modules/logging.lua b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/logging.lua similarity index 99% rename from docs/_extensions/mcanouil/atelier/_modules/logging.lua rename to docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/logging.lua index a538809..c20a1b0 100644 --- a/docs/_extensions/mcanouil/atelier/_modules/logging.lua +++ b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/logging.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/gitlink/_modules/metadata.lua b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/metadata.lua similarity index 99% rename from docs/_extensions/mcanouil/gitlink/_modules/metadata.lua rename to docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/metadata.lua index 2e1b6d5..776016a 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/metadata.lua +++ b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/metadata.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/gitlink/_modules/string.lua b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/string.lua similarity index 99% rename from docs/_extensions/mcanouil/gitlink/_modules/string.lua rename to docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/string.lua index 435c310..d1e95e2 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/string.lua +++ b/docs/_extensions/mcanouil/atelier/_vendor/quarto-lua-modules/string.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.1.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/atelier/social-metadata.lua b/docs/_extensions/mcanouil/atelier/social-metadata.lua index 0d0ac75..b836cbb 100644 --- a/docs/_extensions/mcanouil/atelier/social-metadata.lua +++ b/docs/_extensions/mcanouil/atelier/social-metadata.lua @@ -17,9 +17,9 @@ local EXTENSION_NAME = 'atelier' --- Load modules -local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua$', '')) -local log = require(quarto.utils.resolve_path('_modules/logging.lua'):gsub('%.lua$', '')) -local meta_mod = require(quarto.utils.resolve_path('_modules/metadata.lua'):gsub('%.lua$', '')) +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) +local meta_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/metadata.lua'):gsub('%.lua$', '')) --- Link tags built from the configured paths, in head order. --- `option` names the `extensions.atelier` key that supplies the `href`; diff --git a/docs/_extensions/mcanouil/gitlink/_dependencies.yml b/docs/_extensions/mcanouil/gitlink/_dependencies.yml new file mode 100644 index 0000000..47effc9 --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_dependencies.yml @@ -0,0 +1,41 @@ +schema: 1 +sources: + quarto-lua-modules: + origin: "https://github.com/mcanouil/quarto-lua-modules" + fetch: "{origin}/releases/download/{version}/{file}" + version: "2.2.0" + licence: MIT + files: + colour.lua: + sha256: "d7a436844d203542f174c9a1f1132b3f862963e54f3ec377bedf596fae2fbc3c" + runtime: any + git.lua: + sha256: "898149328e6d1e66490a57907c407f751d89699a5c1aa63edd52472eea19b11c" + runtime: any + html.lua: + sha256: "d5b01fe9e02923746edc905734bb99387083b9e419a0f7effdd4a3fb95a792d3" + runtime: any + logging.lua: + sha256: "1a8339db434d4cc8f794a2e2526ffe002124b3f71df3e3ef47eb9db3a3b24296" + runtime: any + metadata.lua: + sha256: "83fc3757c1ca7d7d63fcf5bf6a8d4ac60144adfc95af2a90559d52d203e153fb" + runtime: any + paths.lua: + sha256: "81b01600967ece6d9033f3b1cff9567445d21a6c403994f6f7d616ecf10e0bb1" + runtime: any + schema-check.lua: + sha256: "05277cebd3f1164544897e3fbe563404b88115364ed9a93e1d464f246b157b25" + runtime: any + string.lua: + sha256: "f14a487f61d027e38c9cb32f280bd5266e73ef76e31bd61fdff184c2e588698d" + runtime: any + quarto-wizard: + origin: "https://github.com/mcanouil/quarto-wizard" + fetch: "{origin}/releases/download/{version}/{file}" + version: "3.5.0" + licence: MIT + files: + schema.lua: + sha256: "30b830304ddeb410e507edf176ce4e056cdc242d51161f5f8a8d65a2e01ecd5a" + runtime: any diff --git a/docs/_extensions/mcanouil/gitlink/_extension.yml b/docs/_extensions/mcanouil/gitlink/_extension.yml index 52336d3..b3dbe9b 100644 --- a/docs/_extensions/mcanouil/gitlink/_extension.yml +++ b/docs/_extensions/mcanouil/gitlink/_extension.yml @@ -1,9 +1,10 @@ title: gitlink author: Mickaël Canouil -version: 1.10.1 +version: 1.11.0 quarto-required: ">=1.9.38" contributes: filters: - path: gitlink.lua at: post-quarto -source: mcanouil/quarto-gitlink@1.10.1 +source: mcanouil/quarto-gitlink@1.11.0 +source-type: github diff --git a/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua b/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua index 4a95e6c..ef86e2c 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua @@ -4,16 +4,7 @@ --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- Load a sibling module from the same directory as this file. ---- @param filename string The sibling module filename (e.g., 'string.lua') ---- @return table The loaded module -local function load_sibling(filename) - local source = debug.getinfo(1, 'S').source:sub(2) - local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) -end - -local str = load_sibling('string.lua') +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) local bitbucket_module = {} diff --git a/docs/_extensions/mcanouil/gitlink/_modules/paths.lua b/docs/_extensions/mcanouil/gitlink/_modules/paths.lua deleted file mode 100644 index 4ffea7c..0000000 --- a/docs/_extensions/mcanouil/gitlink/_modules/paths.lua +++ /dev/null @@ -1,54 +0,0 @@ ---- MC Paths - Path resolution utilities for Quarto Lua filters and shortcodes ---- @module "paths" ---- @license MIT ---- @copyright 2026 Mickaël Canouil ---- @author Mickaël Canouil ---- @version 1.0.0 - -local M = {} - ---- Load a sibling module from the same directory as this file. ---- @param filename string The sibling module filename (e.g., 'string.lua') ---- @return table The loaded module -local function load_sibling(filename) - local source = debug.getinfo(1, 'S').source:sub(2) - local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) -end - ---- Load string module for is_empty -local str = load_sibling('string.lua') - --- ============================================================================ --- PATH UTILITIES --- ============================================================================ - ---- Resolve a path relative to the project directory. ---- If the path starts with `/`, it is treated as relative to the project directory. ---- If `quarto.project.directory` is available, it is prepended to the path. ---- If `quarto.project.directory` is nil, the leading `/` is removed. ---- @param path string The path to resolve (may start with `/`) ---- @return string The resolved path ---- @usage local resolved = M.resolve_project_path("/config.yml") ---- @usage local resolved = M.resolve_project_path("config.yml") -function M.resolve_project_path(path) - if str.is_empty(path) then - return path - end - - if path:sub(1, 1) == '/' then - if quarto.project.directory then - return pandoc.path.join({ quarto.project.directory, path:sub(2) }) - else - return path:sub(2) - end - else - return path - end -end - --- ============================================================================ --- MODULE EXPORT --- ============================================================================ - -return M diff --git a/docs/_extensions/mcanouil/gitlink/_modules/schema.lua b/docs/_extensions/mcanouil/gitlink/_modules/schema.lua deleted file mode 100644 index ecec74d..0000000 --- a/docs/_extensions/mcanouil/gitlink/_modules/schema.lua +++ /dev/null @@ -1,412 +0,0 @@ ---- Schema Validation Module ---- @module schema ---- @license MIT ---- @copyright 2026 Mickaël Canouil ---- @author Mickaël Canouil - -local schema_module = {} - --- ============================================================================ --- CONSTANTS --- ============================================================================ - ---- Required pattern types that must exist in every platform configuration ---- @type table -local REQUIRED_PATTERN_TYPES = { 'issue', 'merge_request', 'commit', 'user' } - ---- Required URL format types for platform configurations ---- @type table -local REQUIRED_URL_FORMAT_TYPES = { 'issue', 'merge_request', 'pull', 'commit', 'user' } - ---- Validation error severity levels ---- @type table -local ERROR_LEVELS = { - ERROR = 1, - WARNING = 2, - INFO = 3 -} - --- ============================================================================ --- VALIDATION RESULT CLASS --- ============================================================================ - ---- Validation result object containing errors, warnings, and metadata ---- @class ValidationResult ---- @field valid boolean Whether validation passed without errors ---- @field errors table List of error messages ---- @field warnings table List of warning messages ---- @field info table List of informational messages - ---- Create a new validation result ---- @return ValidationResult -local function create_validation_result() - return { - valid = true, - errors = {}, - warnings = {}, - info = {} - } -end - ---- Add an error to the validation result ---- @param result ValidationResult The validation result to update ---- @param message string The error message ---- @return nil -local function add_error(result, message) - table.insert(result.errors, message) - result.valid = false -end - ---- Add a warning to the validation result ---- @param result ValidationResult The validation result to update ---- @param message string The warning message ---- @return nil -local function add_warning(result, message) - table.insert(result.warnings, message) -end - ---- Add an informational message to the validation result ---- @param result ValidationResult The validation result to update ---- @param message string The informational message ---- @return nil -local function add_info(result, message) - table.insert(result.info, message) -end - --- ============================================================================ --- TYPE VALIDATION HELPERS --- ============================================================================ - ---- Check if a value is a string ---- @param val any The value to check ---- @return boolean -local function is_string(val) - return type(val) == 'string' -end - ---- Check if a value is a table ---- @param val any The value to check ---- @return boolean -local function is_table(val) - return type(val) == 'table' -end - ---- Check if a value is an array (table with numeric keys) ---- @param val any The value to check ---- @return boolean -local function is_array(val) - if not is_table(val) then - return false - end - for k, _ in pairs(val) do - if not (type(k) == 'number' and k > 0 and k == math.floor(k)) then - return false - end - end - return true -end - ---- Check if a Lua regex pattern is valid ---- @param pattern string The pattern to validate ---- @return boolean, string|nil Whether valid, and error message if invalid -local function is_valid_lua_pattern(pattern) - if not is_string(pattern) then - return false, 'Pattern must be a string' - end - - local success, err = pcall(function() - _ = string.find('test', pattern) - end) - - if not success then - return false, tostring(err) - end - - return true, nil -end - ---- Check if a URL format template is valid ---- @param url_format string The URL format string to validate ---- @return boolean, string|nil Whether valid, and error message if invalid -local function is_valid_url_format(url_format) - if not is_string(url_format) then - return false, 'URL format must be a string' - end - - if not url_format:find('/', 1, true) then - return false, 'URL format must start with a forward slash (e.g., "/{repo}/issues/{number}")' - end - - if not url_format:match('{') then - return false, 'URL format must contain at least one placeholder (e.g., {repo}, {number})' - end - - return true, nil -end - ---- Check if a URL is valid format ---- @param url string The URL to validate ---- @return boolean, string|nil Whether valid, and error message if invalid -local function is_valid_base_url(url) - if not is_string(url) then - return false, 'Base URL must be a string' - end - - if url:find('^https?://', 1) == nil then - return false, 'Base URL must start with http:// or https://' - end - - return true, nil -end - --- ============================================================================ --- SCHEMA VALIDATORS --- ============================================================================ - ---- Validate a pattern object (array of patterns or single string for user) ---- @param patterns any The patterns to validate ---- @param pattern_type string The type of pattern (for error messages) ---- @param result ValidationResult The validation result to update ---- @return nil -local function validate_patterns(patterns, pattern_type, result) - if not patterns then - local hint = 'Expected: patterns:\n ' .. pattern_type:gsub('_', '-') .. ': [\'pattern1\', \'pattern2\']' - add_error(result, string.format('Missing required pattern type: "%s" (%s)', pattern_type:gsub('_', '-'), hint)) - return - end - - if pattern_type == 'user' and is_string(patterns) then - local valid, err = is_valid_lua_pattern(patterns) - if not valid then - add_error(result, string.format('Invalid Lua regex in user: %s (e.g., "@([%%w%%-%%%%]+)")', err)) - end - return - end - - if not is_array(patterns) then - add_error( - result, - string.format( - 'Pattern type "%s" must be an array of patterns, got %s (e.g., [\'#(%%d+)\', \'owner/repo#(%%d+)\'])', - pattern_type:gsub('_', '-'), type(patterns)) - ) - return - end - - if #patterns == 0 then - add_warning(result, - string.format('Pattern type "%s" is empty (add at least one pattern)', pattern_type:gsub('_', '-'))) - return - end - - for i, pattern in ipairs(patterns) do - local valid, err = is_valid_lua_pattern(pattern) - if not valid then - add_error(result, string.format('Invalid Lua regex in %s[%d]: %s', pattern_type:gsub('_', '-'), i, err)) - end - end -end - ---- Validate the patterns section of a platform configuration ---- @param patterns any The patterns object to validate ---- @param result ValidationResult The validation result to update ---- @return nil -local function validate_patterns_section(patterns, result) - if not patterns then - add_error(result, - 'Missing required field: "patterns" (add patterns section with: issue, merge-request, commit, user)') - return - end - - if not is_table(patterns) then - add_error(result, string.format('Field "patterns" must be a table, got %s', type(patterns))) - return - end - - for _, pattern_type in ipairs(REQUIRED_PATTERN_TYPES) do - validate_patterns(patterns[pattern_type], pattern_type, result) - end - - for key, _ in pairs(patterns) do - local found = false - for _, pattern_type in ipairs(REQUIRED_PATTERN_TYPES) do - if key == pattern_type then - found = true - break - end - end - if not found then - add_warning(result, string.format('Unknown pattern type: "%s" (not recognised)', key:gsub('_', '-'))) - end - end -end - ---- Validate the url-formats section of a platform configuration ---- @param url_formats any The url-formats object to validate ---- @param result ValidationResult The validation result to update ---- @return nil -local function validate_url_formats_section(url_formats, result) - if not url_formats then - add_error(result, - 'Missing required field: "url-formats" (add url-formats section with: issue, pull, merge-request, commit, user)') - return - end - - if not is_table(url_formats) then - add_error(result, string.format('Field "url-formats" must be a table, got %s', type(url_formats))) - return - end - - for _, format_type in ipairs(REQUIRED_URL_FORMAT_TYPES) do - local format = url_formats[format_type] - if not format then - local hint = format_type == 'issue' and '/{repo}/issues/{number}' or - format_type == 'pull' and '/{repo}/pull/{number}' or - format_type == 'merge_request' and '/{repo}/pull/{number}' or - format_type == 'commit' and '/{repo}/commit/{sha}' or - format_type == 'user' and '/{username}' or '/{path}' - add_error(result, string.format('Missing required URL format: "%s" (e.g., "%s")', format_type:gsub('_', '-'), hint)) - else - local valid, err = is_valid_url_format(format) - if not valid then - add_error(result, string.format('Invalid url-formats.%s: %s', format_type:gsub('_', '-'), err)) - end - end - end - - for key, _ in pairs(url_formats) do - local found = false - for _, format_type in ipairs(REQUIRED_URL_FORMAT_TYPES) do - if key == format_type then - found = true - break - end - end - if not found then - add_warning(result, string.format('Unknown URL format type: "%s" (not recognised)', key:gsub('_', '-'))) - end - end -end - --- ============================================================================ --- PUBLIC API --- ============================================================================ - ---- Validate a complete platform configuration ---- Checks schema, types, patterns, and URLs ---- @param platform_name string The name of the platform being validated ---- @param config table The platform configuration to validate ---- @return ValidationResult ---- @usage ---- local result = schema_module.validate_platform('github', config) ---- if not result.valid then ---- for _, err in ipairs(result.errors) do ---- print('ERROR: ' .. err) ---- end ---- end -function schema_module.validate_platform(platform_name, config) - local result = create_validation_result() - - if not is_string(platform_name) or platform_name == '' then - add_error(result, 'Platform name must be a non-empty string') - return result - end - - if not is_table(config) then - add_error(result, string.format('Platform configuration must be a table, got %s', type(config))) - return result - end - - if not config.base_url then - add_error(result, 'Missing required field: "base-url" (e.g., https://github.com)') - else - local valid, err = is_valid_base_url(config.base_url) - if not valid then - add_error(result, string.format('Invalid base-url: %s (e.g., https://git.example.com)', err)) - end - end - - validate_patterns_section(config.patterns, result) - - validate_url_formats_section(config.url_formats, result) - - return result -end - ---- Validate all platforms in a configuration table ---- @param platforms table Table of platform configurations keyed by name ---- @return table Validation results for each platform ---- @usage ---- local results = schema_module.validate_all_platforms(platforms_config) -function schema_module.validate_all_platforms(platforms) - local results = {} - - if not is_table(platforms) then - results['__global__'] = create_validation_result() - add_error(results['__global__'], 'Platforms configuration must be a table') - return results - end - - for platform_name, config in pairs(platforms) do - results[platform_name] = schema_module.validate_platform(platform_name, config) - end - - return results -end - ---- Format validation results as human-readable strings ---- @param result ValidationResult The validation result to format ---- @return string, string|nil Formatted message, and platform_name if provided ---- @usage ---- local msg = schema_module.format_result(result) ---- print(msg) -function schema_module.format_result(result) - local lines = {} - - if result.valid then - table.insert(lines, 'Validation passed.') - else - table.insert(lines, 'Validation failed with ' .. #result.errors .. ' error(s):') - for i, err in ipairs(result.errors) do - table.insert(lines, string.format(' [Error %d] %s', i, err)) - end - end - - if #result.warnings > 0 then - table.insert(lines, '') - table.insert(lines, #result.warnings .. ' warning(s):') - for i, warn in ipairs(result.warnings) do - table.insert(lines, string.format(' [Warning %d] %s', i, warn)) - end - end - - if #result.info > 0 then - table.insert(lines, '') - table.insert(lines, #result.info .. ' info message(s):') - for i, info in ipairs(result.info) do - table.insert(lines, string.format(' [Info %d] %s', i, info)) - end - end - - return table.concat(lines, '\n') -end - ---- Get a summary of validation errors and warnings ---- @param result ValidationResult The validation result ---- @return string Summary string ---- @usage ---- local summary = schema_module.get_summary(result) -function schema_module.get_summary(result) - return string.format( - 'Status: %s | Errors: %d | Warnings: %d', - result.valid and 'PASSED' or 'FAILED', - #result.errors, - #result.warnings - ) -end - --- ============================================================================ --- MODULE EXPORT --- ============================================================================ - -return schema_module diff --git a/docs/_extensions/mcanouil/gitlink/_modules/widget.lua b/docs/_extensions/mcanouil/gitlink/_modules/widget.lua index 18074d4..a68a4ad 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/widget.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/widget.lua @@ -9,18 +9,9 @@ local widget_module = {} ---- Load a sibling module from the same directory as this file. ---- @param filename string The sibling module filename (e.g., 'string.lua') ---- @return table The loaded module -local function load_sibling(filename) - local source = debug.getinfo(1, 'S').source:sub(2) - local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) -end - -local str = load_sibling('string.lua') -local log = load_sibling('logging.lua') -local html_mod = load_sibling('html.lua') +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) +local html_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/html.lua'):gsub('%.lua$', '')) --- @type string Version used for the injected HTML dependency local WIDGET_DEPENDENCY_VERSION = '1.9.0' diff --git a/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/LICENSE b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/LICENSE new file mode 100644 index 0000000..4b53bb8 --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mickaël Canouil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/_extensions/mcanouil/gitlink/_modules/colour.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/colour.lua similarity index 99% rename from docs/_extensions/mcanouil/gitlink/_modules/colour.lua rename to docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/colour.lua index 51d0a06..f30d01f 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/colour.lua +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/colour.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/gitlink/_modules/git.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/git.lua similarity index 98% rename from docs/_extensions/mcanouil/gitlink/_modules/git.lua rename to docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/git.lua index 7b11f33..f848a18 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/git.lua +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/git.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/gitlink/_modules/html.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/html.lua similarity index 99% rename from docs/_extensions/mcanouil/gitlink/_modules/html.lua rename to docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/html.lua index 8212cc5..4076864 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/html.lua +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/html.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/iconify/_modules/logging.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/logging.lua similarity index 99% rename from docs/_extensions/mcanouil/iconify/_modules/logging.lua rename to docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/logging.lua index a538809..c20a1b0 100644 --- a/docs/_extensions/mcanouil/iconify/_modules/logging.lua +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/logging.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/atelier/_modules/metadata.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/metadata.lua similarity index 99% rename from docs/_extensions/mcanouil/atelier/_modules/metadata.lua rename to docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/metadata.lua index 2e1b6d5..776016a 100644 --- a/docs/_extensions/mcanouil/atelier/_modules/metadata.lua +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/metadata.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/paths.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/paths.lua new file mode 100644 index 0000000..a51782e --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/paths.lua @@ -0,0 +1,107 @@ +--- MC Paths - Path resolution and file type checks for Quarto Lua filters and shortcodes +--- @module "paths" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 2.2.0 + +local M = {} + +--- Load a sibling module from the same directory as this file. +--- @param filename string The sibling module filename (e.g., 'string.lua') +--- @return table The loaded module +local function load_sibling(filename) + local source = debug.getinfo(1, 'S').source:sub(2) + local dir = source:match('(.*[/\\])') or '' + return require((dir .. filename):gsub('%.lua$', '')) +end + +--- Load string module for is_empty +local str = load_sibling('string.lua') + +-- ============================================================================ +-- PATH UTILITIES +-- ============================================================================ + +--- Resolve a path relative to the project directory. +--- If the path starts with `/`, it is treated as relative to the project directory. +--- If `quarto.project.directory` is available, it is prepended to the path. +--- If `quarto.project.directory` is nil, the leading `/` is removed. +--- @param path string The path to resolve (may start with `/`) +--- @return string The resolved path +--- @usage local resolved = M.resolve_project_path("/config.yml") +--- @usage local resolved = M.resolve_project_path("config.yml") +function M.resolve_project_path(path) + if str.is_empty(path) then + return path + end + + if path:sub(1, 1) == '/' then + if quarto.project.directory then + return pandoc.path.join({ quarto.project.directory, path:sub(2) }) + else + return path:sub(2) + end + else + return path + end +end + +-- ============================================================================ +-- FILE TYPE CHECKS +-- ============================================================================ + +--- Check if URI has one of the specified extensions. +--- Performs case-insensitive extension matching by default. +--- Extensions can be provided with or without leading dot. +--- +--- @param uri string|nil File URI to check +--- @param extensions table Array of extensions (e.g., {".md", "qmd", ".txt"}) +--- @param case_sensitive boolean|nil Whether to match case-sensitively (default: false) +--- @return boolean True if URI ends with one of the extensions, false otherwise +--- @usage local is_md = M.has_extension("file.md", {".md", ".markdown"}) -- returns true +--- @usage local is_md = M.has_extension("file.MD", {".md"}, true) -- returns false (case-sensitive) +function M.has_extension(uri, extensions, case_sensitive) + if uri == nil or uri == '' or extensions == nil then + return false + end + + --- @type string URI for matching (lowercase if case-insensitive) + local match_uri = case_sensitive and uri or uri:lower() + + for _, ext in ipairs(extensions) do + --- @type string Extension to match (ensure it starts with dot) + local match_ext = ext + if not match_ext:match('^%.') then + match_ext = '.' .. match_ext + end + + --- @type string Extension for matching (lowercase if case-insensitive) + if not case_sensitive then + match_ext = match_ext:lower() + end + + -- Check if URI ends with this extension + if match_uri:match('%' .. match_ext .. '$') then + return true + end + end + + return false +end + +--- Check if URI is a markdown file. +--- Convenience function to check for markdown extensions: .md, .markdown, .qmd +--- +--- @param uri string|nil File URI to check +--- @return boolean True if markdown file, false otherwise +--- @usage local is_markdown = M.is_markdown("doc.qmd") -- returns true +function M.is_markdown(uri) + return M.has_extension(uri, { '.md', '.markdown', '.qmd' }) +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/schema-check.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/schema-check.lua new file mode 100644 index 0000000..3f25ee7 --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/schema-check.lua @@ -0,0 +1,362 @@ +--- MC Schema Check - Runtime schema checks for Quarto extensions +--- @module "schema-check" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 2.2.0 +--- +--- Holds the wiring that every extension would otherwise copy: read the schema +--- once, check the document configuration against it, check one shortcode call +--- against it, and report what it finds through `logging`. +--- +--- The schema is `_schema.yml` beside the entry point that runs. An extension +--- whose entry points sit in a subdirectory names its own path instead, with +--- `'../_schema.yml'` for one level down. +--- +--- 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`. +--- +--- Nothing here stops a render. A schema is configuration, and a fault in the +--- configuration must not remove the document. + +local M = {} + +--- Load a sibling module from the same directory as this file. +--- @param filename string The sibling module filename (e.g., 'string.lua') +--- @return table The loaded module +local function load_sibling(filename) + local source = debug.getinfo(1, 'S').source:sub(2) + local dir = source:match('(.*[/\\])') or '' + return require((dir .. filename):gsub('%.lua$', '')) +end + +--- Load required modules +local log = load_sibling('logging.lua') +local str = load_sibling('string.lua') + +-- ============================================================================ +-- SEVERITY +-- ============================================================================ + +--- The level each kind of finding is reported at. This is the only place a +--- severity is decided, so a later change to what an extension must correct is +--- a change to this table and nothing else. +--- +--- The current policy reports and never stops a render. A finding is a warning, +--- with two exceptions. An unreadable schema is an error, because no check runs +--- after it. A missing required argument is an error, because the shortcode +--- renders nothing without it, which is the one finding that changes the +--- 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. +--- @type table +local SEVERITY = { + schema = 'error', + option_error = 'error', + option_warning = 'warning', + call_error = 'warning', + call_warning = 'warning', + missing_argument = 'error', +} + +--- The reporting function for each level. +--- @type table +local REPORTERS = { + error = log.log_error, + warning = log.log_warning, +} + +-- A level with no reporter is the mistake a change to `SEVERITY` makes, and it +-- is caught here, at load, rather than at the one render that reaches that kind +-- of finding. +for kind, level in pairs(SEVERITY) do + assert(REPORTERS[level] ~= nil, + string.format('schema-check: "%s" maps to the unknown level "%s"', kind, tostring(level))) +end + +-- ============================================================================ +-- PRIVATE HELPERS +-- ============================================================================ + +--- Read an attribute value with a surrounding quote pair removed. +--- Quarto's body parser strips those quotes before the value reaches the +--- shortcode, but the parser it uses for a text or attribute string hands the +--- raw token over instead, so `aria-hidden='true'` arrives as the five +--- character string `'true'`. Stripping here is what makes a quoted and an +--- unquoted value check as the same thing. +--- @param kwargs table Key-value options for the call +--- @param key string The attribute name to read +--- @return string +local function attr_value(kwargs, key) + --- @type string + local value = str.stringify(kwargs[key]) + --- @type string + local quote = value:sub(1, 1) + if #value > 1 and (quote == '"' or quote == "'") and value:sub(-1) == quote then + return value:sub(2, -2) + end + return value +end + +--- Copy a value, and every table inside it, all the way down. +--- The copy exists so that a caller writing into what it received cannot +--- change what every later reader of the same checker sees. +--- +--- The depth comes from the schema format, not from the schemas written so +--- far. The vocabulary allows `type: array` and `type: object`, and the +--- validator compiles a declared `default` by coercing it against that type +--- and returning it untouched once it already matches. Any option declaring a +--- collection type with a literal default therefore resolves to a Lua table, +--- at whatever depth the schema nests it, so a copy one level deep would hand +--- two callers the same inner table and leave the fault one level down. +--- +--- A table already copied on this walk is reused rather than copied again, +--- which keeps shared structure shared and makes a cycle terminate. A schema +--- file cannot express a cycle, because the validator's parser refuses anchors +--- and aliases, but the validator is injected and what it returns is not this +--- module's to assume. +--- @param value any The value to copy +--- @param seen table|nil Tables already copied on this walk +--- @return any +local function deep_copy(value, seen) + if type(value) ~= 'table' then + return value + end + seen = seen or {} + if seen[value] ~= nil then + return seen[value] + end + --- @type table + local copy = {} + seen[value] = copy + for key, item in pairs(value) do + copy[key] = deep_copy(item, seen) + end + return copy +end + +--- Flatten a call's named options to plain strings for the validator. +--- @param kwargs table Key-value options for the call +--- @return table +local function plain_kwargs(kwargs) + --- @type table + local plain = {} + for key in pairs(kwargs) do + plain[tostring(key)] = attr_value(kwargs, key) + end + return plain +end + +-- ============================================================================ +-- CHECKER +-- ============================================================================ + +--- @class Checker +--- @field validator table The validator the caller injected +--- @field extension string The extension name every message carries +--- @field schema table|nil The parsed schema, nil when it could not be read +--- @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 +local Checker = {} +Checker.__index = Checker + +--- Report one finding at the level its kind is mapped to. +--- A kind with no entry in `SEVERITY` is a fault in this module, and it says so +--- rather than reporting the finding at a level nobody chose. It is reported +--- rather than raised, because a fault here must not remove a document. +--- @param kind string A key of `SEVERITY` +--- @param message string The message to report +--- @return nil +function Checker:_report(kind, message) + --- @type string|nil + local level = SEVERITY[kind] + if level == nil then + log.log_error(self.extension, string.format( + 'schema-check has no severity for "%s": %s', tostring(kind), message)) + return + end + REPORTERS[level](self.extension, message) +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. +--- +--- The defaults come first, because that is what most extensions want, and +--- because the schema is the one place they are written. They come from a +--- second pass over an empty table, which yields the declared defaults alone. +--- +--- The second return holds three tables, because they answer different +--- questions: +--- provided what the document actually set, which is the only way to tell +--- a deliberate `false` or `0` from an absent key, +--- merged the same values with coercion and defaults applied, +--- defaults the schema defaults on their own. +--- It is nil when there is nothing to resolve against, so an extension can +--- tell an unreadable schema from a document that set nothing. +--- +--- 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 +--- @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) + if self.options_checked then + return deep_copy(self.defaults), self.resolved + end + self.options_checked = true + + --- @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. `call` makes the same allowance + -- for `shortcodes` one field over. + if loaded == nil or next(loaded.options or {}) == nil then + return deep_copy(self.defaults), self.resolved + end + + --- @type table + local provided = self.validator.extract_meta_options(meta, self.extension) + local valid, errors, warnings, merged = self.validator.validate(provided, loaded.options) + + for _, message in ipairs(warnings) do + self:_report('option_warning', message) + end + if not valid then + for _, message in ipairs(errors) do + self:_report('option_error', message) + end + end + + local _, _, _, defaults = self.validator.validate({}, loaded.options, { unknown = 'ignore' }) + self.defaults = defaults or {} + self.resolved = { provided = provided, merged = merged, defaults = self.defaults } + + return deep_copy(self.defaults), self.resolved +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. +--- @param name string Shortcode name +--- @param args table Positional arguments +--- @param kwargs table Key-value options for the call +--- @return nil +function Checker:call(name, args, kwargs) + --- @type table|nil + local loaded = self.schema + if loaded == nil then return end + + --- @type table|nil + local entry = loaded.shortcodes and loaded.shortcodes[name] + if entry == nil then return end + + args = args or {} + kwargs = kwargs or {} + + --- @type table + local positional = {} + for index, value in ipairs(args) do + positional[index] = str.stringify(value) + end + + local _, errors, warnings = self.validator.validate_shortcode( + name, positional, plain_kwargs(kwargs), entry) + + for _, message in ipairs(warnings) do + self:_report('call_warning', message) + end + + -- A required argument that is absent is read here rather than out of the + -- validator's findings, because the validator reports a nested argument + -- fault under one `arguments` entry, which cannot tell a missing argument + -- from a malformed one. + --- @type table + local missing = {} + for index, argument in ipairs(entry.arguments or {}) do + if argument.required == true and str.is_empty(positional[index]) then + missing[#missing + 1] = argument + end + end + + if #missing > 0 then + -- The only message about the missing argument: the schema's own `required` + -- wording says the same thing, and reporting both would state one fault + -- twice at two severities. Attribute warnings above still stand, and every + -- other finding about this call is secondary to there being no output. + for _, argument in ipairs(missing) do + --- The example comes from the schema, so that each shortcode carries its + --- own rather than this message naming one shortcode for all of them. + --- @type string + local advice = '' + local example = type(argument.examples) == 'table' and argument.examples[1] or nil + if example ~= nil then + advice = string.format(' For example: {{< %s %s >}}.', name, tostring(example)) + end + self:_report('missing_argument', string.format( + 'The "%s" shortcode needs its "%s" argument.%s', name, argument.name, advice)) + end + return + end + + for _, message in ipairs(errors) do + self:_report('call_error', message) + end +end + +-- ============================================================================ +-- PUBLIC API +-- ============================================================================ + +--- Build a checker for one extension, reading its schema once. +--- A schema that cannot be read is reported, and the checker it returns does +--- nothing: `options` gives an empty table and `call` gives no message. +--- +--- The path is resolved with `quarto.utils.resolve_path`, which answers +--- relative to the entry point that is running rather than to the extension +--- directory. An entry point in a subdirectory therefore has to say where the +--- 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` +--- @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`. +--- @return Checker +--- @usage local checker = M.new(validator, 'iconify', '../_schema.yml') +function M.new(validator, extension_name, schema_path) + --- @type Checker + local checker = setmetatable({ + validator = validator, + extension = extension_name, + schema = nil, + defaults = {}, + resolved = nil, + options_checked = false, + }, Checker) + + -- The default is chosen here rather than in the signature, so a caller that + -- passes two arguments reads the same file it read before this argument + -- existed. + local loaded, err = validator.load_schema( + quarto.utils.resolve_path(schema_path or '_schema.yml')) + if err then + checker:_report('schema', err) + else + checker.schema = loaded + end + + return checker +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/atelier/_modules/string.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/string.lua similarity index 99% rename from docs/_extensions/mcanouil/atelier/_modules/string.lua rename to docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/string.lua index 435c310..d1e95e2 100644 --- a/docs/_extensions/mcanouil/atelier/_modules/string.lua +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-lua-modules/string.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.1.0 +--- @version 2.2.0 local M = {} diff --git a/docs/_extensions/mcanouil/gitlink/_vendor/quarto-wizard/LICENSE b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-wizard/LICENSE new file mode 100644 index 0000000..4b53bb8 --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-wizard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mickaël Canouil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/_extensions/mcanouil/gitlink/_vendor/quarto-wizard/schema.lua b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-wizard/schema.lua new file mode 100644 index 0000000..a7e0c56 --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_vendor/quarto-wizard/schema.lua @@ -0,0 +1,2513 @@ +--- MC Schema - Reference validator for Quarto extension schemas (`_schema.yml`) +--- @module "schema" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 2.0.0 +--- +--- Implements the v2 extension schema vocabulary published at +--- . +--- The v1 dual-key vocabulary (`min`, `max`, `enum-case-insensitive`, +--- `element-attributes`, `pattern-exact`) is not accepted. +--- +--- The module is standalone: it requires no sibling module, and it reaches +--- `pandoc` and `quarto` only through the `M._env` table, so it can be +--- exercised outside a Quarto render with stubs in place. + +local M = {} + +--- Meta-schema this module implements. +M.SCHEMA_VERSION = 'https://m.canouil.dev/quarto-wizard/assets/schema/v2/extension-schema.json' + +-- ============================================================================ +-- ENVIRONMENT SEAM +-- ============================================================================ + +--- Indirection over the two host globals, resolved at call time so a test +--- harness can install stubs before the first call. +local _env = {} + +--- Render a Pandoc value as plain text. +--- @param value any Pandoc value +--- @return string Plain text +function _env.stringify(value) + return pandoc.utils.stringify(value) +end + +--- Report the Pandoc type of a value. +--- @param value any Pandoc value +--- @return string Pandoc type name +function _env.pandoc_type(value) + return pandoc.utils.type(value) +end + +--- Emit a warning through the host logger. +--- @param message string Message to emit +--- @return nil +function _env.warn(message) + if quarto and quarto.log and quarto.log.warning then + quarto.log.warning(message) + else + io.stderr:write(message .. '\n') + end +end + +--- Emit an error through the host logger. +--- @param message string Message to emit +--- @return nil +function _env.report_error(message) + if quarto and quarto.log and quarto.log.error then + quarto.log.error(message) + else + io.stderr:write(message .. '\n') + end +end + +M._env = _env + +-- ============================================================================ +-- CONSTANTS +-- ============================================================================ + +--- Maximum nesting depth accepted when converting a parsed schema. +local MAX_DEPTH = 16 + +--- Maximum number of Lua patterns one regex may expand into. +local MAX_PATTERN_BRANCHES = 64 + +--- Descriptor keywords holding a numeric bound. +local NUMERIC_KEYWORDS = { + 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', + 'minLength', 'maxLength', 'minItems', 'maxItems', +} + +--- Every keyword the v2 field descriptor may carry, mapped to whether this +--- module enforces it. Annotations are carried through untouched for tooling. +M.KEYWORDS = { + type = true, + required = true, + default = true, + const = true, + enum = true, + enumCaseInsensitive = true, + pattern = true, + minimum = true, + maximum = true, + exclusiveMinimum = true, + exclusiveMaximum = true, + multipleOf = true, + minLength = true, + maxLength = true, + minItems = true, + maxItems = true, + uniqueItems = true, + items = true, + properties = true, + additionalProperties = true, + propertyNames = true, + dependentRequired = true, + aliases = true, + deprecated = true, + name = true, + description = false, + title = false, + examples = false, + format = false, + completion = false, + contentEncoding = false, + contentMediaType = false, +} + +-- ============================================================================ +-- PRIVATE HELPERS +-- ============================================================================ + +--- Report whether a table is a dense array. +--- @param value any Value to inspect +--- @return boolean True when the table has only keys 1..n +local function _is_array(value) + if type(value) ~= 'table' then + return false + end + local count = 0 + for _ in pairs(value) do + count = count + 1 + end + return count == #value +end + +--- Read a key from a map, accepting the hyphen and underscore spellings. +--- Keys are never rewritten, so a schema keeps the names its author wrote. +--- @param map table|nil Map to read +--- @param key string|nil Key to read +--- @return any value The stored value, or nil +--- @return string|nil found_key The spelling that matched +local function _lookup(map, key) + if type(map) ~= 'table' or type(key) ~= 'string' then + return nil, nil + end + if map[key] ~= nil then + return map[key], key + end + local underscored = (key:gsub('%-', '_')) + if underscored ~= key and map[underscored] ~= nil then + return map[underscored], underscored + end + local hyphenated = (key:gsub('_', '-')) + if hyphenated ~= key and map[hyphenated] ~= nil then + return map[hyphenated], hyphenated + end + return nil, nil +end + +--- Render any value for an error message, including mixed-type arrays. +--- Replaces `table.concat`, which throws on a boolean entry. +--- @param value any Value to render +--- @return string Readable representation +local function _format_value(value) + local kind = type(value) + if kind == 'string' then + if value == '' then + return '""' + end + return value + elseif kind == 'number' or kind == 'boolean' then + return tostring(value) + elseif kind == 'nil' then + return 'null' + elseif kind == 'table' then + if _is_array(value) then + local parts = {} + for index = 1, #value do + parts[index] = _format_value(value[index]) + end + return '[' .. table.concat(parts, ', ') .. ']' + end + return '{object}' + end + return tostring(value) +end + +--- Render a list of values as a comma-separated string. +--- @param values table Array of values +--- @return string Readable list +local function _format_list(values) + local parts = {} + for index = 1, #values do + parts[index] = _format_value(values[index]) + end + return table.concat(parts, ', ') +end + +--- Normalise a type spec to an array of type names. +--- @param type_spec string|table|nil Declared type +--- @return table|nil Array of type names, or nil when no type is declared +local function _type_names(type_spec) + if type_spec == nil then + return nil + end + if type(type_spec) == 'table' then + return type_spec + end + return { type_spec } +end + +--- Render a type spec for an error message. +--- @param type_spec string|table Declared type +--- @return string Readable type +local function _format_type(type_spec) + if type(type_spec) == 'table' then + return table.concat(type_spec, ' | ') + end + return tostring(type_spec) +end + +--- Report the type name of a value, distinguishing arrays from objects. +--- @param value any Value to inspect +--- @return string Type name +local function _actual_type(value) + local kind = type(value) + if kind == 'table' then + return _is_array(value) and 'array' or 'object' + end + if kind == 'nil' then + return 'null' + end + return kind +end + +--- Report whether a value satisfies a single type name. +--- @param value any Value to check +--- @param name string Type name +--- @return boolean True when the value matches +local function _matches_type(value, name) + if name == 'content' then + return value ~= nil + end + if name == 'null' then + return value == nil + end + if name == 'integer' then + return type(value) == 'number' + and value == value + and value ~= math.huge + and value ~= -math.huge + and value == math.floor(value) + end + if name == 'number' then + return type(value) == 'number' + end + if name == 'boolean' then + return type(value) == 'boolean' + end + if name == 'string' then + return type(value) == 'string' + end + if name == 'array' then + return type(value) == 'table' and _is_array(value) + end + if name == 'object' then + -- An array is not an object. An empty table is both, and Lua cannot tell + -- the two apart, so it satisfies either. + return type(value) == 'table' and (next(value) == nil or not _is_array(value)) + end + return false +end + +--- Report whether a value satisfies any declared type. +--- @param value any Value to check +--- @param type_spec string|table|nil Declared type +--- @return boolean True when the value matches, or no type is declared +local function _matches_type_spec(value, type_spec) + local names = _type_names(type_spec) + if names == nil then + return true + end + for _, name in ipairs(names) do + if _matches_type(value, name) then + return true + end + end + return false +end + +--- Coerce a string toward a single scalar type. +--- Pandoc renders every YAML number as a string, so the incoming value and +--- the schema's own literals both need this before any comparison. +--- @param value any Value to coerce +--- @param name string Target type name +--- @return any coerced Coerced value, or nil +--- @return boolean ok True when the coercion applied +local function _coerce_scalar(value, name) + if type(value) ~= 'string' then + return nil, false + end + if name == 'number' then + local number = tonumber(value) + if number then + return number, true + end + elseif name == 'integer' then + local number = tonumber(value) + if number and number == math.floor(number) then + return number, true + end + elseif name == 'boolean' then + local lowered = value:lower() + if lowered == 'true' or lowered == 'yes' then + return true, true + end + if lowered == 'false' or lowered == 'no' then + return false, true + end + end + return nil, false +end + +--- Coerce a value toward its declared type. +--- A value already matching one declared type is returned untouched, so +--- `type: [boolean, string]` keeps the string "fenced" a string. +--- @param value any Value to coerce +--- @param type_spec string|table|nil Declared type +--- @return any Coerced value +local function _coerce(value, type_spec) + local names = _type_names(type_spec) + if names == nil or value == nil then + return value + end + for _, name in ipairs(names) do + if _matches_type(value, name) then + return value + end + end + for _, name in ipairs(names) do + local coerced, ok = _coerce_scalar(value, name) + if ok then + return coerced + end + end + return value +end + +-- ============================================================================ +-- PATTERN COMPILATION +-- ============================================================================ + +--- Lua replacements for JS shorthand classes, outside a character class. +local ESCAPE_OUTSIDE = { + d = '%d', D = '%D', w = '[%w_]', W = '[^%w_]', s = '%s', S = '%S', +} + +--- Lua replacements for JS shorthand classes, inside a character class. +--- `\W` has no in-class form, because the underscore cannot be excluded there. +local ESCAPE_INSIDE = { + d = '%d', D = '%D', w = '%w_', s = '%s', S = '%S', +} + +--- Escapes with a real Lua equivalent. Anything else alphanumeric is refused, +--- because compiling it to the bare letter would accept the wrong values. +local CONTROL_ESCAPES = { + n = '\n', + t = '\t', + r = '\r', + f = '\f', + v = '\v', +} + +--- Characters Lua treats as magic outside a character class. +local LUA_MAGIC = '^$()%.[]*+-?' + +--- Escape one literal character for use in a Lua pattern. +--- @param char string Single character +--- @return string Escaped character +local function _escape_literal(char) + if LUA_MAGIC:find(char, 1, true) then + return '%' .. char + end + return char +end + +--- Compile a JS regular expression into a list of equivalent Lua patterns. +--- Alternation is expanded into one pattern per branch, which is how Lua, +--- lacking alternation, can honour the commonest JSON Schema idiom. +--- Anything that cannot be expressed is reported rather than accepted. +--- @param regex string JS regular expression +--- @return table|nil branches Array of Lua patterns, or nil on failure +--- @return string|nil reason Why compilation failed +local function _compile_pattern(regex) + local position = 1 + local length = #regex + local anchor_start = false + local anchor_end = false + local has_top_level_alternation = false + local parse_alternation + + --- Combine a set of prefixes with a set of continuations. + local function cross(prefixes, continuations) + local out = {} + for _, prefix in ipairs(prefixes) do + for _, continuation in ipairs(continuations) do + if #out >= MAX_PATTERN_BRANCHES then + return nil, 'alternation expands past ' .. MAX_PATTERN_BRANCHES .. ' branches' + end + out[#out + 1] = prefix .. continuation + end + end + return out + end + + --- Parse a bracketed character class starting at the current position. + local function parse_class() + local out = { '[' } + position = position + 1 + if regex:sub(position, position) == '^' then + out[#out + 1] = '^' + position = position + 1 + end + if regex:sub(position, position) == ']' then + out[#out + 1] = '%]' + position = position + 1 + end + while position <= length do + local char = regex:sub(position, position) + if char == ']' then + position = position + 1 + out[#out + 1] = ']' + return table.concat(out) + elseif char == '\\' then + local next_char = regex:sub(position + 1, position + 1) + if next_char == '' then + return nil, 'trailing backslash' + end + if next_char == 'W' then + return nil, 'unsupported "\\W" inside a character class' + end + local mapped = ESCAPE_INSIDE[next_char] + if mapped then + out[#out + 1] = mapped + elseif next_char:match('[1-9]') then + return nil, 'unsupported backreference "\\' .. next_char .. '"' + elseif next_char == '%' then + out[#out + 1] = '%%' + elseif CONTROL_ESCAPES[next_char] then + out[#out + 1] = CONTROL_ESCAPES[next_char] + elseif next_char:match('%w') then + return nil, 'unsupported escape "\\' .. next_char .. '"' + else + out[#out + 1] = '%' .. next_char + end + position = position + 2 + elseif char == '%' then + out[#out + 1] = '%%' + position = position + 1 + else + out[#out + 1] = char + position = position + 1 + end + end + return nil, 'unterminated character class' + end + + --- Read a trailing quantifier, rejecting the forms Lua cannot express. + local function parse_quantifier() + local char = regex:sub(position, position) + if char ~= '*' and char ~= '+' and char ~= '?' then + return '' + end + if regex:sub(position + 1, position + 1) == '?' then + return nil, 'unsupported non-greedy quantifier "' .. char .. '?"' + end + position = position + 1 + return char + end + + --- Parse a single atom and return the branches it contributes. + local function parse_atom(depth, at_sequence_start) + local char = regex:sub(position, position) + + if char == '(' then + if regex:sub(position + 1, position + 1) == '?' then + local marker = regex:sub(position + 2, position + 2) + if marker == '=' or marker == '!' or marker == '<' then + return nil, 'unsupported lookahead or lookbehind' + end + if marker ~= ':' then + return nil, 'unsupported group modifier "(?' .. marker .. '"' + end + position = position + 3 + else + position = position + 1 + end + local branches, reason = parse_alternation(depth + 1) + if not branches then + return nil, reason + end + if regex:sub(position, position) ~= ')' then + return nil, 'unterminated group' + end + position = position + 1 + local quantifier = regex:sub(position, position) + if quantifier == '*' or quantifier == '+' or quantifier == '?' then + return nil, 'unsupported quantifier applied to a group' + end + return branches + end + + if char == '[' then + local class, reason = parse_class() + if not class then + return nil, reason + end + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { class .. quantifier } + end + + if char == '\\' then + local next_char = regex:sub(position + 1, position + 1) + if next_char == '' then + return nil, 'trailing backslash' + end + if next_char:match('[1-9]') then + return nil, 'unsupported backreference "\\' .. next_char .. '"' + end + position = position + 2 + local mapped = ESCAPE_OUTSIDE[next_char] + local atom + if mapped then + atom = mapped + elseif CONTROL_ESCAPES[next_char] then + atom = CONTROL_ESCAPES[next_char] + elseif next_char:match('%w') then + return nil, 'unsupported escape "\\' .. next_char .. '"' + else + atom = _escape_literal(next_char) + end + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { atom .. quantifier } + end + + if char == '^' then + if depth ~= 0 or not at_sequence_start or position ~= 1 then + return nil, 'unsupported anchor "^" away from the start of the pattern' + end + position = position + 1 + anchor_start = true + return { '' } + end + + if char == '$' then + if depth ~= 0 or position ~= length then + return nil, 'unsupported anchor "$" away from the end of the pattern' + end + position = position + 1 + anchor_end = true + return { '' } + end + + if char == '{' then + return nil, 'unsupported counted quantifier "{n,m}"' + end + + if char == '*' or char == '+' or char == '?' then + return nil, 'quantifier "' .. char .. '" with nothing to repeat' + end + + if char == '.' then + position = position + 1 + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { '.' .. quantifier } + end + + position = position + 1 + local atom = _escape_literal(char) + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { atom .. quantifier } + end + + --- Parse one alternative: a run of atoms up to `|`, `)` or the end. + local function parse_sequence(depth) + local branches = { '' } + local first = true + while position <= length do + local char = regex:sub(position, position) + if char == '|' or char == ')' then + break + end + local atom_branches, reason = parse_atom(depth, first) + if not atom_branches then + return nil, reason + end + first = false + local crossed, cross_reason = cross(branches, atom_branches) + if not crossed then + return nil, cross_reason + end + branches = crossed + end + return branches + end + + parse_alternation = function(depth) + local all = {} + local iterations = 0 + while true do + iterations = iterations + 1 + local branches, reason = parse_sequence(depth) + if not branches then + return nil, reason + end + for _, branch in ipairs(branches) do + if #all >= MAX_PATTERN_BRANCHES then + return nil, 'alternation expands past ' .. MAX_PATTERN_BRANCHES .. ' branches' + end + all[#all + 1] = branch + end + if regex:sub(position, position) == '|' then + position = position + 1 + else + break + end + end + if depth == 0 and iterations > 1 then + has_top_level_alternation = true + end + return all + end + + local branches, reason = parse_alternation(0) + if not branches then + return nil, reason + end + if position <= length then + return nil, 'unbalanced ")" in pattern' + end + + -- The anchors are collected for the expression as a whole, so applying them + -- to multiple top-level branches would anchor branches the author did not anchor. + if has_top_level_alternation and (anchor_start or anchor_end) then + return nil, 'unsupported anchor in a top-level alternation' + end + + local prefix = anchor_start and '^' or '' + local suffix = anchor_end and '$' or '' + local out = {} + for index, branch in ipairs(branches) do + out[index] = prefix .. branch .. suffix + end + return out +end + +M._compile_pattern = _compile_pattern + +--- Report whether a string satisfies any compiled branch. +--- @param value string Value to test +--- @param branches table Array of Lua patterns +--- @return boolean True when a branch matches +local function _pattern_matches(value, branches) + for _, branch in ipairs(branches) do + local ok, matched = pcall(string.match, value, branch) + if ok and matched ~= nil then + return true + end + end + return false +end + +-- ============================================================================ +-- PANDOC VALUE CONVERSION +-- ============================================================================ + +--- Convert a Pandoc metadata value to a native Lua value. +--- Pandoc's Lua objects carry no `t` field, so the kind has to come from +--- `pandoc.utils.type`; testing `value.t` silently turns every string option +--- into a list of its inline elements. +--- @param value any Pandoc metadata value +--- @return any Native Lua value +local function _convert_pandoc_value(value) + local kind = _env.pandoc_type(value) + + -- A bare key, `null` and `~` all arrive as an empty Pandoc `string`, which + -- is how Pandoc collapses YAML null. An explicit `""` arrives as an empty + -- `Inlines` instead, so the two are told apart before either is unwrapped. + if kind == 'string' and value == '' then + return nil + end + + if type(value) ~= 'table' then + return value + end + + if kind == 'Inlines' or kind == 'Blocks' or kind == 'Inline' or kind == 'Block' then + return _env.stringify(value) + end + + if kind == 'List' then + -- A null element is absent, the same rule this module applies to a null + -- key. Writing it into a running count rather than the source index + -- keeps the result a proper sequence instead of leaving a gap. + local result = {} + for index = 1, #value do + local converted = _convert_pandoc_value(value[index]) + if converted ~= nil then + result[#result + 1] = converted + end + end + return result + end + + if _is_array(value) and #value > 0 then + -- A null element is absent, the same rule this module applies to a null + -- key. Writing it into a running count rather than the source index + -- keeps the result a proper sequence instead of leaving a gap. + local result = {} + for index = 1, #value do + local converted = _convert_pandoc_value(value[index]) + if converted ~= nil then + result[#result + 1] = converted + end + end + return result + end + + local result = {} + for key, item in pairs(value) do + result[tostring(key)] = _convert_pandoc_value(item) + end + return result +end + +-- ============================================================================ +-- YAML PARSING +-- ============================================================================ + +--- Pandoc parses YAML string scalars as Markdown, which rewrites a schema in +--- place: `^[a-z]+$` arrives as `+$`, and `^\d+\.?\d*m?s$` arrives as +--- `^.??s$`. The schema file is therefore parsed here instead. +--- +--- The subset covers what an extension schema needs: block maps and +--- sequences, plain and quoted scalars, folded and literal block scalars, +--- flow sequences and flow maps (including ones spanning several lines), and +--- `#` comments. Anchors, aliases, tags, multiple documents and complex keys +--- are not supported and are reported rather than misread. +--- +--- Every function here reports a failure by returning a message rather than +--- by calling `error`. Quarto replaces `error` with a logger that returns +--- instead of unwinding, so a module that trusts `error` to stop execution +--- carries on with invalid state and fails later somewhere unrelated. +--- +--- One deliberate deviation from YAML: a block scalar is always stripped of +--- its trailing newline unless it is chomped with `+`. Schema text is +--- annotation, and a trailing newline there is noise rather than meaning. + +--- Build a parse error message carrying the offending line number. +--- @param line_number number Line number +--- @param message string Explanation +--- @return string Formatted message +local function _yaml_error(line_number, message) + return string.format('line %d: %s', line_number, message) +end + +--- Remove a trailing `#` comment, respecting quoted spans. +--- @param text string Line content +--- @return string Content without its comment +local function _strip_comment(text) + local out = {} + local quote = nil + local index = 1 + while index <= #text do + local char = text:sub(index, index) + if quote then + out[#out + 1] = char + if char == '\\' and quote == '"' then + index = index + 1 + out[#out + 1] = text:sub(index, index) + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + out[#out + 1] = char + elseif char == '#' and (index == 1 or text:sub(index - 1, index - 1):match('%s')) then + break + else + out[#out + 1] = char + end + index = index + 1 + end + return (table.concat(out):gsub('%s+$', '')) +end + +--- Trim leading and trailing whitespace. +--- @param text string Text to trim +--- @return string Trimmed text +local function _trim(text) + return (text:gsub('^%s+', ''):gsub('%s+$', '')) +end + +--- Parse a scalar, giving numbers and booleans their real Lua types. +--- @param text string Scalar text +--- @param line_number number Line number for errors +--- @return any value Parsed value, nil for an explicit null +--- @return string|nil err +local function _parse_scalar(text, line_number) + text = _trim(text) + + if text == '' or text == '~' or text == 'null' or text == 'Null' or text == 'NULL' then + return nil, nil + end + + local first = text:sub(1, 1) + + if first == '"' then + if #text < 2 or text:sub(-1) ~= '"' then + return nil, _yaml_error(line_number, 'unterminated double-quoted scalar') + end + return (text:sub(2, -2):gsub('\\(.)', function(escaped) + if escaped == 'n' then return '\n' end + if escaped == 't' then return '\t' end + if escaped == 'r' then return '\r' end + return escaped + end)), nil + end + + if first == "'" then + if #text < 2 or text:sub(-1) ~= "'" then + return nil, _yaml_error(line_number, 'unterminated single-quoted scalar') + end + return (text:sub(2, -2):gsub("''", "'")), nil + end + + if first == '&' or first == '*' or first == '!' then + return nil, _yaml_error(line_number, 'anchors, aliases and tags are not supported') + end + + if text == 'true' or text == 'True' or text == 'TRUE' or text == 'yes' or text == 'Yes' then + return true, nil + end + if text == 'false' or text == 'False' or text == 'FALSE' or text == 'no' or text == 'No' then + return false, nil + end + + local number = tonumber(text) + if number ~= nil and text:match('^[%-+]?[%d%.eE%+%-]+$') then + return number, nil + end + + return text, nil +end + +--- Split a flow collection body on its top-level commas. +--- @param body string Text between the brackets +--- @return table Array of raw item strings +local function _split_flow(body) + local items = {} + local depth = 0 + local quote = nil + local start = 1 + local index = 1 + while index <= #body do + local char = body:sub(index, index) + if quote then + if char == '\\' and quote == '"' then + index = index + 1 + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == '[' or char == '{' then + depth = depth + 1 + elseif char == ']' or char == '}' then + depth = depth - 1 + elseif char == ',' and depth == 0 then + items[#items + 1] = body:sub(start, index - 1) + start = index + 1 + end + index = index + 1 + end + local tail = body:sub(start) + if tail:match('%S') then + items[#items + 1] = tail + end + return items +end + +--- Strip the quotes from a mapping key. +--- @param key string Raw key text +--- @param line_number number Line number for errors +--- @return string|nil key +--- @return string|nil err +local function _unquote_key(key, line_number) + key = _trim(key) + local first = key:sub(1, 1) + if first == '"' or first == "'" then + local parsed, err = _parse_scalar(key, line_number) + if err then + return nil, err + end + return tostring(parsed), nil + end + return key, nil +end + +local _parse_flow + +--- Parse a value written in flow style, or a plain scalar. +--- @param text string Value text +--- @param line_number number Line number for errors +--- @param depth number|nil Current nesting depth +--- @return any value +--- @return string|nil err +_parse_flow = function(text, line_number, depth) + depth = depth or 0 + if depth > MAX_DEPTH then + return nil, _yaml_error(line_number, 'value nests deeper than ' .. MAX_DEPTH .. ' levels') + end + + text = _trim(text) + local first = text:sub(1, 1) + + if first == '[' then + if text:sub(-1) ~= ']' then + return nil, _yaml_error(line_number, 'unterminated flow sequence') + end + local out = {} + for index, item in ipairs(_split_flow(text:sub(2, -2))) do + local value, err = _parse_flow(item, line_number, depth + 1) + if err then + return nil, err + end + out[index] = value + end + return out, nil + end + + if first == '{' then + if text:sub(-1) ~= '}' then + return nil, _yaml_error(line_number, 'unterminated flow mapping') + end + local out = {} + for _, item in ipairs(_split_flow(text:sub(2, -2))) do + local key, rest = item:match('^%s*(.-)%s*:%s*(.*)$') + if not key or key == '' then + return nil, _yaml_error(line_number, 'expected "key: value" inside a flow mapping') + end + local name, key_err = _unquote_key(key, line_number) + if key_err then + return nil, key_err + end + local value, err = _parse_flow(rest, line_number, depth + 1) + if err then + return nil, err + end + out[name] = value + end + return out, nil + end + + return _parse_scalar(text, line_number) +end + +--- Split a mapping line into its key and the rest of the line. +--- The separator is the first unquoted colon followed by a space or the end +--- of the line, so a URL value such as `https://...` is not split. +--- @param text string Line content +--- @return string|nil key +--- @return string|nil rest +local function _split_key(text) + local quote = nil + local index = 1 + while index <= #text do + local char = text:sub(index, index) + if quote then + if char == '\\' and quote == '"' then + index = index + 1 + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == ':' then + local following = text:sub(index + 1, index + 1) + if following == '' or following == ' ' then + return text:sub(1, index - 1), _trim(text:sub(index + 2)) + end + end + index = index + 1 + end + return nil, nil +end + +--- Break the source into classified lines. +--- @param source string YAML text +--- @return table|nil lines Array of {raw, indent, number, blank, comment} +--- @return string|nil err +local function _scan_lines(source) + local lines = {} + local number = 0 + for raw in (source .. '\n'):gmatch('([^\n]*)\n') do + number = number + 1 + if raw:find('\t') then + return nil, _yaml_error(number, 'tabs are not valid YAML indentation') + end + local indent = #(raw:match('^ *') or '') + local body = raw:sub(indent + 1) + lines[#lines + 1] = { + raw = raw, + indent = indent, + number = number, + blank = body == '', + comment = body:sub(1, 1) == '#', + } + end + return lines, nil +end + +--- Advance past blank and comment lines. +--- @param lines table Scanned lines +--- @param index number Starting index +--- @return number Index of the next significant line +local function _skip_insignificant(lines, index) + while index <= #lines and (lines[index].blank or lines[index].comment) do + index = index + 1 + end + return index +end + +--- Read the comment-stripped content of a line. +--- @param line table Scanned line +--- @return string Content +local function _content(line) + return _strip_comment(line.raw:sub(line.indent + 1)) +end + +--- Track bracket depth across one line, ignoring quoted spans. +--- @param text string Line content +--- @param depth number Depth carried in +--- @param quote string|nil Open quote carried in +--- @return number depth +--- @return string|nil quote +local function _scan_flow_depth(text, depth, quote) + local index = 1 + while index <= #text do + local char = text:sub(index, index) + if quote then + if char == '\\' and quote == '"' then + index = index + 1 + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == '[' or char == '{' then + depth = depth + 1 + elseif char == ']' or char == '}' then + depth = depth - 1 + end + index = index + 1 + end + return depth, quote +end + +--- Collect a flow collection that may run across several lines. +--- @param lines table Scanned lines +--- @param start number Index of the line holding the opening bracket +--- @param initial string Flow text already read from that line +--- @param line_number number Line number for errors +--- @return string|nil text The whole flow collection on one line +--- @return number next_index Index of the first line after it +--- @return string|nil err +local function _gather_flow(lines, start, initial, line_number) + local pieces = { initial } + local depth, quote = _scan_flow_depth(initial, 0, nil) + local index = start + + while depth > 0 do + index = index + 1 + if index > #lines then + return nil, index, _yaml_error(line_number, 'unterminated flow collection') + end + local content = _content(lines[index]) + pieces[#pieces + 1] = content + depth, quote = _scan_flow_depth(content, depth, quote) + end + + return table.concat(pieces, ' '), index + 1, nil +end + +--- Read a folded or literal block scalar. +--- @param lines table Scanned lines +--- @param start number First candidate line +--- @param parent_indent number Indentation of the owning key +--- @param style string Block style, such as '>', '>-' or '|' +--- @return string text +--- @return number next_index +local function _read_block_scalar(lines, start, parent_indent, style) + local kind = style:sub(1, 1) + local chomp = style:sub(2, 2) + + local collected = {} + local block_indent = nil + local index = start + + while index <= #lines do + local line = lines[index] + if line.blank then + collected[#collected + 1] = '' + index = index + 1 + elseif line.indent > parent_indent then + block_indent = block_indent or line.indent + collected[#collected + 1] = line.raw:sub(block_indent + 1) + index = index + 1 + else + break + end + end + + while #collected > 0 and collected[#collected] == '' do + table.remove(collected) + end + + local text + if kind == '>' then + local parts = {} + local buffer = {} + for _, line in ipairs(collected) do + if line == '' then + if #buffer > 0 then + parts[#parts + 1] = table.concat(buffer, ' ') + buffer = {} + end + parts[#parts + 1] = '\n' + else + buffer[#buffer + 1] = (line:gsub('%s+$', '')) + end + end + if #buffer > 0 then + parts[#parts + 1] = table.concat(buffer, ' ') + end + text = table.concat(parts) + else + text = table.concat(collected, '\n') + end + + if chomp == '+' then + text = text .. '\n' + end + + return text, index +end + +local _parse_block +local _parse_sequence + +--- The order in which each parsed mapping declared its keys. +--- Lua tables have no key order, but a schema is authored in a meaningful one, +--- and a generator that turns a schema into documentation needs it. The order +--- is kept beside the data rather than inside it, so it cannot be mistaken for +--- a key of the mapping. Weak keys let a discarded mapping take its order with +--- it. +local _key_order = setmetatable({}, { __mode = 'k' }) + +--- Parse a block mapping at a known indentation. +--- @return table|nil map +--- @return number next_index +--- @return string|nil err +local function _parse_map(lines, start, indent, depth) + local map = {} + local order = {} + local index = start + + while index <= #lines do + local line = lines[index] + if line.blank or line.comment then + index = index + 1 + elseif line.indent < indent then + break + else + if line.indent > indent then + return nil, index, _yaml_error(line.number, 'unexpected indentation inside a mapping') + end + + local content = _content(line) + if content == '' then + index = index + 1 + else + if content == '---' or content == '...' then + return nil, index, + _yaml_error(line.number, 'multiple YAML documents are not supported') + end + + if content:sub(1, 1) == '-' then + return nil, index, + _yaml_error(line.number, 'a sequence item cannot appear inside a mapping here') + end + + local raw_key, rest = _split_key(content) + if raw_key == nil or _trim(raw_key) == '' then + return nil, index, _yaml_error(line.number, 'expected "key: value"') + end + + local key, key_err = _unquote_key(raw_key, line.number) + if key_err then + return nil, index, key_err + end + if map[key] == nil then + order[#order + 1] = key + end + index = index + 1 + + -- An explicit indentation indicator (`|2`, `>2-`) is reported rather + -- than misread: without this the whole block is stored as the string + -- "|2" and its body is then parsed as further mapping lines. + if rest:match('^[|>]%d') or rest:match('^[|>][+%-]%d') then + return nil, index, _yaml_error( + line.number, + 'a block scalar indentation indicator is not supported: ' .. rest + ) + end + + local block_style = rest:match('^([|>][+%-]?)$') + if block_style then + local text, next_index = _read_block_scalar(lines, index, indent, block_style) + map[key] = text + index = next_index + elseif rest == '' then + local value, next_index, err = _parse_block(lines, index, indent, depth + 1) + if err then + return nil, next_index, err + end + + -- A block sequence may sit at the column of its own key, which + -- `_parse_block` declines because its guard wants a deeper line. + -- Nothing was consumed in that case, so read the sequence here. + if value == nil and next_index == index then + local peek = _skip_insignificant(lines, index) + if peek <= #lines and lines[peek].indent == indent then + local ahead = _content(lines[peek]) + if ahead == '-' or ahead:sub(1, 2) == '- ' then + value, next_index, err = _parse_sequence(lines, peek, indent, depth + 1) + if err then + return nil, next_index, err + end + end + end + end + + map[key] = value + index = next_index + elseif rest:sub(1, 1) == '[' or rest:sub(1, 1) == '{' then + local text, next_index, err = _gather_flow(lines, index - 1, rest, line.number) + if err then + return nil, next_index, err + end + local value, value_err = _parse_flow(text, line.number, depth) + if value_err then + return nil, next_index, value_err + end + map[key] = value + index = next_index + else + local value, err = _parse_flow(rest, line.number, depth) + if err then + return nil, index, err + end + map[key] = value + end + end + end + end + + _key_order[map] = order + return map, index, nil +end + +--- Parse a block sequence at a known indentation. +--- @return table|nil sequence +--- @return number next_index +--- @return string|nil err +_parse_sequence = function(lines, start, indent, depth) + local sequence = {} + local index = start + + while index <= #lines do + local line = lines[index] + if line.blank or line.comment then + index = index + 1 + elseif line.indent < indent then + break + else + if line.indent > indent then + return nil, index, _yaml_error(line.number, 'unexpected indentation inside a sequence') + end + + local content = _content(line) + local rest = content:match('^%-%s*(.*)$') + if rest == nil then + break + end + + if rest == '' then + local value, next_index, err = _parse_block(lines, index + 1, indent, depth + 1) + if err then + return nil, next_index, err + end + -- A Lua array cannot hold an interior nil, so an empty item would be + -- dropped and every later item would shift down one index, moving the + -- element that `minItems` and an `items` path such as `preload[2]` + -- refer to. Report it instead. + if value == nil then + return nil, index, _yaml_error(line.number, 'an empty sequence item is not supported') + end + sequence[#sequence + 1] = value + index = next_index + elseif _split_key(rest) == nil then + -- A plain or flow scalar item, such as `- password` or `- [a, b]`. + if rest:sub(1, 1) == '[' or rest:sub(1, 1) == '{' then + local text, next_index, err = _gather_flow(lines, index, rest, line.number) + if err then + return nil, next_index, err + end + local value, value_err = _parse_flow(text, line.number, depth) + if value_err then + return nil, next_index, value_err + end + sequence[#sequence + 1] = value + index = next_index + else + local value, err = _parse_flow(rest, line.number, depth) + if err then + return nil, index, err + end + sequence[#sequence + 1] = value + index = index + 1 + end + else + -- The remainder of the dash line is the first line of the item, and it + -- sits at the column where that remainder begins. + local offset = #content - #rest + local synthetic = { + raw = string.rep(' ', indent + offset) .. rest, + indent = indent + offset, + number = line.number, + blank = false, + comment = false, + } + local saved = lines[index] + lines[index] = synthetic + local value, next_index, err = _parse_block(lines, index, indent, depth + 1) + lines[index] = saved + if err then + return nil, next_index, err + end + sequence[#sequence + 1] = value + index = next_index + end + end + end + + return sequence, index, nil +end + +--- Parse whichever block collection begins at `start`. +--- @param lines table Scanned lines +--- @param start number Index to start from +--- @param parent_indent number|nil Indentation that the block must exceed +--- @param depth number Current nesting depth +--- @return any value +--- @return number next_index +--- @return string|nil err +_parse_block = function(lines, start, parent_indent, depth) + depth = depth or 0 + local index = _skip_insignificant(lines, start) + if index > #lines then + return nil, index, nil + end + + local line = lines[index] + if parent_indent ~= nil and line.indent <= parent_indent then + return nil, start, nil + end + + if depth > MAX_DEPTH then + return nil, index, _yaml_error(line.number, 'schema nests deeper than ' .. MAX_DEPTH .. ' levels') + end + + local indent = line.indent + local content = _content(line) + + if content:sub(1, 1) == '[' or content:sub(1, 1) == '{' then + local text, next_index, err = _gather_flow(lines, index, content, line.number) + if err then + return nil, next_index, err + end + local value, value_err = _parse_flow(text, line.number, depth) + if value_err then + return nil, next_index, value_err + end + return value, next_index, nil + end + + if content == '-' or content:sub(1, 2) == '- ' then + return _parse_sequence(lines, index, indent, depth) + end + return _parse_map(lines, index, indent, depth) +end + +--- Parse YAML text into a native Lua table. +--- @param source string YAML text +--- @return table|nil tree +--- @return string|nil err +local function _parse_yaml_text(source) + local lines, scan_err = _scan_lines(source) + if scan_err then + return nil, scan_err + end + + -- A single leading `---` opens one document, which is valid and common. + -- A later one opens a second document, which `_parse_map` reports. Testing + -- the raw text instead would also reject a `---` inside a block scalar. + local first = _skip_insignificant(lines, 1) + if first <= #lines and _content(lines[first]) == '---' then + lines[first].blank = true + end + + local value, _, err = _parse_block(lines, 1, nil, 0) + if err then + return nil, err + end + if value == nil then + return {}, nil + end + if type(value) ~= 'table' or (_is_array(value) and #value > 0) then + return nil, 'the file must contain a mapping at its top level' + end + return value, nil +end + +M._parse_yaml_text = _parse_yaml_text + +--- Read and parse a YAML schema file. +--- @param filename string Path to the schema file +--- @return table|nil tree +--- @return string|nil err +local function _parse_yaml_file(filename) + local handle = io.open(filename, 'r') + if not handle then + return nil, string.format('Could not open schema file: %s', filename) + end + + local content = handle:read('*a') + handle:close() + + local tree, err = _parse_yaml_text(content) + if err then + return nil, string.format('Could not parse schema file %s: %s', filename, err) + end + return tree, nil +end + + +-- ============================================================================ +-- DESCRIPTOR COMPILATION +-- ============================================================================ + +local _compiled_cache = setmetatable({}, { __mode = 'k' }) + +local _compile + +--- Compile a field descriptor: coerce its literals against its own declared +--- type, compile its patterns, and recurse into nested descriptors. +--- Returns a new table, so the caller's schema is never mutated. +--- @param spec table Raw field descriptor +--- @return table Compiled descriptor +_compile = function(spec) + if type(spec) ~= 'table' then + return spec + end + + local cached = _compiled_cache[spec] + if cached then + return cached + end + + local out = {} + for key, value in pairs(spec) do + out[key] = value + end + + local type_spec = out.type + + if out.default ~= nil then + out.default = _coerce(out.default, type_spec) + end + if out.const ~= nil then + out.const = _coerce(out.const, type_spec) + end + if type(out.enum) == 'table' then + local values = {} + for index = 1, #out.enum do + values[index] = _coerce(out.enum[index], type_spec) + end + out.enum = values + end + + for _, keyword in ipairs(NUMERIC_KEYWORDS) do + if out[keyword] ~= nil then + out[keyword] = tonumber(out[keyword]) or out[keyword] + end + end + + if type(out.required) == 'string' then + out.required = out.required:lower() == 'true' or out.required:lower() == 'yes' + end + if type(out.uniqueItems) == 'string' then + out.uniqueItems = out.uniqueItems:lower() == 'true' + end + if type(out.enumCaseInsensitive) == 'string' then + out.enumCaseInsensitive = out.enumCaseInsensitive:lower() == 'true' + end + if out.additionalProperties == 'false' then + out.additionalProperties = false + elseif out.additionalProperties == 'true' then + out.additionalProperties = true + end + + if type(out.pattern) == 'string' then + out._pattern, out._pattern_error = _compile_pattern(out.pattern) + end + if type(out.propertyNames) == 'string' then + out._property_names, out._property_names_error = _compile_pattern(out.propertyNames) + end + + if type(out.items) == 'table' then + out.items = _compile(out.items) + end + if type(out.properties) == 'table' then + local properties = {} + for key, value in pairs(out.properties) do + properties[key] = _compile(value) + end + out.properties = properties + end + if type(out.additionalProperties) == 'table' then + out.additionalProperties = _compile(out.additionalProperties) + end + + _compiled_cache[spec] = out + return out +end + +-- ============================================================================ +-- FINDINGS +-- ============================================================================ + +--- Record a finding against a path. +--- @param context table Validation context +--- @param severity string 'error' or 'warning' +--- @param path string Dotted path of the offending value +--- @param keyword string Keyword that produced the finding +--- @param message string Human-readable explanation +--- @return nil +local function _report(context, severity, path, keyword, message) + table.insert(context.findings, { + path = path, + keyword = keyword, + message = message, + severity = severity, + }) +end + +--- Split findings into the string arrays the public API returns. +--- @param findings table Array of findings +--- @return table errors Array of error strings +--- @return table warnings Array of warning strings +local function _split_findings(findings) + local errors = {} + local warnings = {} + for _, finding in ipairs(findings) do + local line = finding.path and (finding.path .. ': ' .. finding.message) or finding.message + if finding.severity == 'warning' then + table.insert(warnings, line) + else + table.insert(errors, line) + end + end + return errors, warnings +end + +-- ============================================================================ +-- KEYWORD CHECKS +-- ============================================================================ + +local _validate_value +local _validate_map + +--- Check `enum`, honouring `enumCaseInsensitive`. +local function _check_enum(value, spec, path, context) + if type(spec.enum) ~= 'table' then + return + end + local case_insensitive = spec.enumCaseInsensitive == true + for _, allowed in ipairs(spec.enum) do + if value == allowed then + return + end + if case_insensitive and type(value) == 'string' and type(allowed) == 'string' + and value:lower() == allowed:lower() then + return + end + end + _report(context, 'error', path, 'enum', string.format( + 'must be one of: %s, got %s.', _format_list(spec.enum), _format_value(value) + )) +end + +--- Check `pattern`, reporting a pattern the module cannot compile. +local function _check_pattern(value, spec, path, context) + if spec.pattern == nil or type(value) ~= 'string' then + return + end + if spec._pattern == nil then + _report(context, 'error', path, 'pattern', string.format( + 'schema declares a pattern this validator cannot compile (%s): %s.', + spec._pattern_error or 'unsupported construct', spec.pattern + )) + return + end + if not _pattern_matches(value, spec._pattern) then + _report(context, 'error', path, 'pattern', string.format( + 'does not match required pattern: %s.', spec.pattern + )) + end +end + +--- Check the numeric bounds. +local function _check_numeric(value, spec, path, context) + if type(value) ~= 'number' then + return + end + if type(spec.minimum) == 'number' and value < spec.minimum then + _report(context, 'error', path, 'minimum', string.format( + 'must be at least %s, got %s.', _format_value(spec.minimum), _format_value(value) + )) + end + if type(spec.maximum) == 'number' and value > spec.maximum then + _report(context, 'error', path, 'maximum', string.format( + 'must be at most %s, got %s.', _format_value(spec.maximum), _format_value(value) + )) + end + if type(spec.exclusiveMinimum) == 'number' and value <= spec.exclusiveMinimum then + _report(context, 'error', path, 'exclusiveMinimum', string.format( + 'must be greater than %s, got %s.', _format_value(spec.exclusiveMinimum), _format_value(value) + )) + end + if type(spec.exclusiveMaximum) == 'number' and value >= spec.exclusiveMaximum then + _report(context, 'error', path, 'exclusiveMaximum', string.format( + 'must be less than %s, got %s.', _format_value(spec.exclusiveMaximum), _format_value(value) + )) + end + if type(spec.multipleOf) == 'number' and spec.multipleOf ~= 0 then + local quotient = value / spec.multipleOf + if quotient ~= math.floor(quotient) then + _report(context, 'error', path, 'multipleOf', string.format( + 'must be a multiple of %s, got %s.', _format_value(spec.multipleOf), _format_value(value) + )) + end + end +end + +--- Check string length. +local function _check_string_length(value, spec, path, context) + if type(value) ~= 'string' then + return + end + -- Count characters rather than bytes, so a multi-byte glyph counts as one. + local length = utf8 and utf8.len(value) or nil + if length == nil then + length = #value + end + if type(spec.minLength) == 'number' and length < spec.minLength then + _report(context, 'error', path, 'minLength', string.format( + 'must be at least %d characters, got %d.', spec.minLength, length + )) + end + if type(spec.maxLength) == 'number' and length > spec.maxLength then + _report(context, 'error', path, 'maxLength', string.format( + 'must be at most %d characters, got %d.', spec.maxLength, length + )) + end +end + +--- Check array length and uniqueness. +local function _check_array(value, spec, path, context) + if type(value) ~= 'table' then + return + end + local length = #value + if type(spec.minItems) == 'number' and length < spec.minItems then + _report(context, 'error', path, 'minItems', string.format( + 'must have at least %d items, got %d.', spec.minItems, length + )) + end + if type(spec.maxItems) == 'number' and length > spec.maxItems then + _report(context, 'error', path, 'maxItems', string.format( + 'must have at most %d items, got %d.', spec.maxItems, length + )) + end + if spec.uniqueItems == true then + local seen = {} + for index = 1, length do + local element = value[index] + local rendered = _format_value(element) + -- The key carries the type so 1 and "1" are different items. A string + -- keys on its own text rather than on the rendering, because + -- `_format_value` renders an empty string as `""`, which would make it + -- collide with the two-character string `""`. The message carries only + -- the rendering, because the key is not the author's text. + local raw = type(element) == 'string' and element or rendered + local key = type(element) .. '\0' .. raw + if seen[key] then + _report(context, 'error', path, 'uniqueItems', string.format( + 'must not repeat items, but %s appears more than once.', rendered + )) + break + end + seen[key] = true + end + end +end + +--- Validate array elements against `items`. +local function _check_items(value, spec, path, context) + if type(spec.items) ~= 'table' or type(value) ~= 'table' then + return + end + for index = 1, #value do + local element = _coerce(value[index], spec.items.type) + value[index] = element + if element ~= nil then + _validate_value(element, spec.items, string.format('%s[%d]', path, index), context) + end + end +end + +--- Validate object members against `properties` and its companions. +local function _check_object(value, spec, path, context) + if type(value) ~= 'table' then + return + end + + if spec._property_names ~= nil or spec._property_names_error ~= nil then + if spec._property_names == nil then + _report(context, 'error', path, 'propertyNames', string.format( + 'schema declares a propertyNames pattern this validator cannot compile (%s): %s.', + spec._property_names_error or 'unsupported construct', spec.propertyNames + )) + else + for key in pairs(value) do + if type(key) == 'string' and not _pattern_matches(key, spec._property_names) then + _report(context, 'error', path .. '.' .. key, 'propertyNames', string.format( + 'key does not match required pattern: %s.', spec.propertyNames + )) + end + end + end + end + + local defaulted = {} + + if type(spec.properties) == 'table' then + local sub, filled = _validate_map(value, spec.properties, path, context, { + unknown = spec.additionalProperties == false and 'error' or 'ignore', + additional = type(spec.additionalProperties) == 'table' and spec.additionalProperties or nil, + }) + + -- `_validate_map` builds a new table, so its defaults, coercion and alias + -- resolution have to be written back into the value the parent holds. + -- Without this a nested `properties` contributes nothing to `merged`. + local stale = {} + for key in pairs(value) do + if sub[key] == nil then + stale[#stale + 1] = key + end + end + for _, key in ipairs(stale) do + value[key] = nil + end + for key, member in pairs(sub) do + value[key] = member + end + defaulted = filled + elseif type(spec.additionalProperties) == 'table' then + for key, member in pairs(value) do + local coerced = _coerce(member, spec.additionalProperties.type) + value[key] = coerced + if coerced ~= nil then + _validate_value(coerced, spec.additionalProperties, path .. '.' .. tostring(key), context) + end + end + end + + -- Runs after the properties block, which writes the resolved members back + -- into `value`. Before that, a dependent supplied under an alias is not yet + -- there to be found. A `default` is an annotation and does not change the + -- instance, so a key that only holds one neither triggers a requirement nor + -- satisfies it. + if type(spec.dependentRequired) == 'table' then + for key, dependents in pairs(spec.dependentRequired) do + local trigger, trigger_key = _lookup(value, key) + if trigger ~= nil and not defaulted[trigger_key] and type(dependents) == 'table' then + for _, dependent in ipairs(dependents) do + local supplied, dependent_key = _lookup(value, dependent) + if supplied == nil or defaulted[dependent_key] then + _report(context, 'error', path, 'dependentRequired', string.format( + 'requires "%s" when "%s" is present.', dependent, key + )) + end + end + end + end + end +end + +--- Run every keyword check against one value. +--- A failed type check short-circuits, so one mistake yields one message. +--- @param value any Value to validate +--- @param spec table Compiled descriptor +--- @param path string Dotted path of the value +--- @param context table Validation context +--- @return nil +_validate_value = function(value, spec, path, context) + local names = _type_names(spec.type) + + if names then + for _, name in ipairs(names) do + if name == 'content' then + return + end + end + end + + if not _matches_type_spec(value, spec.type) then + _report(context, 'error', path, 'type', string.format( + 'must be of type "%s", got "%s".', _format_type(spec.type), _actual_type(value) + )) + return + end + + if spec.const ~= nil and value ~= spec.const then + _report(context, 'error', path, 'const', string.format( + 'must be %s, got %s.', _format_value(spec.const), _format_value(value) + )) + end + + _check_enum(value, spec, path, context) + _check_pattern(value, spec, path, context) + _check_numeric(value, spec, path, context) + _check_string_length(value, spec, path, context) + _check_array(value, spec, path, context) + _check_items(value, spec, path, context) + _check_object(value, spec, path, context) +end + +-- ============================================================================ +-- MAP VALIDATION +-- ============================================================================ + +--- Build the deprecation warning for a descriptor, and forward its value. +--- @param field string Field name +--- @param spec table Compiled descriptor +--- @param value any Current value +--- @param merged table Working values +--- @return string message Warning text +--- @return boolean cleared True when the deprecated key was removed +local function _apply_deprecation(field, spec, value, merged) + local deprecated = spec.deprecated + + if type(deprecated) == 'string' then + return string.format('option "%s" is deprecated. %s', field, deprecated), false + end + + if type(deprecated) ~= 'table' then + return string.format('option "%s" is deprecated.', field), false + end + + local message + if deprecated.since then + message = string.format('option "%s" is deprecated since %s.', field, deprecated.since) + else + message = string.format('option "%s" is deprecated.', field) + end + if deprecated.message then + message = message .. ' ' .. deprecated.message + end + + -- A v1 schema writes `replace-with`, which this vocabulary does not accept. + -- Say so, rather than dropping the forwarding without a word. + local unrecognised = {} + for key in pairs(deprecated) do + if key ~= 'since' and key ~= 'message' and key ~= 'replaceWith' then + unrecognised[#unrecognised + 1] = key + end + end + if #unrecognised > 0 then + table.sort(unrecognised) + message = message .. string.format( + ' The deprecation declares %s, which this vocabulary does not accept; use "replaceWith".', + table.concat(unrecognised, ', ') + ) + end + + local cleared = false + if deprecated.replaceWith then + local replacement = deprecated.replaceWith + message = message .. string.format(' Use "%s" instead.', replacement) + if _lookup(merged, replacement) == nil then + merged[replacement] = value + end + merged[field] = nil + cleared = true + end + + return message, cleared +end + +--- Record that a descriptor accepts a spelling, and report a contested one. +--- +--- Two descriptors can name the same spelling, when one declares as an alias +--- what another declares as its own name, or when two of them declare the same +--- alias. `pairs` yields descriptors in no particular order, so which field a +--- contested spelling resolves to is not decidable from the schema. It is +--- reported rather than resolved in silence, because the schema is what is +--- wrong and only its author can settle it. +--- +--- @param sources table Map of spelling to declared name +--- @param spelling string The spelling being declared +--- @param field string The declared name that accepts it +--- @param base_path string|nil Path prefix for findings +--- @param context table Validation context +local function _declare_spelling(sources, spelling, field, base_path, context) + -- The owner is read through `_lookup`, like every other name comparison in + -- this module, so a contest between the hyphen and the underscore spelling + -- of one name is found as well. Those sit under different keys but reach the + -- same document key at merge time, so they contest just as directly. + local owner = _lookup(sources, spelling) + if owner ~= nil and owner ~= field then + _report(context, 'warning', base_path and (base_path .. '.' .. spelling) or spelling, + 'aliases', string.format( + 'is declared by both "%s" and "%s"; which one accepts it is not defined.', + owner, field)) + end + -- Stored under the literal spelling either way. The map is also the set of + -- keys a descriptor claimed, and a contested spelling is still claimed: the + -- schema is ambiguous, which the warning says, and treating the key as + -- undeclared on top of that would report it as unknown as well. + sources[spelling] = sources[spelling] or field +end + + +--- Validate a map of values against a map of field descriptors. +--- @param values table Values to validate +--- @param descriptors table Field descriptor map +--- @param base_path string|nil Path prefix for findings +--- @param context table Validation context +--- @param options table|nil {unknown = 'warn'|'error'|'ignore', additional = descriptor} +--- @return table merged Values with aliases, coercion and defaults applied +--- @return table defaulted Set of field names whose value came from `default` +--- @return table sources Map of every spelling the schema accepts to the +--- declared name it resolves to. A reader that has to answer a question +--- about a name the caller wrote asks this rather than walking the +--- descriptors again, because the merge has already decided the answer. +--- It doubles as the set of keys a descriptor claimed, which is what the +--- unknown key pass below reads. +_validate_map = function(values, descriptors, base_path, context, options) + options = options or {} + local unknown_policy = options.unknown or 'ignore' + + local merged = {} + for key, value in pairs(values) do + merged[key] = value + end + + local defaulted = {} + local sources = {} + local fields = {} + + -- Three passes, because `pairs` yields descriptors in no particular order. + -- Aliases must land before deprecation reads a value, and a `replaceWith` + -- target must be populated before its own descriptor is validated. + for field, raw_spec in pairs(descriptors) do + local spec = _compile(raw_spec) + fields[#fields + 1] = { + name = field, + spec = spec, + path = base_path and (base_path .. '.' .. field) or field, + } + -- Two writes with different weight. Declaring a spelling fills an empty + -- slot only, while claiming the value under one overwrites whatever was + -- there. The value lands under whichever descriptor claimed it, so the + -- claim is what the map has to follow: a declaration that outranked it + -- would send a reader to a field the merge has already emptied. + _declare_spelling(sources, field, field, base_path, context) + + -- A value found under an alias, or under the other spelling, moves to the + -- name the schema declares. The key it came from is removed, so `merged` + -- never carries the same value twice, once coerced and once raw. + -- The declared name is read first, so it wins over any alias. + local supplied_key + local found, found_key = _lookup(merged, field) + if found ~= nil then + merged[field] = found + sources[found_key] = field + supplied_key = found_key + if found_key ~= field then + merged[found_key] = nil + end + end + + if type(spec.aliases) == 'table' then + for _, alias in ipairs(spec.aliases) do + _declare_spelling(sources, alias, field, base_path, context) + local aliased, alias_key = _lookup(merged, alias) + if aliased ~= nil then + sources[alias_key] = field + if merged[field] == nil then + merged[field] = aliased + supplied_key = alias_key + elseif alias_key ~= field then + -- Two spellings were supplied. The first one read wins, and the + -- other is reported rather than left behind unvalidated. Both + -- names come from the document, never from the schema. + _report(context, 'warning', + base_path and (base_path .. '.' .. field) or field, + 'aliases', + string.format('was given as both "%s" and "%s"; "%s" was used.', + supplied_key, alias_key, supplied_key)) + end + if alias_key ~= field then + merged[alias_key] = nil + end + end + end + end + end + + for _, entry in ipairs(fields) do + local value = merged[entry.name] + if entry.spec.deprecated and value ~= nil then + local message, cleared = _apply_deprecation(entry.name, entry.spec, value, merged) + _report(context, 'warning', entry.path, 'deprecated', message) + entry.cleared = cleared + end + end + + for _, entry in ipairs(fields) do + local field, spec, path = entry.name, entry.spec, entry.path + local value + + if entry.cleared then + value = nil + else + value = _coerce(merged[field], spec.type) + merged[field] = value + + if value == nil and spec.default ~= nil then + value = spec.default + merged[field] = value + defaulted[field] = true + end + end + + -- An empty string is a value the author wrote. Only a missing key is + -- absent, so `minLength`, `const` and `enum` can be tested against ''. + if spec.required == true and value == nil then + _report(context, 'error', path, 'required', 'is required but was not provided.') + elseif value ~= nil then + _validate_value(value, spec, path, context) + end + end + + if unknown_policy ~= 'ignore' or options.additional then + for key, value in pairs(values) do + if sources[key] == nil then + local path = base_path and (base_path .. '.' .. tostring(key)) or tostring(key) + if options.additional then + local coerced = _coerce(value, options.additional.type) + merged[key] = coerced + if coerced ~= nil then + _validate_value(coerced, options.additional, path, context) + end + elseif unknown_policy == 'error' then + _report(context, 'error', path, 'additionalProperties', 'is not a recognised key.') + else + _report(context, 'warning', path, 'additionalProperties', 'is not a recognised key and was ignored.') + end + end + end + end + + return merged, defaulted, sources +end + +--- Start a validation run. +--- @return table Fresh validation context +local function _new_context() + return { findings = {} } +end + +--- Close a validation run and shape the public return values. +--- @param context table Validation context +--- @param merged table Merged values +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged +--- @return table findings +local function _finish(context, merged) + local errors, warnings = _split_findings(context.findings) + return #errors == 0, errors, warnings, merged, context.findings +end + +-- ============================================================================ +-- PUBLIC API +-- ============================================================================ + +--- Load and parse a schema file. +--- Sections absent from the file default to an empty table. +--- +--- A failure is returned, not raised. Quarto replaces `error` with a logger +--- that returns instead of unwinding, so raising here would let a caller +--- carry on with a nil schema and fail later somewhere unrelated. +--- +--- @param filename string|nil Path to the schema file (defaults to '_schema.yml') +--- @return table|nil schema Schema with {['$schema'], options, shortcodes, formats, projects, attributes, classes} +--- @return string|nil err Why the schema could not be read +--- @usage local schema, err = M.load_schema('_schema.yml') +function M.load_schema(filename) + local raw, err = _parse_yaml_file(filename or '_schema.yml') + if err then + return nil, err + end + + return { + ['$schema'] = raw['$schema'], + options = raw.options or {}, + shortcodes = raw.shortcodes or {}, + formats = raw.formats or {}, + projects = raw.projects or {}, + attributes = raw.attributes or {}, + classes = raw.classes or {}, + }, nil +end + +--- List the keys of a parsed mapping in the order the schema declared them. +--- A schema is authored in a meaningful order, and a generator that turns one +--- into documentation needs that order rather than an arbitrary one. +--- Falls back to sorted keys for a table this module did not parse. +--- @param map table A mapping from a loaded schema +--- @return table Array of keys +--- @usage for _, name in ipairs(schema.key_order(loaded.options)) do +function M.key_order(map) + if type(map) ~= 'table' then + return {} + end + + local order = _key_order[map] + if order ~= nil then + local copy = {} + for index, key in ipairs(order) do + copy[index] = key + end + return copy + end + + local keys = {} + for key in pairs(map) do + keys[#keys + 1] = key + end + table.sort(keys) + return keys +end + +--- Validate a map of values against a map of field descriptors. +--- @param values table Values to validate +--- @param descriptors table Field descriptor map +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid True when no error was reported +--- @return table errors Array of error strings +--- @return table warnings Array of warning strings +--- @return table merged Values with aliases, coercion and defaults applied +--- @return table findings Array of structured findings +--- @usage local valid, errors, warnings, merged = M.validate(options, schema.options) +function M.validate(values, descriptors, options) + options = options or {} + local context = _new_context() + local merged = _validate_map(values or {}, descriptors or {}, nil, context, { + unknown = options.unknown or 'warn', + }) + return _finish(context, merged) +end + +--- Validate positional shortcode arguments against an argument descriptor array. +--- Each descriptor should carry a `name`; the merged table is keyed by name. +--- @param args table Array of positional argument values +--- @param argument_specs table Array of argument descriptors +--- @param options table|nil {unknown = 'warn'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged Map keyed by argument name +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_arguments(args, specs) +function M.validate_arguments(args, argument_specs, options) + options = options or {} + args = args or {} + argument_specs = argument_specs or {} + + local context = _new_context() + local merged = {} + + for index, raw_spec in ipairs(argument_specs) do + local spec = _compile(raw_spec) + local name = spec.name or tostring(index) + local path = string.format('argument %d ("%s")', index, name) + + local value = _coerce(args[index], spec.type) + if value == nil and spec.default ~= nil then + value = spec.default + end + merged[name] = value + + if spec.required == true and value == nil then + _report(context, 'error', path, 'required', 'is required but was not provided.') + elseif value ~= nil then + _validate_value(value, spec, path, context) + end + end + + if (options.unknown or 'warn') ~= 'ignore' and #args > #argument_specs then + _report(context, 'warning', nil, 'arguments', string.format( + 'Received %d arguments but only %d are defined. The extra arguments were ignored.', + #args, #argument_specs + )) + end + + return _finish(context, merged) +end + +--- Validate one shortcode call against its schema entry. +--- Covers the positional `arguments`, the named `attributes`, and the +--- parent-level `required` array of attribute names. +--- @param name string Shortcode name, used as the path prefix +--- @param args table Array of positional argument values +--- @param kwargs table Map of named attribute values +--- @param entry table Shortcode entry from `schema.shortcodes[name]` +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged {arguments = ..., attributes = ...} +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_shortcode('iconify', args, kwargs, schema.shortcodes.iconify) +function M.validate_shortcode(name, args, kwargs, entry, options) + options = options or {} + entry = entry or {} + + local context = _new_context() + local merged = { arguments = {}, attributes = {} } + + if type(entry.arguments) == 'table' then + local _, errors, warnings, arguments = M.validate_arguments(args or {}, entry.arguments, options) + merged.arguments = arguments + for _, message in ipairs(errors) do + _report(context, 'error', nil, 'arguments', name .. ' ' .. message) + end + for _, message in ipairs(warnings) do + _report(context, 'warning', nil, 'arguments', name .. ' ' .. message) + end + end + + local declared = type(entry.attributes) == 'table' + local spellings + if declared then + local attributes, _, resolved = _validate_map(kwargs or {}, entry.attributes, name, context, { + unknown = options.unknown or 'warn', + }) + merged.attributes = attributes + spellings = resolved + end + + if type(entry.required) == 'table' then + -- `merged.attributes` is filled only when the entry declares `attributes`, + -- and it is authoritative when it is: it keeps every unclaimed key and it + -- drops a key that a deprecation cleared. The vocabulary allows `required` + -- on its own, so fall back to what the caller supplied only when the + -- entry declares no `attributes`. A name that names an alias is not + -- missing either: `_validate_map` moves its value to the field that + -- declares the alias and clears the alias spelling, the same way it + -- clears a deprecated key, so look for the value under that field. The + -- merge reports which declared name each spelling resolves to, so that + -- is one lookup rather than a second walk over the descriptors. It goes + -- through `_lookup`, the same as every other name comparison in this + -- module, so a hyphen in one spelling and an underscore in the other + -- still match. + -- + -- This asks what the shortcode receives, not what the author wrote, so + -- a `default` present in `merged.attributes` satisfies a name here. + -- That is the opposite of `dependentRequired` above, which asks what + -- the author wrote and treats a default as an annotation nobody + -- supplied. Neither reading is a bug: they answer different questions + -- about the same instance. + local function _required_satisfied(required) + local field = (spellings and _lookup(spellings, required)) or required + if _lookup(merged.attributes, field) ~= nil then + return true + end + if not declared then + return _lookup(kwargs or {}, required) ~= nil + end + return false + end + + for _, required in ipairs(entry.required) do + if not _required_satisfied(required) then + _report(context, 'error', name .. '.' .. required, 'required', + 'is required but was not provided.') + end + end + end + + return _finish(context, merged) +end + +--- Validate Pandoc element attributes against one group of the `attributes` +--- section. Undeclared attributes are ignored by default, because a Pandoc +--- element legitimately carries attributes from Quarto and other filters. +--- @param attributes table Map of attribute values +--- @param group string Group key, such as a class, identifier or element name +--- @param schema table Loaded schema +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_attributes(el.attributes, 'callout-note', schema) +function M.validate_attributes(attributes, group, schema, options) + options = options or {} + local descriptors = schema and schema.attributes and _lookup(schema.attributes, group) or nil + + local context = _new_context() + if descriptors == nil then + return _finish(context, attributes or {}) + end + + local merged = _validate_map(attributes or {}, descriptors, group, context, { + unknown = options.unknown or 'ignore', + }) + return _finish(context, merged) +end + +--- Validate the options of one output format against the `formats` section. +--- @param meta table Document metadata +--- @param format string Format name, such as 'html' or 'typst' +--- @param schema table Loaded schema +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_format(meta, 'typst', schema) +function M.validate_format(meta, format, schema, options) + options = options or {} + local descriptors = schema and schema.formats and _lookup(schema.formats, format) or nil + + local context = _new_context() + if descriptors == nil then + return _finish(context, {}) + end + + 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) + end + end + + local merged = _validate_map(values, descriptors, format, context, { + unknown = options.unknown or 'ignore', + }) + return _finish(context, merged) +end + +--- Extract an extension's options from document metadata. +--- Reads `meta.extensions[extension_name]` and converts Pandoc values to +--- native Lua values, keeping every key exactly as the document wrote it. +--- @param meta table Document metadata +--- @param extension_name string Extension name +--- @return table Map of option values +--- @usage local options = M.extract_meta_options(meta, 'iconify') +function M.extract_meta_options(meta, extension_name) + local extension_meta = meta and meta['extensions'] and meta['extensions'][extension_name] + if not extension_meta then + return {} + end + + local options = {} + for key, value in pairs(extension_meta) do + options[tostring(key)] = _convert_pandoc_value(value) + end + return options +end + +--- Load a schema, validate an extension's options, and log the outcome. +--- A schema that cannot be loaded is reported and the render continues with +--- no validated options, rather than aborting on a configuration file. +--- @param meta table Document metadata +--- @param extension_name string Extension name +--- @param schema_path string Path to the schema file, resolved by the caller +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return table merged Validated options with defaults applied +--- @usage local options = M.validate_options(meta, 'iconify', quarto.utils.resolve_path('_schema.yml')) +function M.validate_options(meta, extension_name, schema_path, options) + local schema, err = M.load_schema(schema_path) + if err then + _env.report_error(string.format('Extension "%s": %s', extension_name, err)) + return {} + end + + if not schema.options or next(schema.options) == nil then + return {} + end + + local values = M.extract_meta_options(meta, extension_name) + local valid, errors, warnings, merged = M.validate(values, schema.options, options) + + if #warnings > 0 then + _env.warn(M.format_warnings(warnings, extension_name)) + end + if not valid then + _env.report_error(M.format_errors(errors, extension_name)) + end + + return merged +end + +--- Format error messages as one readable block. +--- @param errors table Array of error strings +--- @param extension_name string|nil Extension name for context +--- @return string Formatted message, or an empty string +--- @usage local message = M.format_errors(errors, 'iconify') +function M.format_errors(errors, extension_name) + if #errors == 0 then + return '' + end + + local prefix = extension_name and ('Extension "' .. extension_name .. '": ') or '' + local lines = {} + for index, message in ipairs(errors) do + table.insert(lines, ' ' .. index .. '. ' .. message) + end + + return prefix .. 'Configuration validation failed:\n' .. table.concat(lines, '\n') +end + +--- Format warning messages as one readable block. +--- @param warnings table Array of warning strings +--- @param extension_name string|nil Extension name for context +--- @return string Formatted message, or an empty string +--- @usage local message = M.format_warnings(warnings, 'iconify') +function M.format_warnings(warnings, extension_name) + if #warnings == 0 then + return '' + end + + local prefix = extension_name and ('Extension "' .. extension_name .. '": ') or '' + local lines = {} + for index, message in ipairs(warnings) do + table.insert(lines, ' ' .. index .. '. ' .. message) + end + + return prefix .. 'Configuration warnings:\n' .. table.concat(lines, '\n') +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/gitlink/gitlink.lua b/docs/_extensions/mcanouil/gitlink/gitlink.lua index 5def634..f1f4087 100644 --- a/docs/_extensions/mcanouil/gitlink/gitlink.lua +++ b/docs/_extensions/mcanouil/gitlink/gitlink.lua @@ -7,16 +7,27 @@ local EXTENSION_NAME = 'gitlink' --- Load modules -local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua$', '')) -local log = require(quarto.utils.resolve_path('_modules/logging.lua'):gsub('%.lua$', '')) -local meta_mod = require(quarto.utils.resolve_path('_modules/metadata.lua'):gsub('%.lua$', '')) -local html_mod = require(quarto.utils.resolve_path('_modules/html.lua'):gsub('%.lua$', '')) -local paths = require(quarto.utils.resolve_path('_modules/paths.lua'):gsub('%.lua$', '')) -local git = require(quarto.utils.resolve_path('_modules/git.lua'):gsub('%.lua$', '')) +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) +local meta_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/metadata.lua'):gsub('%.lua$', '')) +local html_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/html.lua'):gsub('%.lua$', '')) +local paths = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/paths.lua'):gsub('%.lua$', '')) +local git = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/git.lua'):gsub('%.lua$', '')) local bitbucket = require(quarto.utils.resolve_path('_modules/bitbucket.lua'):gsub('%.lua$', '')) local platforms = require(quarto.utils.resolve_path('_modules/platforms.lua'):gsub('%.lua$', '')) -local colour = require(quarto.utils.resolve_path('_modules/colour.lua'):gsub('%.lua$', '')) +local colour = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/colour.lua'):gsub('%.lua$', '')) local widget = require(quarto.utils.resolve_path('_modules/widget.lua'):gsub('%.lua$', '')) +local schema = require(quarto.utils.resolve_path('_vendor/quarto-wizard/schema.lua'):gsub('%.lua$', '')) +local check = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/schema-check.lua'):gsub('%.lua$', '')) + +--- Checks the document configuration against `_schema.yml` and reports what the +--- extension cannot use. +--- The validator is injected rather than required by the checker, so the two +--- vendored sources stay independent of each other. +--- The schema is read once here, at file scope, rather than once per document. +--- An unreadable schema is reported and never stops a render, because a fault +--- in the configuration must not remove the document. +local checker = check.new(schema, EXTENSION_NAME) --- @type string The platform type (github, gitlab, codeberg, gitea, bitbucket) local platform = 'github' @@ -311,6 +322,10 @@ local function get_repository(meta) -- render in a batch does not bleed into this one. reset_state() + -- After the reset and before the first option read, so that every option this + -- pass goes on to read has already been reported on. + checker:options(meta) + -- Allow opt-out at the document level for drafts, templates, or any -- document where automatic link rewriting is undesirable. The navbar -- widget is gated independently so a site can run widget-only with diff --git a/docs/_extensions/mcanouil/iconify/_dependencies.yml b/docs/_extensions/mcanouil/iconify/_dependencies.yml new file mode 100644 index 0000000..5c49a89 --- /dev/null +++ b/docs/_extensions/mcanouil/iconify/_dependencies.yml @@ -0,0 +1,26 @@ +schema: 1 +sources: + quarto-lua-modules: + origin: "https://github.com/mcanouil/quarto-lua-modules" + fetch: "{origin}/releases/download/{version}/{file}" + version: "2.0.0" + licence: MIT + files: + logging.lua: + sha256: "a69a69ce7cf41b02914889074eb7445cff72003e6dd8b55b6f47d32d09848ccd" + runtime: any + metadata.lua: + sha256: "3f82a6208c337ae9a2e4a0a6f294f894cc506e05aedfb1c13167e55b52b8c457" + runtime: any + string.lua: + sha256: "32b2c16f50ecf7e752441ed80bd7fbcee11af83c4ed1e9242cc745d200bc73e0" + runtime: any + quarto-wizard: + origin: "https://github.com/mcanouil/quarto-wizard" + fetch: "https://raw.githubusercontent.com/mcanouil/quarto-wizard/{version}/packages/schema/src/validation/{file}" + version: "3.4.0" + licence: MIT + files: + schema.lua: + sha256: "30b830304ddeb410e507edf176ce4e056cdc242d51161f5f8a8d65a2e01ecd5a" + runtime: any diff --git a/docs/_extensions/mcanouil/iconify/_extension.yml b/docs/_extensions/mcanouil/iconify/_extension.yml index c3beeeb..8b36419 100644 --- a/docs/_extensions/mcanouil/iconify/_extension.yml +++ b/docs/_extensions/mcanouil/iconify/_extension.yml @@ -1,11 +1,11 @@ title: Iconify author: Mickaël Canouil -version: 4.1.2 +version: 4.2.0 quarto-required: ">=1.5.57" contributes: shortcodes: - iconify.lua filters: - iconify-filter.lua -source: mcanouil/quarto-iconify@4.1.2 +source: mcanouil/quarto-iconify@4.2.0 source-type: registry diff --git a/docs/_extensions/mcanouil/iconify/_modules/typst.lua b/docs/_extensions/mcanouil/iconify/_modules/typst.lua index 511ae36..6524d46 100644 --- a/docs/_extensions/mcanouil/iconify/_modules/typst.lua +++ b/docs/_extensions/mcanouil/iconify/_modules/typst.lua @@ -15,27 +15,13 @@ local M = {} ---- Load a sibling module from the same directory as this file. ---- @param filename string The sibling module filename (e.g., 'string.lua') ---- @return table The loaded module -local function load_sibling(filename) - local source = debug.getinfo(1, 'S').source:sub(2) - local dir = source:match('(.*[/\\])') or '' - return require((dir .. filename):gsub('%.lua$', '')) -end - -local str = load_sibling('string.lua') -local log = load_sibling('logging.lua') -local meta_mod = load_sibling('metadata.lua') +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) --- Extension name constant local EXTENSION_NAME = 'iconify' --- Base URL of the public Iconify SVG API. local API_BASE = 'https://api.iconify.design/' ---- Default cache directory, relative to the Quarto project root. -local DEFAULT_CACHE_REL = '.quarto/iconify-svg' ---- Default maximum age (days) before a cache entry is evicted. -local DEFAULT_MAX_AGE_DAYS = 30 --- Grace window (seconds) during which a recently-used entry is never evicted --- by the count cap, protecting icons an in-flight render is relying on. local GRACE_SECONDS = 300 @@ -62,19 +48,13 @@ local function project_base() return base end ---- Resolve the cache directory relative to the project root, honouring the ---- `extensions.iconify.typst-cache` metadata override. The result is kept ---- inside the project root: leading/trailing slashes are trimmed and any ---- `..` segments are dropped so the override cannot escape the project (and ---- so the Typst root-relative `#image` path stays valid). ---- @param meta table|nil Document metadata ---- @return string A normalised, project-root-relative directory -local function resolve_reldir(meta) - local rel = meta and meta_mod.get_metadata_value(meta, 'iconify', 'typst-cache') - if str.is_empty(rel) then - rel = DEFAULT_CACHE_REL - end - --- @cast rel string +--- Normalise a directory so it stays inside the project root: leading and +--- trailing slashes are trimmed, and any `..` or `.` segment is dropped, so +--- the value cannot escape the project and the Typst root-relative `#image` +--- path stays valid. +--- @param rel string A directory, relative to the project root +--- @return string|nil The normalised directory, or nil when nothing is left +local function normalise_reldir(rel) --- @type table local segments = {} for segment in rel:gmatch('[^/\\]+') do @@ -83,11 +63,47 @@ local function resolve_reldir(meta) end end if #segments == 0 then - return DEFAULT_CACHE_REL + return nil end return table.concat(segments, '/') end +--- Resolve the cache directory relative to the project root. +--- The value and its fallback both come from `_schema.yml`, resolved by the +--- caller, so no default is written here. +--- @param options table Cache options: `cache_dir` and `cache_fallback` +--- @return string|nil A normalised, project-root-relative directory +local function resolve_reldir(options) + options = options or {} + + --- @type string|nil + local resolved = nil + if not str.is_empty(options.cache_dir) then + resolved = normalise_reldir(options.cache_dir) + if resolved == nil then + log.log_warning( + EXTENSION_NAME, + 'The typst-cache directory "' .. options.cache_dir .. '" leaves nothing ' .. + 'inside the project root. Using the default instead.' + ) + end + end + + if resolved == nil and not str.is_empty(options.cache_fallback) then + resolved = normalise_reldir(options.cache_fallback) + end + + if resolved == nil then + log.log_error( + EXTENSION_NAME, + 'No Typst cache directory is available, so no icon can be cached. ' .. + 'This needs a readable _schema.yml declaring a typst-cache default.' + ) + end + + return resolved +end + --- URL-encode a query parameter value (RFC 3986 unreserved set kept verbatim). --- @param value string --- @return string @@ -256,10 +272,13 @@ end --- @param set string --- @param icon string --- @param query string Pre-built query string (may be empty) ---- @param meta table|nil Document metadata +--- @param options table Cache options resolved from `_schema.yml` --- @return string|nil Typst project-root-relative image path, or nil on failure -function M.ensure_cached(set, icon, query, meta) - local reldir = resolve_reldir(meta) +function M.ensure_cached(set, icon, query, options) + local reldir = resolve_reldir(options) + if reldir == nil then + return nil + end local dir = project_base() .. '/' .. reldir local name = entry_name(set, icon, query) local fspath = dir .. '/' .. name @@ -288,10 +307,10 @@ end --- Render an icon as a Typst `#image`, fetching/caching as needed. --- @param opts table Fields: set, icon, query, size_value, inline, alt, ---- fallback, meta. +--- fallback, options. --- @return any Pandoc RawInline (Typst), Str (fallback), or Null function M.render(opts) - local typst_path = M.ensure_cached(opts.set, opts.icon, opts.query, opts.meta) + local typst_path = M.ensure_cached(opts.set, opts.icon, opts.query, opts.options) if typst_path == nil then log.log_warning( EXTENSION_NAME, @@ -352,10 +371,15 @@ end --- unused for `max-age` days (which no live render references, since every use --- re-stamps), and the count cap never evicts entries used within a short --- grace window (so an in-flight render's freshly-cached icons are protected). ---- @param meta table Document metadata +--- @param options table Cache options resolved from `_schema.yml`: +--- `cache_dir`, `cache_fallback`, `max_age_days` and `max_entries`. --- @return nil -function M.cleanup(meta) - local dir = project_base() .. '/' .. resolve_reldir(meta) +function M.cleanup(options) + local reldir = resolve_reldir(options) + if reldir == nil then + return + end + local dir = project_base() .. '/' .. reldir --- @type table|nil local listing @@ -365,14 +389,13 @@ function M.cleanup(meta) return end + --- Both caps come from `_schema.yml`, which declares their defaults. A + --- document may still write something that is not a number, so the parse + --- can fail; treat that as no cap rather than as a reason to stop. --- @type number - local max_age_days = tonumber( - meta_mod.get_metadata_value(meta, 'iconify', 'typst-cache-max-age') - ) or DEFAULT_MAX_AGE_DAYS + local max_age_days = tonumber(options.max_age_days) or 0 --- @type number - local max_entries = tonumber( - meta_mod.get_metadata_value(meta, 'iconify', 'typst-cache-max-entries') - ) or 0 + local max_entries = tonumber(options.max_entries) or 0 --- @type integer local now = os.time() diff --git a/docs/_extensions/mcanouil/iconify/_schema.yml b/docs/_extensions/mcanouil/iconify/_schema.yml index fa4b49d..56e75a9 100644 --- a/docs/_extensions/mcanouil/iconify/_schema.yml +++ b/docs/_extensions/mcanouil/iconify/_schema.yml @@ -1,4 +1,14 @@ # Schema for the iconify extension +# +# This file is the single source of truth for every default the extension +# applies. The Lua reads the defaults from here at render time, and +# docs/_filters/schema-tables.lua builds the reference tables from the same +# descriptions, so a default is written once and read everywhere. +# +# `inline` is deliberately typed differently at the two levels. A document +# option is a real YAML boolean, while a shortcode attribute arrives as text, +# so the attribute is a string with a "true"/"false" enum. The Lua compares +# against the string form in both cases. $schema: https://m.canouil.dev/quarto-wizard/assets/schema/v2/extension-schema.json @@ -6,36 +16,39 @@ options: set: type: string default: "octicon" - description: "Default icon set to use when not specified in the shortcode (e.g. 'mdi', 'fa-solid')." + description: "The icon set used when the shortcode names none, such as `mdi` or `fa6-brands`." size: type: string - description: "Default icon size as a keyword or CSS value (e.g. 'large', '2x', '1.5em')." + description: "A keyword or CSS length. Sets `font-size`, and suppresses `width` and `height`." width: type: string - description: "Default icon width." + description: "Explicit width, used only when `size` is absent. HTML output only." height: type: string - description: "Default icon height." + description: "Explicit height, used only when `size` is absent. HTML output only." flip: type: string - description: "Default flip transformation for icons." + description: "Flip transformation applied to every icon." enum: - horizontal - vertical - horizontal,vertical rotate: type: string - description: "Default rotation for icons (e.g. '90deg', '180deg', '1')." + description: "Rotation applied to every icon: `90deg`, `180deg`, or a quarter-turn count such as `1`." + color: + type: string + description: "Icon colour for Typst output, such as `red` or `#74aadb`, applied to monochrome icons through the Iconify API. With no `color`, a `color:` declaration inside `style` is read instead." style: type: string - description: "Default additional inline CSS styles applied to all icons." + description: "Extra inline CSS, written before the `font-size` that `size` generates." inline: type: boolean default: true - description: "Whether to render icons inline by default." + description: "Whether icons sit on the text baseline." mode: type: string - description: "Default rendering mode for icons." + description: "Rendering mode. HTML output only." enum: - svg - style @@ -43,24 +56,24 @@ options: - mask fallback: type: string - description: "Default fallback text or emoji shown when an icon cannot be loaded (e.g. unknown name, offline, CDN unreachable)." + description: "Text or an emoji shown when an icon cannot be loaded, whether the name is wrong, the CDN is unreachable, or the reader is offline." preload: type: array - description: "List of local file paths containing Iconify icon-collection JSON to preload, enabling offline rendering of those icons; requires the iconify filter to be active (e.g. `filters: [iconify]`)." + description: "Paths to Iconify collection JSON files, for offline rendering. Requires the filter to be active, with `filters: [iconify]`." items: type: string typst-cache: type: string default: ".quarto/iconify-svg" - description: "Directory (relative to the project root) where SVGs retrieved for Typst output are cached. Point at a tracked directory to commit a reproducible offline cache." + description: "Where the SVGs are kept, relative to the project root. Point it at a tracked directory to commit a reproducible offline cache." typst-cache-max-age: type: number default: 30 - description: "Maximum age in days before an unused Typst cache entry is pruned. Set to 0 to disable age-based pruning." + description: "Days before an unused entry is pruned. `0` disables pruning by age." typst-cache-max-entries: type: number default: 0 - description: "Maximum number of Typst cache entries; the least-recently-used beyond this cap are pruned. 0 means unlimited." + description: "Cap on the number of entries, least recently used pruned first. `0` is unlimited." shortcodes: iconify: @@ -68,6 +81,8 @@ shortcodes: arguments: - name: set-or-icon type: string + required: true + examples: ["mdi:home"] description: "Icon set name, or the combined 'set:icon' format." - name: icon type: string @@ -75,35 +90,35 @@ shortcodes: attributes: size: type: string - description: "Icon size as a keyword or CSS value (e.g. 'large', '2x', '1.5em')." + description: "A keyword or CSS length. Sets `font-size`, and suppresses `width` and `height`." width: type: string - description: "Icon width." + description: "Explicit width, used only when `size` is absent." height: type: string - description: "Icon height." + description: "Explicit height, used only when `size` is absent." flip: type: string - description: "Flip transformation for the icon." + description: "`horizontal`, `vertical`, or `horizontal,vertical`." enum: - horizontal - vertical - horizontal,vertical rotate: type: string - description: "Rotation for the icon (e.g. '90deg', '180deg', '1')." + description: "`90deg`, `180deg`, or a quarter-turn count such as `1`." color: type: string - description: "Icon colour for Typst output (e.g. 'red', '#74aadb'); applied to monochrome icons via the Iconify API." + description: "Typst output only. Applied to monochrome icons through the Iconify API." inline: type: string - description: "Whether to render the icon inline." + description: "`true` or `false`. Any other value is treated as `true`." enum: - "true" - "false" mode: type: string - description: "Rendering mode for the icon." + description: "`svg`, `style`, `bg`, or `mask`. Any other value is dropped." enum: - svg - style @@ -111,53 +126,56 @@ shortcodes: - mask style: type: string - description: "Additional inline CSS styles for the icon." + description: "Extra inline CSS, written before the `font-size` that `size` generates." label: type: string - description: "Accessible label for the icon (aria-label)." + description: "The `aria-label`. Replaces the generated one." title: type: string - description: "Title attribute for the icon tooltip." + description: "The tooltip. Replaces the generated one." aria-hidden: type: string - description: "Marks the icon as decorative, omitting role, aria-label and title so assistive technology skips it. Use when visible text beside the icon already carries the meaning." + description: "`true` marks the icon decorative, dropping `role`, `aria-label`, and `title`." enum: - "true" - "false" fallback: type: string - description: "Text or emoji shown when the icon cannot be loaded (e.g. unknown name, offline, CDN unreachable)." + description: "Text or an emoji revealed when the icon fails to load." quarto: description: "Renders the Quarto icon with default blue styling from the simple-icons set." attributes: size: type: string - description: "Icon size as a keyword or CSS value." + description: "A keyword or CSS length. Sets `font-size`, and suppresses `width` and `height`." width: type: string - description: "Icon width." + description: "Explicit width, used only when `size` is absent." height: type: string - description: "Icon height." + description: "Explicit height, used only when `size` is absent." flip: type: string - description: "Flip transformation for the icon." + description: "`horizontal`, `vertical`, or `horizontal,vertical`." enum: - horizontal - vertical - horizontal,vertical rotate: type: string - description: "Rotation for the icon (e.g. '90deg', '180deg', '1')." + description: "`90deg`, `180deg`, or a quarter-turn count such as `1`." + color: + type: string + description: "Typst output only. The HTML output keeps the Quarto blue." inline: type: string - description: "Whether to render the icon inline." + description: "`true` or `false`. Any other value is treated as `true`." enum: - "true" - "false" mode: type: string - description: "Rendering mode for the icon." + description: "`svg`, `style`, `bg`, or `mask`. Any other value is dropped." enum: - svg - style @@ -165,19 +183,19 @@ shortcodes: - mask style: type: string - description: "Additional inline CSS styles for the icon." + description: "Extra inline CSS. Any `color:` in it is replaced by the Quarto blue." label: type: string - description: "Accessible label for the icon." + description: "Ignored. The `quarto` shortcode fixes the label to `Quarto icon`." title: type: string - description: "Title attribute for the icon tooltip." + description: "Ignored. The `quarto` shortcode fixes the tooltip to `Quarto icon`." aria-hidden: type: string - description: "Marks the icon as decorative, omitting role, aria-label and title so assistive technology skips it." + description: "`true` marks the icon decorative, dropping `role`, `aria-label`, and `title`." enum: - "true" - "false" fallback: type: string - description: "Text or emoji shown when the icon cannot be loaded." + description: "Text or an emoji revealed when the icon fails to load." diff --git a/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/LICENSE b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/LICENSE new file mode 100644 index 0000000..4b53bb8 --- /dev/null +++ b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mickaël Canouil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/_extensions/mcanouil/gitlink/_modules/logging.lua b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/logging.lua similarity index 99% rename from docs/_extensions/mcanouil/gitlink/_modules/logging.lua rename to docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/logging.lua index a538809..ebcc3a7 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/logging.lua +++ b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/logging.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.0.0 local M = {} diff --git a/docs/_extensions/mcanouil/iconify/_modules/metadata.lua b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/metadata.lua similarity index 99% rename from docs/_extensions/mcanouil/iconify/_modules/metadata.lua rename to docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/metadata.lua index 2e1b6d5..77ed7d4 100644 --- a/docs/_extensions/mcanouil/iconify/_modules/metadata.lua +++ b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/metadata.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.0.0 local M = {} diff --git a/docs/_extensions/mcanouil/iconify/_modules/string.lua b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/string.lua similarity index 52% rename from docs/_extensions/mcanouil/iconify/_modules/string.lua rename to docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/string.lua index cdd46b5..eb233fe 100644 --- a/docs/_extensions/mcanouil/iconify/_modules/string.lua +++ b/docs/_extensions/mcanouil/iconify/_vendor/quarto-lua-modules/string.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 2.0.0 local M = {} @@ -76,6 +76,158 @@ function M.to_string(val) return str ~= '' and str or nil end +--- Strip one layer of surrounding bracket or punctuation characters. +--- Handles balanced pairs: () [] {} "" '' `` «» +--- Handles trailing-only punctuation: , . ; : ! ? +--- @param text string The input text +--- @return string prefix Characters stripped from the start (may be empty) +--- @return string inner The inner text after stripping +--- @return string suffix Characters stripped from the end (may be empty) +function M.strip_surrounding(text) + if not text or #text < 2 then + return '', text or '', '' + end + + local balanced = { + ['('] = ')', ['['] = ']', ['{'] = '}', + ['"'] = '"', ["'"] = "'", ['`'] = '`', + } + -- UTF-8 guillemets + local first_two = text:sub(1, 2) + local last_two = text:sub(-2) + if first_two == '\xC2\xAB' and last_two == '\xC2\xBB' then + return first_two, text:sub(3, -3), last_two + end + + local first = text:sub(1, 1) + local last = text:sub(-1) + + if balanced[first] and last == balanced[first] then + return first, text:sub(2, -2), last + end + + local trailing = { + [','] = true, ['.'] = true, [';'] = true, + [':'] = true, ['!'] = true, ['?'] = true, + } + if trailing[last] then + return '', text:sub(1, -2), last + end + + return '', text, '' +end + +--- Peel unbalanced surrounding brackets and trailing punctuation from a token. +--- Unlike `strip_surrounding`, this does not require a balanced pair: it removes +--- any run of leading opening-bracket characters and any run of trailing +--- closing-bracket or punctuation characters. This handles bracket groups that +--- Pandoc split across whitespace, e.g. "(#2," and "#3)" from "(#2, #3)". +--- Leading set: ( [ { " ' ` and the 2-byte UTF-8 « (\xC2\xAB). +--- Trailing set: ) ] } " ' ` , . ; : ! ? and the 2-byte UTF-8 » (\xC2\xBB). +--- @param text string The input text +--- @return string prefix Characters peeled from the start (may be empty) +--- @return string inner The inner text after peeling +--- @return string suffix Characters peeled from the end (may be empty) +function M.strip_edges(text) + if not text or text == '' then + return '', text or '', '' + end + + local leading = { + ['('] = true, ['['] = true, ['{'] = true, + ['"'] = true, ["'"] = true, ['`'] = true, + } + local trailing = { + [')'] = true, [']'] = true, ['}'] = true, + ['"'] = true, ["'"] = true, ['`'] = true, + [','] = true, ['.'] = true, [';'] = true, + [':'] = true, ['!'] = true, ['?'] = true, + } + + local first = 1 + local last = #text + local prefix = '' + local suffix = '' + + while first <= last do + if text:sub(first, first + 1) == '\xC2\xAB' then + prefix = prefix .. '\xC2\xAB' + first = first + 2 + elseif leading[text:sub(first, first)] then + prefix = prefix .. text:sub(first, first) + first = first + 1 + else + break + end + end + + while last >= first do + -- The two-byte window can only match a real «/» pair: the leading loop + -- never leaves \xC2 at first - 1 (openers are ASCII or the \xAB of a peeled + -- «), so the guillemet check cannot straddle the already-peeled prefix. + if last >= 2 and text:sub(last - 1, last) == '\xC2\xBB' then + suffix = '\xC2\xBB' .. suffix + last = last - 2 + elseif trailing[text:sub(last, last)] then + suffix = text:sub(last, last) .. suffix + last = last - 1 + else + break + end + end + + return prefix, text:sub(first, last), suffix +end + +--- Find a balanced bracket pair anywhere in the text and split around it. +--- Walks the text from `start_pos` looking for an opening bracket whose matching +--- closing bracket appears later in the string. Returns the text split into +--- a prefix (up to and including the opening bracket), the inner content, and +--- a suffix (closing bracket and everything after). +--- Supports the same bracket pairs as `strip_surrounding`: +--- () [] {} "" '' `` and the 2-byte UTF-8 guillemets «». +--- @param text string The input text +--- @param start_pos integer|nil Byte position to start searching from (default 1) +--- @return string|nil prefix Text up to and including the opening bracket +--- @return string|nil content Non-empty text between the brackets +--- @return string|nil suffix Closing bracket and trailing text +--- @return integer|nil open_pos Byte position of the opening bracket +function M.find_bracketed_content(text, start_pos) + if not text or #text < 2 then + return nil, nil, nil, nil + end + start_pos = start_pos or 1 + + local balanced = { + ['('] = ')', ['['] = ']', ['{'] = '}', + ['"'] = '"', ["'"] = "'", ['`'] = '`', + } + + local i = start_pos + while i <= #text do + -- UTF-8 guillemet «…» + if text:sub(i, i + 1) == '\xC2\xAB' then + local close_pos = text:find('\xC2\xBB', i + 2, true) + if close_pos and close_pos > i + 2 then + return text:sub(1, i + 1), text:sub(i + 2, close_pos - 1), text:sub(close_pos), i + end + i = i + 2 + else + local c = text:sub(i, i) + local close_char = balanced[c] + if close_char then + local close_pos = text:find(close_char, i + 1, true) + if close_pos and close_pos > i + 1 then + return text:sub(1, i), text:sub(i + 1, close_pos - 1), text:sub(close_pos), i + end + end + i = i + 1 + end + end + + return nil, nil, nil, nil +end + -- ============================================================================ -- ESCAPE UTILITIES -- ============================================================================ @@ -106,10 +258,37 @@ function M.escape_typst(text) end --- Escape characters for Typst string literals (inside `"..."`). +--- Handles backslash, double quote, newline, carriage return, and tab. --- @param text string The text to escape --- @return string The escaped text safe for Typst string literals function M.escape_typst_string(text) - return (text:gsub('\\', '\\\\'):gsub('"', '\\"')) + return (text + :gsub('\\', '\\\\') + :gsub('"', '\\"') + :gsub('\n', '\\n') + :gsub('\r', '\\r') + :gsub('\t', '\\t')) +end + +--- Escape characters for JavaScript string literals (inside `"..."` or `'...'`). +--- Handles backslash, both quote styles, newlines, carriage returns, tabs, +--- form feeds, and the `` block. +--- @param text string|nil The text to escape +--- @return string The escaped text safe for JavaScript string literals +--- @usage local safe = M.escape_js_string([[a "b" ]]) +function M.escape_js_string(text) + if text == nil then return '' end + if type(text) ~= 'string' then text = tostring(text) end + return (text + :gsub('\\', '\\\\') + :gsub('"', '\\"') + :gsub("'", "\\'") + :gsub('\n', '\\n') + :gsub('\r', '\\r') + :gsub('\t', '\\t') + :gsub('\f', '\\f') + :gsub('. +--- The v1 dual-key vocabulary (`min`, `max`, `enum-case-insensitive`, +--- `element-attributes`, `pattern-exact`) is not accepted. +--- +--- The module is standalone: it requires no sibling module, and it reaches +--- `pandoc` and `quarto` only through the `M._env` table, so it can be +--- exercised outside a Quarto render with stubs in place. + +local M = {} + +--- Meta-schema this module implements. +M.SCHEMA_VERSION = 'https://m.canouil.dev/quarto-wizard/assets/schema/v2/extension-schema.json' + +-- ============================================================================ +-- ENVIRONMENT SEAM +-- ============================================================================ + +--- Indirection over the two host globals, resolved at call time so a test +--- harness can install stubs before the first call. +local _env = {} + +--- Render a Pandoc value as plain text. +--- @param value any Pandoc value +--- @return string Plain text +function _env.stringify(value) + return pandoc.utils.stringify(value) +end + +--- Report the Pandoc type of a value. +--- @param value any Pandoc value +--- @return string Pandoc type name +function _env.pandoc_type(value) + return pandoc.utils.type(value) +end + +--- Emit a warning through the host logger. +--- @param message string Message to emit +--- @return nil +function _env.warn(message) + if quarto and quarto.log and quarto.log.warning then + quarto.log.warning(message) + else + io.stderr:write(message .. '\n') + end +end + +--- Emit an error through the host logger. +--- @param message string Message to emit +--- @return nil +function _env.report_error(message) + if quarto and quarto.log and quarto.log.error then + quarto.log.error(message) + else + io.stderr:write(message .. '\n') + end +end + +M._env = _env + +-- ============================================================================ +-- CONSTANTS +-- ============================================================================ + +--- Maximum nesting depth accepted when converting a parsed schema. +local MAX_DEPTH = 16 + +--- Maximum number of Lua patterns one regex may expand into. +local MAX_PATTERN_BRANCHES = 64 + +--- Descriptor keywords holding a numeric bound. +local NUMERIC_KEYWORDS = { + 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', + 'minLength', 'maxLength', 'minItems', 'maxItems', +} + +--- Every keyword the v2 field descriptor may carry, mapped to whether this +--- module enforces it. Annotations are carried through untouched for tooling. +M.KEYWORDS = { + type = true, + required = true, + default = true, + const = true, + enum = true, + enumCaseInsensitive = true, + pattern = true, + minimum = true, + maximum = true, + exclusiveMinimum = true, + exclusiveMaximum = true, + multipleOf = true, + minLength = true, + maxLength = true, + minItems = true, + maxItems = true, + uniqueItems = true, + items = true, + properties = true, + additionalProperties = true, + propertyNames = true, + dependentRequired = true, + aliases = true, + deprecated = true, + name = true, + description = false, + title = false, + examples = false, + format = false, + completion = false, + contentEncoding = false, + contentMediaType = false, +} + +-- ============================================================================ +-- PRIVATE HELPERS +-- ============================================================================ + +--- Report whether a table is a dense array. +--- @param value any Value to inspect +--- @return boolean True when the table has only keys 1..n +local function _is_array(value) + if type(value) ~= 'table' then + return false + end + local count = 0 + for _ in pairs(value) do + count = count + 1 + end + return count == #value +end + +--- Read a key from a map, accepting the hyphen and underscore spellings. +--- Keys are never rewritten, so a schema keeps the names its author wrote. +--- @param map table|nil Map to read +--- @param key string|nil Key to read +--- @return any value The stored value, or nil +--- @return string|nil found_key The spelling that matched +local function _lookup(map, key) + if type(map) ~= 'table' or type(key) ~= 'string' then + return nil, nil + end + if map[key] ~= nil then + return map[key], key + end + local underscored = (key:gsub('%-', '_')) + if underscored ~= key and map[underscored] ~= nil then + return map[underscored], underscored + end + local hyphenated = (key:gsub('_', '-')) + if hyphenated ~= key and map[hyphenated] ~= nil then + return map[hyphenated], hyphenated + end + return nil, nil +end + +--- Render any value for an error message, including mixed-type arrays. +--- Replaces `table.concat`, which throws on a boolean entry. +--- @param value any Value to render +--- @return string Readable representation +local function _format_value(value) + local kind = type(value) + if kind == 'string' then + if value == '' then + return '""' + end + return value + elseif kind == 'number' or kind == 'boolean' then + return tostring(value) + elseif kind == 'nil' then + return 'null' + elseif kind == 'table' then + if _is_array(value) then + local parts = {} + for index = 1, #value do + parts[index] = _format_value(value[index]) + end + return '[' .. table.concat(parts, ', ') .. ']' + end + return '{object}' + end + return tostring(value) +end + +--- Render a list of values as a comma-separated string. +--- @param values table Array of values +--- @return string Readable list +local function _format_list(values) + local parts = {} + for index = 1, #values do + parts[index] = _format_value(values[index]) + end + return table.concat(parts, ', ') +end + +--- Normalise a type spec to an array of type names. +--- @param type_spec string|table|nil Declared type +--- @return table|nil Array of type names, or nil when no type is declared +local function _type_names(type_spec) + if type_spec == nil then + return nil + end + if type(type_spec) == 'table' then + return type_spec + end + return { type_spec } +end + +--- Render a type spec for an error message. +--- @param type_spec string|table Declared type +--- @return string Readable type +local function _format_type(type_spec) + if type(type_spec) == 'table' then + return table.concat(type_spec, ' | ') + end + return tostring(type_spec) +end + +--- Report the type name of a value, distinguishing arrays from objects. +--- @param value any Value to inspect +--- @return string Type name +local function _actual_type(value) + local kind = type(value) + if kind == 'table' then + return _is_array(value) and 'array' or 'object' + end + if kind == 'nil' then + return 'null' + end + return kind +end + +--- Report whether a value satisfies a single type name. +--- @param value any Value to check +--- @param name string Type name +--- @return boolean True when the value matches +local function _matches_type(value, name) + if name == 'content' then + return value ~= nil + end + if name == 'null' then + return value == nil + end + if name == 'integer' then + return type(value) == 'number' + and value == value + and value ~= math.huge + and value ~= -math.huge + and value == math.floor(value) + end + if name == 'number' then + return type(value) == 'number' + end + if name == 'boolean' then + return type(value) == 'boolean' + end + if name == 'string' then + return type(value) == 'string' + end + if name == 'array' then + return type(value) == 'table' and _is_array(value) + end + if name == 'object' then + -- An array is not an object. An empty table is both, and Lua cannot tell + -- the two apart, so it satisfies either. + return type(value) == 'table' and (next(value) == nil or not _is_array(value)) + end + return false +end + +--- Report whether a value satisfies any declared type. +--- @param value any Value to check +--- @param type_spec string|table|nil Declared type +--- @return boolean True when the value matches, or no type is declared +local function _matches_type_spec(value, type_spec) + local names = _type_names(type_spec) + if names == nil then + return true + end + for _, name in ipairs(names) do + if _matches_type(value, name) then + return true + end + end + return false +end + +--- Coerce a string toward a single scalar type. +--- Pandoc renders every YAML number as a string, so the incoming value and +--- the schema's own literals both need this before any comparison. +--- @param value any Value to coerce +--- @param name string Target type name +--- @return any coerced Coerced value, or nil +--- @return boolean ok True when the coercion applied +local function _coerce_scalar(value, name) + if type(value) ~= 'string' then + return nil, false + end + if name == 'number' then + local number = tonumber(value) + if number then + return number, true + end + elseif name == 'integer' then + local number = tonumber(value) + if number and number == math.floor(number) then + return number, true + end + elseif name == 'boolean' then + local lowered = value:lower() + if lowered == 'true' or lowered == 'yes' then + return true, true + end + if lowered == 'false' or lowered == 'no' then + return false, true + end + end + return nil, false +end + +--- Coerce a value toward its declared type. +--- A value already matching one declared type is returned untouched, so +--- `type: [boolean, string]` keeps the string "fenced" a string. +--- @param value any Value to coerce +--- @param type_spec string|table|nil Declared type +--- @return any Coerced value +local function _coerce(value, type_spec) + local names = _type_names(type_spec) + if names == nil or value == nil then + return value + end + for _, name in ipairs(names) do + if _matches_type(value, name) then + return value + end + end + for _, name in ipairs(names) do + local coerced, ok = _coerce_scalar(value, name) + if ok then + return coerced + end + end + return value +end + +-- ============================================================================ +-- PATTERN COMPILATION +-- ============================================================================ + +--- Lua replacements for JS shorthand classes, outside a character class. +local ESCAPE_OUTSIDE = { + d = '%d', D = '%D', w = '[%w_]', W = '[^%w_]', s = '%s', S = '%S', +} + +--- Lua replacements for JS shorthand classes, inside a character class. +--- `\W` has no in-class form, because the underscore cannot be excluded there. +local ESCAPE_INSIDE = { + d = '%d', D = '%D', w = '%w_', s = '%s', S = '%S', +} + +--- Escapes with a real Lua equivalent. Anything else alphanumeric is refused, +--- because compiling it to the bare letter would accept the wrong values. +local CONTROL_ESCAPES = { + n = '\n', + t = '\t', + r = '\r', + f = '\f', + v = '\v', +} + +--- Characters Lua treats as magic outside a character class. +local LUA_MAGIC = '^$()%.[]*+-?' + +--- Escape one literal character for use in a Lua pattern. +--- @param char string Single character +--- @return string Escaped character +local function _escape_literal(char) + if LUA_MAGIC:find(char, 1, true) then + return '%' .. char + end + return char +end + +--- Compile a JS regular expression into a list of equivalent Lua patterns. +--- Alternation is expanded into one pattern per branch, which is how Lua, +--- lacking alternation, can honour the commonest JSON Schema idiom. +--- Anything that cannot be expressed is reported rather than accepted. +--- @param regex string JS regular expression +--- @return table|nil branches Array of Lua patterns, or nil on failure +--- @return string|nil reason Why compilation failed +local function _compile_pattern(regex) + local position = 1 + local length = #regex + local anchor_start = false + local anchor_end = false + local has_top_level_alternation = false + local parse_alternation + + --- Combine a set of prefixes with a set of continuations. + local function cross(prefixes, continuations) + local out = {} + for _, prefix in ipairs(prefixes) do + for _, continuation in ipairs(continuations) do + if #out >= MAX_PATTERN_BRANCHES then + return nil, 'alternation expands past ' .. MAX_PATTERN_BRANCHES .. ' branches' + end + out[#out + 1] = prefix .. continuation + end + end + return out + end + + --- Parse a bracketed character class starting at the current position. + local function parse_class() + local out = { '[' } + position = position + 1 + if regex:sub(position, position) == '^' then + out[#out + 1] = '^' + position = position + 1 + end + if regex:sub(position, position) == ']' then + out[#out + 1] = '%]' + position = position + 1 + end + while position <= length do + local char = regex:sub(position, position) + if char == ']' then + position = position + 1 + out[#out + 1] = ']' + return table.concat(out) + elseif char == '\\' then + local next_char = regex:sub(position + 1, position + 1) + if next_char == '' then + return nil, 'trailing backslash' + end + if next_char == 'W' then + return nil, 'unsupported "\\W" inside a character class' + end + local mapped = ESCAPE_INSIDE[next_char] + if mapped then + out[#out + 1] = mapped + elseif next_char:match('[1-9]') then + return nil, 'unsupported backreference "\\' .. next_char .. '"' + elseif next_char == '%' then + out[#out + 1] = '%%' + elseif CONTROL_ESCAPES[next_char] then + out[#out + 1] = CONTROL_ESCAPES[next_char] + elseif next_char:match('%w') then + return nil, 'unsupported escape "\\' .. next_char .. '"' + else + out[#out + 1] = '%' .. next_char + end + position = position + 2 + elseif char == '%' then + out[#out + 1] = '%%' + position = position + 1 + else + out[#out + 1] = char + position = position + 1 + end + end + return nil, 'unterminated character class' + end + + --- Read a trailing quantifier, rejecting the forms Lua cannot express. + local function parse_quantifier() + local char = regex:sub(position, position) + if char ~= '*' and char ~= '+' and char ~= '?' then + return '' + end + if regex:sub(position + 1, position + 1) == '?' then + return nil, 'unsupported non-greedy quantifier "' .. char .. '?"' + end + position = position + 1 + return char + end + + --- Parse a single atom and return the branches it contributes. + local function parse_atom(depth, at_sequence_start) + local char = regex:sub(position, position) + + if char == '(' then + if regex:sub(position + 1, position + 1) == '?' then + local marker = regex:sub(position + 2, position + 2) + if marker == '=' or marker == '!' or marker == '<' then + return nil, 'unsupported lookahead or lookbehind' + end + if marker ~= ':' then + return nil, 'unsupported group modifier "(?' .. marker .. '"' + end + position = position + 3 + else + position = position + 1 + end + local branches, reason = parse_alternation(depth + 1) + if not branches then + return nil, reason + end + if regex:sub(position, position) ~= ')' then + return nil, 'unterminated group' + end + position = position + 1 + local quantifier = regex:sub(position, position) + if quantifier == '*' or quantifier == '+' or quantifier == '?' then + return nil, 'unsupported quantifier applied to a group' + end + return branches + end + + if char == '[' then + local class, reason = parse_class() + if not class then + return nil, reason + end + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { class .. quantifier } + end + + if char == '\\' then + local next_char = regex:sub(position + 1, position + 1) + if next_char == '' then + return nil, 'trailing backslash' + end + if next_char:match('[1-9]') then + return nil, 'unsupported backreference "\\' .. next_char .. '"' + end + position = position + 2 + local mapped = ESCAPE_OUTSIDE[next_char] + local atom + if mapped then + atom = mapped + elseif CONTROL_ESCAPES[next_char] then + atom = CONTROL_ESCAPES[next_char] + elseif next_char:match('%w') then + return nil, 'unsupported escape "\\' .. next_char .. '"' + else + atom = _escape_literal(next_char) + end + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { atom .. quantifier } + end + + if char == '^' then + if depth ~= 0 or not at_sequence_start or position ~= 1 then + return nil, 'unsupported anchor "^" away from the start of the pattern' + end + position = position + 1 + anchor_start = true + return { '' } + end + + if char == '$' then + if depth ~= 0 or position ~= length then + return nil, 'unsupported anchor "$" away from the end of the pattern' + end + position = position + 1 + anchor_end = true + return { '' } + end + + if char == '{' then + return nil, 'unsupported counted quantifier "{n,m}"' + end + + if char == '*' or char == '+' or char == '?' then + return nil, 'quantifier "' .. char .. '" with nothing to repeat' + end + + if char == '.' then + position = position + 1 + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { '.' .. quantifier } + end + + position = position + 1 + local atom = _escape_literal(char) + local quantifier, quantifier_reason = parse_quantifier() + if not quantifier then + return nil, quantifier_reason + end + return { atom .. quantifier } + end + + --- Parse one alternative: a run of atoms up to `|`, `)` or the end. + local function parse_sequence(depth) + local branches = { '' } + local first = true + while position <= length do + local char = regex:sub(position, position) + if char == '|' or char == ')' then + break + end + local atom_branches, reason = parse_atom(depth, first) + if not atom_branches then + return nil, reason + end + first = false + local crossed, cross_reason = cross(branches, atom_branches) + if not crossed then + return nil, cross_reason + end + branches = crossed + end + return branches + end + + parse_alternation = function(depth) + local all = {} + local iterations = 0 + while true do + iterations = iterations + 1 + local branches, reason = parse_sequence(depth) + if not branches then + return nil, reason + end + for _, branch in ipairs(branches) do + if #all >= MAX_PATTERN_BRANCHES then + return nil, 'alternation expands past ' .. MAX_PATTERN_BRANCHES .. ' branches' + end + all[#all + 1] = branch + end + if regex:sub(position, position) == '|' then + position = position + 1 + else + break + end + end + if depth == 0 and iterations > 1 then + has_top_level_alternation = true + end + return all + end + + local branches, reason = parse_alternation(0) + if not branches then + return nil, reason + end + if position <= length then + return nil, 'unbalanced ")" in pattern' + end + + -- The anchors are collected for the expression as a whole, so applying them + -- to multiple top-level branches would anchor branches the author did not anchor. + if has_top_level_alternation and (anchor_start or anchor_end) then + return nil, 'unsupported anchor in a top-level alternation' + end + + local prefix = anchor_start and '^' or '' + local suffix = anchor_end and '$' or '' + local out = {} + for index, branch in ipairs(branches) do + out[index] = prefix .. branch .. suffix + end + return out +end + +M._compile_pattern = _compile_pattern + +--- Report whether a string satisfies any compiled branch. +--- @param value string Value to test +--- @param branches table Array of Lua patterns +--- @return boolean True when a branch matches +local function _pattern_matches(value, branches) + for _, branch in ipairs(branches) do + local ok, matched = pcall(string.match, value, branch) + if ok and matched ~= nil then + return true + end + end + return false +end + +-- ============================================================================ +-- PANDOC VALUE CONVERSION +-- ============================================================================ + +--- Convert a Pandoc metadata value to a native Lua value. +--- Pandoc's Lua objects carry no `t` field, so the kind has to come from +--- `pandoc.utils.type`; testing `value.t` silently turns every string option +--- into a list of its inline elements. +--- @param value any Pandoc metadata value +--- @return any Native Lua value +local function _convert_pandoc_value(value) + local kind = _env.pandoc_type(value) + + -- A bare key, `null` and `~` all arrive as an empty Pandoc `string`, which + -- is how Pandoc collapses YAML null. An explicit `""` arrives as an empty + -- `Inlines` instead, so the two are told apart before either is unwrapped. + if kind == 'string' and value == '' then + return nil + end + + if type(value) ~= 'table' then + return value + end + + if kind == 'Inlines' or kind == 'Blocks' or kind == 'Inline' or kind == 'Block' then + return _env.stringify(value) + end + + if kind == 'List' then + -- A null element is absent, the same rule this module applies to a null + -- key. Writing it into a running count rather than the source index + -- keeps the result a proper sequence instead of leaving a gap. + local result = {} + for index = 1, #value do + local converted = _convert_pandoc_value(value[index]) + if converted ~= nil then + result[#result + 1] = converted + end + end + return result + end + + if _is_array(value) and #value > 0 then + -- A null element is absent, the same rule this module applies to a null + -- key. Writing it into a running count rather than the source index + -- keeps the result a proper sequence instead of leaving a gap. + local result = {} + for index = 1, #value do + local converted = _convert_pandoc_value(value[index]) + if converted ~= nil then + result[#result + 1] = converted + end + end + return result + end + + local result = {} + for key, item in pairs(value) do + result[tostring(key)] = _convert_pandoc_value(item) + end + return result +end + +-- ============================================================================ +-- YAML PARSING +-- ============================================================================ + +--- Pandoc parses YAML string scalars as Markdown, which rewrites a schema in +--- place: `^[a-z]+$` arrives as `+$`, and `^\d+\.?\d*m?s$` arrives as +--- `^.??s$`. The schema file is therefore parsed here instead. +--- +--- The subset covers what an extension schema needs: block maps and +--- sequences, plain and quoted scalars, folded and literal block scalars, +--- flow sequences and flow maps (including ones spanning several lines), and +--- `#` comments. Anchors, aliases, tags, multiple documents and complex keys +--- are not supported and are reported rather than misread. +--- +--- Every function here reports a failure by returning a message rather than +--- by calling `error`. Quarto replaces `error` with a logger that returns +--- instead of unwinding, so a module that trusts `error` to stop execution +--- carries on with invalid state and fails later somewhere unrelated. +--- +--- One deliberate deviation from YAML: a block scalar is always stripped of +--- its trailing newline unless it is chomped with `+`. Schema text is +--- annotation, and a trailing newline there is noise rather than meaning. + +--- Build a parse error message carrying the offending line number. +--- @param line_number number Line number +--- @param message string Explanation +--- @return string Formatted message +local function _yaml_error(line_number, message) + return string.format('line %d: %s', line_number, message) +end + +--- Remove a trailing `#` comment, respecting quoted spans. +--- @param text string Line content +--- @return string Content without its comment +local function _strip_comment(text) + local out = {} + local quote = nil + local index = 1 + while index <= #text do + local char = text:sub(index, index) + if quote then + out[#out + 1] = char + if char == '\\' and quote == '"' then + index = index + 1 + out[#out + 1] = text:sub(index, index) + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + out[#out + 1] = char + elseif char == '#' and (index == 1 or text:sub(index - 1, index - 1):match('%s')) then + break + else + out[#out + 1] = char + end + index = index + 1 + end + return (table.concat(out):gsub('%s+$', '')) +end + +--- Trim leading and trailing whitespace. +--- @param text string Text to trim +--- @return string Trimmed text +local function _trim(text) + return (text:gsub('^%s+', ''):gsub('%s+$', '')) +end + +--- Parse a scalar, giving numbers and booleans their real Lua types. +--- @param text string Scalar text +--- @param line_number number Line number for errors +--- @return any value Parsed value, nil for an explicit null +--- @return string|nil err +local function _parse_scalar(text, line_number) + text = _trim(text) + + if text == '' or text == '~' or text == 'null' or text == 'Null' or text == 'NULL' then + return nil, nil + end + + local first = text:sub(1, 1) + + if first == '"' then + if #text < 2 or text:sub(-1) ~= '"' then + return nil, _yaml_error(line_number, 'unterminated double-quoted scalar') + end + return (text:sub(2, -2):gsub('\\(.)', function(escaped) + if escaped == 'n' then return '\n' end + if escaped == 't' then return '\t' end + if escaped == 'r' then return '\r' end + return escaped + end)), nil + end + + if first == "'" then + if #text < 2 or text:sub(-1) ~= "'" then + return nil, _yaml_error(line_number, 'unterminated single-quoted scalar') + end + return (text:sub(2, -2):gsub("''", "'")), nil + end + + if first == '&' or first == '*' or first == '!' then + return nil, _yaml_error(line_number, 'anchors, aliases and tags are not supported') + end + + if text == 'true' or text == 'True' or text == 'TRUE' or text == 'yes' or text == 'Yes' then + return true, nil + end + if text == 'false' or text == 'False' or text == 'FALSE' or text == 'no' or text == 'No' then + return false, nil + end + + local number = tonumber(text) + if number ~= nil and text:match('^[%-+]?[%d%.eE%+%-]+$') then + return number, nil + end + + return text, nil +end + +--- Split a flow collection body on its top-level commas. +--- @param body string Text between the brackets +--- @return table Array of raw item strings +local function _split_flow(body) + local items = {} + local depth = 0 + local quote = nil + local start = 1 + local index = 1 + while index <= #body do + local char = body:sub(index, index) + if quote then + if char == '\\' and quote == '"' then + index = index + 1 + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == '[' or char == '{' then + depth = depth + 1 + elseif char == ']' or char == '}' then + depth = depth - 1 + elseif char == ',' and depth == 0 then + items[#items + 1] = body:sub(start, index - 1) + start = index + 1 + end + index = index + 1 + end + local tail = body:sub(start) + if tail:match('%S') then + items[#items + 1] = tail + end + return items +end + +--- Strip the quotes from a mapping key. +--- @param key string Raw key text +--- @param line_number number Line number for errors +--- @return string|nil key +--- @return string|nil err +local function _unquote_key(key, line_number) + key = _trim(key) + local first = key:sub(1, 1) + if first == '"' or first == "'" then + local parsed, err = _parse_scalar(key, line_number) + if err then + return nil, err + end + return tostring(parsed), nil + end + return key, nil +end + +local _parse_flow + +--- Parse a value written in flow style, or a plain scalar. +--- @param text string Value text +--- @param line_number number Line number for errors +--- @param depth number|nil Current nesting depth +--- @return any value +--- @return string|nil err +_parse_flow = function(text, line_number, depth) + depth = depth or 0 + if depth > MAX_DEPTH then + return nil, _yaml_error(line_number, 'value nests deeper than ' .. MAX_DEPTH .. ' levels') + end + + text = _trim(text) + local first = text:sub(1, 1) + + if first == '[' then + if text:sub(-1) ~= ']' then + return nil, _yaml_error(line_number, 'unterminated flow sequence') + end + local out = {} + for index, item in ipairs(_split_flow(text:sub(2, -2))) do + local value, err = _parse_flow(item, line_number, depth + 1) + if err then + return nil, err + end + out[index] = value + end + return out, nil + end + + if first == '{' then + if text:sub(-1) ~= '}' then + return nil, _yaml_error(line_number, 'unterminated flow mapping') + end + local out = {} + for _, item in ipairs(_split_flow(text:sub(2, -2))) do + local key, rest = item:match('^%s*(.-)%s*:%s*(.*)$') + if not key or key == '' then + return nil, _yaml_error(line_number, 'expected "key: value" inside a flow mapping') + end + local name, key_err = _unquote_key(key, line_number) + if key_err then + return nil, key_err + end + local value, err = _parse_flow(rest, line_number, depth + 1) + if err then + return nil, err + end + out[name] = value + end + return out, nil + end + + return _parse_scalar(text, line_number) +end + +--- Split a mapping line into its key and the rest of the line. +--- The separator is the first unquoted colon followed by a space or the end +--- of the line, so a URL value such as `https://...` is not split. +--- @param text string Line content +--- @return string|nil key +--- @return string|nil rest +local function _split_key(text) + local quote = nil + local index = 1 + while index <= #text do + local char = text:sub(index, index) + if quote then + if char == '\\' and quote == '"' then + index = index + 1 + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == ':' then + local following = text:sub(index + 1, index + 1) + if following == '' or following == ' ' then + return text:sub(1, index - 1), _trim(text:sub(index + 2)) + end + end + index = index + 1 + end + return nil, nil +end + +--- Break the source into classified lines. +--- @param source string YAML text +--- @return table|nil lines Array of {raw, indent, number, blank, comment} +--- @return string|nil err +local function _scan_lines(source) + local lines = {} + local number = 0 + for raw in (source .. '\n'):gmatch('([^\n]*)\n') do + number = number + 1 + if raw:find('\t') then + return nil, _yaml_error(number, 'tabs are not valid YAML indentation') + end + local indent = #(raw:match('^ *') or '') + local body = raw:sub(indent + 1) + lines[#lines + 1] = { + raw = raw, + indent = indent, + number = number, + blank = body == '', + comment = body:sub(1, 1) == '#', + } + end + return lines, nil +end + +--- Advance past blank and comment lines. +--- @param lines table Scanned lines +--- @param index number Starting index +--- @return number Index of the next significant line +local function _skip_insignificant(lines, index) + while index <= #lines and (lines[index].blank or lines[index].comment) do + index = index + 1 + end + return index +end + +--- Read the comment-stripped content of a line. +--- @param line table Scanned line +--- @return string Content +local function _content(line) + return _strip_comment(line.raw:sub(line.indent + 1)) +end + +--- Track bracket depth across one line, ignoring quoted spans. +--- @param text string Line content +--- @param depth number Depth carried in +--- @param quote string|nil Open quote carried in +--- @return number depth +--- @return string|nil quote +local function _scan_flow_depth(text, depth, quote) + local index = 1 + while index <= #text do + local char = text:sub(index, index) + if quote then + if char == '\\' and quote == '"' then + index = index + 1 + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == '[' or char == '{' then + depth = depth + 1 + elseif char == ']' or char == '}' then + depth = depth - 1 + end + index = index + 1 + end + return depth, quote +end + +--- Collect a flow collection that may run across several lines. +--- @param lines table Scanned lines +--- @param start number Index of the line holding the opening bracket +--- @param initial string Flow text already read from that line +--- @param line_number number Line number for errors +--- @return string|nil text The whole flow collection on one line +--- @return number next_index Index of the first line after it +--- @return string|nil err +local function _gather_flow(lines, start, initial, line_number) + local pieces = { initial } + local depth, quote = _scan_flow_depth(initial, 0, nil) + local index = start + + while depth > 0 do + index = index + 1 + if index > #lines then + return nil, index, _yaml_error(line_number, 'unterminated flow collection') + end + local content = _content(lines[index]) + pieces[#pieces + 1] = content + depth, quote = _scan_flow_depth(content, depth, quote) + end + + return table.concat(pieces, ' '), index + 1, nil +end + +--- Read a folded or literal block scalar. +--- @param lines table Scanned lines +--- @param start number First candidate line +--- @param parent_indent number Indentation of the owning key +--- @param style string Block style, such as '>', '>-' or '|' +--- @return string text +--- @return number next_index +local function _read_block_scalar(lines, start, parent_indent, style) + local kind = style:sub(1, 1) + local chomp = style:sub(2, 2) + + local collected = {} + local block_indent = nil + local index = start + + while index <= #lines do + local line = lines[index] + if line.blank then + collected[#collected + 1] = '' + index = index + 1 + elseif line.indent > parent_indent then + block_indent = block_indent or line.indent + collected[#collected + 1] = line.raw:sub(block_indent + 1) + index = index + 1 + else + break + end + end + + while #collected > 0 and collected[#collected] == '' do + table.remove(collected) + end + + local text + if kind == '>' then + local parts = {} + local buffer = {} + for _, line in ipairs(collected) do + if line == '' then + if #buffer > 0 then + parts[#parts + 1] = table.concat(buffer, ' ') + buffer = {} + end + parts[#parts + 1] = '\n' + else + buffer[#buffer + 1] = (line:gsub('%s+$', '')) + end + end + if #buffer > 0 then + parts[#parts + 1] = table.concat(buffer, ' ') + end + text = table.concat(parts) + else + text = table.concat(collected, '\n') + end + + if chomp == '+' then + text = text .. '\n' + end + + return text, index +end + +local _parse_block +local _parse_sequence + +--- The order in which each parsed mapping declared its keys. +--- Lua tables have no key order, but a schema is authored in a meaningful one, +--- and a generator that turns a schema into documentation needs it. The order +--- is kept beside the data rather than inside it, so it cannot be mistaken for +--- a key of the mapping. Weak keys let a discarded mapping take its order with +--- it. +local _key_order = setmetatable({}, { __mode = 'k' }) + +--- Parse a block mapping at a known indentation. +--- @return table|nil map +--- @return number next_index +--- @return string|nil err +local function _parse_map(lines, start, indent, depth) + local map = {} + local order = {} + local index = start + + while index <= #lines do + local line = lines[index] + if line.blank or line.comment then + index = index + 1 + elseif line.indent < indent then + break + else + if line.indent > indent then + return nil, index, _yaml_error(line.number, 'unexpected indentation inside a mapping') + end + + local content = _content(line) + if content == '' then + index = index + 1 + else + if content == '---' or content == '...' then + return nil, index, + _yaml_error(line.number, 'multiple YAML documents are not supported') + end + + if content:sub(1, 1) == '-' then + return nil, index, + _yaml_error(line.number, 'a sequence item cannot appear inside a mapping here') + end + + local raw_key, rest = _split_key(content) + if raw_key == nil or _trim(raw_key) == '' then + return nil, index, _yaml_error(line.number, 'expected "key: value"') + end + + local key, key_err = _unquote_key(raw_key, line.number) + if key_err then + return nil, index, key_err + end + if map[key] == nil then + order[#order + 1] = key + end + index = index + 1 + + -- An explicit indentation indicator (`|2`, `>2-`) is reported rather + -- than misread: without this the whole block is stored as the string + -- "|2" and its body is then parsed as further mapping lines. + if rest:match('^[|>]%d') or rest:match('^[|>][+%-]%d') then + return nil, index, _yaml_error( + line.number, + 'a block scalar indentation indicator is not supported: ' .. rest + ) + end + + local block_style = rest:match('^([|>][+%-]?)$') + if block_style then + local text, next_index = _read_block_scalar(lines, index, indent, block_style) + map[key] = text + index = next_index + elseif rest == '' then + local value, next_index, err = _parse_block(lines, index, indent, depth + 1) + if err then + return nil, next_index, err + end + + -- A block sequence may sit at the column of its own key, which + -- `_parse_block` declines because its guard wants a deeper line. + -- Nothing was consumed in that case, so read the sequence here. + if value == nil and next_index == index then + local peek = _skip_insignificant(lines, index) + if peek <= #lines and lines[peek].indent == indent then + local ahead = _content(lines[peek]) + if ahead == '-' or ahead:sub(1, 2) == '- ' then + value, next_index, err = _parse_sequence(lines, peek, indent, depth + 1) + if err then + return nil, next_index, err + end + end + end + end + + map[key] = value + index = next_index + elseif rest:sub(1, 1) == '[' or rest:sub(1, 1) == '{' then + local text, next_index, err = _gather_flow(lines, index - 1, rest, line.number) + if err then + return nil, next_index, err + end + local value, value_err = _parse_flow(text, line.number, depth) + if value_err then + return nil, next_index, value_err + end + map[key] = value + index = next_index + else + local value, err = _parse_flow(rest, line.number, depth) + if err then + return nil, index, err + end + map[key] = value + end + end + end + end + + _key_order[map] = order + return map, index, nil +end + +--- Parse a block sequence at a known indentation. +--- @return table|nil sequence +--- @return number next_index +--- @return string|nil err +_parse_sequence = function(lines, start, indent, depth) + local sequence = {} + local index = start + + while index <= #lines do + local line = lines[index] + if line.blank or line.comment then + index = index + 1 + elseif line.indent < indent then + break + else + if line.indent > indent then + return nil, index, _yaml_error(line.number, 'unexpected indentation inside a sequence') + end + + local content = _content(line) + local rest = content:match('^%-%s*(.*)$') + if rest == nil then + break + end + + if rest == '' then + local value, next_index, err = _parse_block(lines, index + 1, indent, depth + 1) + if err then + return nil, next_index, err + end + -- A Lua array cannot hold an interior nil, so an empty item would be + -- dropped and every later item would shift down one index, moving the + -- element that `minItems` and an `items` path such as `preload[2]` + -- refer to. Report it instead. + if value == nil then + return nil, index, _yaml_error(line.number, 'an empty sequence item is not supported') + end + sequence[#sequence + 1] = value + index = next_index + elseif _split_key(rest) == nil then + -- A plain or flow scalar item, such as `- password` or `- [a, b]`. + if rest:sub(1, 1) == '[' or rest:sub(1, 1) == '{' then + local text, next_index, err = _gather_flow(lines, index, rest, line.number) + if err then + return nil, next_index, err + end + local value, value_err = _parse_flow(text, line.number, depth) + if value_err then + return nil, next_index, value_err + end + sequence[#sequence + 1] = value + index = next_index + else + local value, err = _parse_flow(rest, line.number, depth) + if err then + return nil, index, err + end + sequence[#sequence + 1] = value + index = index + 1 + end + else + -- The remainder of the dash line is the first line of the item, and it + -- sits at the column where that remainder begins. + local offset = #content - #rest + local synthetic = { + raw = string.rep(' ', indent + offset) .. rest, + indent = indent + offset, + number = line.number, + blank = false, + comment = false, + } + local saved = lines[index] + lines[index] = synthetic + local value, next_index, err = _parse_block(lines, index, indent, depth + 1) + lines[index] = saved + if err then + return nil, next_index, err + end + sequence[#sequence + 1] = value + index = next_index + end + end + end + + return sequence, index, nil +end + +--- Parse whichever block collection begins at `start`. +--- @param lines table Scanned lines +--- @param start number Index to start from +--- @param parent_indent number|nil Indentation that the block must exceed +--- @param depth number Current nesting depth +--- @return any value +--- @return number next_index +--- @return string|nil err +_parse_block = function(lines, start, parent_indent, depth) + depth = depth or 0 + local index = _skip_insignificant(lines, start) + if index > #lines then + return nil, index, nil + end + + local line = lines[index] + if parent_indent ~= nil and line.indent <= parent_indent then + return nil, start, nil + end + + if depth > MAX_DEPTH then + return nil, index, _yaml_error(line.number, 'schema nests deeper than ' .. MAX_DEPTH .. ' levels') + end + + local indent = line.indent + local content = _content(line) + + if content:sub(1, 1) == '[' or content:sub(1, 1) == '{' then + local text, next_index, err = _gather_flow(lines, index, content, line.number) + if err then + return nil, next_index, err + end + local value, value_err = _parse_flow(text, line.number, depth) + if value_err then + return nil, next_index, value_err + end + return value, next_index, nil + end + + if content == '-' or content:sub(1, 2) == '- ' then + return _parse_sequence(lines, index, indent, depth) + end + return _parse_map(lines, index, indent, depth) +end + +--- Parse YAML text into a native Lua table. +--- @param source string YAML text +--- @return table|nil tree +--- @return string|nil err +local function _parse_yaml_text(source) + local lines, scan_err = _scan_lines(source) + if scan_err then + return nil, scan_err + end + + -- A single leading `---` opens one document, which is valid and common. + -- A later one opens a second document, which `_parse_map` reports. Testing + -- the raw text instead would also reject a `---` inside a block scalar. + local first = _skip_insignificant(lines, 1) + if first <= #lines and _content(lines[first]) == '---' then + lines[first].blank = true + end + + local value, _, err = _parse_block(lines, 1, nil, 0) + if err then + return nil, err + end + if value == nil then + return {}, nil + end + if type(value) ~= 'table' or (_is_array(value) and #value > 0) then + return nil, 'the file must contain a mapping at its top level' + end + return value, nil +end + +M._parse_yaml_text = _parse_yaml_text + +--- Read and parse a YAML schema file. +--- @param filename string Path to the schema file +--- @return table|nil tree +--- @return string|nil err +local function _parse_yaml_file(filename) + local handle = io.open(filename, 'r') + if not handle then + return nil, string.format('Could not open schema file: %s', filename) + end + + local content = handle:read('*a') + handle:close() + + local tree, err = _parse_yaml_text(content) + if err then + return nil, string.format('Could not parse schema file %s: %s', filename, err) + end + return tree, nil +end + + +-- ============================================================================ +-- DESCRIPTOR COMPILATION +-- ============================================================================ + +local _compiled_cache = setmetatable({}, { __mode = 'k' }) + +local _compile + +--- Compile a field descriptor: coerce its literals against its own declared +--- type, compile its patterns, and recurse into nested descriptors. +--- Returns a new table, so the caller's schema is never mutated. +--- @param spec table Raw field descriptor +--- @return table Compiled descriptor +_compile = function(spec) + if type(spec) ~= 'table' then + return spec + end + + local cached = _compiled_cache[spec] + if cached then + return cached + end + + local out = {} + for key, value in pairs(spec) do + out[key] = value + end + + local type_spec = out.type + + if out.default ~= nil then + out.default = _coerce(out.default, type_spec) + end + if out.const ~= nil then + out.const = _coerce(out.const, type_spec) + end + if type(out.enum) == 'table' then + local values = {} + for index = 1, #out.enum do + values[index] = _coerce(out.enum[index], type_spec) + end + out.enum = values + end + + for _, keyword in ipairs(NUMERIC_KEYWORDS) do + if out[keyword] ~= nil then + out[keyword] = tonumber(out[keyword]) or out[keyword] + end + end + + if type(out.required) == 'string' then + out.required = out.required:lower() == 'true' or out.required:lower() == 'yes' + end + if type(out.uniqueItems) == 'string' then + out.uniqueItems = out.uniqueItems:lower() == 'true' + end + if type(out.enumCaseInsensitive) == 'string' then + out.enumCaseInsensitive = out.enumCaseInsensitive:lower() == 'true' + end + if out.additionalProperties == 'false' then + out.additionalProperties = false + elseif out.additionalProperties == 'true' then + out.additionalProperties = true + end + + if type(out.pattern) == 'string' then + out._pattern, out._pattern_error = _compile_pattern(out.pattern) + end + if type(out.propertyNames) == 'string' then + out._property_names, out._property_names_error = _compile_pattern(out.propertyNames) + end + + if type(out.items) == 'table' then + out.items = _compile(out.items) + end + if type(out.properties) == 'table' then + local properties = {} + for key, value in pairs(out.properties) do + properties[key] = _compile(value) + end + out.properties = properties + end + if type(out.additionalProperties) == 'table' then + out.additionalProperties = _compile(out.additionalProperties) + end + + _compiled_cache[spec] = out + return out +end + +-- ============================================================================ +-- FINDINGS +-- ============================================================================ + +--- Record a finding against a path. +--- @param context table Validation context +--- @param severity string 'error' or 'warning' +--- @param path string Dotted path of the offending value +--- @param keyword string Keyword that produced the finding +--- @param message string Human-readable explanation +--- @return nil +local function _report(context, severity, path, keyword, message) + table.insert(context.findings, { + path = path, + keyword = keyword, + message = message, + severity = severity, + }) +end + +--- Split findings into the string arrays the public API returns. +--- @param findings table Array of findings +--- @return table errors Array of error strings +--- @return table warnings Array of warning strings +local function _split_findings(findings) + local errors = {} + local warnings = {} + for _, finding in ipairs(findings) do + local line = finding.path and (finding.path .. ': ' .. finding.message) or finding.message + if finding.severity == 'warning' then + table.insert(warnings, line) + else + table.insert(errors, line) + end + end + return errors, warnings +end + +-- ============================================================================ +-- KEYWORD CHECKS +-- ============================================================================ + +local _validate_value +local _validate_map + +--- Check `enum`, honouring `enumCaseInsensitive`. +local function _check_enum(value, spec, path, context) + if type(spec.enum) ~= 'table' then + return + end + local case_insensitive = spec.enumCaseInsensitive == true + for _, allowed in ipairs(spec.enum) do + if value == allowed then + return + end + if case_insensitive and type(value) == 'string' and type(allowed) == 'string' + and value:lower() == allowed:lower() then + return + end + end + _report(context, 'error', path, 'enum', string.format( + 'must be one of: %s, got %s.', _format_list(spec.enum), _format_value(value) + )) +end + +--- Check `pattern`, reporting a pattern the module cannot compile. +local function _check_pattern(value, spec, path, context) + if spec.pattern == nil or type(value) ~= 'string' then + return + end + if spec._pattern == nil then + _report(context, 'error', path, 'pattern', string.format( + 'schema declares a pattern this validator cannot compile (%s): %s.', + spec._pattern_error or 'unsupported construct', spec.pattern + )) + return + end + if not _pattern_matches(value, spec._pattern) then + _report(context, 'error', path, 'pattern', string.format( + 'does not match required pattern: %s.', spec.pattern + )) + end +end + +--- Check the numeric bounds. +local function _check_numeric(value, spec, path, context) + if type(value) ~= 'number' then + return + end + if type(spec.minimum) == 'number' and value < spec.minimum then + _report(context, 'error', path, 'minimum', string.format( + 'must be at least %s, got %s.', _format_value(spec.minimum), _format_value(value) + )) + end + if type(spec.maximum) == 'number' and value > spec.maximum then + _report(context, 'error', path, 'maximum', string.format( + 'must be at most %s, got %s.', _format_value(spec.maximum), _format_value(value) + )) + end + if type(spec.exclusiveMinimum) == 'number' and value <= spec.exclusiveMinimum then + _report(context, 'error', path, 'exclusiveMinimum', string.format( + 'must be greater than %s, got %s.', _format_value(spec.exclusiveMinimum), _format_value(value) + )) + end + if type(spec.exclusiveMaximum) == 'number' and value >= spec.exclusiveMaximum then + _report(context, 'error', path, 'exclusiveMaximum', string.format( + 'must be less than %s, got %s.', _format_value(spec.exclusiveMaximum), _format_value(value) + )) + end + if type(spec.multipleOf) == 'number' and spec.multipleOf ~= 0 then + local quotient = value / spec.multipleOf + if quotient ~= math.floor(quotient) then + _report(context, 'error', path, 'multipleOf', string.format( + 'must be a multiple of %s, got %s.', _format_value(spec.multipleOf), _format_value(value) + )) + end + end +end + +--- Check string length. +local function _check_string_length(value, spec, path, context) + if type(value) ~= 'string' then + return + end + -- Count characters rather than bytes, so a multi-byte glyph counts as one. + local length = utf8 and utf8.len(value) or nil + if length == nil then + length = #value + end + if type(spec.minLength) == 'number' and length < spec.minLength then + _report(context, 'error', path, 'minLength', string.format( + 'must be at least %d characters, got %d.', spec.minLength, length + )) + end + if type(spec.maxLength) == 'number' and length > spec.maxLength then + _report(context, 'error', path, 'maxLength', string.format( + 'must be at most %d characters, got %d.', spec.maxLength, length + )) + end +end + +--- Check array length and uniqueness. +local function _check_array(value, spec, path, context) + if type(value) ~= 'table' then + return + end + local length = #value + if type(spec.minItems) == 'number' and length < spec.minItems then + _report(context, 'error', path, 'minItems', string.format( + 'must have at least %d items, got %d.', spec.minItems, length + )) + end + if type(spec.maxItems) == 'number' and length > spec.maxItems then + _report(context, 'error', path, 'maxItems', string.format( + 'must have at most %d items, got %d.', spec.maxItems, length + )) + end + if spec.uniqueItems == true then + local seen = {} + for index = 1, length do + local element = value[index] + local rendered = _format_value(element) + -- The key carries the type so 1 and "1" are different items. A string + -- keys on its own text rather than on the rendering, because + -- `_format_value` renders an empty string as `""`, which would make it + -- collide with the two-character string `""`. The message carries only + -- the rendering, because the key is not the author's text. + local raw = type(element) == 'string' and element or rendered + local key = type(element) .. '\0' .. raw + if seen[key] then + _report(context, 'error', path, 'uniqueItems', string.format( + 'must not repeat items, but %s appears more than once.', rendered + )) + break + end + seen[key] = true + end + end +end + +--- Validate array elements against `items`. +local function _check_items(value, spec, path, context) + if type(spec.items) ~= 'table' or type(value) ~= 'table' then + return + end + for index = 1, #value do + local element = _coerce(value[index], spec.items.type) + value[index] = element + if element ~= nil then + _validate_value(element, spec.items, string.format('%s[%d]', path, index), context) + end + end +end + +--- Validate object members against `properties` and its companions. +local function _check_object(value, spec, path, context) + if type(value) ~= 'table' then + return + end + + if spec._property_names ~= nil or spec._property_names_error ~= nil then + if spec._property_names == nil then + _report(context, 'error', path, 'propertyNames', string.format( + 'schema declares a propertyNames pattern this validator cannot compile (%s): %s.', + spec._property_names_error or 'unsupported construct', spec.propertyNames + )) + else + for key in pairs(value) do + if type(key) == 'string' and not _pattern_matches(key, spec._property_names) then + _report(context, 'error', path .. '.' .. key, 'propertyNames', string.format( + 'key does not match required pattern: %s.', spec.propertyNames + )) + end + end + end + end + + local defaulted = {} + + if type(spec.properties) == 'table' then + local sub, filled = _validate_map(value, spec.properties, path, context, { + unknown = spec.additionalProperties == false and 'error' or 'ignore', + additional = type(spec.additionalProperties) == 'table' and spec.additionalProperties or nil, + }) + + -- `_validate_map` builds a new table, so its defaults, coercion and alias + -- resolution have to be written back into the value the parent holds. + -- Without this a nested `properties` contributes nothing to `merged`. + local stale = {} + for key in pairs(value) do + if sub[key] == nil then + stale[#stale + 1] = key + end + end + for _, key in ipairs(stale) do + value[key] = nil + end + for key, member in pairs(sub) do + value[key] = member + end + defaulted = filled + elseif type(spec.additionalProperties) == 'table' then + for key, member in pairs(value) do + local coerced = _coerce(member, spec.additionalProperties.type) + value[key] = coerced + if coerced ~= nil then + _validate_value(coerced, spec.additionalProperties, path .. '.' .. tostring(key), context) + end + end + end + + -- Runs after the properties block, which writes the resolved members back + -- into `value`. Before that, a dependent supplied under an alias is not yet + -- there to be found. A `default` is an annotation and does not change the + -- instance, so a key that only holds one neither triggers a requirement nor + -- satisfies it. + if type(spec.dependentRequired) == 'table' then + for key, dependents in pairs(spec.dependentRequired) do + local trigger, trigger_key = _lookup(value, key) + if trigger ~= nil and not defaulted[trigger_key] and type(dependents) == 'table' then + for _, dependent in ipairs(dependents) do + local supplied, dependent_key = _lookup(value, dependent) + if supplied == nil or defaulted[dependent_key] then + _report(context, 'error', path, 'dependentRequired', string.format( + 'requires "%s" when "%s" is present.', dependent, key + )) + end + end + end + end + end +end + +--- Run every keyword check against one value. +--- A failed type check short-circuits, so one mistake yields one message. +--- @param value any Value to validate +--- @param spec table Compiled descriptor +--- @param path string Dotted path of the value +--- @param context table Validation context +--- @return nil +_validate_value = function(value, spec, path, context) + local names = _type_names(spec.type) + + if names then + for _, name in ipairs(names) do + if name == 'content' then + return + end + end + end + + if not _matches_type_spec(value, spec.type) then + _report(context, 'error', path, 'type', string.format( + 'must be of type "%s", got "%s".', _format_type(spec.type), _actual_type(value) + )) + return + end + + if spec.const ~= nil and value ~= spec.const then + _report(context, 'error', path, 'const', string.format( + 'must be %s, got %s.', _format_value(spec.const), _format_value(value) + )) + end + + _check_enum(value, spec, path, context) + _check_pattern(value, spec, path, context) + _check_numeric(value, spec, path, context) + _check_string_length(value, spec, path, context) + _check_array(value, spec, path, context) + _check_items(value, spec, path, context) + _check_object(value, spec, path, context) +end + +-- ============================================================================ +-- MAP VALIDATION +-- ============================================================================ + +--- Build the deprecation warning for a descriptor, and forward its value. +--- @param field string Field name +--- @param spec table Compiled descriptor +--- @param value any Current value +--- @param merged table Working values +--- @return string message Warning text +--- @return boolean cleared True when the deprecated key was removed +local function _apply_deprecation(field, spec, value, merged) + local deprecated = spec.deprecated + + if type(deprecated) == 'string' then + return string.format('option "%s" is deprecated. %s', field, deprecated), false + end + + if type(deprecated) ~= 'table' then + return string.format('option "%s" is deprecated.', field), false + end + + local message + if deprecated.since then + message = string.format('option "%s" is deprecated since %s.', field, deprecated.since) + else + message = string.format('option "%s" is deprecated.', field) + end + if deprecated.message then + message = message .. ' ' .. deprecated.message + end + + -- A v1 schema writes `replace-with`, which this vocabulary does not accept. + -- Say so, rather than dropping the forwarding without a word. + local unrecognised = {} + for key in pairs(deprecated) do + if key ~= 'since' and key ~= 'message' and key ~= 'replaceWith' then + unrecognised[#unrecognised + 1] = key + end + end + if #unrecognised > 0 then + table.sort(unrecognised) + message = message .. string.format( + ' The deprecation declares %s, which this vocabulary does not accept; use "replaceWith".', + table.concat(unrecognised, ', ') + ) + end + + local cleared = false + if deprecated.replaceWith then + local replacement = deprecated.replaceWith + message = message .. string.format(' Use "%s" instead.', replacement) + if _lookup(merged, replacement) == nil then + merged[replacement] = value + end + merged[field] = nil + cleared = true + end + + return message, cleared +end + +--- Record that a descriptor accepts a spelling, and report a contested one. +--- +--- Two descriptors can name the same spelling, when one declares as an alias +--- what another declares as its own name, or when two of them declare the same +--- alias. `pairs` yields descriptors in no particular order, so which field a +--- contested spelling resolves to is not decidable from the schema. It is +--- reported rather than resolved in silence, because the schema is what is +--- wrong and only its author can settle it. +--- +--- @param sources table Map of spelling to declared name +--- @param spelling string The spelling being declared +--- @param field string The declared name that accepts it +--- @param base_path string|nil Path prefix for findings +--- @param context table Validation context +local function _declare_spelling(sources, spelling, field, base_path, context) + -- The owner is read through `_lookup`, like every other name comparison in + -- this module, so a contest between the hyphen and the underscore spelling + -- of one name is found as well. Those sit under different keys but reach the + -- same document key at merge time, so they contest just as directly. + local owner = _lookup(sources, spelling) + if owner ~= nil and owner ~= field then + _report(context, 'warning', base_path and (base_path .. '.' .. spelling) or spelling, + 'aliases', string.format( + 'is declared by both "%s" and "%s"; which one accepts it is not defined.', + owner, field)) + end + -- Stored under the literal spelling either way. The map is also the set of + -- keys a descriptor claimed, and a contested spelling is still claimed: the + -- schema is ambiguous, which the warning says, and treating the key as + -- undeclared on top of that would report it as unknown as well. + sources[spelling] = sources[spelling] or field +end + + +--- Validate a map of values against a map of field descriptors. +--- @param values table Values to validate +--- @param descriptors table Field descriptor map +--- @param base_path string|nil Path prefix for findings +--- @param context table Validation context +--- @param options table|nil {unknown = 'warn'|'error'|'ignore', additional = descriptor} +--- @return table merged Values with aliases, coercion and defaults applied +--- @return table defaulted Set of field names whose value came from `default` +--- @return table sources Map of every spelling the schema accepts to the +--- declared name it resolves to. A reader that has to answer a question +--- about a name the caller wrote asks this rather than walking the +--- descriptors again, because the merge has already decided the answer. +--- It doubles as the set of keys a descriptor claimed, which is what the +--- unknown key pass below reads. +_validate_map = function(values, descriptors, base_path, context, options) + options = options or {} + local unknown_policy = options.unknown or 'ignore' + + local merged = {} + for key, value in pairs(values) do + merged[key] = value + end + + local defaulted = {} + local sources = {} + local fields = {} + + -- Three passes, because `pairs` yields descriptors in no particular order. + -- Aliases must land before deprecation reads a value, and a `replaceWith` + -- target must be populated before its own descriptor is validated. + for field, raw_spec in pairs(descriptors) do + local spec = _compile(raw_spec) + fields[#fields + 1] = { + name = field, + spec = spec, + path = base_path and (base_path .. '.' .. field) or field, + } + -- Two writes with different weight. Declaring a spelling fills an empty + -- slot only, while claiming the value under one overwrites whatever was + -- there. The value lands under whichever descriptor claimed it, so the + -- claim is what the map has to follow: a declaration that outranked it + -- would send a reader to a field the merge has already emptied. + _declare_spelling(sources, field, field, base_path, context) + + -- A value found under an alias, or under the other spelling, moves to the + -- name the schema declares. The key it came from is removed, so `merged` + -- never carries the same value twice, once coerced and once raw. + -- The declared name is read first, so it wins over any alias. + local supplied_key + local found, found_key = _lookup(merged, field) + if found ~= nil then + merged[field] = found + sources[found_key] = field + supplied_key = found_key + if found_key ~= field then + merged[found_key] = nil + end + end + + if type(spec.aliases) == 'table' then + for _, alias in ipairs(spec.aliases) do + _declare_spelling(sources, alias, field, base_path, context) + local aliased, alias_key = _lookup(merged, alias) + if aliased ~= nil then + sources[alias_key] = field + if merged[field] == nil then + merged[field] = aliased + supplied_key = alias_key + elseif alias_key ~= field then + -- Two spellings were supplied. The first one read wins, and the + -- other is reported rather than left behind unvalidated. Both + -- names come from the document, never from the schema. + _report(context, 'warning', + base_path and (base_path .. '.' .. field) or field, + 'aliases', + string.format('was given as both "%s" and "%s"; "%s" was used.', + supplied_key, alias_key, supplied_key)) + end + if alias_key ~= field then + merged[alias_key] = nil + end + end + end + end + end + + for _, entry in ipairs(fields) do + local value = merged[entry.name] + if entry.spec.deprecated and value ~= nil then + local message, cleared = _apply_deprecation(entry.name, entry.spec, value, merged) + _report(context, 'warning', entry.path, 'deprecated', message) + entry.cleared = cleared + end + end + + for _, entry in ipairs(fields) do + local field, spec, path = entry.name, entry.spec, entry.path + local value + + if entry.cleared then + value = nil + else + value = _coerce(merged[field], spec.type) + merged[field] = value + + if value == nil and spec.default ~= nil then + value = spec.default + merged[field] = value + defaulted[field] = true + end + end + + -- An empty string is a value the author wrote. Only a missing key is + -- absent, so `minLength`, `const` and `enum` can be tested against ''. + if spec.required == true and value == nil then + _report(context, 'error', path, 'required', 'is required but was not provided.') + elseif value ~= nil then + _validate_value(value, spec, path, context) + end + end + + if unknown_policy ~= 'ignore' or options.additional then + for key, value in pairs(values) do + if sources[key] == nil then + local path = base_path and (base_path .. '.' .. tostring(key)) or tostring(key) + if options.additional then + local coerced = _coerce(value, options.additional.type) + merged[key] = coerced + if coerced ~= nil then + _validate_value(coerced, options.additional, path, context) + end + elseif unknown_policy == 'error' then + _report(context, 'error', path, 'additionalProperties', 'is not a recognised key.') + else + _report(context, 'warning', path, 'additionalProperties', 'is not a recognised key and was ignored.') + end + end + end + end + + return merged, defaulted, sources +end + +--- Start a validation run. +--- @return table Fresh validation context +local function _new_context() + return { findings = {} } +end + +--- Close a validation run and shape the public return values. +--- @param context table Validation context +--- @param merged table Merged values +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged +--- @return table findings +local function _finish(context, merged) + local errors, warnings = _split_findings(context.findings) + return #errors == 0, errors, warnings, merged, context.findings +end + +-- ============================================================================ +-- PUBLIC API +-- ============================================================================ + +--- Load and parse a schema file. +--- Sections absent from the file default to an empty table. +--- +--- A failure is returned, not raised. Quarto replaces `error` with a logger +--- that returns instead of unwinding, so raising here would let a caller +--- carry on with a nil schema and fail later somewhere unrelated. +--- +--- @param filename string|nil Path to the schema file (defaults to '_schema.yml') +--- @return table|nil schema Schema with {['$schema'], options, shortcodes, formats, projects, attributes, classes} +--- @return string|nil err Why the schema could not be read +--- @usage local schema, err = M.load_schema('_schema.yml') +function M.load_schema(filename) + local raw, err = _parse_yaml_file(filename or '_schema.yml') + if err then + return nil, err + end + + return { + ['$schema'] = raw['$schema'], + options = raw.options or {}, + shortcodes = raw.shortcodes or {}, + formats = raw.formats or {}, + projects = raw.projects or {}, + attributes = raw.attributes or {}, + classes = raw.classes or {}, + }, nil +end + +--- List the keys of a parsed mapping in the order the schema declared them. +--- A schema is authored in a meaningful order, and a generator that turns one +--- into documentation needs that order rather than an arbitrary one. +--- Falls back to sorted keys for a table this module did not parse. +--- @param map table A mapping from a loaded schema +--- @return table Array of keys +--- @usage for _, name in ipairs(schema.key_order(loaded.options)) do +function M.key_order(map) + if type(map) ~= 'table' then + return {} + end + + local order = _key_order[map] + if order ~= nil then + local copy = {} + for index, key in ipairs(order) do + copy[index] = key + end + return copy + end + + local keys = {} + for key in pairs(map) do + keys[#keys + 1] = key + end + table.sort(keys) + return keys +end + +--- Validate a map of values against a map of field descriptors. +--- @param values table Values to validate +--- @param descriptors table Field descriptor map +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid True when no error was reported +--- @return table errors Array of error strings +--- @return table warnings Array of warning strings +--- @return table merged Values with aliases, coercion and defaults applied +--- @return table findings Array of structured findings +--- @usage local valid, errors, warnings, merged = M.validate(options, schema.options) +function M.validate(values, descriptors, options) + options = options or {} + local context = _new_context() + local merged = _validate_map(values or {}, descriptors or {}, nil, context, { + unknown = options.unknown or 'warn', + }) + return _finish(context, merged) +end + +--- Validate positional shortcode arguments against an argument descriptor array. +--- Each descriptor should carry a `name`; the merged table is keyed by name. +--- @param args table Array of positional argument values +--- @param argument_specs table Array of argument descriptors +--- @param options table|nil {unknown = 'warn'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged Map keyed by argument name +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_arguments(args, specs) +function M.validate_arguments(args, argument_specs, options) + options = options or {} + args = args or {} + argument_specs = argument_specs or {} + + local context = _new_context() + local merged = {} + + for index, raw_spec in ipairs(argument_specs) do + local spec = _compile(raw_spec) + local name = spec.name or tostring(index) + local path = string.format('argument %d ("%s")', index, name) + + local value = _coerce(args[index], spec.type) + if value == nil and spec.default ~= nil then + value = spec.default + end + merged[name] = value + + if spec.required == true and value == nil then + _report(context, 'error', path, 'required', 'is required but was not provided.') + elseif value ~= nil then + _validate_value(value, spec, path, context) + end + end + + if (options.unknown or 'warn') ~= 'ignore' and #args > #argument_specs then + _report(context, 'warning', nil, 'arguments', string.format( + 'Received %d arguments but only %d are defined. The extra arguments were ignored.', + #args, #argument_specs + )) + end + + return _finish(context, merged) +end + +--- Validate one shortcode call against its schema entry. +--- Covers the positional `arguments`, the named `attributes`, and the +--- parent-level `required` array of attribute names. +--- @param name string Shortcode name, used as the path prefix +--- @param args table Array of positional argument values +--- @param kwargs table Map of named attribute values +--- @param entry table Shortcode entry from `schema.shortcodes[name]` +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged {arguments = ..., attributes = ...} +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_shortcode('iconify', args, kwargs, schema.shortcodes.iconify) +function M.validate_shortcode(name, args, kwargs, entry, options) + options = options or {} + entry = entry or {} + + local context = _new_context() + local merged = { arguments = {}, attributes = {} } + + if type(entry.arguments) == 'table' then + local _, errors, warnings, arguments = M.validate_arguments(args or {}, entry.arguments, options) + merged.arguments = arguments + for _, message in ipairs(errors) do + _report(context, 'error', nil, 'arguments', name .. ' ' .. message) + end + for _, message in ipairs(warnings) do + _report(context, 'warning', nil, 'arguments', name .. ' ' .. message) + end + end + + local declared = type(entry.attributes) == 'table' + local spellings + if declared then + local attributes, _, resolved = _validate_map(kwargs or {}, entry.attributes, name, context, { + unknown = options.unknown or 'warn', + }) + merged.attributes = attributes + spellings = resolved + end + + if type(entry.required) == 'table' then + -- `merged.attributes` is filled only when the entry declares `attributes`, + -- and it is authoritative when it is: it keeps every unclaimed key and it + -- drops a key that a deprecation cleared. The vocabulary allows `required` + -- on its own, so fall back to what the caller supplied only when the + -- entry declares no `attributes`. A name that names an alias is not + -- missing either: `_validate_map` moves its value to the field that + -- declares the alias and clears the alias spelling, the same way it + -- clears a deprecated key, so look for the value under that field. The + -- merge reports which declared name each spelling resolves to, so that + -- is one lookup rather than a second walk over the descriptors. It goes + -- through `_lookup`, the same as every other name comparison in this + -- module, so a hyphen in one spelling and an underscore in the other + -- still match. + -- + -- This asks what the shortcode receives, not what the author wrote, so + -- a `default` present in `merged.attributes` satisfies a name here. + -- That is the opposite of `dependentRequired` above, which asks what + -- the author wrote and treats a default as an annotation nobody + -- supplied. Neither reading is a bug: they answer different questions + -- about the same instance. + local function _required_satisfied(required) + local field = (spellings and _lookup(spellings, required)) or required + if _lookup(merged.attributes, field) ~= nil then + return true + end + if not declared then + return _lookup(kwargs or {}, required) ~= nil + end + return false + end + + for _, required in ipairs(entry.required) do + if not _required_satisfied(required) then + _report(context, 'error', name .. '.' .. required, 'required', + 'is required but was not provided.') + end + end + end + + return _finish(context, merged) +end + +--- Validate Pandoc element attributes against one group of the `attributes` +--- section. Undeclared attributes are ignored by default, because a Pandoc +--- element legitimately carries attributes from Quarto and other filters. +--- @param attributes table Map of attribute values +--- @param group string Group key, such as a class, identifier or element name +--- @param schema table Loaded schema +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_attributes(el.attributes, 'callout-note', schema) +function M.validate_attributes(attributes, group, schema, options) + options = options or {} + local descriptors = schema and schema.attributes and _lookup(schema.attributes, group) or nil + + local context = _new_context() + if descriptors == nil then + return _finish(context, attributes or {}) + end + + local merged = _validate_map(attributes or {}, descriptors, group, context, { + unknown = options.unknown or 'ignore', + }) + return _finish(context, merged) +end + +--- Validate the options of one output format against the `formats` section. +--- @param meta table Document metadata +--- @param format string Format name, such as 'html' or 'typst' +--- @param schema table Loaded schema +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return boolean valid +--- @return table errors +--- @return table warnings +--- @return table merged +--- @return table findings +--- @usage local valid, errors, warnings, merged = M.validate_format(meta, 'typst', schema) +function M.validate_format(meta, format, schema, options) + options = options or {} + local descriptors = schema and schema.formats and _lookup(schema.formats, format) or nil + + local context = _new_context() + if descriptors == nil then + return _finish(context, {}) + end + + 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) + end + end + + local merged = _validate_map(values, descriptors, format, context, { + unknown = options.unknown or 'ignore', + }) + return _finish(context, merged) +end + +--- Extract an extension's options from document metadata. +--- Reads `meta.extensions[extension_name]` and converts Pandoc values to +--- native Lua values, keeping every key exactly as the document wrote it. +--- @param meta table Document metadata +--- @param extension_name string Extension name +--- @return table Map of option values +--- @usage local options = M.extract_meta_options(meta, 'iconify') +function M.extract_meta_options(meta, extension_name) + local extension_meta = meta and meta['extensions'] and meta['extensions'][extension_name] + if not extension_meta then + return {} + end + + local options = {} + for key, value in pairs(extension_meta) do + options[tostring(key)] = _convert_pandoc_value(value) + end + return options +end + +--- Load a schema, validate an extension's options, and log the outcome. +--- A schema that cannot be loaded is reported and the render continues with +--- no validated options, rather than aborting on a configuration file. +--- @param meta table Document metadata +--- @param extension_name string Extension name +--- @param schema_path string Path to the schema file, resolved by the caller +--- @param options table|nil {unknown = 'warn'|'error'|'ignore'} +--- @return table merged Validated options with defaults applied +--- @usage local options = M.validate_options(meta, 'iconify', quarto.utils.resolve_path('_schema.yml')) +function M.validate_options(meta, extension_name, schema_path, options) + local schema, err = M.load_schema(schema_path) + if err then + _env.report_error(string.format('Extension "%s": %s', extension_name, err)) + return {} + end + + if not schema.options or next(schema.options) == nil then + return {} + end + + local values = M.extract_meta_options(meta, extension_name) + local valid, errors, warnings, merged = M.validate(values, schema.options, options) + + if #warnings > 0 then + _env.warn(M.format_warnings(warnings, extension_name)) + end + if not valid then + _env.report_error(M.format_errors(errors, extension_name)) + end + + return merged +end + +--- Format error messages as one readable block. +--- @param errors table Array of error strings +--- @param extension_name string|nil Extension name for context +--- @return string Formatted message, or an empty string +--- @usage local message = M.format_errors(errors, 'iconify') +function M.format_errors(errors, extension_name) + if #errors == 0 then + return '' + end + + local prefix = extension_name and ('Extension "' .. extension_name .. '": ') or '' + local lines = {} + for index, message in ipairs(errors) do + table.insert(lines, ' ' .. index .. '. ' .. message) + end + + return prefix .. 'Configuration validation failed:\n' .. table.concat(lines, '\n') +end + +--- Format warning messages as one readable block. +--- @param warnings table Array of warning strings +--- @param extension_name string|nil Extension name for context +--- @return string Formatted message, or an empty string +--- @usage local message = M.format_warnings(warnings, 'iconify') +function M.format_warnings(warnings, extension_name) + if #warnings == 0 then + return '' + end + + local prefix = extension_name and ('Extension "' .. extension_name .. '": ') or '' + local lines = {} + for index, message in ipairs(warnings) do + table.insert(lines, ' ' .. index .. '. ' .. message) + end + + return prefix .. 'Configuration warnings:\n' .. table.concat(lines, '\n') +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/iconify/iconify-filter.lua b/docs/_extensions/mcanouil/iconify/iconify-filter.lua index 8730556..6c00170 100644 --- a/docs/_extensions/mcanouil/iconify/iconify-filter.lua +++ b/docs/_extensions/mcanouil/iconify/iconify-filter.lua @@ -1,4 +1,4 @@ ---- @module iconify-filter +--- @module "iconify-filter" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil @@ -14,8 +14,8 @@ local EXTENSION_NAME = "iconify" --- Load modules -local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua$', '')) -local log = require(quarto.utils.resolve_path('_modules/logging.lua'):gsub('%.lua$', '')) +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) --- Tracker so the preload payload is only injected once per document, even --- when the filter runs multiple passes. diff --git a/docs/_extensions/mcanouil/iconify/iconify.lua b/docs/_extensions/mcanouil/iconify/iconify.lua index adcd80e..e3f53a3 100644 --- a/docs/_extensions/mcanouil/iconify/iconify.lua +++ b/docs/_extensions/mcanouil/iconify/iconify.lua @@ -1,4 +1,4 @@ ---- @module iconify +--- @module "iconify" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil @@ -7,10 +7,44 @@ local EXTENSION_NAME = "iconify" --- Load modules -local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua$', '')) -local log = require(quarto.utils.resolve_path('_modules/logging.lua'):gsub('%.lua$', '')) -local meta_mod = require(quarto.utils.resolve_path('_modules/metadata.lua'):gsub('%.lua$', '')) +local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) +local meta_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/metadata.lua'):gsub('%.lua$', '')) local typst = require(quarto.utils.resolve_path('_modules/typst.lua'):gsub('%.lua$', '')) +local schema = require(quarto.utils.resolve_path('_vendor/quarto-wizard/schema.lua'):gsub('%.lua$', '')) + +--- The parsed `_schema.yml`, loaded once and reused by every shortcode call. +--- Nil means the file could not be read, in which case calls are not checked +--- and the render carries on: a configuration file must not stop a document. +--- @type table|nil +local extension_schema = nil + +--- Whether loading has already been attempted this render. +--- @type boolean +local schema_loaded = false + +--- Last resort for the two values the extension cannot render without, used +--- only when `_schema.yml` could not be read. `_schema.yml` stays the source +--- of truth on every normal path; without these an unreadable configuration +--- file would turn every icon into `icon=":name"` and stop Typst caching. +--- @type table +local SCHEMA_UNAVAILABLE = { + set = 'octicon', + ['typst-cache'] = '.quarto/iconify-svg', +} + +--- Whether the document configuration has already been checked this render. +--- The check lives here rather than in the companion filter because that +--- filter is opt-in (`filters: [iconify]`), while a shortcode always runs +--- when there is an icon to render. +--- @type boolean +local options_validated = false + +--- The document configuration resolved against `_schema.yml`, holding the +--- `provided`, `merged` and `defaults` tables. Nil until the first shortcode +--- runs, and left nil when there is no readable schema. +--- @type table|nil +local resolved_options = nil --- Per-key deprecation warning tracker. Each deprecated metadata key warns --- at least once per render rather than once total. The companion filter @@ -270,10 +304,202 @@ local function recover_kwargs(args, kwargs) return positional end +--- Load `_schema.yml` once per render. +--- @return table|nil The parsed schema, or nil when it could not be read +local function load_extension_schema() + if not schema_loaded then + schema_loaded = true + local loaded, err = schema.load_schema(quarto.utils.resolve_path('_schema.yml')) + if err then + log.log_error(EXTENSION_NAME, err) + else + extension_schema = loaded + end + end + return extension_schema +end + +--- Flatten a shortcode's named options to plain strings for validation. +--- Every value is read through `attr_value`, so a quoted and an unquoted +--- value are checked as the same thing. +--- @param kwargs table Key-value options for the icon +--- @return table +local function plain_kwargs(kwargs) + --- @type table + local plain = {} + for key in pairs(kwargs) do + plain[tostring(key)] = attr_value(kwargs, key) + end + return plain +end + +--- Check `extensions.iconify` against `_schema.yml` once per render, and keep +--- what it resolves. `_schema.yml` holds every default this extension applies, +--- so the values it produces are read back here rather than restated in Lua. +--- +--- Three tables are kept, because they answer different questions: +--- provided what the document actually set, which is the only way to tell +--- a deliberate `false` or `0` from an absent key, +--- merged the same values with coercion and defaults applied, +--- defaults the schema defaults on their own, from a pass over an empty +--- table, used as the last resort in `document_option`. +--- @param meta table Document metadata +--- @return table|nil The resolved tables, or nil when there is no schema +local function resolve_document_options(meta) + if resolved_options ~= nil then + return resolved_options + end + if options_validated then + return nil + end + options_validated = true + + --- @type table|nil + local loaded = load_extension_schema() + if loaded == nil or next(loaded.options) == nil then return nil end + + --- @type table + local provided = schema.extract_meta_options(meta, EXTENSION_NAME) + local valid, errors, warnings, merged = schema.validate(provided, loaded.options) + + for _, message in ipairs(warnings) do + log.log_warning(EXTENSION_NAME, message) + end + if not valid then + for _, message in ipairs(errors) do + log.log_error(EXTENSION_NAME, message) + end + end + + --- Validating an empty configuration yields the declared defaults alone. + local _, _, _, defaults = schema.validate({}, loaded.options, { unknown = 'ignore' }) + + resolved_options = { provided = provided, merged = merged, defaults = defaults } + return resolved_options +end + +--- Check one shortcode call against its entry in `_schema.yml` and report +--- whatever it finds. This reports only; nothing about the rendered icon +--- changes, so an unrecognised attribute is surfaced rather than dropped. +--- @param name string Shortcode name, 'iconify' or 'quarto' +--- @param args table Positional arguments +--- @param kwargs table Key-value options for the icon +--- @return nil +local function validate_call(name, args, kwargs) + --- @type table|nil + local loaded = load_extension_schema() + if loaded == nil then return end + + --- @type table|nil + local entry = loaded.shortcodes and loaded.shortcodes[name] + if entry == nil then return end + + --- @type table + local positional = {} + for index, value in ipairs(args) do + positional[index] = str.stringify(value) + end + + local _, errors, warnings = schema.validate_shortcode( + name, positional, plain_kwargs(kwargs), entry) + + -- Reported as warnings, not errors: the rendered icon never changes because + -- of a schema mismatch on an attribute, so this is advice rather than a + -- failure. An unrecognised value is still handled by the code that reads it. + for _, message in ipairs(warnings) do + log.log_warning(EXTENSION_NAME, message) + end + + -- A required argument that is absent is the one case the rule above does not + -- cover: without it there is no icon, so the output does change. The check + -- is made here rather than read out of the validator's findings because the + -- validator reports a nested argument fault under one `arguments` entry, + -- which cannot tell a missing argument from a malformed one. + --- @type table + local missing = {} + for index, argument in ipairs(entry.arguments or {}) do + if argument.required == true and str.is_empty(positional[index]) then + missing[#missing + 1] = argument + end + end + + if #missing > 0 then + -- The only message about the missing argument: the schema's own `required` + -- wording says the same thing, and reporting both would state one fault + -- twice at two severities. Attribute warnings above still stand, and every + -- other finding about this call is secondary to there being no icon. + for _, argument in ipairs(missing) do + --- The example comes from the schema so that each shortcode carries its + --- own, rather than this message naming one shortcode for all of them. + --- @type string + local advice = '' + local example = type(argument.examples) == 'table' and argument.examples[1] or nil + if example ~= nil then + advice = string.format(' For example: {{< %s %s >}}.', name, tostring(example)) + end + log.log_error(EXTENSION_NAME, string.format( + 'The "%s" shortcode needs its "%s" argument.%s', name, argument.name, advice)) + end + return + end + + for _, message in ipairs(errors) do + log.log_warning(EXTENSION_NAME, message) + end +end + +--- Render a resolved option for the string contract every caller expects. +--- A schema `inline: true` therefore reads as "true", which is what +--- `meta_mod.get_metadata_value` produces for the same value in metadata. +--- @param value any A value from the schema, of any scalar type +--- @return string +local function option_to_string(value) + local kind = type(value) + if kind == 'string' then + return value + end + if kind == 'number' or kind == 'boolean' then + return tostring(value) + end + return '' +end + +--- Get a document-level option, ignoring shortcode attributes. +--- Resolution order: nested `extensions.iconify.`, then the deprecated +--- top-level `iconify.` (with a per-key deprecation warning), then the +--- default declared in `_schema.yml`. +--- +--- The schema default is deliberately last. Placed any earlier it would mask +--- the deprecated form, because a default is always present once declared. +--- +--- Presence is tested with `~= nil` rather than truthiness, so a deliberate +--- `inline: false` or `typst-cache-max-age: 0` is honoured instead of being +--- read as an absent key and replaced by its own default. +--- @param key string The option name to retrieve +--- @param meta table Document metadata table +--- @return string The option value as a string +local function document_option(key, meta) + --- @type table|nil + local options = resolve_document_options(meta) + + if options ~= nil and options.provided[key] ~= nil then + return option_to_string(options.merged[key]) + end + + local deprecated_value = check_deprecated_config(meta, key) + if deprecated_value then + return deprecated_value + end + + if options ~= nil then + return option_to_string(options.defaults[key]) + end + + return SCHEMA_UNAVAILABLE[key] or '' +end + --- Get an iconify option from arguments or metadata. ---- Resolution order: positional/named kwargs first, then nested ---- `extensions.iconify.`, then the deprecated top-level `iconify.` ---- (with a per-key deprecation warning). +--- The shortcode attribute wins, then the document tiers in `document_option`. --- @param x string The option name to retrieve --- @param arg table Arguments table containing options --- @param meta table Document metadata table @@ -286,17 +512,25 @@ local function get_iconify_options(x, arg, meta) return arg_value end - local meta_value = meta_mod.get_metadata_value(meta, 'iconify', x) - if not str.is_empty(meta_value) then - return meta_value - end - - local deprecated_value = check_deprecated_config(meta, x) - if deprecated_value then - return deprecated_value - end + return document_option(x, meta) +end - return arg_value +--- Collect the Typst cache options for the typst module. +--- The module holds no default of its own, so both the value and the +--- last-resort fallback are read from `_schema.yml` here. +--- @param meta table Document metadata +--- @return table +local function typst_cache_options(meta) + --- @type table|nil + local options = resolve_document_options(meta) + return { + cache_dir = document_option('typst-cache', meta), + cache_fallback = options + and option_to_string(options.defaults['typst-cache']) + or SCHEMA_UNAVAILABLE['typst-cache'], + max_age_days = document_option('typst-cache-max-age', meta), + max_entries = document_option('typst-cache-max-entries', meta), + } end --- Render an Iconify icon as a Typst `#image`, delegating retrieval and @@ -358,6 +592,9 @@ local function render_typst(icon, set, default_label, decorative, kwargs, meta) --- @type string local inline = get_iconify_options('inline', kwargs, meta) + --- @type table + local cache_options = typst_cache_options(meta) + --- @type any local result = typst.render({ set = set, @@ -367,25 +604,37 @@ local function render_typst(icon, set, default_label, decorative, kwargs, meta) inline = str.is_empty(inline) or inline ~= 'false', alt = alt, fallback = get_iconify_options('fallback', kwargs, meta), - meta = meta + options = cache_options }) -- Prune the cache once per render, after at least one icon has populated it. if not typst_cleanup_done then typst_cleanup_done = true - typst.cleanup(meta) + typst.cleanup(cache_options) end return result end --- Render an Iconify icon as a Pandoc RawInline for HTML output. +--- Expects `args` to have been through `recover_kwargs` already, and does not +--- validate: each shortcode entry point checks its own call against the +--- schema entry that describes it. --- @param args table Icon arguments (icon set and name) --- @param kwargs table Key-value options for the icon --- @param meta table Document metadata --- @return any Pandoc RawInline for HTML or Pandoc Null for other formats -local function iconify(args, kwargs, meta) - args = recover_kwargs(args, kwargs) +local function render_icon(args, kwargs, meta) + + -- Checked before the format gate below, so a call with no icon is handled + -- the same way for every output format rather than only the two that render + -- something. An empty first argument counts as no icon, which is what the + -- schema's `required` check already decided, so the two agree. + -- `validate_call` has reported this to the author; there is nothing to add + -- here beyond not reading a first argument that is not there. + if #args == 0 or str.is_empty(str.stringify(args[1])) then + return pandoc.Null() + end -- HTML (excluding epub which will not host the Web Component) renders the -- Web Component; Typst renders a cached SVG. Every other format renders @@ -400,19 +649,12 @@ local function iconify(args, kwargs, meta) --- @type string local icon = str.stringify(args[1]) - --- @type string - local set = 'octicon' - -- Resolve the default icon set, preferring the nested metadata structure. - local meta_set = meta_mod.get_metadata_value(meta, 'iconify', 'set') - if not str.is_empty(meta_set) then - set = meta_set - else - local deprecated_set = check_deprecated_config(meta, 'set') - if deprecated_set then - set = deprecated_set - end - end + --- The icon set comes from the positional arguments, never from a shortcode + --- attribute, so this reads the document tiers only. The fallback is the + --- `set` default in `_schema.yml`. + --- @type string + local set = document_option('set', meta) if #args > 1 and string.find(str.stringify(args[2]), ':') then log.log_warning( @@ -575,6 +817,18 @@ local function iconify(args, kwargs, meta) ) end +--- The `iconify` shortcode: check the call, then render it. +--- @param args table Icon arguments (icon set and name) +--- @param kwargs table Key-value options for the icon +--- @param meta table Document metadata +--- @return any Pandoc RawInline for HTML or Pandoc Null for other formats +local function iconify(args, kwargs, meta) + resolve_document_options(meta) + args = recover_kwargs(args, kwargs) + validate_call('iconify', args, kwargs) + return render_icon(args, kwargs, meta) +end + --- Render Quarto icon using the iconify function with preset styling. --- @param args table Icon arguments (the icon is a preset, so these are read only for attributes a metadata field left unparsed) --- @param kwargs table|nil Key-value options that might override default styling @@ -585,7 +839,9 @@ local function iconify_quarto(args, kwargs, meta) local quarto_args = { 'simple-icons:quarto' } --- @type table local quarto_kwargs = kwargs or {} + resolve_document_options(meta) recover_kwargs(args, quarto_kwargs) + validate_call('quarto', {}, quarto_kwargs) -- A decorative icon carries neither, and setting them here would re-introduce -- exactly what `aria-hidden` removes. if not is_decorative(quarto_kwargs, false) then @@ -606,7 +862,7 @@ local function iconify_quarto(args, kwargs, meta) else quarto_kwargs['style'] = quarto_colour end - return iconify(quarto_args, quarto_kwargs, meta) + return render_icon(quarto_args, quarto_kwargs, meta) end --- @type table