diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d903ca..e2dec39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,15 @@ ### Bug Fixes - fix: unwrap Quarto's `DecoratedCodeBlock` Div to prevent double filename wrapping in Typst output. +- 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. +### 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/_extension.yml b/_extensions/code-window/_extension.yml index 8badc0f..36c17df 100644 --- a/_extensions/code-window/_extension.yml +++ b/_extensions/code-window/_extension.yml @@ -6,3 +6,5 @@ contributes: filters: - at: pre-quarto path: main.lua + - at: post-quarto + 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 b63fb13..a409963 100644 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua @@ -218,18 +218,64 @@ 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). +--- @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 + +--- Walk the document tree and convert inline Code to RawInline with +--- 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 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 = function(el) + return pandoc.RawInline('typst', '`' .. el.text .. '`') + end, + } + + 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 }, }, } 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..7847f96 --- /dev/null +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -0,0 +1,106 @@ +--- @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 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 +#let %s(title: none, ..args) = { + let t = if title != none and type(title) == str { + eval(title, mode: "markup") + } else { + 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 +--- @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 + +--- 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) + if not quarto.doc.is_format('typst') then + 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' + and blk.text:find('code-window: hot-fix for Quarto rendering theorem titles', 1, true) then + return doc + end + end + + local func_names = detect_theorem_types() + if #func_names == 0 then + return doc + end + + -- 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, + }, +} 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