to fire click events. */
+ cursor: pointer;
+ transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
+}
+
+.gitlink-widget-trigger:hover,
+.gitlink-widget-trigger:focus-visible,
+.gitlink-widget-open .gitlink-widget-trigger {
+ background: var(--gitlink-widget-accent-soft, color-mix(in srgb, var(--gitlink-widget-accent, var(--bs-link-color, #0d6efd)) 15%, transparent));
+ border-color: var(--gitlink-widget-accent, var(--bs-link-color, #0d6efd));
+ color: var(--gitlink-widget-accent, var(--bs-link-color, #0d6efd));
+}
+
+.gitlink-widget-platform-icon {
+ flex-shrink: 0;
+}
+
+.gitlink-widget-stats {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.gitlink-widget-stat {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+.gitlink-widget-stat svg {
+ opacity: 0.85;
+}
+
+.gitlink-widget-count {
+ min-width: 1rem;
+ text-align: center;
+}
+
+.gitlink-widget-arrow {
+ opacity: 0.7;
+ transition: transform 150ms ease;
+}
+
+.gitlink-widget-open .gitlink-widget-arrow {
+ transform: rotate(180deg);
+}
+
+/* Menu rules are scoped under `.gitlink-widget` so they outrank Quarto's
+ `.navbar a` and `.sidebar-navigation a` colours, which would otherwise
+ force the surrounding navigation foreground onto the items. */
+.gitlink-widget .gitlink-widget-dropdown {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ min-width: 14rem;
+ background: var(--gitlink-widget-menu-bg, var(--bs-body-bg, #ffffff));
+ border: 1px solid var(--gitlink-widget-border, var(--bs-border-color-translucent, rgba(128, 128, 128, 0.3)));
+ border-radius: 0.5rem;
+ box-shadow: 0 12px 30px -16px rgba(0, 0, 0, 0.55);
+ padding: 0.35rem;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-8px);
+ transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease;
+ z-index: 1000;
+}
+
+.gitlink-widget.gitlink-widget-open .gitlink-widget-dropdown {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.gitlink-widget .gitlink-widget-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0.4rem 0.7rem;
+ color: var(--gitlink-widget-menu-fg, var(--bs-body-color, #212529));
+ text-decoration: none;
+ font-size: 0.9rem;
+ font-weight: 500;
+ border-radius: 0.375rem;
+ transition: background 150ms ease, color 150ms ease;
+}
+
+.gitlink-widget .gitlink-widget-item svg,
+.gitlink-widget .gitlink-widget-item .bi {
+ flex-shrink: 0;
+}
+
+/* Labels may carry inline markup (e.g. an iconify icon from a shortcode in
+ the entry's `text`); keep any such icon aligned with the text. */
+.gitlink-widget .gitlink-widget-item .gitlink-widget-item-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.gitlink-widget .gitlink-widget-item:hover,
+.gitlink-widget .gitlink-widget-item:focus-visible {
+ background: var(--gitlink-widget-accent-soft, color-mix(in srgb, var(--gitlink-widget-accent, var(--bs-link-color, #0d6efd)) 15%, transparent));
+ color: var(--gitlink-widget-accent, var(--bs-link-color, #0d6efd));
+ text-decoration: none;
+}
+
+.gitlink-widget .gitlink-widget-divider {
+ height: 1px;
+ margin: 0.35rem 0;
+ background: var(--gitlink-widget-border, var(--bs-border-color-translucent, rgba(128, 128, 128, 0.3)));
+}
+
+/* Sidebar navigation item (`sidebar.contents`). Quarto's sidebar is an
+ `overflow-auto` scroll container, which would clip an absolutely positioned
+ menu, so the menu expands inline below the trigger, like a sidebar section.
+ `display` is not animatable, hence no transition. */
+.gitlink-widget-sidebar {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+}
+
+.gitlink-widget-sidebar .gitlink-widget-trigger {
+ width: 100%;
+ justify-content: space-between;
+}
+
+.gitlink-widget-sidebar .gitlink-widget-dropdown {
+ position: static;
+ display: none;
+ min-width: 0;
+ width: 100%;
+ margin-top: 0.35rem;
+ box-shadow: none;
+ transform: none;
+ transition: none;
+}
+
+.gitlink-widget-sidebar.gitlink-widget-open .gitlink-widget-dropdown {
+ display: block;
+}
+
+/* A tools row holding the widget stacks vertically: the widget trigger is a
+ counted pill, not an icon, so it takes its own line above the remaining
+ tools (colour-scheme toggle, reader toggle, overlay search). The marker
+ class is set by widget.js on whichever tools container the placeholder was
+ in; the id keeps this ahead of Quarto's own `.sidebar-tools-main` rather
+ than relying on stylesheet order.
+
+ The row is also the containing block for the overlay menu below. It spans
+ the sidebar, where the centred trigger does not, so anchoring the menu here
+ is what keeps it inside the column. */
+#quarto-sidebar .gitlink-widget-tools {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.4rem;
+ width: 100%;
+}
+
+/* Quarto pulls the tools row up under the sidebar title (`margin-top: -6px`),
+ which crowds the widget pill; give it room instead. */
+.sidebar-title .sidebar-tools-main.gitlink-widget-tools {
+ margin-top: 0.5rem;
+}
+
+/* The widget sits below the icon tools (colour-scheme and reader toggles) and
+ above the search control, whether search is the sidebar field below the row
+ or an overlay button inside it. `position: static` hands the menu's
+ containing block up to the row: the widget is `position: relative` by
+ default, which would otherwise anchor the menu to the trigger pill. */
+.gitlink-widget-tools .gitlink-widget {
+ order: 1;
+ position: static;
+}
+
+.gitlink-widget-tools #quarto-search {
+ order: 2;
+}
+
+/* A tool sits in a strip of icon-sized controls, where an inline menu would
+ be laid out as another flex item beside them, so it keeps the overlay menu.
+
+ Quarto's sidebar does not clip: it sets `overflow-y: auto` and leaves
+ `overflow-x` at `visible`, which computes to `auto`, so a menu wider than
+ the column scrolls it sideways instead. The menu is hidden with
+ `visibility` rather than `display`, so it takes part in layout even while
+ shut and the scrollbar is there before anything is opened.
+
+ Stretching it between both edges of the row (which the rule above made the
+ containing block) is what bounds it: the trigger is centred, so anchoring
+ to the trigger left the menu running past the column's right edge. `width`
+ has to give way with it, since a specified width over-constrains an
+ absolutely positioned box and `right` is the offset that gets dropped, and
+ the 14rem floor has to go for the same reason on a narrow sidebar. */
+.gitlink-widget-tools .gitlink-widget-dropdown {
+ left: 0;
+ right: 0;
+ min-width: 0;
+ width: auto;
+}
+
+/* The collapsed navbar-nav is a Bootstrap `.navbar-nav-scroll` region
+ (max-height + overflow-y: auto) that clips the absolute dropdown, showing a
+ scrollbar instead of the menu. Neutralise it so the menu overlays on top,
+ and anchor the menu to the left edge so it stays on screen. */
+@media (max-width: 991.98px) {
+ .navbar .navbar-collapse .navbar-nav {
+ max-height: none;
+ overflow: visible;
+ }
+
+ .navbar .gitlink-widget .gitlink-widget-dropdown {
+ left: 0;
+ right: auto;
+ }
+}
+
+/* Visually hidden, available to assistive technology. */
+.gitlink-widget-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .gitlink-widget-trigger,
+ .gitlink-widget-arrow,
+ .gitlink-widget-dropdown,
+ .gitlink-widget-item {
+ transition: none;
+ }
+}
diff --git a/docs/_extensions/mcanouil/gitlink/widget.js b/docs/_extensions/mcanouil/gitlink/widget.js
new file mode 100644
index 0000000..cb9dd90
--- /dev/null
+++ b/docs/_extensions/mcanouil/gitlink/widget.js
@@ -0,0 +1,374 @@
+/**
+ * Gitlink - Repository Navigation Widget
+ * Replaces every `#gitlink-widget` placeholder (navbar item, sidebar tool, or
+ * sidebar contents item) with a button showing live star and fork counts and a
+ * dropdown menu of repository links. Configuration is injected by the gitlink
+ * Lua filter as a JSON script element; counts come from the platform REST API,
+ * cached in localStorage for four hours so the rate limit is not hit on every
+ * page view, and shared by every widget on the page. A stale cache or `?`
+ * covers a failed request. Octicon path data for the icons the menu uses
+ * arrives in the configuration (`icons`); other icon names render as Bootstrap
+ * Icons, which Quarto bundles with every HTML page.
+ *
+ * @license MIT
+ * @copyright 2026 Mickaël Canouil
+ * @author Mickaël Canouil
+ */
+// Widget inspired by and derived from the GitHub button in
+// https://github.com/posit-dev/great-docs by Rich Iannone
+// (https://github.com/rich-iannone).
+(function () {
+ "use strict";
+
+ const CACHE_DURATION = 4 * 60 * 60 * 1000;
+
+ const SVG_NS = "http://www.w3.org/2000/svg";
+
+ const ARROW_PATHS = [
+ { d: "M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z" },
+ ];
+
+ const readConfig = () => {
+ const node = document.getElementById("gitlink-widget-config");
+ if (!node) return null;
+ try {
+ return JSON.parse(node.textContent);
+ } catch (error) {
+ return null;
+ }
+ };
+
+ // Large numbers read better abbreviated: 1500 -> 1.5k, 2000000 -> 2M.
+ const formatCount = (value) => {
+ if (value === undefined || value === null || value === "?") return "?";
+ if (value >= 1e6) return (value / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
+ if (value >= 1e3) return (value / 1e3).toFixed(1).replace(/\.0$/, "") + "k";
+ return String(value);
+ };
+
+ // Read and parse the cached stats, or null. Freshness is judged by the
+ // caller so expired data can still serve as a fallback on a failed refresh.
+ const readCache = (cacheKey) => {
+ try {
+ const raw = localStorage.getItem(cacheKey);
+ return raw ? JSON.parse(raw) : null;
+ } catch (error) {
+ return null;
+ }
+ };
+
+ const writeCache = (cacheKey, data) => {
+ try {
+ localStorage.setItem(cacheKey, JSON.stringify(data));
+ } catch (error) {
+ // localStorage may be unavailable (private mode, quota); counts still
+ // render from the live response, only the cache is skipped.
+ }
+ };
+
+ // Only http(s) links belong in the menu; anything else (e.g. javascript:)
+ // is dropped.
+ const safeHref = (href) => {
+ try {
+ const url = new URL(href, window.location.href);
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
+ } catch (error) {
+ return null;
+ }
+ };
+
+ // Labels arrive either as plain strings or as structured parts emitted by
+ // the Lua filter: {text} or {tag, attrs} with tags and attributes already
+ // allowlisted at render time. The same allowlist is enforced again here,
+ // and everything is built with createElement/createTextNode, so no HTML
+ // string is ever parsed in the browser.
+ const ALLOWED_LABEL_TAGS = ["span", "em", "strong", "code", "sub", "sup", "i", "iconify-icon"];
+ const ALLOWED_LABEL_ATTRIBUTES = [
+ "class", "icon", "width", "height", "inline", "title", "role",
+ "aria-label", "aria-hidden",
+ ];
+
+ const buildLabelInto = (target, label) => {
+ if (typeof label === "string") {
+ target.appendChild(document.createTextNode(label));
+ return;
+ }
+ (label || []).forEach((part) => {
+ if (typeof part.text === "string") {
+ target.appendChild(document.createTextNode(part.text));
+ return;
+ }
+ if (!part.tag || ALLOWED_LABEL_TAGS.indexOf(part.tag) === -1) return;
+ const element = document.createElement(part.tag);
+ const attrs = part.attrs || {};
+ ALLOWED_LABEL_ATTRIBUTES.forEach((name) => {
+ if (typeof attrs[name] === "string") {
+ element.setAttribute(name, attrs[name]);
+ }
+ });
+ target.appendChild(element);
+ });
+ };
+
+ // Build an SVG element from path attribute objects ({d, fill-rule}) so no
+ // HTML string is ever parsed.
+ const buildSvg = (paths, size) => {
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("width", String(size));
+ svg.setAttribute("height", String(size));
+ svg.setAttribute("fill", "currentColor");
+ svg.setAttribute("aria-hidden", "true");
+ paths.forEach((attrs) => {
+ const path = document.createElementNS(SVG_NS, "path");
+ path.setAttribute("d", attrs.d);
+ if (attrs["fill-rule"]) path.setAttribute("fill-rule", attrs["fill-rule"]);
+ svg.appendChild(path);
+ });
+ return svg;
+ };
+
+ // An icon spec is a name whose octicon paths were embedded in the config,
+ // or a Bootstrap icon name (Quarto bundles Bootstrap Icons and uses the
+ // same names for its navbar tools and callouts). Returns an element or null.
+ const buildIcon = (spec, size, icons) => {
+ if (!spec) return null;
+ const paths = icons && icons[spec];
+ if (paths) {
+ return buildSvg(paths, size);
+ }
+ if (typeof spec === "string" && /^[a-z0-9-]+$/.test(spec)) {
+ const icon = document.createElement("i");
+ icon.className = "bi bi-" + spec;
+ icon.setAttribute("aria-hidden", "true");
+ icon.style.fontSize = size + "px";
+ icon.style.lineHeight = "1";
+ return icon;
+ }
+ return null;
+ };
+
+ const buildStat = (name, title, iconKey, icons) => {
+ const stat = document.createElement("span");
+ stat.className = "gitlink-widget-stat gitlink-widget-" + name;
+ stat.title = title;
+ const icon = buildIcon(iconKey, 14, icons);
+ if (icon) stat.appendChild(icon);
+ const count = document.createElement("span");
+ count.className = "gitlink-widget-count";
+ count.dataset.stat = name;
+ count.textContent = "-";
+ stat.appendChild(count);
+ return stat;
+ };
+
+ // Disclosure pattern: a native button toggling a plain list of links, per
+ // the ARIA Authoring Practices for navigation link collections. No menu
+ // roles, so Tab moves through the links naturally.
+ const buildTrigger = (config, menuId) => {
+ const trigger = document.createElement("button");
+ trigger.type = "button";
+ trigger.className = "gitlink-widget-trigger";
+ trigger.setAttribute("aria-expanded", "false");
+ trigger.setAttribute("aria-controls", menuId);
+ trigger.setAttribute("aria-label", config.menuLabel);
+
+ const platformIcon = buildIcon(config.icon, 20, config.icons);
+ if (platformIcon) {
+ platformIcon.classList.add("gitlink-widget-platform-icon");
+ trigger.appendChild(platformIcon);
+ }
+
+ if (config.api) {
+ const stats = document.createElement("span");
+ stats.className = "gitlink-widget-stats";
+ stats.appendChild(buildStat("stars", "Stars", "star", config.icons));
+ stats.appendChild(buildStat("forks", "Forks", "fork", config.icons));
+ trigger.appendChild(stats);
+ }
+
+ const arrow = buildSvg(ARROW_PATHS, 16);
+ arrow.classList.add("gitlink-widget-arrow");
+ trigger.appendChild(arrow);
+ return trigger;
+ };
+
+ const buildDropdown = (config, menuId) => {
+ const dropdown = document.createElement("div");
+ dropdown.id = menuId;
+ dropdown.className = "gitlink-widget-dropdown";
+ dropdown.setAttribute("aria-hidden", "true");
+
+ config.links.forEach((link) => {
+ if (link.divider) {
+ const divider = document.createElement("div");
+ divider.className = "gitlink-widget-divider";
+ dropdown.appendChild(divider);
+ return;
+ }
+ const href = safeHref(link.href);
+ if (!href) return;
+ const item = document.createElement("a");
+ item.className = "gitlink-widget-item";
+ item.href = href;
+ item.target = "_blank";
+ item.rel = "noopener";
+ const icon = buildIcon(link.icon, 16, config.icons);
+ if (icon) item.appendChild(icon);
+ const label = document.createElement("span");
+ label.className = "gitlink-widget-item-label";
+ buildLabelInto(label, link.label);
+ item.appendChild(label);
+ const newTabHint = document.createElement("span");
+ newTabHint.className = "gitlink-widget-sr-only";
+ newTabHint.textContent = " (opens in new tab)";
+ item.appendChild(newTabHint);
+ dropdown.appendChild(item);
+ });
+ return dropdown;
+ };
+
+ // The first widget on a page keeps the historical `gitlink-widget` id; any
+ // further placeholder gets a suffixed one so ids stay unique.
+ const buildWidget = (config, index) => {
+ const rootId = index === 0 ? "gitlink-widget" : "gitlink-widget-" + index;
+ const menuId = rootId + "-menu";
+ const root = document.createElement("div");
+ root.id = rootId;
+ root.className = "gitlink-widget";
+ root.appendChild(buildTrigger(config, menuId));
+ root.appendChild(buildDropdown(config, menuId));
+ return root;
+ };
+
+ const showStats = (widget, stats, config) => {
+ const setCount = (selector, value) => {
+ const node = widget.querySelector(selector);
+ if (node) node.textContent = formatCount(value);
+ };
+ setCount('.gitlink-widget-count[data-stat="stars"]', stats.stars);
+ setCount('.gitlink-widget-count[data-stat="forks"]', stats.forks);
+
+ // Surface the counts to assistive technology; the visual counters are
+ // bare numbers with mouse-only tooltips.
+ const spoken = [];
+ if (typeof stats.stars === "number") spoken.push(formatCount(stats.stars) + " stars");
+ if (typeof stats.forks === "number") spoken.push(formatCount(stats.forks) + " forks");
+ if (spoken.length > 0) {
+ const trigger = widget.querySelector(".gitlink-widget-trigger");
+ if (trigger) {
+ trigger.setAttribute("aria-label", config.menuLabel + ", " + spoken.join(", "));
+ }
+ }
+ };
+
+ // The in-flight request, shared by every widget on the page (they all read
+ // the same configuration) so several placeholders make one API call instead
+ // of racing each other before the cache is written.
+ let pendingStats = null;
+
+ const fetchStats = (config) => {
+ // Read + parse the cache once; branch on freshness so the stale-fallback
+ // path does not re-read and re-parse the same entry.
+ const cached = readCache(config.cacheKey);
+ if (cached && Date.now() - cached.timestamp <= CACHE_DURATION) {
+ return Promise.resolve(cached);
+ }
+ if (pendingStats) return pendingStats;
+
+ const options = { headers: config.api.headers || {} };
+ // Bound the request so a slow API falls back to the stale cache instead
+ // of leaving the counts pending; older browsers just skip the timeout.
+ if (typeof AbortSignal !== "undefined" && AbortSignal.timeout) {
+ options.signal = AbortSignal.timeout(8000);
+ }
+
+ const request = fetch(config.api.endpoint, options)
+ .then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
+ .then((data) => {
+ const stats = {
+ stars: data[config.api.starsField],
+ forks: data[config.api.forksField],
+ timestamp: Date.now(),
+ };
+ writeCache(config.cacheKey, stats);
+ return stats;
+ })
+ .catch(() => cached || { stars: "?", forks: "?" });
+ pendingStats = request;
+ return request;
+ };
+
+ const loadStats = (widget, config) => {
+ if (!config.api) return;
+ fetchStats(config).then((stats) => showStats(widget, stats, config));
+ };
+
+ const setupDropdown = (widget) => {
+ const trigger = widget.querySelector(".gitlink-widget-trigger");
+ const dropdown = widget.querySelector(".gitlink-widget-dropdown");
+ if (!trigger || !dropdown) return;
+
+ const setOpen = (open) => {
+ trigger.setAttribute("aria-expanded", String(open));
+ dropdown.setAttribute("aria-hidden", String(!open));
+ widget.classList.toggle("gitlink-widget-open", open);
+ };
+
+ // The trigger is a native button, so Enter/Space activation is built in.
+ trigger.addEventListener("click", (event) => {
+ event.stopPropagation();
+ setOpen(trigger.getAttribute("aria-expanded") !== "true");
+ });
+
+ document.addEventListener("click", (event) => {
+ if (!widget.contains(event.target)) setOpen(false);
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key !== "Escape") return;
+ // Closing while focus is on a link inside the menu would drop focus
+ // into a hidden subtree; hand it back to the trigger.
+ if (widget.contains(document.activeElement) && document.activeElement !== trigger) {
+ trigger.focus();
+ }
+ setOpen(false);
+ });
+ };
+
+ // Replace one placeholder anchor with a widget. Only the anchor goes, so
+ // whatever Quarto wrapped it in (`li.nav-item`, `li.sidebar-item >
+ // div.sidebar-item-container`, `div.sidebar-tools-main`) is preserved, and
+ // the widget inherits the spacing and colours of its neighbours.
+ const mountWidget = (anchor, index, config) => {
+ const root = buildWidget(config, index);
+
+ // The tools row is a horizontal strip of icon-sized controls; flag it so
+ // the CSS can stack the widget above the search and colour-scheme
+ // controls instead of squeezing the counts in beside them.
+ const tools = anchor.closest(".sidebar-tools-main, .sidebar-tools-collapse");
+ if (tools) {
+ tools.classList.add("gitlink-widget-tools");
+ } else if (anchor.closest("#quarto-sidebar")) {
+ // A sidebar navigation item is as wide as the sidebar and sits in its
+ // scroll container, so its menu expands inline rather than overlaying.
+ root.classList.add("gitlink-widget-sidebar");
+ }
+
+ anchor.replaceWith(root);
+
+ loadStats(root, config);
+ setupDropdown(root);
+ };
+
+ document.addEventListener("DOMContentLoaded", function () {
+ const config = readConfig();
+ if (!config) return;
+
+ // Quarto rewrites the placeholder href to a relative path
+ // (`./#gitlink-widget` at the root, `../#gitlink-widget` deeper), so
+ // match the fragment suffix rather than an exact href.
+ const anchors = document.querySelectorAll('a[href$="#gitlink-widget"]');
+ anchors.forEach((anchor, index) => mountWidget(anchor, index, config));
+ });
+})();
diff --git a/docs/_extensions/mcanouil/iconify/LICENSE b/docs/_extensions/mcanouil/iconify/LICENSE
new file mode 100644
index 0000000..4b53bb8
--- /dev/null
+++ b/docs/_extensions/mcanouil/iconify/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/iconify/_extension.yml b/docs/_extensions/mcanouil/iconify/_extension.yml
new file mode 100644
index 0000000..36d2ef0
--- /dev/null
+++ b/docs/_extensions/mcanouil/iconify/_extension.yml
@@ -0,0 +1,11 @@
+title: Iconify
+author: Mickaël Canouil
+version: 4.0.0
+quarto-required: '>=1.5.57'
+source: mcanouil/quarto-iconify@4.0.0
+source-type: registry
+contributes:
+ filters:
+ - iconify-filter.lua
+ shortcodes:
+ - iconify.lua
diff --git a/docs/_extensions/mcanouil/iconify/_modules/logging.lua b/docs/_extensions/mcanouil/iconify/_modules/logging.lua
new file mode 100644
index 0000000..a538809
--- /dev/null
+++ b/docs/_extensions/mcanouil/iconify/_modules/logging.lua
@@ -0,0 +1,62 @@
+--- MC Logging - Formatted log output for Quarto Lua filters and shortcodes
+--- @module "logging"
+--- @license MIT
+--- @copyright 2026 Mickaël Canouil
+--- @author Mickaël Canouil
+--- @version 1.0.0
+
+local M = {}
+
+-- ============================================================================
+-- LOGGING UTILITIES
+-- ============================================================================
+
+--- Format and log an error message with extension prefix.
+--- Provides standardised error messages with consistent formatting across extensions.
+--- Format: [extension-name] Message with details.
+---
+--- @param extension_name string The name of the extension (e.g., "external", "lua-env")
+--- @param message string The error message to display
+--- @usage M.log_error("external", "Could not open file 'example.md'.")
+function M.log_error(extension_name, message)
+ quarto.log.error('[' .. extension_name .. '] ' .. message)
+end
+
+--- Format and log a warning message with extension prefix.
+--- Provides standardised warning messages with consistent formatting across extensions.
+--- Format: [extension-name] Message with details.
+---
+--- @param extension_name string The name of the extension (e.g., "external", "lua-env")
+--- @param message string The warning message to display
+--- @usage M.log_warning("lua-env", "No variable name provided.")
+function M.log_warning(extension_name, message)
+ quarto.log.warning('[' .. extension_name .. '] ' .. message)
+end
+
+--- Format and log an output message with extension prefix.
+--- Provides standardised informational messages with consistent formatting across extensions.
+--- Format: [extension-name] Message with details.
+---
+--- @param extension_name string The name of the extension (e.g., "lua-env")
+--- @param message string The informational message to display
+--- @usage M.log_output("lua-env", "Exported metadata to: output.json")
+function M.log_output(extension_name, message)
+ quarto.log.output('[' .. extension_name .. '] ' .. message)
+end
+
+--- Format and log a debug message with extension prefix.
+--- Provides standardised debug messages with consistent formatting across extensions.
+--- Format: [extension-name] Message with details.
+---
+--- @param extension_name string The name of the extension (e.g., "lua-env")
+--- @param message string The debug message to display
+--- @usage M.log_debug("lua-env", "Variable 'x' has value: 42")
+function M.log_debug(extension_name, message)
+ quarto.log.debug('[' .. extension_name .. '] ' .. message)
+end
+
+-- ============================================================================
+-- MODULE EXPORT
+-- ============================================================================
+
+return M
diff --git a/docs/_extensions/mcanouil/iconify/_modules/metadata.lua b/docs/_extensions/mcanouil/iconify/_modules/metadata.lua
new file mode 100644
index 0000000..bab2087
--- /dev/null
+++ b/docs/_extensions/mcanouil/iconify/_modules/metadata.lua
@@ -0,0 +1,182 @@
+--- MC Metadata - Extension configuration and metadata access for Quarto Lua filters and shortcodes
+--- @module "metadata"
+--- @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 required modules
+local str = load_sibling('string.lua')
+local log = load_sibling('logging.lua')
+
+-- ============================================================================
+-- METADATA UTILITIES
+-- ============================================================================
+
+--- Get configuration from extensions.name namespace.
+--- @param meta table Document metadata
+--- @param extension_name string The extension name (e.g., "github", "iconify")
+--- @return any The value/table or nil
+--- @usage local value = M.get_extension_config(meta, 'section-outline')
+function M.get_extension_config(meta, extension_name)
+ local config_ext = meta.extensions and meta.extensions[extension_name]
+ if not config_ext then return nil end
+ return config_ext
+end
+
+--- Extract metadata value from document meta using nested structure.
+--- Supports the extensions.{extension-name}.{key} pattern.
+--- @param meta table The document metadata table
+--- @param extension_name string The extension name (e.g., "github", "iconify")
+--- @param key string The metadata key to retrieve
+--- @return string|nil The metadata value as a string, or nil if not found
+--- @usage local repo = M.get_metadata_value(meta, "github", "repository-name")
+function M.get_metadata_value(meta, extension_name, key)
+ if meta['extensions'] and meta['extensions'][extension_name] and meta['extensions'][extension_name][key] then
+ return str.stringify(meta['extensions'][extension_name][key])
+ end
+ return nil
+end
+
+--- Check for deprecated top-level configuration and emit warning
+--- @param meta table The document metadata table
+--- @param extension_name string The extension name
+--- @param key string|nil The configuration key being accessed (nil to check entire extension config)
+--- @param deprecation_warning_shown boolean Flag to track if warning has been shown
+--- @return any|nil The value from deprecated config, or nil if not found
+--- @return boolean Updated deprecation warning flag
+function M.check_deprecated_config(meta, extension_name, key, deprecation_warning_shown)
+ -- Handle array-based configuration (when key is nil)
+ if key == nil then
+ if not str.is_empty(meta[extension_name]) then
+ if not deprecation_warning_shown then
+ log.log_warning(
+ extension_name,
+ 'Top-level "' .. extension_name .. '" configuration is deprecated. ' ..
+ 'Please use:\n' ..
+ 'extensions:\n' ..
+ ' ' .. extension_name .. ':\n' ..
+ ' - (configuration array)'
+ )
+ deprecation_warning_shown = true
+ end
+ return meta[extension_name], deprecation_warning_shown
+ end
+ return nil, deprecation_warning_shown
+ end
+
+ -- Handle key-value configuration (original behaviour)
+ if not str.is_empty(meta[extension_name]) and not str.is_empty(meta[extension_name][key]) then
+ if not deprecation_warning_shown then
+ log.log_warning(
+ extension_name,
+ 'Top-level "' .. extension_name .. '" configuration is deprecated. ' ..
+ 'Please use:\n' ..
+ 'extensions:\n' ..
+ ' ' .. extension_name .. ':\n' ..
+ ' ' .. key .. ': value'
+ )
+ deprecation_warning_shown = true
+ end
+ return str.stringify(meta[extension_name][key]), deprecation_warning_shown
+ end
+ return nil, deprecation_warning_shown
+end
+
+-- ============================================================================
+-- ENHANCED METADATA/CONFIGURATION UTILITIES
+-- ============================================================================
+
+--- Get option value with fallback hierarchy: args -> extensions.{extension}.{key} -> defaults.
+--- Provides a standardised way to read configuration values with multiple fallback levels.
+--- Priority: 1. Named arguments (kwargs), 2. Document metadata, 3. Default values.
+---
+--- @param spec table Configuration spec with fields: extension (string), key (string), args (table|nil), meta (table|nil), default (any|nil)
+--- @return any The resolved option value (type depends on what's stored in config)
+--- @usage local duration = M.get_option_with_fallbacks({extension = 'animate', key = 'duration', args = kwargs, meta = meta, default = '3s'})
+function M.get_option_with_fallbacks(spec)
+ -- Validate required fields
+ if not spec.extension or not spec.key then
+ error("Configuration spec must include 'extension' and 'key' fields")
+ end
+
+ --- @type string The extension name
+ local extension = spec.extension
+ --- @type string The configuration key
+ local key = spec.key
+ --- @type table|nil Named arguments table
+ local args = spec.args
+ --- @type table|nil Document metadata
+ local meta = spec.meta
+ --- @type any Default value if not found elsewhere
+ local default = spec.default
+
+ -- Priority 1: Check named arguments (kwargs)
+ if args and args[key] then
+ local arg_value = str.stringify(args[key])
+ if not str.is_empty(arg_value) then
+ return arg_value
+ end
+ end
+
+ -- Priority 2: Check metadata extensions.{extension}.{key}
+ if meta then
+ local meta_value = M.get_metadata_value(meta, extension, key)
+ if not str.is_empty(meta_value) then
+ return meta_value
+ end
+ end
+
+ -- Priority 3: Return default value
+ return default
+end
+
+--- Get multiple option values at once with fallback hierarchy.
+--- Batch version of get_option_with_fallbacks for retrieving multiple configuration values.
+--- Returns a table mapping each key to its resolved value.
+---
+--- @param spec table Configuration spec with fields: extension (string), keys (table
), args (table|nil), meta (table|nil), defaults (table|nil)
+--- @return table Table mapping each key to its resolved value
+--- @usage local opts = M.get_options({extension = 'animate', keys = {'duration', 'delay'}, args = kwargs, meta = meta, defaults = {duration = '3s', delay = '2s'}})
+function M.get_options(spec)
+ -- Validate required fields
+ if not spec.extension or not spec.keys then
+ error("Configuration spec must include 'extension' and 'keys' fields")
+ end
+
+ --- @type table Result table
+ local result = {}
+
+ --- @type table Default values table
+ local defaults = spec.defaults or {}
+
+ -- Get each key using the single-option fallback logic
+ for _, key in ipairs(spec.keys) do
+ result[key] = M.get_option_with_fallbacks({
+ extension = spec.extension,
+ key = key,
+ args = spec.args,
+ meta = spec.meta,
+ default = defaults[key]
+ })
+ end
+
+ return result
+end
+
+-- ============================================================================
+-- MODULE EXPORT
+-- ============================================================================
+
+return M
diff --git a/docs/_extensions/mcanouil/iconify/_modules/string.lua b/docs/_extensions/mcanouil/iconify/_modules/string.lua
new file mode 100644
index 0000000..8f89e0b
--- /dev/null
+++ b/docs/_extensions/mcanouil/iconify/_modules/string.lua
@@ -0,0 +1,198 @@
+--- MC String - String manipulation and escaping for Quarto Lua filters and shortcodes
+--- @module "string"
+--- @license MIT
+--- @copyright 2026 Mickaël Canouil
+--- @author Mickaël Canouil
+--- @version 1.0.0
+
+local M = {}
+
+-- ============================================================================
+-- STRING UTILITIES
+-- ============================================================================
+
+--- Pandoc utility function for converting values to strings
+--- @type function
+M.stringify = pandoc.utils.stringify
+
+--- Check if a string is empty or nil.
+--- Utility function to determine if a value is empty or nil,
+--- which is useful for parameter validation throughout the module.
+--- @param s string|nil|table The value to check for emptiness
+--- @return boolean True if the value is nil or empty, false otherwise
+--- @usage local result = M.is_empty("") -- returns true
+--- @usage local result = M.is_empty(nil) -- returns true
+--- @usage local result = M.is_empty("hello") -- returns false
+function M.is_empty(s)
+ return s == nil or s == ''
+end
+
+--- Escape special pattern characters in a string for Lua pattern matching
+--- @param s string The string to escape
+--- @return string The escaped string
+--- @usage local escaped = M.escape_pattern("user/repo#123")
+function M.escape_pattern(s)
+ local escaped = s:gsub('([%^%$%(%)%%%.%[%]%*%+%-%?])', '%%%1')
+ return escaped
+end
+
+--- Split a string by a separator
+--- @param str string The string to split
+--- @param sep string The separator pattern
+--- @return table Array of string fields
+--- @usage local parts = M.split("a.b.c", ".")
+function M.split(str, sep)
+ local fields = {}
+ local pattern = string.format('([^%s]+)', sep)
+ str:gsub(pattern, function(c) fields[#fields + 1] = c end)
+ return fields
+end
+
+--- Trim leading and trailing whitespace from a string
+--- @param str string The string to trim
+--- @return string The trimmed string
+--- @usage local trimmed = M.trim(" hello world ") -- returns "hello world"
+function M.trim(str)
+ if str == nil then return '' end
+ 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
+
+-- ============================================================================
+-- ESCAPE UTILITIES
+-- ============================================================================
+
+--- Escape special LaTeX characters in text.
+--- @param text string The text to escape
+--- @return string The escaped text safe for LaTeX
+function M.escape_latex(text)
+ text = string.gsub(text, '\\', '\\textbackslash{}')
+ text = string.gsub(text, '%{', '\\{')
+ text = string.gsub(text, '%}', '\\}')
+ text = string.gsub(text, '%$', '\\$')
+ text = string.gsub(text, '%&', '\\&')
+ text = string.gsub(text, '%%', '\\%%')
+ text = string.gsub(text, '%#', '\\#')
+ text = string.gsub(text, '%^', '\\textasciicircum{}')
+ text = string.gsub(text, '%_', '\\_')
+ text = string.gsub(text, '~', '\\textasciitilde{}')
+ return text
+end
+
+--- Escape special Typst characters in text.
+--- @param text string The text to escape
+--- @return string The escaped text safe for Typst
+function M.escape_typst(text)
+ text = string.gsub(text, '%#', '\\#')
+ return text
+end
+
+--- Escape characters for Typst string literals (inside `"..."`).
+--- @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('"', '\\"')
+end
+
+--- Escape special Lua pattern characters for use in string.gsub.
+--- @param text string The text containing characters to escape
+--- @return string The escaped text safe for Lua patterns
+function M.escape_lua_pattern(text)
+ text = string.gsub(text, '%%', '%%%%')
+ text = string.gsub(text, '%^', '%%^')
+ text = string.gsub(text, '%$', '%%$')
+ text = string.gsub(text, '%(', '%%(')
+ text = string.gsub(text, '%)', '%%)')
+ text = string.gsub(text, '%.', '%%.')
+ text = string.gsub(text, '%[', '%%[')
+ text = string.gsub(text, '%]', '%%]')
+ text = string.gsub(text, '%*', '%%*')
+ text = string.gsub(text, '%+', '%%+')
+ text = string.gsub(text, '%-', '%%-')
+ text = string.gsub(text, '%?', '%%?')
+ return text
+end
+
+--- Escape special HTML characters in text.
+--- Escapes &, <, >, ", and ' to prevent XSS and ensure valid HTML.
+--- @param text string The text to escape
+--- @return string Escaped text safe for use in HTML
+--- @usage local escaped = M.escape_html('Hello ')
+function M.escape_html(text)
+ if text == nil then return '' end
+ if type(text) ~= 'string' then text = tostring(text) end
+ local result = text
+ :gsub('&', '&')
+ :gsub('<', '<')
+ :gsub('>', '>')
+ :gsub('"', '"')
+ :gsub("'", ''')
+ return result
+end
+
+--- Escape special HTML attribute characters.
+--- Escapes characters that could break attribute values.
+--- @param value string The attribute value to escape
+--- @return string Escaped value safe for use in HTML attributes
+--- @usage local escaped = M.escape_attribute('Hello "World"')
+function M.escape_attribute(value)
+ if value == nil then return '' end
+ if type(value) ~= 'string' then value = tostring(value) end
+ local result = value
+ :gsub('&', '&')
+ :gsub('"', '"')
+ :gsub('<', '<')
+ :gsub('>', '>')
+ return result
+end
+
+--- Escape text for different formats.
+--- @param text string The text to escape
+--- @param format string The format to escape for (e.g., "latex", "typst", "lua")
+--- @return string The escaped text
+function M.escape_text(text, format)
+ local escape_functions = {
+ latex = M.escape_latex,
+ typst = M.escape_typst,
+ lua = M.escape_lua_pattern
+ }
+
+ local escape = escape_functions[format]
+ if escape then
+ return escape(text)
+ else
+ error('Unsupported escape format: ' .. format)
+ end
+end
+
+--- Converts a string to a valid HTML id by lowercasing and replacing spaces.
+--- @param text string The text to convert
+--- @return string The HTML id
+function M.ascii_id(text)
+ local id = text:lower():gsub('[^a-z0-9 ]', ''):gsub(' +', '-')
+ return id
+end
+
+-- ============================================================================
+-- MODULE EXPORT
+-- ============================================================================
+
+return M
diff --git a/docs/_extensions/mcanouil/iconify/_modules/typst.lua b/docs/_extensions/mcanouil/iconify/_modules/typst.lua
new file mode 100644
index 0000000..511ae36
--- /dev/null
+++ b/docs/_extensions/mcanouil/iconify/_modules/typst.lua
@@ -0,0 +1,435 @@
+--- MC Iconify Typst - Iconify SVG retrieval, on-disk caching and Typst emission
+--- @module "typst"
+--- @license MIT
+--- @copyright 2026 Mickaël Canouil
+--- @author Mickaël Canouil
+--- @version 1.0.0
+---
+--- Typst cannot natively reach the Iconify icon library. This module fetches
+--- the SVG for a requested icon from the public Iconify API, caches it on disk
+--- under the Quarto project cache, and emits a Typst `#image(...)` referencing
+--- the cached file. The cache is bounded (age- and count-based) and the
+--- cleanup is lock-free yet concurrency-safe: it only ever removes entries
+--- whose `.used` stamp is older than the threshold, and every in-flight render
+--- re-stamps the icons it uses so a concurrent sweep never selects them.
+
+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')
+
+--- 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
+
+--- Units that Typst's `image` height/width accept. CSS units such as `px`,
+--- `rem`, `ex` and the viewport units are intentionally excluded.
+--- @type table
+local TYPST_UNITS = {
+ pt = true, mm = true, cm = true, ['in'] = true, em = true, ['%'] = true
+}
+
+-- ============================================================================
+-- PATH / NAMING HELPERS
+-- ============================================================================
+
+--- Resolve the Quarto project root. Falls back to the working directory for
+--- single-file renders where no project is active.
+--- @return string
+local function project_base()
+ local base = os.getenv('QUARTO_PROJECT_DIR')
+ if base == nil or base == '' then
+ return '.'
+ end
+ 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
+ --- @type table
+ local segments = {}
+ for segment in rel:gmatch('[^/\\]+') do
+ if segment ~= '..' and segment ~= '.' then
+ segments[#segments + 1] = segment
+ end
+ end
+ if #segments == 0 then
+ return DEFAULT_CACHE_REL
+ end
+ return table.concat(segments, '/')
+end
+
+--- URL-encode a query parameter value (RFC 3986 unreserved set kept verbatim).
+--- @param value string
+--- @return string
+local function urlencode(value)
+ return (value:gsub('[^%w%-_%.~]', function(c)
+ return string.format('%%%02X', string.byte(c))
+ end))
+end
+
+--- Build a deterministic, sorted query string from a parameter table.
+--- Keys are sorted so the resulting string (and its hash) is stable.
+--- @param params table
+--- @return string The query string without a leading `?` (empty if no params)
+function M.build_query(params)
+ --- @type table
+ local keys = {}
+ for k, v in pairs(params) do
+ if not str.is_empty(v) then
+ keys[#keys + 1] = k
+ end
+ end
+ table.sort(keys)
+ --- @type table
+ local parts = {}
+ for _, k in ipairs(keys) do
+ parts[#parts + 1] = k .. '=' .. urlencode(params[k])
+ end
+ return table.concat(parts, '&')
+end
+
+--- Assemble the Iconify SVG API URL for an icon and query.
+--- @param set string Icon set (prefix)
+--- @param icon string Icon name
+--- @param query string Pre-built query string (may be empty)
+--- @return string
+function M.build_api_url(set, icon, query)
+ local url = API_BASE .. set .. '/' .. icon .. '.svg'
+ if query ~= '' then
+ url = url .. '?' .. query
+ end
+ return url
+end
+
+--- Deterministic cache file name for an icon variant.
+--- @param set string
+--- @param icon string
+--- @param query string
+--- @return string
+local function entry_name(set, icon, query)
+ local hash = pandoc.utils.sha1(set .. '/' .. icon .. '?' .. query):sub(1, 8)
+ return set .. '-' .. icon .. '-' .. hash .. '.svg'
+end
+
+-- ============================================================================
+-- FILESYSTEM HELPERS
+-- ============================================================================
+
+--- Ensure a directory exists (creating parents). Failure is non-fatal.
+--- @param dir string
+--- @return nil
+local function ensure_dir(dir)
+ pcall(function() pandoc.system.make_directory(dir, true) end)
+end
+
+--- Write content to a path atomically: write to a unique temp file then
+--- rename into place so a concurrent reader never sees a partial file.
+--- @param path string Destination path
+--- @param content string
+--- @return boolean True on success
+local function write_atomic(path, content)
+ local tmp = path .. '.tmp.' .. tostring(os.time()) .. '.' ..
+ tostring(math.random(0, 1000000)) .. tostring(os.clock())
+ local handle = io.open(tmp, 'wb')
+ if not handle then
+ return false
+ end
+ handle:write(content)
+ handle:close()
+ if os.rename(tmp, path) then
+ return true
+ end
+ -- Windows rename fails if the destination exists; replace then retry.
+ os.remove(path)
+ if os.rename(tmp, path) then
+ return true
+ end
+ os.remove(tmp)
+ return false
+end
+
+--- Path of the sidecar last-used stamp for a cache entry.
+--- @param fspath string
+--- @return string
+local function stamp_path(fspath)
+ return fspath .. '.used'
+end
+
+--- Record the current time as the last-used stamp for a cache entry.
+--- @param fspath string
+--- @return nil
+local function touch(fspath)
+ write_atomic(stamp_path(fspath), tostring(os.time()))
+end
+
+--- Read the last-used epoch from a cache entry's stamp.
+--- @param fspath string
+--- @return integer|nil
+local function read_stamp(fspath)
+ local handle = io.open(stamp_path(fspath), 'rb')
+ if not handle then
+ return nil
+ end
+ local content = handle:read('*a')
+ handle:close()
+ if content == nil then
+ return nil
+ end
+ return tonumber(content:match('%d+'))
+end
+
+-- ============================================================================
+-- FETCHING
+-- ============================================================================
+
+--- Check that a payload looks like an SVG document.
+--- @param data string|nil
+--- @return boolean
+local function valid_svg(data)
+ if str.is_empty(data) then
+ return false
+ end
+ --- @cast data string
+ local head = data:gsub('^%s+', ''):sub(1, 5):lower()
+ return head:sub(1, 4) == '