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 `` sequence so payloads cannot break out of a
+--- surrounding inline `]])
+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('', '<\\/'))
end
--- Escape special Lua pattern characters for use in string.gsub.
diff --git a/docs/_extensions/mcanouil/atelier/html/theme.scss b/docs/_extensions/mcanouil/atelier/html/theme.scss
index 4e2f3e0..862b892 100644
--- a/docs/_extensions/mcanouil/atelier/html/theme.scss
+++ b/docs/_extensions/mcanouil/atelier/html/theme.scss
@@ -5,6 +5,11 @@ $border-radius-sm: 0.375rem !default;
$border-radius-lg: 0.75rem !default;
$enable-smooth-scroll: true !default;
+// `.btn-close-white` asks Bootstrap to invert the dismiss icon to force a light
+// one. The icon follows the text around it instead, see DISMISS BUTTON below,
+// so there is nothing left for the class to invert.
+$btn-close-white-filter: none !default;
+
/*-- scss:functions --*/
// Mix the page background and foreground. brand injects different
@@ -756,6 +761,32 @@ body .navbar .gitlink-widget-dropdown {
}
}
+// ===========================================================================
+// DISMISS BUTTON
+// ===========================================================================
+// Bootstrap bakes black into the .btn-close icon and lightens it under
+// [data-bs-theme="dark"], which Quarto never sets: it marks the scheme on the
+// body and swaps the whole stylesheet bundle. The icon stayed black on the
+// dark surface of a modal, an offcanvas, or a toast.
+//
+// Painting it from the page foreground would only move the problem. A
+// contextual dismissible alert keeps its light tint in the dark bundle,
+// because Bootstrap gates the dark tints on that same attribute, and a light
+// icon is no more readable there than a black one is on a modal. The icon is
+// masked from the text colour around it instead, so it follows the surface it
+// sits on, whichever bundle is active.
+.btn-close,
+.btn-close:hover {
+ color: inherit;
+}
+
+.btn-close {
+ background-image: none;
+ background-color: currentcolor;
+ mask: var(--bs-btn-close-bg) center / $btn-close-width auto no-repeat;
+ -webkit-mask: var(--bs-btn-close-bg) center / $btn-close-width auto no-repeat;
+}
+
// ===========================================================================
// 404 PAGE
// ===========================================================================
diff --git a/docs/_extensions/mcanouil/gitlink/_extension.yml b/docs/_extensions/mcanouil/gitlink/_extension.yml
index fa97fb5..52336d3 100644
--- a/docs/_extensions/mcanouil/gitlink/_extension.yml
+++ b/docs/_extensions/mcanouil/gitlink/_extension.yml
@@ -1,10 +1,9 @@
title: gitlink
author: Mickaël Canouil
-version: 1.10.0
-quarto-required: '>=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 `` sequence so payloads cannot break out of a
+--- surrounding inline `]])
+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('', '<\\/'))
end
--- Escape special Lua pattern characters for use in string.gsub.
diff --git a/docs/_extensions/mcanouil/gitlink/_modules/widget.lua b/docs/_extensions/mcanouil/gitlink/_modules/widget.lua
index 1212673..18074d4 100644
--- a/docs/_extensions/mcanouil/gitlink/_modules/widget.lua
+++ b/docs/_extensions/mcanouil/gitlink/_modules/widget.lua
@@ -1,5 +1,5 @@
--- Gitlink Repository Navbar Widget
---- @module widget
+--- @module "widget"
--- @license MIT
--- @copyright 2026 Mickaël Canouil
--- @author Mickaël Canouil
diff --git a/docs/_extensions/mcanouil/gitlink/_snippets.json b/docs/_extensions/mcanouil/gitlink/_snippets.json
index 853b0ad..e5105b0 100644
--- a/docs/_extensions/mcanouil/gitlink/_snippets.json
+++ b/docs/_extensions/mcanouil/gitlink/_snippets.json
@@ -3,8 +3,7 @@
"prefix": "github-meta",
"body": [
"filters:",
- " - path: gitlink",
- " at: post-quarto",
+ " - gitlink",
"extensions:",
" gitlink:",
" platform: github",
@@ -16,8 +15,7 @@
"prefix": "gitlink-widget-meta",
"body": [
"filters:",
- " - path: gitlink",
- " at: post-quarto",
+ " - gitlink",
"extensions:",
" gitlink:",
" platform: ${1:github}",
@@ -31,8 +29,7 @@
"prefix": "gitlab-meta",
"body": [
"filters:",
- " - path: gitlink",
- " at: post-quarto",
+ " - gitlink",
"extensions:",
" gitlink:",
" platform: gitlab",
diff --git a/docs/_extensions/mcanouil/gitlink/gitlink.lua b/docs/_extensions/mcanouil/gitlink/gitlink.lua
index 0067d62..5def634 100644
--- a/docs/_extensions/mcanouil/gitlink/gitlink.lua
+++ b/docs/_extensions/mcanouil/gitlink/gitlink.lua
@@ -1,4 +1,4 @@
---- @module gitlink
+--- @module "gitlink"
--- @license MIT
--- @copyright 2026 Mickaël Canouil
--- @author Mickaël Canouil
@@ -75,6 +75,9 @@ local COMMIT_SHA_MIN_LENGTH = 7
--- @type string Lua pattern matching a 3-, 4-, 6-, or 8-character hex colour with leading #
local HEX_COLOUR_PATTERN = '^#%x%x%x%x?%x?%x?%x?%x?$'
+--- @type string Class Quarto puts on the markdown-pipeline envelope elements
+local MARKDOWN_ENVELOPE_CLASS = 'quarto-markdown-envelope-contents'
+
--- Validate a colour value as a hex code or CSS named colour.
--- Returns the original value if valid, or nil if invalid.
--- @param value string|nil The candidate colour value
@@ -1020,6 +1023,52 @@ local function process_link(elem)
return elem
end
+--- Skip the content of an inline markdown-pipeline entry.
+--- Quarto renders navigation hrefs and social metadata values as hidden inline
+--- snippets in such a span, then reads the rendered fragment back with
+--- `innerText` and puts the result in an attribute. A converted reference would
+--- add its badge text to that value.
+--- The same envelope also carries values that Quarto inserts with `innerHTML`,
+--- such as a navbar or sidebar title, a navigation entry text, an `about` link
+--- text, and a next or previous page title. A reference in those no longer
+--- converts. The two kinds cannot be told apart, because a navigation entry
+--- registers its text and its href under one identifier prefix, so the whole
+--- envelope is skipped.
+--- A `Div` with the same class is a block entry (page footer, margin and body
+--- header and footer, announcement). Quarto inserts those with `innerHTML` as
+--- well, and they are a separate envelope, so only spans are skipped.
+--- @param span pandoc.Span The span element to inspect
+--- @return pandoc.Span span The unchanged span
+--- @return boolean|nil descend False to stop traversal of the subtree
+local function skip_markdown_envelope(span)
+ if span.classes:includes(MARKDOWN_ENVELOPE_CLASS) then
+ return span, false
+ end
+ return span
+end
+
+--- Convert a string element and stop traversal of the result.
+--- Top-down traversal descends into a returned element, so without this a
+--- created link would have its own text converted a second time.
+--- @param elem pandoc.Str The string element to process
+--- @return pandoc.Str|pandoc.Link|pandoc.List The result of `process_gitlink`
+--- @return boolean descend Always false
+local function process_gitlink_topdown(elem)
+ return process_gitlink(elem), false
+end
+
+--- Turn element handlers into a top-down pass that skips envelope spans.
+--- Each pass needs its own prune point, because the passes that create links
+--- stay separate walks: `process_link` unwraps an autolink into a `Str` that the
+--- later `Str` pass has to convert.
+--- @param handlers table The element handlers for the pass
+--- @return table The filter table for the pass
+local function envelope_safe_pass(handlers)
+ handlers.traverse = 'topdown'
+ handlers.Span = skip_markdown_envelope
+ return handlers
+end
+
--- Pandoc filter configuration
--- Defines the order of filter execution:
--- 1. Extract references from the document
@@ -1032,7 +1081,7 @@ return {
{ Pandoc = get_references },
{ Meta = get_repository },
{ Plain = process_inlines, Para = process_inlines },
- { Link = process_link },
- { Str = process_gitlink },
- { Cite = process_mentions }
+ envelope_safe_pass({ Link = process_link }),
+ envelope_safe_pass({ Str = process_gitlink_topdown }),
+ envelope_safe_pass({ Cite = process_mentions })
}