Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions _extensions/code-window/_extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ contributes:
filters:
- at: pre-quarto
path: main.lua
- at: post-quarto
path: _modules/hotfix/typst-title-fix.lua
56 changes: 51 additions & 5 deletions _extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
}
106 changes: 106 additions & 0 deletions _extensions/code-window/_modules/hotfix/typst-title-fix.lua
Original file line number Diff line number Diff line change
@@ -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,
},
}
46 changes: 40 additions & 6 deletions _extensions/code-window/code-window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ local DEFAULTS = {
local HOTFIX_DEFAULTS = {
['code-annotations'] = true,
['skylighting'] = true,
['typst-title'] = true,
}

local CURRENT_FORMAT = nil
Expand Down Expand Up @@ -454,27 +455,50 @@ 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
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
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
Expand All @@ -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
Expand Down