From 492994585a35e029117d75fcecda1a7632fc71df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micka=C3=ABl=20Canouil?=
<8896044+mcanouil@users.noreply.github.com>
Date: Thu, 28 May 2026 23:02:18 +0200
Subject: [PATCH 1/5] feat: bump to v1.5.0 with review fixes and new options
Address the v1.4.0 monorepo review findings and ship the requested
enhancements as a single v1.5.0 release.
Bug fixes
- JavaScript-escape anchor IDs and audio paths via a new
escape_js_string helper so values containing backslashes, quotes,
newlines, or cannot break out of the 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.
--- @param text string The text containing characters to escape
--- @return string The escaped text safe for Lua patterns
diff --git a/_extensions/elevator/_schema.yml b/_extensions/elevator/_schema.yml
index 0cb1092..218b1e4 100644
--- a/_extensions/elevator/_schema.yml
+++ b/_extensions/elevator/_schema.yml
@@ -20,7 +20,7 @@ shortcodes:
attributes:
audio:
type: string
- description: "Path to the main audio file played during the scroll animation."
+ description: "Path to the main audio file played during the scroll animation. The built-in name 'ding' resolves to the bundled ding.mp3."
completion:
type: file
extensions:
@@ -30,10 +30,32 @@ shortcodes:
end:
type: string
default: "ding.mp3"
- description: "Path to the audio file played when the scroll completes."
+ description: "Path to the audio file played when the scroll completes. The built-in name 'ding' resolves to the bundled ding.mp3."
completion:
type: file
extensions:
- .mp3
- .ogg
- .wav
+ volume:
+ type: number
+ description: "Playback volume in the range [0.0, 1.0]. Values outside the range are clamped and a warning is emitted."
+ minimum: 0.0
+ maximum: 1.0
+ loop-audio:
+ type: boolean
+ default: true
+ description: "Whether the main audio loops while scrolling. Set to false to play the audio only once."
+ shortcut:
+ type: string
+ description: "Optional keyboard key (matched against KeyboardEvent.key) that triggers the elevator from anywhere outside form fields. Examples: 't', 'Escape'."
+
+metadata:
+ elevator:
+ type: ["boolean", "object"]
+ description: "Set to false to disable every {{< elevator >}} shortcode in the document. Accepts an object with an 'enabled' boolean as an alternative."
+ properties:
+ enabled:
+ type: boolean
+ default: true
+ description: "Whether {{< elevator >}} shortcodes render anything. Defaults to true."
diff --git a/_extensions/elevator/_snippets.json b/_extensions/elevator/_snippets.json
index 0fafd40..2b7da08 100644
--- a/_extensions/elevator/_snippets.json
+++ b/_extensions/elevator/_snippets.json
@@ -3,5 +3,20 @@
"prefix": "elevator",
"body": "{{< elevator \"${1:Back to top}\" >}}",
"description": "Insert an elevator (back to top) shortcode"
+ },
+ "Elevator shortcode with audio": {
+ "prefix": "elevator-audio",
+ "body": "{{< elevator \"${1:Back to top}\" audio=\"${2:music.mp3}\" >}}",
+ "description": "Insert an elevator shortcode with main audio"
+ },
+ "Elevator shortcode with target": {
+ "prefix": "elevator-target",
+ "body": "{{< elevator \"${1:Go up}\" \"${2:target-id}\" >}}",
+ "description": "Insert an elevator shortcode scrolling to a target id"
+ },
+ "Elevator shortcode with volume and shortcut": {
+ "prefix": "elevator-full",
+ "body": "{{< elevator \"${1:Back to top}\" audio=\"${2:music.mp3}\" volume=${3:0.5} loop-audio=${4:true} shortcut=\"${5:t}\" >}}",
+ "description": "Insert an elevator shortcode with volume, loop, and keyboard shortcut"
}
}
diff --git a/_extensions/elevator/elevator.lua b/_extensions/elevator/elevator.lua
index 674c243..ba2dc19 100644
--- a/_extensions/elevator/elevator.lua
+++ b/_extensions/elevator/elevator.lua
@@ -1,4 +1,4 @@
---- @module elevator
+--- @module "elevator"
--- @license MIT
--- @copyright 2026 Mickaël Canouil
--- @author Mickaël Canouil
@@ -6,92 +6,360 @@
--- Load modules
local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua$', ''))
local html_mod = require(quarto.utils.resolve_path('_modules/html.lua'):gsub('%.lua$', ''))
+local log = require(quarto.utils.resolve_path('_modules/logging.lua'):gsub('%.lua$', ''))
+
+--- Extension name used as a prefix for log messages.
+--- @type string
+local EXTENSION = 'elevator'
+
+--- Default end-of-scroll audio file shipped with the extension.
+--- @type string
+local DEFAULT_END_AUDIO = 'ding.mp3'
+
+--- Built-in named sound aliases mapped to their bundled audio paths.
+--- Keys are user-facing names; values are filenames bundled with the extension.
+--- @type table
+local NAMED_SOUNDS = {
+ ding = 'ding.mp3',
+}
+
+--- Module-level guard preventing the default audio resource from being added
+--- more than once per Quarto render (one process can host several shortcodes).
+--- @type boolean
+local default_audio_registered = false
+
+--- Module-level guard ensuring the missing-default warning fires at most once
+--- per Quarto render to avoid log spam when the shortcode is used repeatedly.
+--- @type boolean
+local missing_default_warned = false
+
+--- Module-level cache for the document-level `elevator: false` opt-out.
+--- `nil` means "not yet inspected"; `true`/`false` mean the metadata has been
+--- read and the result is recorded.
+--- @type boolean|nil
+local globally_disabled = nil
+
+--- Parse a value into a boolean.
+--- Accepts native booleans plus the case-insensitive strings `true`, `yes`,
+--- `1`, `on` (truthy) and `false`, `no`, `0`, `off` (falsy).
+--- @param value any The value to parse
+--- @param default boolean Fallback when the value is missing or unrecognised
+--- @return boolean
+local function parse_boolean(value, default)
+ if value == nil then return default end
+ if type(value) == 'boolean' then return value end
+ local text = str.stringify(value)
+ if str.is_empty(text) then return default end
+ text = text:lower()
+ if text == 'true' or text == 'yes' or text == '1' or text == 'on' then
+ return true
+ end
+ if text == 'false' or text == 'no' or text == '0' or text == 'off' then
+ return false
+ end
+ return default
+end
+
+--- Resolve an audio reference, expanding built-in named sounds to their
+--- bundled filenames. Empty input returns an empty string.
+--- @param reference string The raw audio value supplied by the user
+--- @return string Resolved audio path (possibly the original value)
+local function resolve_audio(reference)
+ if str.is_empty(reference) then return '' end
+ local key = reference:lower()
+ local bundled = NAMED_SOUNDS[key]
+ if bundled then
+ quarto.doc.add_format_resource(bundled)
+ return bundled
+ end
+ return reference
+end
+
+--- Validate and clamp a volume value to the range supported by HTMLAudioElement.
+--- Emits a warning when the input is non-numeric or out of bounds.
+--- @param raw_value string The raw user input
+--- @return number|nil Clamped volume in [0.0, 1.0] or nil when not supplied
+local function parse_volume(raw_value)
+ if str.is_empty(raw_value) then return nil end
+ local number = tonumber(raw_value)
+ if not number then
+ log.log_warning(EXTENSION, "Ignoring non-numeric volume '" .. raw_value .. "'.")
+ return nil
+ end
+ if number < 0 or number > 1 then
+ log.log_warning(
+ EXTENSION,
+ "Volume '" .. raw_value .. "' out of range [0.0, 1.0]; clamping."
+ )
+ if number < 0 then number = 0 end
+ if number > 1 then number = 1 end
+ end
+ return number
+end
+
+--- Determine whether the document opts out of the elevator entirely via the
+--- top-level `elevator: false` metadata flag. The result is cached per
+--- document; pass the `meta` table supplied to the shortcode handler.
+--- @param meta table|nil The document metadata passed by Quarto
+--- @return boolean True when the shortcode should render nothing
+local function is_globally_disabled(meta)
+ if globally_disabled ~= nil then return globally_disabled end
+ if meta and meta.elevator ~= nil then
+ -- Accept both `elevator: false` and `elevator: { enabled: false }`.
+ if type(meta.elevator) == 'table' and meta.elevator.enabled ~= nil then
+ globally_disabled = not parse_boolean(meta.elevator.enabled, true)
+ else
+ globally_disabled = not parse_boolean(meta.elevator, true)
+ end
+ else
+ globally_disabled = false
+ end
+ return globally_disabled
+end
+
+--- Warn (once per render) when the default `ding.mp3` resource cannot be
+--- located alongside this Lua filter. Returns the resolved path on success.
+--- @return string|nil The resolved path to the default audio, or nil when missing
+local function ensure_default_audio()
+ local resolved = quarto.utils.resolve_path(DEFAULT_END_AUDIO)
+ -- `resolve_path` returns a path even when the file does not exist, so probe
+ -- the filesystem to confirm the resource ships with the extension.
+ local handle = io.open(resolved, 'rb')
+ if handle then
+ handle:close()
+ return resolved
+ end
+ if not missing_default_warned then
+ log.log_warning(
+ EXTENSION,
+ "Default audio '" .. DEFAULT_END_AUDIO ..
+ "' was not found alongside the extension; no end-of-scroll sound will play."
+ )
+ missing_default_warned = true
+ end
+ return nil
+end
+
+--- Build the JavaScript snippet wiring one DOM selector to an Elevator
+--- instance. All option fragments are passed pre-serialised so the caller
+--- controls escaping; this helper only stitches the pieces together. Each
+--- block is wrapped in its own IIFE so the local `instance` variable does
+--- not collide with sibling blocks (`var` is function-scoped in JS).
+--- @param selector_js string JavaScript expression returning the element
+--- @param target_js string `targetElement: ...,` snippet (may be empty)
+--- @param main_audio_js string `mainAudio: "...",` snippet (may be empty)
+--- @param end_audio_js string `endAudio: "...",` snippet (may be empty)
+--- @param loop_audio_js string `loopAudio: true|false,` snippet (may be empty)
+--- @param volume_pre_js string `window.Audio` wrap installed before construction
+--- @param volume_post_js string `window.Audio` restore installed after construction
+--- @param shortcut_js string Keyboard-shortcut wiring (may be empty)
+--- @param missing_label string Human-readable label used in `not found` logs
+--- @return string A JavaScript block ready to embed inside a `')
-
- return pandoc.RawInline(
- 'html',
- ''
- )
- else
- return pandoc.Null()
+ local shortcut_js = ''
+ if not str.is_empty(shortcut) then
+ local shortcut_key = str.escape_js_string(shortcut)
+ shortcut_js =
+ ' document.addEventListener("keydown", function(ev) {' ..
+ ' var target = ev.target;' ..
+ ' var tag = target && target.tagName ? target.tagName.toUpperCase() : "";' ..
+ ' var editable = target && target.isContentEditable;' ..
+ ' if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable) { return; }' ..
+ ' if (ev.key === "' .. shortcut_key .. '") {' ..
+ ' ev.preventDefault();' ..
+ ' instance.elevate();' ..
+ ' }' ..
+ ' });'
end
+
+ local custom_wiring = build_wiring(
+ 'document.querySelector(".elevator-button")',
+ target_anchor_js,
+ main_audio_js,
+ end_audio_js,
+ loop_audio_js,
+ volume_pre_js,
+ volume_post_js,
+ shortcut_js,
+ 'Custom button'
+ )
+
+ local quarto_wiring =
+ '(function () {' ..
+ ' var qbtt = document.querySelector("#quarto-back-to-top");' ..
+ ' if (qbtt) {' ..
+ ' qbtt.removeAttribute("onclick");' ..
+ ' qbtt.onclick = null;' ..
+ volume_pre_js ..
+ ' var instance = new Elevator({' ..
+ ' element: qbtt,' ..
+ target_anchor_js ..
+ main_audio_js ..
+ end_audio_js ..
+ loop_audio_js ..
+ ' });' ..
+ volume_post_js ..
+ shortcut_js ..
+ ' }' ..
+ '})();'
+
+ local init_script =
+ 'window.addEventListener("load", function() {' ..
+ custom_wiring ..
+ quarto_wiring ..
+ '});'
+
+ quarto.doc.include_text('after-body', '')
+
+ return pandoc.RawInline(
+ 'html',
+ ''
+ )
end
--- Module export table.
diff --git a/example.qmd b/example.qmd
index b8a91a1..17e7b9c 100644
--- a/example.qmd
+++ b/example.qmd
@@ -18,7 +18,7 @@ Animation is only available for HTML-based documents.
## Installation
```bash
-quarto add mcanouil/quarto-elevator@1.4.0
+quarto add mcanouil/quarto-elevator@1.5.0
```
This will install the extension under the `_extensions` subdirectory.
@@ -41,6 +41,26 @@ To add an elevator button, use the `{{{< elevator >}}}` shortcode:
{{{< elevator