diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d903ca..6293c30 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: replace global `hotfix.quarto-version` with per-hotfix thresholds for independent auto-disable. + ### Refactoring - refactor: extract language normalisation into dedicated `_modules/language.lua` module. 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/_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..e872f0c 100644 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua @@ -218,18 +218,66 @@ 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). +--- 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) + 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..3fd086c --- /dev/null +++ b/_extensions/code-window/_modules/hotfix/typst-title-fix.lua @@ -0,0 +1,111 @@ +--- @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. +--- 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 = {} + local seen = {} + for _, input_file in ipairs(PANDOC_STATE.input_files) do + local f = io.open(input_file, 'r') + if f then + 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]) + 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. + -- 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 + end, + }, +} 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 efd0ef3..d25047e 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,27 +455,33 @@ 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" } local hotfix = {} - local hotfix_version_override = 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 + 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 + -- 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 - end - end - - for key, default in pairs(HOTFIX_DEFAULTS) do - if hotfix_version_override then - hotfix[key] = false - elseif hotfix_meta and hotfix_meta[key] ~= nil then - hotfix[key] = pandoc.utils.stringify(hotfix_meta[key]) == 'true' + hotfix[key] = enabled + elseif entry ~= nil then + -- Simple boolean/string form + hotfix[key] = pandoc.utils.stringify(entry) == 'true' else hotfix[key] = default end @@ -487,9 +494,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'] = pandoc.MetaMap({}) + end + meta['_code-window-hotfix']['typst-title'] = pandoc.MetaString( + CONFIG.enabled and 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