From f79361a4dd3768a7539a2dfece915c47c7a58f96 Mon Sep 17 00:00:00 2001 From: "mcanouil-dev[bot]" <211049963+mcanouil-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:44:07 +0000 Subject: [PATCH] chore(deps): update 2 Quarto extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcanouil/atelier: 0.10.2 → 0.10.3 - mcanouil/gitlink: 1.10.0 → 1.10.1 --- .../mcanouil/atelier/_extension.yml | 80 ++-- .../mcanouil/atelier/_modules/string.lua | 183 +++++++- .../mcanouil/atelier/html/theme.scss | 31 ++ .../mcanouil/gitlink/_extension.yml | 7 +- .../mcanouil/gitlink/_modules/bitbucket.lua | 13 +- .../mcanouil/gitlink/_modules/git.lua | 2 +- .../mcanouil/gitlink/_modules/html.lua | 2 +- .../mcanouil/gitlink/_modules/logging.lua | 2 +- .../mcanouil/gitlink/_modules/paths.lua | 2 +- .../gitlink/_modules/platform-schema.lua | 412 ++++++++++++++++++ .../mcanouil/gitlink/_modules/platforms.lua | 14 +- .../mcanouil/gitlink/_modules/string.lua | 123 ++++-- .../mcanouil/gitlink/_modules/widget.lua | 2 +- .../mcanouil/gitlink/_snippets.json | 9 +- docs/_extensions/mcanouil/gitlink/gitlink.lua | 57 ++- 15 files changed, 826 insertions(+), 113 deletions(-) create mode 100644 docs/_extensions/mcanouil/gitlink/_modules/platform-schema.lua diff --git a/docs/_extensions/mcanouil/atelier/_extension.yml b/docs/_extensions/mcanouil/atelier/_extension.yml index 3470083..f48ed37 100644 --- a/docs/_extensions/mcanouil/atelier/_extension.yml +++ b/docs/_extensions/mcanouil/atelier/_extension.yml @@ -1,20 +1,59 @@ title: Atelier author: Mickaël Canouil -version: 0.10.2 -quarto-required: '>=1.9.36' -source: mcanouil/quarto-atelier@0.10.2 -source-type: registry +version: 0.10.3 +quarto-required: ">=1.9.36" contributes: + project: + project: + type: website + output-dir: _site + website: + repo-actions: [edit, issue] + repo-link-target: _blank + repo-link-rel: noopener noreferrer + # `locale` matches `lang: en-GB` below; override the two together. + # Naming `twitter-card` at all is what switches Quarto's Twitter + # provider on, after which it inherits title, description, image, and + # image alt from each page. + open-graph: + locale: en_GB + twitter-card: + card-style: summary_large_image + page-navigation: true + back-to-top-navigation: true + llms-txt: true + search: + location: navbar + type: overlay + # `background` and `foreground` are left to the theme on both bars: the + # chrome palette in `html/chrome.scss` sets `$navbar-bg`, `$navbar-fg`, + # `$footer-bg`, and `$footer-fg` from a layer Quarto evaluates first, so + # naming them here would be a configuration with no effect. + navbar: + search: true + page-footer: + border: true + left: | + Powered by [Quarto](https://quarto.org){target="_blank" rel="noopener noreferrer"}. + center: | + © []{#current-year} [Mickaël CANOUIL](https://mickael.canouil.fr){target="_blank" rel="noopener noreferrer"}. + format: atelier-html formats: common: lang: en-GB - date-format: dddd[, the] Do [of] MMMM, YYYY + date-format: "dddd[, the] Do [of] MMMM, YYYY" code-copy: true code-overflow: wrap code-link: false html: respect-user-color-scheme: true + # Quarto builds the canonical link from `website.site-url`, giving a + # directory index the URL of its directory. Set `canonical-url: false` + # on a page served from more than one URL, such as `404.qmd`. canonical-url: true + # `html/chrome.scss` comes before `brand` on purpose: Quarto evaluates + # user layer defaults in reverse list order, so a file placed first is + # evaluated last, after the brand palette it derives the chrome from. theme: light: - html/chrome.scss @@ -45,33 +84,4 @@ contributes: - file: html/scripts/ordinal-dates.html - file: html/scripts/a11y-fixes.html - file: html/scripts/navbar-tooltips.html - project: - project: - type: website - output-dir: _site - website: - repo-actions: - - edit - - issue - repo-link-target: _blank - repo-link-rel: noopener noreferrer - open-graph: - locale: en_GB - twitter-card: - card-style: summary_large_image - page-navigation: true - back-to-top-navigation: true - llms-txt: true - search: - location: navbar - type: overlay - navbar: - search: true - page-footer: - border: true - left: | - Powered by [Quarto](https://quarto.org){target="_blank" rel="noopener noreferrer"}. - center: > - © []{#current-year} [Mickaël CANOUIL](https://mickael.canouil.fr){target="_blank" rel="noopener - noreferrer"}. - format: atelier-html +source: mcanouil/quarto-atelier@0.10.3 diff --git a/docs/_extensions/mcanouil/atelier/_modules/string.lua b/docs/_extensions/mcanouil/atelier/_modules/string.lua index 8f89e0b..435c310 100644 --- a/docs/_extensions/mcanouil/atelier/_modules/string.lua +++ b/docs/_extensions/mcanouil/atelier/_modules/string.lua @@ -3,7 +3,7 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 1.1.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('=1.9.38' -source: mcanouil/quarto-gitlink@1.10.0 -source-type: registry +version: 1.10.1 +quarto-required: ">=1.9.38" contributes: filters: - path: gitlink.lua at: post-quarto +source: mcanouil/quarto-gitlink@1.10.1 diff --git a/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua b/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua index 6946e10..4a95e6c 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/bitbucket.lua @@ -1,10 +1,19 @@ --- MC Bitbucket - Bitbucket-specific functionality for gitlink extension ---- @module bitbucket +--- @module "bitbucket" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil -local str = require("_modules/string") +--- 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 bitbucket_module = {} diff --git a/docs/_extensions/mcanouil/gitlink/_modules/git.lua b/docs/_extensions/mcanouil/gitlink/_modules/git.lua index 10ff160..7b11f33 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/git.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/git.lua @@ -1,5 +1,5 @@ --- MC Git - Git repository utilities for Quarto Lua filters and shortcodes ---- @module git +--- @module "git" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil diff --git a/docs/_extensions/mcanouil/gitlink/_modules/html.lua b/docs/_extensions/mcanouil/gitlink/_modules/html.lua index e022cef..8212cc5 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/html.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/html.lua @@ -1,5 +1,5 @@ --- MC HTML - HTML generation and dependency management for Quarto Lua filters and shortcodes ---- @module html +--- @module "html" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil diff --git a/docs/_extensions/mcanouil/gitlink/_modules/logging.lua b/docs/_extensions/mcanouil/gitlink/_modules/logging.lua index abf4171..a538809 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/logging.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/logging.lua @@ -1,5 +1,5 @@ --- MC Logging - Formatted log output for Quarto Lua filters and shortcodes ---- @module logging +--- @module "logging" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil diff --git a/docs/_extensions/mcanouil/gitlink/_modules/paths.lua b/docs/_extensions/mcanouil/gitlink/_modules/paths.lua index 378113b..4ffea7c 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/paths.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/paths.lua @@ -1,5 +1,5 @@ --- MC Paths - Path resolution utilities for Quarto Lua filters and shortcodes ---- @module paths +--- @module "paths" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil diff --git a/docs/_extensions/mcanouil/gitlink/_modules/platform-schema.lua b/docs/_extensions/mcanouil/gitlink/_modules/platform-schema.lua new file mode 100644 index 0000000..af00579 --- /dev/null +++ b/docs/_extensions/mcanouil/gitlink/_modules/platform-schema.lua @@ -0,0 +1,412 @@ +--- MC Platform Schema - Validator for gitlink platform configurations (platforms.yml) +--- @module "platform-schema" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil + +local platform_schema = {} + +-- ============================================================================ +-- 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 = platform_schema.validate_platform('github', config) +--- if not result.valid then +--- for _, err in ipairs(result.errors) do +--- print('ERROR: ' .. err) +--- end +--- end +function platform_schema.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 = platform_schema.validate_all_platforms(platforms_config) +function platform_schema.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] = platform_schema.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 = platform_schema.format_result(result) +--- print(msg) +function platform_schema.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 = platform_schema.get_summary(result) +function platform_schema.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 platform_schema diff --git a/docs/_extensions/mcanouil/gitlink/_modules/platforms.lua b/docs/_extensions/mcanouil/gitlink/_modules/platforms.lua index 950981a..1da58c6 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/platforms.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/platforms.lua @@ -1,13 +1,13 @@ --- Platform Configuration Module ---- @module platforms +--- @module "platforms" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil local platforms_module = {} --- Load schema validation module -local schema = require(quarto.utils.resolve_path('_modules/schema.lua'):gsub('%.lua$', '')) +-- Load platform schema validation module +local platform_schema = require(quarto.utils.resolve_path('_modules/platform-schema.lua'):gsub('%.lua$', '')) -- ============================================================================ -- CONFIGURATION STORAGE @@ -112,7 +112,7 @@ function platforms_module.initialise(yaml_path) return false, msg end - local validation_results_all = schema.validate_all_platforms(loaded_configs) + local validation_results_all = platform_schema.validate_all_platforms(loaded_configs) local has_errors = false local error_messages = {} @@ -221,7 +221,7 @@ function platforms_module.register_custom_platform(platform_name, config) return false, 'Platform name and configuration are required' end - local result = schema.validate_platform(platform_name, config) + local result = platform_schema.validate_platform(platform_name, config) if not result.valid then local error_lines = {} @@ -303,7 +303,7 @@ end --- local result = platforms_module.validate_platform_config('forgejo', config) --- if result.valid then print('OK') else print(table.concat(result.errors, ', ')) end function platforms_module.validate_platform_config(platform_name, config) - return schema.validate_platform(platform_name, config) + return platform_schema.validate_platform(platform_name, config) end --- Validate all platforms in a configuration table @@ -313,7 +313,7 @@ end --- @usage --- local results = platforms_module.validate_all_platforms(platforms_config) function platforms_module.validate_all_platforms(platforms) - return schema.validate_all_platforms(platforms) + return platform_schema.validate_all_platforms(platforms) end -- ============================================================================ diff --git a/docs/_extensions/mcanouil/gitlink/_modules/string.lua b/docs/_extensions/mcanouil/gitlink/_modules/string.lua index f5280ab..435c310 100644 --- a/docs/_extensions/mcanouil/gitlink/_modules/string.lua +++ b/docs/_extensions/mcanouil/gitlink/_modules/string.lua @@ -1,9 +1,9 @@ --- MC String - String manipulation and escaping for Quarto Lua filters and shortcodes ---- @module string +--- @module "string" --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 1.0.0 +--- @version 1.1.0 local M = {} @@ -57,6 +57,25 @@ function M.trim(str) return str:match('^%s*(.-)%s*$') end +--- Convert any value to a string, handling Pandoc objects and empty values. +--- Returns nil for empty or nil values, otherwise returns a string representation. +--- @param val any The value to convert +--- @return string|nil The string value or nil if empty +--- @usage local str = M.to_string(kwargs.value) +function M.to_string(val) + if not val then return nil end + if type(val) == 'string' then + return val ~= '' and val or nil + end + -- Handle Pandoc objects + if pandoc and pandoc.utils and pandoc.utils.stringify then + local str = pandoc.utils.stringify(val) + return str ~= '' and str or nil + end + local str = tostring(val) + return str ~= '' and str or nil +end + --- Strip one layer of surrounding bracket or punctuation characters. --- Handles balanced pairs: () [] {} "" '' `` «» --- Handles trailing-only punctuation: , . ; : ! ? @@ -66,17 +85,17 @@ end --- @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 "", "" + 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 + if first_two == '\xC2\xAB' and last_two == '\xC2\xBB' then return first_two, text:sub(3, -3), last_two end @@ -88,14 +107,14 @@ function M.strip_surrounding(text) end local trailing = { - [","] = true, ["."] = true, [";"] = true, - [":"] = true, ["!"] = true, ["?"] = true, + [','] = true, ['.'] = true, [';'] = true, + [':'] = true, ['!'] = true, ['?'] = true, } if trailing[last] then - return "", text:sub(1, -2), last + return '', text:sub(1, -2), last end - return "", text, "" + return '', text, '' end --- Peel unbalanced surrounding brackets and trailing punctuation from a token. @@ -110,29 +129,29 @@ end --- @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 "", "" + if not text or text == '' then + return '', text or '', '' end local leading = { - ["("] = true, ["["] = true, ["{"] = true, - ['"'] = true, ["'"] = true, ["`"] = true, + ['('] = true, ['['] = true, ['{'] = true, + ['"'] = true, ["'"] = true, ['`'] = true, } local trailing = { - [")"] = true, ["]"] = true, ["}"] = true, - ['"'] = true, ["'"] = true, ["`"] = true, - [","] = true, ["."] = true, [";"] = true, - [":"] = true, ["!"] = true, ["?"] = true, + [')'] = true, [']'] = true, ['}'] = true, + ['"'] = true, ["'"] = true, ['`'] = true, + [','] = true, ['.'] = true, [';'] = true, + [':'] = true, ['!'] = true, ['?'] = true, } local first = 1 local last = #text - local prefix = "" - local suffix = "" + local prefix = '' + local suffix = '' while first <= last do - if text:sub(first, first + 1) == "\xC2\xAB" then - prefix = prefix .. "\xC2\xAB" + 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) @@ -146,8 +165,8 @@ function M.strip_edges(text) -- 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 + 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 @@ -180,15 +199,15 @@ function M.find_bracketed_content(text, start_pos) 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 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 @@ -209,25 +228,6 @@ function M.find_bracketed_content(text, start_pos) return nil, nil, nil, nil end ---- Convert any value to a string, handling Pandoc objects and empty values. ---- Returns nil for empty or nil values, otherwise returns a string representation. ---- @param val any The value to convert ---- @return string|nil The string value or nil if empty ---- @usage local str = M.to_string(kwargs.value) -function M.to_string(val) - if not val then return nil end - if type(val) == 'string' then - return val ~= '' and val or nil - end - -- Handle Pandoc objects - if pandoc and pandoc.utils and pandoc.utils.stringify then - local str = pandoc.utils.stringify(val) - return str ~= '' and str or nil - end - local str = tostring(val) - return str ~= '' and str or nil -end - -- ============================================================================ -- ESCAPE UTILITIES -- ============================================================================ @@ -258,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('