From ff7843d3ae5db19aa5bef5c0ee8e467e9fac77a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:49:59 +0100 Subject: [PATCH 1/8] fix: neutralise inline code in title scaffolds for Typst output Quarto wraps theorem/example titles in __quarto_custom_scaffold Divs with inline-only content. Both the code-window box wrapping and Pandoc's Skylighting generate Typst function calls (#box(...), #NormalTok(...)) that get stringified as literal text when Quarto renders the title parameter as a string. Replace the global Code filter with a selective document walk that converts Code to plain Typst backtick code inside title scaffolds and applies the full box styling everywhere else. --- CHANGELOG.md | 1 + .../_modules/hotfix/skylighting-typst-fix.lua | 62 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d903ca..f0e3fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fixes - fix: unwrap Quarto's `DecoratedCodeBlock` Div to prevent double filename wrapping in Typst output. +- fix: prevent inline code in theorem/example titles from being converted to raw Typst markup that gets stringified. - fix: normalise code blocks with no or unknown language class to `default` for consistent styling across all formats. - fix: default to `#` comment symbol for unknown code block languages (`default`, `txt`, etc.) in annotation detection. - fix: support code annotations with `syntax-highlighting: idiomatic` (native Typst highlighting) via a `show raw.line` rule. diff --git a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua index b63fb13..251766e 100644 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua @@ -218,18 +218,70 @@ function Pandoc(doc) return doc end ---- Wrap inline Code elements with a background box in Typst output. -function Code(el) +--- Check if a Div is a Quarto title scaffold (inline-only content). +--- Quarto wraps theorem/example titles in __quarto_custom_scaffold Divs +--- containing a single Plain or Para block. Converting Code to RawInline +--- inside these Divs causes the raw Typst markup to be stringified as +--- literal text in the title parameter. +--- @param div pandoc.Div +--- @return boolean +local function is_title_scaffold(div) + if div.attributes['__quarto_custom_scaffold'] ~= 'true' then + return false + end + for _, child in ipairs(div.content) do + if child.t ~= 'Plain' and child.t ~= 'Para' then + return false + end + end + return true +end + +--- Convert Code to plain Typst backtick code inside title scaffolds. +--- Prevents both the code-window box wrapping AND Pandoc's Skylighting +--- from generating Typst function calls that get stringified by Quarto. +--- @param el pandoc.Code +--- @return pandoc.RawInline +local function neutralise_title_code(el) + return pandoc.RawInline('typst', '`' .. el.text .. '`') +end + +--- Walk the document tree and convert inline Code to RawInline with +--- background styling, skipping title scaffolds where the conversion +--- would produce raw Typst markup that gets stringified by Quarto. +local function process_inline_code(doc) if not quarto.doc.is_format('typst') then - return el + return doc end - return process_typst_inline(el) + + local code_filter = { Code = function(el) return process_typst_inline(el) end } + local title_filter = { Code = neutralise_title_code } + + local function walk_blocks(blocks) + local new_blocks = {} + for _, blk in ipairs(blocks) do + if blk.t == 'Div' then + if is_title_scaffold(blk) then + table.insert(new_blocks, blk:walk(title_filter)) + else + blk.content = walk_blocks(blk.content) + table.insert(new_blocks, blk) + end + else + table.insert(new_blocks, blk:walk(code_filter)) + end + end + return pandoc.Blocks(new_blocks) + end + + doc.blocks = walk_blocks(doc.blocks) + return doc end return { set_wrapper = set_wrapper, filters = { { Pandoc = Pandoc }, - { Code = Code }, + { Pandoc = process_inline_code }, }, } From e0b0f0c24752f54afe5258fcb42704e7a3719e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:21:44 +0100 Subject: [PATCH 2/8] fix: evaluate theorem title strings as Typst markup Quarto renders custom type titles as title: "..." (string mode) which stringifies any Typst markup. Inline code in titles produces Skylighting tokens with inner quotes that break the Typst string syntax. Two-part fix: - Pre-quarto: convert Code in title scaffolds to plain Typst backtick code, avoiding Skylighting tokens that contain unescaped quotes. - Post-quarto: inject a Typst override of simple-theorem-render that evaluates string titles with eval(mode: "markup"), giving title: [...] semantics even though Quarto emits title: "...". --- CHANGELOG.md | 2 +- _extensions/code-window/_extension.yml | 1 + .../_modules/hotfix/skylighting-typst-fix.lua | 26 +++--- .../_modules/hotfix/typst-title-fix.lua | 79 +++++++++++++++++++ 4 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 _extensions/code-window/_modules/hotfix/typst-title-fix.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e3fd6..145c624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Bug Fixes - fix: unwrap Quarto's `DecoratedCodeBlock` Div to prevent double filename wrapping in Typst output. -- fix: prevent inline code in theorem/example titles from being converted to raw Typst markup that gets stringified. +- fix: evaluate theorem/example title strings as Typst markup so inline code renders correctly instead of being stringified. - fix: normalise code blocks with no or unknown language class to `default` for consistent styling across all formats. - fix: default to `#` comment symbol for unknown code block languages (`default`, `txt`, etc.) in annotation detection. - fix: support code annotations with `syntax-highlighting: idiomatic` (native Typst highlighting) via a `show raw.line` rule. diff --git a/_extensions/code-window/_extension.yml b/_extensions/code-window/_extension.yml index 8badc0f..82af68a 100644 --- a/_extensions/code-window/_extension.yml +++ b/_extensions/code-window/_extension.yml @@ -6,3 +6,4 @@ contributes: filters: - at: pre-quarto path: main.lua + - path: _modules/hotfix/typst-title-fix.lua diff --git a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua index 251766e..a409963 100644 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua @@ -219,10 +219,6 @@ function Pandoc(doc) end --- Check if a Div is a Quarto title scaffold (inline-only content). ---- Quarto wraps theorem/example titles in __quarto_custom_scaffold Divs ---- containing a single Plain or Para block. Converting Code to RawInline ---- inside these Divs causes the raw Typst markup to be stringified as ---- literal text in the title parameter. --- @param div pandoc.Div --- @return boolean local function is_title_scaffold(div) @@ -237,25 +233,23 @@ local function is_title_scaffold(div) return true end ---- Convert Code to plain Typst backtick code inside title scaffolds. ---- Prevents both the code-window box wrapping AND Pandoc's Skylighting ---- from generating Typst function calls that get stringified by Quarto. ---- @param el pandoc.Code ---- @return pandoc.RawInline -local function neutralise_title_code(el) - return pandoc.RawInline('typst', '`' .. el.text .. '`') -end - --- Walk the document tree and convert inline Code to RawInline with ---- background styling, skipping title scaffolds where the conversion ---- would produce raw Typst markup that gets stringified by Quarto. +--- background styling. Code in title scaffolds is converted to plain +--- Typst backtick code to avoid Skylighting tokens with inner quotes +--- that would break the string parameter Quarto generates. +--- The typst-title-fix post-quarto filter then evaluates the string +--- as markup so the backtick code renders with proper inline styling. local function process_inline_code(doc) if not quarto.doc.is_format('typst') then return doc end local code_filter = { Code = function(el) return process_typst_inline(el) end } - local title_filter = { Code = neutralise_title_code } + local title_filter = { + Code = function(el) + return pandoc.RawInline('typst', '`' .. el.text .. '`') + end, + } local function walk_blocks(blocks) local new_blocks = {} diff --git a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua new file mode 100644 index 0000000..2da5040 --- /dev/null +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -0,0 +1,79 @@ +--- @module typst-title-fix +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @brief Hot-fix for Quarto rendering theorem titles as string parameters. +--- Quarto renders custom type titles as title: "..." (string mode) which +--- stringifies any Typst markup. This filter injects a Typst show rule +--- that evaluates string titles as markup so inline code and other +--- formatting render correctly in theorem/example titles. + +--- Typst code that overrides simple-theorem-render to evaluate string +--- titles as Typst markup. Injected after the template definitions so +--- the override replaces the default. The make-frame calls are then +--- re-created with the fixed render function. +local TYPST_TITLE_FIX = [==[ +// code-window: hot-fix for Quarto rendering theorem titles as strings. +// Redefine simple-theorem-render to evaluate string titles as Typst markup. +// This produces title: [...] semantics even though Quarto emits title: "...". +#let simple-theorem-render(prefix: none, title: "", full-title: auto, body) = { + if full-title != "" and full-title != auto and full-title != none { + let rendered-title = if type(full-title) == str { + eval(full-title, mode: "markup") + } else { + full-title + } + strong[#rendered-title.] + h(0.5em) + } + emph(body) + parbreak() +} +]==] + +return { + { + Pandoc = function(doc) + if not quarto.doc.is_format('typst') then + return doc + end + + -- Only inject if there are __quarto_custom Theorem Divs in the document. + local has_theorems = false + local function check_blocks(blocks) + for _, blk in ipairs(blocks) do + if blk.t == 'Div' and blk.attributes['__quarto_custom_type'] == 'Theorem' then + has_theorems = true + return + end + if blk.t == 'Div' then + check_blocks(blk.content) + end + end + end + check_blocks(doc.blocks) + if not has_theorems then + return doc + end + + -- Guard: skip if already injected. + for _, blk in ipairs(doc.blocks) do + if blk.t == 'RawBlock' and blk.format == 'typst' + and blk.text:find('code-window: hot-fix for Quarto rendering theorem titles', 1, true) then + return doc + end + end + + -- Insert at the end of the preamble (before the first non-RawBlock). + local insert_pos = 1 + for idx, blk in ipairs(doc.blocks) do + if blk.t ~= 'RawBlock' then + insert_pos = idx + break + end + end + table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', TYPST_TITLE_FIX)) + return doc + end, + }, +} From 3321bcd8ca1f6fae31c7bc23fdeee93813c6a528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:30:21 +0100 Subject: [PATCH 3/8] fix: wrap theorem functions to evaluate string titles as markup The previous approach redefined simple-theorem-render, but make-frame captures the render function by value at definition time, so the override had no effect. Instead, scan the Typst preamble for make-frame definitions, extract the generated function names (e.g., example, theorem), and inject wrapper functions that evaluate string title parameters with eval(mode: "markup") before delegating to the original function. --- _extensions/code-window/_extension.yml | 3 +- .../_modules/hotfix/typst-title-fix.lua | 70 ++++++++++++------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/_extensions/code-window/_extension.yml b/_extensions/code-window/_extension.yml index 82af68a..36c17df 100644 --- a/_extensions/code-window/_extension.yml +++ b/_extensions/code-window/_extension.yml @@ -6,4 +6,5 @@ contributes: filters: - at: pre-quarto path: main.lua - - path: _modules/hotfix/typst-title-fix.lua + - at: post-quarto + path: _modules/hotfix/typst-title-fix.lua diff --git a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua index 2da5040..e7e0b5c 100644 --- a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -4,33 +4,34 @@ --- @author Mickaël Canouil --- @brief Hot-fix for Quarto rendering theorem titles as string parameters. --- Quarto renders custom type titles as title: "..." (string mode) which ---- stringifies any Typst markup. This filter injects a Typst show rule ---- that evaluates string titles as markup so inline code and other ---- formatting render correctly in theorem/example titles. +--- stringifies any Typst markup. This post-quarto filter scans the Typst +--- preamble for make-frame definitions, then injects wrapper functions that +--- evaluate string titles as Typst markup via eval(mode: "markup"). ---- Typst code that overrides simple-theorem-render to evaluate string ---- titles as Typst markup. Injected after the template definitions so ---- the override replaces the default. The make-frame calls are then ---- re-created with the fixed render function. -local TYPST_TITLE_FIX = [==[ -// code-window: hot-fix for Quarto rendering theorem titles as strings. -// Redefine simple-theorem-render to evaluate string titles as Typst markup. -// This produces title: [...] semantics even though Quarto emits title: "...". -#let simple-theorem-render(prefix: none, title: "", full-title: auto, body) = { - if full-title != "" and full-title != auto and full-title != none { - let rendered-title = if type(full-title) == str { - eval(full-title, mode: "markup") - } else { - full-title - } - strong[#rendered-title.] - h(0.5em) +--- Typst wrapper template. %s is replaced with the function name. +local WRAPPER_TEMPLATE = [==[ +#let _cw-orig-%s = %s +#let %s(title: none, ..args) = { + let t = if title != none and type(title) == str { + eval(title, mode: "markup") + } else { + title } - emph(body) - parbreak() + _cw-orig-%s(title: t, ..args) } ]==] +--- Build Typst code that wraps each theorem function to eval string titles. +--- @param func_names table List of function names to wrap +--- @return string Typst code +local function build_wrappers(func_names) + local parts = { '// code-window: hot-fix for Quarto rendering theorem titles as strings.' } + for _, name in ipairs(func_names) do + table.insert(parts, string.format(WRAPPER_TEMPLATE, name, name, name, name)) + end + return table.concat(parts, '\n') +end + return { { Pandoc = function(doc) @@ -64,15 +65,30 @@ return { end end - -- Insert at the end of the preamble (before the first non-RawBlock). + -- Scan preamble RawBlocks for make-frame definitions to find function names. + -- Pattern: #let (xxx-counter, xxx-box, xxx, show-xxx) = make-frame( + local func_names = {} + for _, blk in ipairs(doc.blocks) do + if blk.t == 'RawBlock' and blk.format == 'typst' then + for name in blk.text:gmatch('#let %([%w%-]+%-counter, [%w%-]+%-box, ([%w%-]+), show%-[%w%-]+%) = make%-frame%(') do + table.insert(func_names, name) + end + end + end + + if #func_names == 0 then + return doc + end + + -- Insert wrappers after the last make-frame definition. local insert_pos = 1 for idx, blk in ipairs(doc.blocks) do - if blk.t ~= 'RawBlock' then - insert_pos = idx - break + if blk.t == 'RawBlock' and blk.format == 'typst' + and blk.text:find('make%-frame%(') then + insert_pos = idx + 1 end end - table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', TYPST_TITLE_FIX)) + table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', build_wrappers(func_names))) return doc end, }, From 6275453551d77442ccb8bbc586e74ce50108bec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:37:05 +0100 Subject: [PATCH 4/8] fix: inject theorem wrappers via doc.blocks instead of header-includes header-includes is placed before template definitions in the Typst preamble, so the theorem functions are not yet defined. RawBlocks inserted into doc.blocks appear after the template preamble, where make-frame has already created the theorem functions. Also scan the source file for cross-reference div IDs (e.g., #exm-, #thm-) to determine which theorem functions to wrap, since this information is not available in the AST at filter time. --- .../_modules/hotfix/typst-title-fix.lua | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua index e7e0b5c..02d67ef 100644 --- a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -4,10 +4,23 @@ --- @author Mickaël Canouil --- @brief Hot-fix for Quarto rendering theorem titles as string parameters. --- Quarto renders custom type titles as title: "..." (string mode) which ---- stringifies any Typst markup. This post-quarto filter scans the Typst ---- preamble for make-frame definitions, then injects wrapper functions that +--- stringifies any Typst markup. This post-quarto filter scans the source +--- for cross-reference div IDs, then injects Typst wrapper functions that --- evaluate string titles as Typst markup via eval(mode: "markup"). +--- Mapping from Quarto cross-reference prefix to Typst function name. +local PREFIX_TO_FUNC = { + thm = 'theorem', + lem = 'lemma', + cor = 'corollary', + prp = 'proposition', + cnj = 'conjecture', + def = 'definition', + exm = 'example', + exr = 'exercise', + sol = 'solution', +} + --- Typst wrapper template. %s is replaced with the function name. local WRAPPER_TEMPLATE = [==[ #let _cw-orig-%s = %s @@ -18,8 +31,7 @@ local WRAPPER_TEMPLATE = [==[ title } _cw-orig-%s(title: t, ..args) -} -]==] +}]==] --- Build Typst code that wraps each theorem function to eval string titles. --- @param func_names table List of function names to wrap @@ -32,6 +44,28 @@ local function build_wrappers(func_names) return table.concat(parts, '\n') end +--- Scan source files for cross-reference div IDs and return the +--- corresponding Typst function names. +--- @return table List of function names +local function detect_theorem_types() + local func_names = {} + local seen = {} + for _, input_file in ipairs(PANDOC_STATE.input_files) do + local f = io.open(input_file, 'r') + if f then + local source = f:read('*a') + f:close() + for prefix in source:gmatch('::: *{#(%w+)%-') do + if PREFIX_TO_FUNC[prefix] and not seen[prefix] then + table.insert(func_names, PREFIX_TO_FUNC[prefix]) + seen[prefix] = true + end + end + end + end + return func_names +end + return { { Pandoc = function(doc) @@ -39,24 +73,6 @@ return { return doc end - -- Only inject if there are __quarto_custom Theorem Divs in the document. - local has_theorems = false - local function check_blocks(blocks) - for _, blk in ipairs(blocks) do - if blk.t == 'Div' and blk.attributes['__quarto_custom_type'] == 'Theorem' then - has_theorems = true - return - end - if blk.t == 'Div' then - check_blocks(blk.content) - end - end - end - check_blocks(doc.blocks) - if not has_theorems then - return doc - end - -- Guard: skip if already injected. for _, blk in ipairs(doc.blocks) do if blk.t == 'RawBlock' and blk.format == 'typst' @@ -65,30 +81,16 @@ return { end end - -- Scan preamble RawBlocks for make-frame definitions to find function names. - -- Pattern: #let (xxx-counter, xxx-box, xxx, show-xxx) = make-frame( - local func_names = {} - for _, blk in ipairs(doc.blocks) do - if blk.t == 'RawBlock' and blk.format == 'typst' then - for name in blk.text:gmatch('#let %([%w%-]+%-counter, [%w%-]+%-box, ([%w%-]+), show%-[%w%-]+%) = make%-frame%(') do - table.insert(func_names, name) - end - end - end - + local func_names = detect_theorem_types() if #func_names == 0 then return doc end - -- Insert wrappers after the last make-frame definition. - local insert_pos = 1 - for idx, blk in ipairs(doc.blocks) do - if blk.t == 'RawBlock' and blk.format == 'typst' - and blk.text:find('make%-frame%(') then - insert_pos = idx + 1 - end - end - table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', build_wrappers(func_names))) + -- Insert at the start of doc.blocks. RawBlocks placed here appear + -- after the Typst template preamble (where make-frame defines the + -- theorem functions), so the wrappers can reference them. + table.insert(doc.blocks, 1, pandoc.RawBlock('typst', build_wrappers(func_names))) + return doc end, }, From 5b007b20080872e26eba989675191b9f272d5d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:00:40 +0100 Subject: [PATCH 5/8] feat: support per-hotfix quarto-version thresholds Each hotfix can now have its own quarto-version threshold for auto-disable, since upstream fixes are unlikely to land in the same Quarto release. The hotfix value can be a boolean or a map with enabled and quarto-version keys: hotfix: code-annotations: quarto-version: "1.10.0" skylighting: quarto-version: "1.11.0" typst-title: quarto-version: "1.10.0" The global quarto-version key is still supported as a fallback. Add typst-title as a new hotfix key for the theorem title fix. --- CHANGELOG.md | 4 ++ .../_modules/hotfix/typst-title-fix.lua | 9 ++++ _extensions/code-window/code-window.lua | 46 ++++++++++++++++--- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 145c624..e2dec39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - fix: default to `#` comment symbol for unknown code block languages (`default`, `txt`, etc.) in annotation detection. - fix: support code annotations with `syntax-highlighting: idiomatic` (native Typst highlighting) via a `show raw.line` rule. +### New Features + +- feat: support per-hotfix `quarto-version` thresholds for independent auto-disable. + ### Refactoring - refactor: extract language normalisation into dedicated `_modules/language.lua` module. diff --git a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua index 02d67ef..7847f96 100644 --- a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -73,6 +73,15 @@ return { return doc end + -- Check if the hotfix is enabled via metadata set by the pre-quarto filter. + local hotfix_meta = doc.meta['_code-window-hotfix'] + if hotfix_meta then + local enabled = hotfix_meta['typst-title'] + if enabled and pandoc.utils.stringify(enabled) == 'false' then + return doc + end + end + -- Guard: skip if already injected. for _, blk in ipairs(doc.blocks) do if blk.t == 'RawBlock' and blk.format == 'typst' diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index efd0ef3..3a7f9d5 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -43,6 +43,7 @@ local DEFAULTS = { local HOTFIX_DEFAULTS = { ['code-annotations'] = true, ['skylighting'] = true, + ['typst-title'] = true, } local CURRENT_FORMAT = nil @@ -454,9 +455,13 @@ function Meta(meta) '"skylighting-fix" is deprecated. Use "hotfix: { skylighting: true/false }" instead.') end - -- Parse hotfix options with version-based auto-disable. + -- Parse hotfix options with per-hotfix version-based auto-disable. + -- Each hotfix value can be: + -- boolean/string: true/false to enable/disable + -- map: { enabled: true/false, quarto-version: "x.y.z" } + -- A global quarto-version key is also supported as a fallback. local hotfix = {} - local hotfix_version_override = false + local global_version_disabled = false if hotfix_meta then local version_str = hotfix_meta['quarto-version'] if version_str then @@ -464,17 +469,36 @@ function Meta(meta) if version_str ~= '' then local ok, threshold = pcall(pandoc.types.Version, version_str) if ok and quarto.version >= threshold then - hotfix_version_override = true + global_version_disabled = true end end end end for key, default in pairs(HOTFIX_DEFAULTS) do - if hotfix_version_override then + local entry = hotfix_meta and hotfix_meta[key] + if entry ~= nil and pandoc.utils.type(entry) == 'table' then + -- Map form: { enabled: bool, quarto-version: "x.y.z" } + local enabled = true + if entry['enabled'] ~= nil then + enabled = pandoc.utils.stringify(entry['enabled']) == 'true' + end + local ver = entry['quarto-version'] + if ver then + ver = pandoc.utils.stringify(ver) + if ver ~= '' then + local ok, threshold = pcall(pandoc.types.Version, ver) + if ok and quarto.version >= threshold then + enabled = false + end + end + end + hotfix[key] = enabled + elseif entry ~= nil then + -- Simple boolean/string form + hotfix[key] = pandoc.utils.stringify(entry) == 'true' + elseif global_version_disabled then hotfix[key] = false - elseif hotfix_meta and hotfix_meta[key] ~= nil then - hotfix[key] = pandoc.utils.stringify(hotfix_meta[key]) == 'true' else hotfix[key] = default end @@ -487,9 +511,19 @@ function Meta(meta) typst_wrapper = opts['wrapper'], hotfix_code_annotations = hotfix['code-annotations'], hotfix_skylighting = hotfix['skylighting'], + hotfix_typst_title = hotfix['typst-title'], code_annotations = annotations_enabled, } + -- Store hotfix state in metadata so the post-quarto typst-title-fix filter + -- can read it (it runs as a separate filter and has no access to CONFIG). + if not meta['_code-window-hotfix'] then + meta['_code-window-hotfix'] = {} + end + meta['_code-window-hotfix']['typst-title'] = pandoc.MetaString( + hotfix['typst-title'] and 'true' or 'false' + ) + -- Cache syntax highlighting background colour for Typst contrast-aware annotations. if CURRENT_FORMAT == 'typst' then local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method From 26830683bb10f187efd2e80321a4c37c0a7357e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:22:16 +0200 Subject: [PATCH 6/8] docs: drop global hotfix quarto-version and update schema/docs Remove the global hotfix.quarto-version fallback in favour of per-hotfix thresholds only. Update _schema.yml to use type: [boolean, object] instead of anyOf (unsupported by quarto-wizard schema). Document the new typst-title hotfix and per-hotfix map form in README. Add fragility comments for internal Quarto dependencies. --- CHANGELOG.md | 2 +- README.md | 41 ++++++++++++++----- .../_modules/hotfix/skylighting-typst-fix.lua | 2 + .../_modules/hotfix/typst-title-fix.lua | 4 ++ _extensions/code-window/_schema.yml | 36 ++++++++++++---- _extensions/code-window/code-window.lua | 17 -------- 6 files changed, 66 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2dec39..6293c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### New Features -- feat: support per-hotfix `quarto-version` thresholds for independent auto-disable. +- feat: replace global `hotfix.quarto-version` with per-hotfix thresholds for independent auto-disable. ### Refactoring diff --git a/README.md b/README.md index b160fe8..a2c37a1 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ extensions: style: "macos" wrapper: "code-window" hotfix: - quarto-version: ~ code-annotations: true skylighting: true + typst-title: true ``` ### Options @@ -77,11 +77,13 @@ extensions: These options are **temporary** and will be removed in a future version (see [Temporary hot-fixes](#temporary-hot-fixes-typst)). -| Option | Type | Default | Description | -| ------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------ | -| `hotfix.quarto-version` | string | _unset_ | Quarto version at or above which all hot-fixes are automatically disabled. | -| `hotfix.code-annotations` | boolean | `true` | Enable the code-annotations hot-fix for Typst output. | -| `hotfix.skylighting` | boolean | `true` | Enable the Skylighting hot-fix for Typst output (overrides block styling and inline code). | +Each hotfix value can be a simple boolean or a map with `enabled` and `quarto-version` keys for per-hotfix version thresholds. + +| Option | Type | Default | Description | +| ------------------------- | ------------ | ------- | ------------------------------------------------------------------------------------------ | +| `hotfix.code-annotations` | boolean/map | `true` | Enable the code-annotations hot-fix for Typst output. | +| `hotfix.skylighting` | boolean/map | `true` | Enable the Skylighting hot-fix for Typst output (overrides block styling and inline code). | +| `hotfix.typst-title` | boolean/map | `true` | Enable the Typst title hot-fix (evaluates theorem title strings as markup). | ### Styles @@ -101,21 +103,37 @@ print("Windows style for this block only") ### Temporary Hot-fixes (Typst) -The extension includes two temporary hot-fixes for Typst output that compensate for missing Quarto/Pandoc features. -Both will be removed once [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170) is released. +The extension includes three temporary hot-fixes for Typst output that compensate for missing Quarto/Pandoc features. +All three will be removed once [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170) is released. After that, the extension will focus solely on **auto-filename** and **code-window-style** features. - **`hotfix.code-annotations`**: processes code annotation markers for Typst, since Quarto does not yet support `code-annotations` in Typst output. The `filename` attribute for code blocks will also become natively supported. - **`hotfix.skylighting`**: overrides Pandoc's Skylighting output for Typst to fix block and inline code styling. +- **`hotfix.typst-title`**: evaluates theorem title strings as Typst markup so that inline formatting (e.g., code) renders correctly. + +Each hotfix can specify its own `quarto-version` threshold, since upstream fixes may land in different Quarto releases: + +```yaml +extensions: + code-window: + hotfix: + code-annotations: + quarto-version: "1.10.0" + skylighting: + quarto-version: "1.11.0" + typst-title: + quarto-version: "1.10.0" +``` -Set `hotfix.quarto-version` to automatically disable both hot-fixes once you update Quarto to the version that includes native support: +The map form also accepts an `enabled` key to explicitly enable or disable a hotfix regardless of version: ```yaml extensions: code-window: hotfix: - quarto-version: "1.10.0" + skylighting: + enabled: false ``` Future removal playbook: @@ -124,7 +142,8 @@ Future removal playbook: 2. Remove the `hotfix` section from `_schema.yml`. 3. Remove the skylighting guard and loader in `main.lua`. 4. Remove annotation processing from `code-window.lua`. -5. Delete `_modules/hotfix/` directory entirely. +5. Remove the typst-title fix filter and metadata bridge. +6. Delete `_modules/hotfix/` directory entirely. ## Example diff --git a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua index a409963..e872f0c 100644 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua @@ -219,6 +219,8 @@ function Pandoc(doc) end --- Check if a Div is a Quarto title scaffold (inline-only content). +--- NOTE: relies on Quarto's internal `__quarto_custom_scaffold` attribute, +--- which is not part of the public API and may change without notice. --- @param div pandoc.Div --- @return boolean local function is_title_scaffold(div) diff --git a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua index 7847f96..7f2361d 100644 --- a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -46,6 +46,8 @@ end --- Scan source files for cross-reference div IDs and return the --- corresponding Typst function names. +--- NOTE: uses raw-text pattern matching on source files, so it will not +--- detect divs inside `include` shortcodes or other indirect sources. --- @return table List of function names local function detect_theorem_types() local func_names = {} @@ -98,6 +100,8 @@ return { -- Insert at the start of doc.blocks. RawBlocks placed here appear -- after the Typst template preamble (where make-frame defines the -- theorem functions), so the wrappers can reference them. + -- This relies on Quarto emitting the template preamble before + -- doc.blocks; if that ordering changes the wrappers will break. table.insert(doc.blocks, 1, pandoc.RawBlock('typst', build_wrappers(func_names))) return doc diff --git a/_extensions/code-window/_schema.yml b/_extensions/code-window/_schema.yml index 27a0c37..5ec2035 100644 --- a/_extensions/code-window/_schema.yml +++ b/_extensions/code-window/_schema.yml @@ -22,17 +22,39 @@ options: type: object description: "Temporary hot-fixes for Typst output. These will be removed when Quarto natively supports the corresponding features (see quarto-dev/quarto-cli#14170)." properties: - quarto-version: - type: string - description: "Quarto version at or above which all hot-fixes are automatically disabled. Leave unset to use individual toggles." code-annotations: - type: boolean + type: [boolean, object] default: true - description: "Enable the code-annotations hot-fix for Typst output." + description: "Enable the code-annotations hot-fix for Typst output. Use a boolean or a map with 'enabled' and 'quarto-version' keys." + properties: + enabled: + type: boolean + description: "Explicitly enable or disable this hot-fix." + quarto-version: + type: string + description: "Quarto version at or above which this hot-fix is automatically disabled." skylighting: - type: boolean + type: [boolean, object] default: true - description: "Enable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background)." + description: "Enable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background). Use a boolean or a map with 'enabled' and 'quarto-version' keys." + properties: + enabled: + type: boolean + description: "Explicitly enable or disable this hot-fix." + quarto-version: + type: string + description: "Quarto version at or above which this hot-fix is automatically disabled." + typst-title: + type: [boolean, object] + default: true + description: "Enable the Typst title hot-fix (evaluates theorem title strings as markup). Use a boolean or a map with 'enabled' and 'quarto-version' keys." + properties: + enabled: + type: boolean + description: "Explicitly enable or disable this hot-fix." + quarto-version: + type: string + description: "Quarto version at or above which this hot-fix is automatically disabled." attributes: CodeBlock: diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 3a7f9d5..9ba139b 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -459,22 +459,7 @@ function Meta(meta) -- Each hotfix value can be: -- boolean/string: true/false to enable/disable -- map: { enabled: true/false, quarto-version: "x.y.z" } - -- A global quarto-version key is also supported as a fallback. local hotfix = {} - local global_version_disabled = false - if hotfix_meta then - local version_str = hotfix_meta['quarto-version'] - if version_str then - version_str = pandoc.utils.stringify(version_str) - if version_str ~= '' then - local ok, threshold = pcall(pandoc.types.Version, version_str) - if ok and quarto.version >= threshold then - global_version_disabled = true - end - end - end - end - for key, default in pairs(HOTFIX_DEFAULTS) do local entry = hotfix_meta and hotfix_meta[key] if entry ~= nil and pandoc.utils.type(entry) == 'table' then @@ -497,8 +482,6 @@ function Meta(meta) elseif entry ~= nil then -- Simple boolean/string form hotfix[key] = pandoc.utils.stringify(entry) == 'true' - elseif global_version_disabled then - hotfix[key] = false else hotfix[key] = default end From fd7a35cfa139ae3c1e510c4776a97b89e8213421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:25:27 +0200 Subject: [PATCH 7/8] fix: use MetaMap for hotfix metadata bridge and guard file read Use pandoc.MetaMap() instead of a plain Lua table when storing hotfix state in document metadata, ensuring correct serialisation across filter boundaries. Wrap f:read in pcall so the file handle is always closed even if the read fails. --- _extensions/code-window/_modules/hotfix/typst-title-fix.lua | 3 ++- _extensions/code-window/code-window.lua | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua index 7f2361d..3fd086c 100644 --- a/_extensions/code-window/_modules/hotfix/typst-title-fix.lua +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -55,8 +55,9 @@ local function detect_theorem_types() for _, input_file in ipairs(PANDOC_STATE.input_files) do local f = io.open(input_file, 'r') if f then - local source = f:read('*a') + local ok, source = pcall(f.read, f, '*a') f:close() + if not ok then source = '' end for prefix in source:gmatch('::: *{#(%w+)%-') do if PREFIX_TO_FUNC[prefix] and not seen[prefix] then table.insert(func_names, PREFIX_TO_FUNC[prefix]) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 9ba139b..ce1d579 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -501,7 +501,7 @@ function Meta(meta) -- Store hotfix state in metadata so the post-quarto typst-title-fix filter -- can read it (it runs as a separate filter and has no access to CONFIG). if not meta['_code-window-hotfix'] then - meta['_code-window-hotfix'] = {} + meta['_code-window-hotfix'] = pandoc.MetaMap({}) end meta['_code-window-hotfix']['typst-title'] = pandoc.MetaString( hotfix['typst-title'] and 'true' or 'false' From ea9f61784c200649327eea5c83710a0b5dcf21f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:29:24 +0200 Subject: [PATCH 8/8] fix: skip typst-title metadata bridge when extension is disabled Guard the hotfix metadata bridge with CONFIG.enabled so the post-quarto typst-title-fix filter does not inject wrappers when the extension is turned off. --- _extensions/code-window/code-window.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index ce1d579..d25047e 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -504,7 +504,7 @@ function Meta(meta) meta['_code-window-hotfix'] = pandoc.MetaMap({}) end meta['_code-window-hotfix']['typst-title'] = pandoc.MetaString( - hotfix['typst-title'] and 'true' or 'false' + CONFIG.enabled and hotfix['typst-title'] and 'true' or 'false' ) -- Cache syntax highlighting background colour for Typst contrast-aware annotations.