diff --git a/CHANGELOG.md b/CHANGELOG.md index e3598cf..9b3fe3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- refactor: Remove the Typst code-annotations and Skylighting hot-fixes and the `hotfix` option, as Quarto 1.12 draws code annotations and filenames in Typst itself. (#91) +- feat: Draw the Typst window around Quarto's own filename helper, so the annotation circles, their links and their text alternatives come from Quarto. (#91) +- feat: Show Quarto's filename bar on a Typst block with a `filename` that sets `code-window-enabled` to `false`. Before, such a block showed no filename at all. (#91) +- refactor: The Typst wrapper function no longer takes `annotations`, `bg-colour` or `block-id`. (#91) +- build: The extension now needs Quarto 1.12 or newer. (#91) + ## 1.8.3 (2026-11-12) - fix: The HTML dependency folder now uses the extension version, so browsers load the new files after an upgrade. (#88) diff --git a/README.md b/README.md index 15d5def..432a604 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ If you're using version control, you will want to check in this directory. ## Documentation -The full documentation lives at : every option and per-block attribute, the three window styles, the folding, and the Typst hot-fixes. +The full documentation lives at : every option and per-block attribute, the three window styles, and the folding. [`example.qmd`](example.qmd) is a short, standalone starting point you can copy. diff --git a/_extensions/code-window/_extension.yml b/_extensions/code-window/_extension.yml index ec74da5..3858797 100644 --- a/_extensions/code-window/_extension.yml +++ b/_extensions/code-window/_extension.yml @@ -1,7 +1,7 @@ title: Code Window author: Mickaël Canouil version: 1.8.3 -quarto-required: ">=1.10.19" +quarto-required: ">=1.12.0" contributes: filters: - at: pre-quarto diff --git a/_extensions/code-window/_modules/hotfix/code-annotations.lua b/_extensions/code-window/_modules/hotfix/code-annotations.lua deleted file mode 100644 index 2e6f56e..0000000 --- a/_extensions/code-window/_modules/hotfix/code-annotations.lua +++ /dev/null @@ -1,197 +0,0 @@ ---- @module "code-annotations" ---- @license MIT ---- @copyright 2026 Mickaël Canouil ---- @author Mickaël Canouil ---- @brief Code annotation detection, stripping, and Typst rendering helpers. ---- Scans CodeBlock elements for inline annotation markers (e.g. # <1>, // <2>) ---- and provides utilities for converting annotations to Typst output. - --- ============================================================================ --- LANGUAGE COMMENT CHARACTERS --- ============================================================================ - ---- Map of language identifiers to their single-line comment prefix. ---- @type table -local LANG_COMMENT_CHARS = { - r = '#', - python = '#', - lua = '--', - javascript = '//', - typescript = '//', - go = '//', - rust = '//', - bash = '#', - sh = '#', - zsh = '#', - fish = '#', - c = '//', - cpp = '//', - cxx = '//', - cc = '//', - cs = '//', - java = '//', - scala = '//', - kotlin = '//', - swift = '//', - objc = '//', - php = '//', - ruby = '#', - perl = '#', - julia = '#', - haskell = '--', - elm = '--', - clojure = ';', - scheme = ';', - lisp = ';', - racket = ';', - erlang = '%%', - elixir = '#', - fortran = '!', - matlab = '%%', - ada = '--', - sql = '--', - plsql = '--', - tsql = '--', - mysql = '--', - sqlite = '--', - postgresql = '--', - vb = "'", - vbnet = "'", - fsharp = '//', - stata = '//', - yaml = '#', - toml = '#', - make = '#', - cmake = '#', - dockerfile = '#', - powershell = '#', - nix = '#', - zig = '//', - dart = '//', - groovy = '//', - d = '//', - nim = '#', - crystal = '#', - v = '//', - odin = '//', - mojo = '#', -} - --- ============================================================================ --- ANNOTATION RESOLUTION --- ============================================================================ - ---- Escape a string for use in a Lua pattern. ---- @param s string ---- @return string -local function escape_pattern(s) - return s:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1') -end - ---- Resolve annotations in a CodeBlock element. ---- Scans each line for a trailing annotation marker (e.g. # <1>) using the ---- language's comment prefix. Strips the marker from the code text and returns ---- the cleaned text along with an annotations table. ---- @param block pandoc.CodeBlock ---- @return string cleaned_text The code with annotation markers removed ---- @return table|nil annotations Maps line numbers (int) to annotation numbers (int), or nil if none found -local function resolve_annotations(block) - if not block.classes or #block.classes == 0 then - return block.text, nil - end - - local lang = block.classes[1]:lower() - local comment = LANG_COMMENT_CHARS[lang] or '#' - - local escaped_comment = escape_pattern(comment) - local pattern = '^(.-)%s*' .. escaped_comment .. '%s*<%s*(%d+)%s*>%s*$' - - local annotations = {} - local lines = {} - local found = false - - local line_num = 0 - for line in (block.text .. '\n'):gmatch('([^\n]*)\n') do - line_num = line_num + 1 - local content, annot_num = line:match(pattern) - if annot_num then - found = true - annotations[line_num] = tonumber(annot_num) - table.insert(lines, content) - else - table.insert(lines, line) - end - end - - if not found then - return block.text, nil - end - - return table.concat(lines, '\n'), annotations -end - --- ============================================================================ --- TYPST CONVERSION HELPERS --- ============================================================================ - ---- Convert an annotations table to a Typst dictionary literal. ---- Keys are stringified line numbers, values are annotation numbers. ---- Example output: (1: 2, 3: 1) ---- @param annotations table Line number to annotation number mapping ---- @return string Typst dictionary literal -local function annotations_to_typst_dict(annotations) - local pairs_list = {} - local keys = {} - for k in pairs(annotations) do - table.insert(keys, k) - end - table.sort(keys) - for _, line_num in ipairs(keys) do - table.insert(pairs_list, - string.format('"%d": %d', line_num, annotations[line_num])) - end - return '(' .. table.concat(pairs_list, ', ') .. ')' -end - ---- Check whether a block is an OrderedList that looks like an annotation list. ---- Annotation lists are OrderedLists immediately following a code block, ---- where each item corresponds to an annotation number. ---- @param block pandoc.Block ---- @return boolean -local function is_annotation_ordered_list(block) - return block and block.t == 'OrderedList' -end - ---- Convert an OrderedList to Typst annotation item RawBlocks. ---- Each list item becomes a #code-window-annotation-item(block-id, n)[...] call. ---- @param ol pandoc.OrderedList The ordered list to convert ---- @param wrapper_prefix string Prefix for the Typst function name ---- @param block_id integer Unique block identifier for bidirectional linking ---- @return pandoc.List List of RawBlock elements -local function ordered_list_to_typst_blocks(ol, wrapper_prefix, block_id) - local blocks = {} - local start = ol.listAttributes and ol.listAttributes.start or 1 - for i, item in ipairs(ol.content) do - local annot_num = start + i - 1 - local content_blocks = pandoc.Blocks(item) - local rendered = pandoc.write(pandoc.Pandoc(content_blocks), 'typst') - rendered = rendered:gsub('%s+$', '') - table.insert(blocks, pandoc.RawBlock('typst', string.format( - '#%s-annotation-item(%d, %d)[%s]', - wrapper_prefix, block_id, annot_num, rendered - ))) - end - return blocks -end - --- ============================================================================ --- MODULE EXPORTS --- ============================================================================ - -return { - LANG_COMMENT_CHARS = LANG_COMMENT_CHARS, - resolve_annotations = resolve_annotations, - annotations_to_typst_dict = annotations_to_typst_dict, - is_annotation_ordered_list = is_annotation_ordered_list, - ordered_list_to_typst_blocks = ordered_list_to_typst_blocks, -} diff --git a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua deleted file mode 100644 index cc62269..0000000 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ /dev/null @@ -1,241 +0,0 @@ ---- @module "skylighting-typst-fix" ---- @license MIT ---- @copyright 2026 Mickaël Canouil ---- @author Mickaël Canouil ---- @brief Typst skylighting styling override and inline code background. ---- This module redefines the Skylighting function to draw code annotation ---- markers, and adds inline code background support for Typst output. ---- Quarto patches the block styling of its own Skylighting function since ---- 1.9.27 (https://github.com/quarto-dev/quarto-cli/issues/14126), but the ---- redefinition replaces that function, so it carries the block styling ---- (width, inset, radius, stroke) and honours its fill parameter itself. - -local _wrapper_prefix = 'code-window' - ---- Set the wrapper prefix for Typst function name generation. ---- Called by main.lua before each handler invocation. ---- @param prefix string The wrapper prefix (e.g. 'code-window' or 'my-window') -local function set_wrapper(prefix) - _wrapper_prefix = prefix -end - ---- Build a Skylighting override that draws annotation markers. ---- It replaces Quarto's patched function, so it sets the block width, inset, ---- radius and stroke itself, and honours its fill parameter. ---- @return string|nil Typst #let Skylighting(...) definition, or nil -local function build_skylighting_override() - local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method - if not hm then return nil end - - local bg = hm['background-color'] - if not bg or type(bg) ~= 'string' then return nil end - - local circled = _wrapper_prefix .. '-circled-number' - return string.format([==[ -// skylighting-typst-fix override -#let Skylighting( - fill: none, - number: false, - start: 1, - sourcelines, -) = { - let bgcolor = if fill != none { fill } else { rgb("%s") } - let has-gutter = start + sourcelines.len() > 999 - - context { - let _page-bg = _cw-page-bg() - let _fg = _cw-fg(_page-bg) - let _border-colour = color.mix((_fg, 15%%), (_page-bg, 85%%)) - let annot-data = _cw-annotations.get() - let blocks = [] - let lnum = start - 1 - let seen-annotes = (:) - - for ln in sourcelines { - lnum = lnum + 1 - if number { - blocks = blocks + box( - width: if has-gutter { 30pt } else { 24pt }, - text([ #lnum ]), - ) - } - - if annot-data != none { - let annot-num = annot-data.annotations.at(str(lnum), default: none) - if annot-num != none { - let lbl-prefix = "cw-" + str(annot-data.block-id) + "-" - if str(annot-num) not in seen-annotes { - seen-annotes.insert(str(annot-num), true) - blocks = blocks + box(width: 100%%)[ - #ln - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - #label(lbl-prefix + "line-" + str(annot-num)) - ] - } else { - blocks = blocks + box(width: 100%%)[ - #ln - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - ] - } - } else { - blocks = blocks + ln - } - } else { - blocks = blocks + ln - } - blocks = blocks + EndLine() - } - - block( - fill: bgcolor, - width: 100%%, - inset: 8pt, - radius: 2pt, - stroke: 0.5pt + _border-colour, - blocks, - ) - } -} -]==], bg, circled, circled) -end - ---- Build a show raw.line rule for annotation support in idiomatic mode. ---- When Skylighting is not available (idiomatic/native Typst highlighting), ---- this rule reads _cw-annotations state and adds circled annotation markers ---- to annotated lines, mirroring what the Skylighting override does. ---- @return string Typst show rule definition -local function build_raw_line_annotation_rule() - local circled = _wrapper_prefix .. '-circled-number' - return string.format([==[ -// idiomatic-mode annotation support for raw blocks -#show raw.line: it => { - context { - let annot-data = _cw-annotations.get() - if annot-data == none { - it - } else { - let annot-num = annot-data.annotations.at(str(it.number), default: none) - if annot-num == none { - it - } else { - let lbl-prefix = "cw-" + str(annot-data.block-id) + "-" - let first-line = calc.min( - ..annot-data.annotations.pairs() - .filter(p => p.at(1) == annot-num) - .map(p => int(p.at(0))) - ) - if it.number == first-line { - box(width: 100%%)[ - #it - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - #label(lbl-prefix + "line-" + str(annot-num)) - ] - } else { - box(width: 100%%)[ - #it - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - ] - } - } - } - } -} -]==], circled, circled) -end - ---- Shape of the box that holds inline code, after its fill. -local _inline_box_style = 'inset: (x: 3pt, y: 0pt), outset: (y: 3pt), ' - .. 'radius: 2pt, stroke: none)[' - ---- Process inline Code for Typst format. ---- Puts the Code element in a box that carries the theme background colour. ---- The element itself stays in the document, so Pandoc highlights it and ---- writes the token definitions for it. ---- @param el pandoc.Code Inline code element ---- @return pandoc.Inlines The boxed code -local function process_typst_inline(el) - local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method - local bg = hm and hm['background-color'] - local opening, closing - - if type(bg) == 'string' then - opening = string.format('#box(fill: rgb("%s"), ', bg) .. _inline_box_style - closing = ']' - else - opening = '#context { let _bg = _cw-page-bg(); let _f = _cw-fg(_bg); ' - .. 'box(fill: color.mix((_f, 10%), (_bg, 90%)), ' .. _inline_box_style - closing = '] }' - end - - return pandoc.Inlines({ - pandoc.RawInline('typst', opening), - el, - pandoc.RawInline('typst', closing), - }) -end - ---- Inject Skylighting override at the start of the document. ---- Always injected when code-window is enabled to support annotation markers. -function Pandoc(doc) - if not quarto.doc.is_format('typst') then - return doc - end - - -- Guard: skip if override or annotation rule already injected. - for _, blk in ipairs(doc.blocks) do - if blk.t == 'RawBlock' and blk.format == 'typst' - and (blk.text:find('// skylighting-typst-fix override', 1, true) - or blk.text:find('// idiomatic-mode annotation support', 1, true)) then - return doc - end - end - - local override = build_skylighting_override() - local rule = override or build_raw_line_annotation_rule() - - -- Insert after the code-window function definitions so the override/rule can - -- reference _cw-annotations and the wrapper-prefixed circled-number function. - local insert_pos = 1 - for idx, blk in ipairs(doc.blocks) do - if blk.t == 'RawBlock' and blk.format == 'typst' - and blk.text:find('_cw%-annotations') then - insert_pos = idx + 1 - break - end - end - table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', rule)) - return doc -end - ---- Walk the document tree and box inline Code with background styling. ---- The extension contributes the pass once, so it sees each Code element ---- once and boxes it once, theorem titles included. Metadata such as the ---- document title is left alone: the template writes it before the ---- definitions the box relies on. -local function process_inline_code(doc) - if not quarto.doc.is_format('typst') then - return doc - end - doc.blocks = doc.blocks:walk({ Code = process_typst_inline }) - return doc -end - -return { - set_wrapper = set_wrapper, - filters = { - { Pandoc = Pandoc }, - { Pandoc = process_inline_code }, - }, -} diff --git a/_extensions/code-window/_modules/inline-code.lua b/_extensions/code-window/_modules/inline-code.lua new file mode 100644 index 0000000..037ee64 --- /dev/null +++ b/_extensions/code-window/_modules/inline-code.lua @@ -0,0 +1,57 @@ +--- @module "inline-code" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @brief Background box for inline code in Typst output. +--- @description Quarto gives inline code no background in Typst. This pass puts +--- each inline Code element in a box filled with the theme background, or with +--- a tone mixed from the page when the theme gives none. The mixed tone uses the +--- colour helpers that the code-window definitions declare. + +--- Shape of the box that holds inline code, after its fill. +local _inline_box_style = 'inset: (x: 3pt, y: 0pt), outset: (y: 3pt), ' + .. 'radius: 2pt, stroke: none)[' + +--- Process inline Code for Typst format. +--- Puts the Code element in a box that carries the theme background colour. +--- The element itself stays in the document, so Pandoc highlights it and +--- writes the token definitions for it. +--- @param el pandoc.Code Inline code element +--- @return pandoc.Inlines The boxed code +local function process_typst_inline(el) + local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method + local bg = hm and hm['background-color'] + local opening, closing + + if type(bg) == 'string' then + opening = string.format('#box(fill: rgb("%s"), ', bg) .. _inline_box_style + closing = ']' + else + opening = '#context { let _bg = _cw-page-bg(); let _f = _cw-fg(_bg); ' + .. 'box(fill: color.mix((_f, 10%), (_bg, 90%)), ' .. _inline_box_style + closing = '] }' + end + + return pandoc.Inlines({ + pandoc.RawInline('typst', opening), + el, + pandoc.RawInline('typst', closing), + }) +end + +--- Walk the document tree and box inline Code with background styling. +--- The extension contributes the pass once, so it sees each Code element +--- once and boxes it once, theorem titles included. Metadata such as the +--- document title is left alone: the template writes it before the +--- definitions the box relies on. +local function process_inline_code(doc) + if not quarto.doc.is_format('typst') then + return doc + end + doc.blocks = doc.blocks:walk({ Code = process_typst_inline }) + return doc +end + +return { + Pandoc = process_inline_code, +} diff --git a/_extensions/code-window/_schema.yml b/_extensions/code-window/_schema.yml index 34003e3..f7c3736 100644 --- a/_extensions/code-window/_schema.yml +++ b/_extensions/code-window/_schema.yml @@ -31,35 +31,6 @@ options: type: boolean default: true description: "Render a small chip next to the filename showing the highlighted-line spec for blocks that set 'code-line-numbers' or 'code-window-lines'." - hotfix: - 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)." - additionalProperties: false - properties: - code-annotations: - type: [boolean, object] - default: true - description: "Enable the code-annotations hot-fix for Typst output. Use a boolean or a map with 'enabled' and 'quarto-version' keys." - additionalProperties: false - 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, object] - default: true - 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." - additionalProperties: false - 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 90bdc65..8f85797 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -13,13 +13,11 @@ local EXTENSION_NAME = 'code-window' local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) -local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) local meta_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/metadata.lua'):gsub('%.lua$', '')) local pdoc = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/pandoc-helpers.lua'):gsub('%.lua$', '')) local html_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/html.lua'):gsub('%.lua$', '')) local cell_output = require(quarto.utils.resolve_path('_modules/cell-output.lua'):gsub('%.lua$', '')) local language = require(quarto.utils.resolve_path('_modules/language.lua'):gsub('%.lua$', '')) -local code_annotations = nil local checker = nil -- ============================================================================ @@ -32,8 +30,6 @@ local checker = nil --- @field style string Window decoration style ('macos', 'windows', 'default') --- @field cell_output boolean Whether the output of an executed cell is framed --- @field typst_wrapper string Typst wrapper function name ---- @field hotfix_code_annotations boolean Whether to apply the code-annotations hot-fix for Typst ---- @field hotfix_skylighting boolean Whether to apply the Skylighting hot-fix for Typst local VALID_STYLES = { ['default'] = true, ['macos'] = true, ['windows'] = true } @@ -46,9 +42,6 @@ local ATTRIBUTE_PREFIX = 'code-window-' --- could not be read, which is a state this extension renders through rather --- than stopping for. Everywhere else _schema.yml decides these seven, so the --- values below are a fallback and not a second place to change one of them. ---- The hotfix defaults are a different story, and are still kept by hand in ---- HOTFIX_DEFAULTS: they are nested in the schema and read on their own path, ---- so changing one in _schema.yml alone still changes nothing. --- All seven stay listed, because the second case has nothing else to read --- from, and an option added to the schema needs an entry here as well or it --- has no default at all on a format the extension does not act on. @@ -74,15 +67,8 @@ local VALID_COLLAPSE = { ['open'] = 'open', } -local HOTFIX_DEFAULTS = { - ['code-annotations'] = true, - ['skylighting'] = true, -} - local CURRENT_FORMAT = nil local CONFIG = nil -local TYPST_BG_COLOUR = nil -local ANNOTATION_BLOCK_COUNTER = 0 -- ============================================================================ -- CELL OUTPUT @@ -353,97 +339,59 @@ local TYPST_COLOUR_HELPERS = [==[ } ]==] ---- Typst annotation helper functions (state, colour, circled numbers, annotation items). ---- Only injected when at least one hot-fix is active. -local TYPST_ANNOTATION_DEF = [==[ -// code-window: annotation state passed to Skylighting via Typst state -#let _cw-annotations = state("cw-annotations", none) - -// Derive a contrasting annotation colour from a background fill. -// Light backgrounds get dark circles; dark backgrounds get light circles. -// Uses ITU-R BT.709 luminance coefficients, matching quarto-cli PR #14170. -#let code-window-annote-colour(bg) = { - if type(bg) == color { - let comps = bg.components(alpha: false) - let lum = if comps.len() == 1 { - comps.at(0) / 100% - } else { - 0.2126 * comps.at(0) / 100% + 0.7152 * comps.at(1) / 100% + 0.0722 * comps.at(2) / 100% - } - if lum < 0.5 { luma(200) } else { luma(60) } - } else { - luma(60) - } -} +--- Typst window state and the replacement for Quarto's filename helper. +--- Quarto draws a block with a filename through quarto-code-filename. The +--- filter keeps Quarto's own helper under another name, then replaces it with +--- one that reads the window state the filter sets before each block. A block +--- that refuses the window, or one the filter never visited, goes to Quarto's +--- helper. Each call clears the state it read, so a block the filter never +--- visited does not take the state of the block before it. "code-window" is +--- renamed with the wrapper option. +local TYPST_WINDOW_STATE = [==[ +#let _cw-window = state("cw-window", none) +]==] -#let code-window-circled-number(n, bg-colour: none) = { - let c = if bg-colour != none { code-window-annote-colour(bg-colour) } else { luma(120) } - box(baseline: 20%, circle( - radius: 4.5pt, - stroke: 0.5pt + c, - )[#set text(size: 5.5pt, fill: c); #align(center + horizon, str(n))]) -} +local TYPST_FILENAME_HELPER = [==[ +#let _cw-native-filename = quarto-code-filename -#let code-window-annotation-item(block-id, n, content) = { - let lbl-prefix = "cw-" + str(block-id) + "-" - context { - let target = label(lbl-prefix + "line-" + str(n)) - let has-target = query(target).len() > 0 - [#block(above: 0.4em, below: 0.4em)[ - #if has-target { - link(target)[#code-window-circled-number(n)] - } else { - code-window-circled-number(n) - } - #h(0.4em) - #content - ] #label(lbl-prefix + "item-" + str(n))] +#let quarto-code-filename(filename, body) = context { + let s = _cw-window.get() + if s != none { + _cw-window.update(none) } -} - -#let code-window-annotated-content(content, annotations: (:), bg-colour: none, block-id: 0) = { - if annotations.len() > 0 { - _cw-annotations.update((annotations: annotations, bg-colour: bg-colour, block-id: block-id)) - content - _cw-annotations.update(none) + if s == none or not s.enabled { + _cw-native-filename(filename, body) } else { - content + code-window( + body, + filename: filename, + is-auto: s.is-auto, + style: s.style, + lines-label: s.lines-label, + ) } } ]==] ---- Typst code-window body content template. ---- Uses annotation wrapper when hot-fixes are active, plain content otherwise. -local TYPST_CONTENT_WITH_ANNOTATIONS = [==[ - code-window-annotated-content( - content, - annotations: annotations, - bg-colour: bg-colour, - block-id: block-id, - ) -]==] - -local TYPST_CONTENT_PLAIN = [==[ - content -]==] - ---- Build the complete Typst code-window function definition. ---- @param has_hotfixes boolean Whether at least one hot-fix is active ---- @return string Typst function definition(s) -local function build_typst_function_def(has_hotfixes) - local content_block = has_hotfixes - and TYPST_CONTENT_WITH_ANNOTATIONS - or TYPST_CONTENT_PLAIN - +--- Build the Typst definitions in two parts. The preamble holds the colour +--- helpers and the window state, which inline code and the state blocks use +--- anywhere in the document. The window part holds the window function and +--- the replacement for Quarto's filename helper, which calls it. +--- @param with_window boolean Whether to define the window function, which a +--- document that defines its own does not need +--- @return string preamble Definitions for the top of the document +--- @return string window Definitions that follow the window function +local function build_typst_function_def(with_window) + local preamble = TYPST_COLOUR_HELPERS .. TYPST_WINDOW_STATE + if not with_window then + return preamble, TYPST_FILENAME_HELPER + end local fn_def = string.format([==[ #let code-window( content, filename: none, is-auto: false, style: "macos", - annotations: (:), - bg-colour: none, - block-id: 0, lines-label: none, ) = { context { @@ -453,6 +401,7 @@ local function build_typst_function_def(has_hotfixes) let surface-fill = color.mix((fg, 5%%), (page-bg, 95%%)) let muted-colour = color.mix((fg, 50%%), (page-bg, 50%%)) let label-colour = color.mix((fg, 65%%), (page-bg, 35%%)) + let outer-code-style = quarto-code-block-style.get() let filename-label = if filename != none { text( @@ -550,113 +499,54 @@ local function build_typst_function_def(has_hotfixes) sticky: true, title-bar, ) - // Strip code block chrome so content fills flush against the window body. - // set block() provides defaults for Skylighting blocks (explicit fill preserved). - // show raw overrides the document-level raw block styling (fill, radius). + // The window draws the border, so Quarto's code blocks inside it lose + // their own radius and outline, and get them back after the window. + // set block() only removes the spacing around the code block: a wider + // rule would also reach the blocks inside Quarto's annotation circles. + // show raw removes the fill of native code blocks. { - set block( - width: 100%%, - inset: 8pt, - radius: 0pt, - stroke: none, - above: 0pt, - below: 0pt, - ) - show raw.where(block: true): set block( - fill: none, - width: 100%%, - radius: 0pt, - stroke: none, - above: 0pt, - below: 0pt, - ) -%s } + set block(above: 0pt, below: 0pt) + show raw.where(block: true): set block(fill: none) + quarto-code-block-style.update((..outer-code-style, radius: 0pt, stroke: none)) + content + quarto-code-block-style.update(outer-code-style) + } }, ) } } -]==], content_block) +]==]) - local result = TYPST_COLOUR_HELPERS - if has_hotfixes then - result = result .. TYPST_ANNOTATION_DEF - end - return result .. fn_def + return preamble, fn_def .. TYPST_FILENAME_HELPER end -- ============================================================================ -- TYPST PROCESSING -- ============================================================================ ---- Get the next unique block ID for annotation linking. ---- @return integer -local function next_block_id() - ANNOTATION_BLOCK_COUNTER = ANNOTATION_BLOCK_COUNTER + 1 - return ANNOTATION_BLOCK_COUNTER -end - ---- Build the Typst bg-colour parameter string. ---- @return string Empty string or ', bg-colour: rgb("...")' -local function typst_bg_colour_param() - if not TYPST_BG_COLOUR then - return '' - end - return string.format(', bg-colour: rgb("%s")', TYPST_BG_COLOUR) -end - ---- Escape a string for embedding in a Typst double-quoted string literal. ---- @param s string ---- @return string -local function typst_escape_string(s) - return (s:gsub('\\', '\\\\'):gsub('"', '\\"')) -end +--- @class WindowState +--- @field enabled boolean Whether the block gets the window +--- @field is_auto boolean Whether the name was derived rather than written +--- @field style string Window decoration style +--- @field lines_label string|nil Highlighted-lines spec for the title bar ---- Build a code-window opening RawBlock for Typst. ---- @param filename string ---- @param is_auto boolean ---- @param style string ---- @param annotations table|nil ---- @param block_id integer ---- @param lines_label string|nil Highlighted-lines spec to display in the title bar +--- Build the raw Typst block that sets the window state for the block after +--- it. Every block the filter visits sets every field, so a block never +--- inherits a value from the block before it. +--- @param params WindowState --- @return pandoc.RawBlock -local function typst_code_window_open(filename, is_auto, style, annotations, block_id, lines_label) - local annot_param = '' - if annotations and next(annotations) then - annot_param = string.format(', annotations: %s, block-id: %d', - code_annotations.annotations_to_typst_dict(annotations), block_id) - end - - local lines_param = '' - if lines_label and lines_label ~= '' then - lines_param = string.format(', lines-label: "%s"', typst_escape_string(lines_label)) +local function typst_window_state(params) + local lines_label = 'none' + if params.lines_label and params.lines_label ~= '' then + lines_label = '"' .. str.escape_typst_string(params.lines_label) .. '"' end - return pandoc.RawBlock('typst', string.format( - '#%s(filename: "%s", is-auto: %s, style: "%s"%s%s%s)[', - CONFIG.typst_wrapper, - typst_escape_string(filename), - is_auto and 'true' or 'false', - style, - annot_param, - typst_bg_colour_param(), - lines_param - )) -end - ---- Build a standalone annotation wrapper for non-windowed blocks. ---- @param annotations table ---- @param block_id integer ---- @return pandoc.RawBlock opening, pandoc.RawBlock closing -local function typst_annotation_wrapper(annotations, block_id) - local open = pandoc.RawBlock('typst', string.format( - '#%s-annotated-content(annotations: %s, block-id: %d%s)[', - CONFIG.typst_wrapper, - code_annotations.annotations_to_typst_dict(annotations), - block_id, - typst_bg_colour_param() + '#_cw-window.update((enabled: %s, is-auto: %s, style: "%s", lines-label: %s))', + tostring(params.enabled), + tostring(params.is_auto), + params.style, + lines_label )) - local close = pandoc.RawBlock('typst', ']') - return open, close end -- ============================================================================ @@ -871,53 +761,6 @@ function Meta(meta) local global_collapse = resolve_collapse(opts['collapse']) - -- Read code-annotations metadata (Quarto standard option). - local annot_meta = meta['code-annotations'] - local annot_value = annot_meta and pandoc.utils.stringify(annot_meta) or '' - local annotations_enabled = annot_value ~= 'none' and annot_value ~= 'false' - - -- Read hotfix sub-table from extensions.code-window.hotfix. - local ext_config = meta_mod.get_extension_config(meta, EXTENSION_NAME) - local hotfix_meta = ext_config and ext_config['hotfix'] or nil - - -- Deprecation check for old flat skylighting-fix key. - if ext_config and ext_config['skylighting-fix'] ~= nil then - log.log_warning(EXTENSION_NAME, - '"skylighting-fix" is deprecated. Use "hotfix: { skylighting: true/false }" instead.') - end - - -- 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 = {} - 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 - hotfix[key] = enabled - elseif entry ~= nil then - -- Simple boolean/string form - hotfix[key] = pandoc.utils.stringify(entry) == 'true' - else - hotfix[key] = default - end - end - CONFIG = { enabled = opts['enabled'] == 'true', auto_filename = opts['auto-filename'] == 'true', @@ -926,22 +769,8 @@ function Meta(meta) typst_wrapper = opts['wrapper'], collapse = global_collapse, lines_label = opts['lines-label'] == 'true', - hotfix_code_annotations = hotfix['code-annotations'], - hotfix_skylighting = hotfix['skylighting'], - code_annotations = annotations_enabled, } - -- 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 - if hm then - local bg = hm['background-color'] - if bg and type(bg) == 'string' then - TYPST_BG_COLOUR = bg - end - end - end - if CURRENT_FORMAT == 'html' and CONFIG.enabled then html_mod.ensure_html_dependency({ name = EXTENSION_NAME, @@ -961,7 +790,7 @@ end --- This handler runs on every CodeBlock structurally, for every format, --- because a plain `CodeBlock` filter entry is not format-scoped the way --- CONFIG.enabled or CURRENT_FORMAT are. For a Typst render, the same block ---- is already checked once by process_typst_block through the Pandoc filter +--- is already checked once by frame_typst_codeblock through the Pandoc filter --- above it in main.lua's filter list, so checker:attributes is called here --- only in the two branches that do not overlap with that pass: the --- disabled/unconfigured branch (where the Typst pass never runs at all, @@ -971,7 +800,7 @@ end --- CodeBlock-group attribute the Typst path leaves unstripped, such as --- code-window-collapse, which is HTML-only and never read on that path. --- Within the html branch, the check runs before the is_plain_output return, ---- not after: process_typst_block (below) checks every attribute +--- not after: frame_typst_codeblock (below) checks every attribute --- unconditionally, before its own is_unnamed_cell_output test, so an --- output-of-an-executed-cell block is validated on the Typst path even --- though nothing about it is otherwise touched. The html branch matches @@ -1060,92 +889,6 @@ local function resolve_window_params(block, resolved, auto_label) return filename, is_auto, overrides.style, overrides.lines_label end ---- Process a single CodeBlock for Typst, returning replacement blocks. ---- Handles both code-window wrapping and standalone annotation rendering. ---- @param block pandoc.CodeBlock ---- @param next_block pandoc.Block|nil The block following this CodeBlock ---- @return pandoc.List replacement_blocks Blocks to splice in ---- @return boolean consumed_next Whether the next block was consumed ---- @return integer|nil annotation_block_id Block ID if annotations were found (for parent propagation) -local function process_typst_block(block, next_block) - if is_quarto_block(block) then - return { block }, false, nil - end - - -- Check this block's own attributes against the CodeBlock group of the - -- schema before anything below reads or rewrites them, in either branch. - local resolved, auto_label = resolve_and_take(block) - - -- The output of an executed cell keeps the shape Quarto gave it, annotations - -- included, unless the engine gave it a filename of its own. - if is_unnamed_cell_output(block) then - return { block }, false, nil - end - - local filename, is_auto, block_style, lines_label = - resolve_window_params(block, resolved, auto_label) - local has_window = filename and filename ~= '' - local effective_style = block_style or CONFIG.style - - -- Resolve annotations if enabled and the code-annotations hot-fix is active. - local annotations = nil - local should_handle_annotations = CONFIG.code_annotations and CONFIG.hotfix_code_annotations - - if should_handle_annotations then - local cleaned_text - cleaned_text, annotations = code_annotations.resolve_annotations(block) - if annotations then - block.text = cleaned_text - end - end - - -- Strip filename attribute so the CodeBlock renders as plain code inside the - -- code-window wrapper (the DecoratedCodeBlock Div is already unwrapped above). - if has_window and block.attributes['filename'] then - block.attributes['filename'] = nil - end - - local has_annotations = annotations and next(annotations) - local consumed_next = false - local result = {} - local block_id = has_annotations and next_block_id() or 0 - - if has_window and has_annotations then - table.insert(result, typst_code_window_open( - filename, is_auto, effective_style, annotations, block_id, lines_label)) - table.insert(result, block) - table.insert(result, pandoc.RawBlock('typst', ']')) - elseif has_window then - table.insert(result, typst_code_window_open( - filename, is_auto, effective_style, nil, 0, lines_label)) - table.insert(result, block) - table.insert(result, pandoc.RawBlock('typst', ']')) - elseif has_annotations then - local open, close = typst_annotation_wrapper(annotations, block_id) - table.insert(result, open) - table.insert(result, block) - table.insert(result, close) - else - table.insert(result, block) - end - - -- Consume the following OrderedList if it is an annotation list. - if has_annotations - and next_block - and code_annotations.is_annotation_ordered_list(next_block) then - local wrapper_prefix = CONFIG.typst_wrapper - local annot_blocks = code_annotations.ordered_list_to_typst_blocks( - next_block, wrapper_prefix, block_id) - for _, ab in ipairs(annot_blocks) do - table.insert(result, ab) - end - consumed_next = true - end - - local returned_block_id = has_annotations and (not consumed_next) and block_id or nil - return result, consumed_next, returned_block_id -end - --- Check if a Div is Quarto's DecoratedCodeBlock wrapper. --- @param div pandoc.Div --- @return boolean @@ -1185,125 +928,174 @@ local function get_decorated_codeblock_filename(div) return nil end ---- Process a flat list of blocks for Typst, handling CodeBlocks and their ---- following OrderedLists. Called recursively on Div contents. +--- Build the window state for a block that Quarto already wrapped in its +--- filename node, from the attributes of the code block inside it. +--- @param div pandoc.Div A DecoratedCodeBlock Div +--- @return pandoc.RawBlock|nil State block, or nil when the node holds no code block for the filter +local function decorated_window_state(div) + local block = extract_codeblock(div) + if not block or is_quarto_block(block) then + return nil + end + local resolved = resolve_and_take(block) + local overrides = read_window_overrides(block, resolved) + return typst_window_state({ + enabled = overrides.enabled, + is_auto = false, + style = overrides.style or CONFIG.style, + lines_label = overrides.lines_label, + }) +end + +--- Frame a code block that has no filename of its own. A block with a derived +--- name is put in Quarto's filename node, which Quarto built for the blocks +--- with a filename before this filter ran, and the window state goes before it. +--- Every other block is returned as it is, its own attributes taken off. +--- @param block pandoc.CodeBlock +--- @return pandoc.RawBlock|nil state Window state to put before the node +--- @return pandoc.Block node Block to put in place of the code block +local function frame_typst_codeblock(block) + if is_quarto_block(block) then + return nil, block + end + -- Check this block's own attributes against the CodeBlock group of the + -- schema before anything below reads them. + local resolved, auto_label = resolve_and_take(block) + if is_unnamed_cell_output(block) then + return nil, block + end + local filename, is_auto, block_style, lines_label = + resolve_window_params(block, resolved, auto_label) + if not is_auto or str.is_empty(filename) then + return nil, block + end + local state = typst_window_state({ + enabled = true, + is_auto = true, + style = block_style or CONFIG.style, + lines_label = lines_label, + }) + -- The constructor returns more than the node. The parentheses keep the node. + return state, (quarto.DecoratedCodeBlock({ filename = filename, code_block = block })) +end + +--- Frame one block when it is a code block, or Quarto's filename node. +--- @param blk pandoc.Block|nil +--- @return pandoc.RawBlock|nil state Window state to put before the node +--- @return pandoc.Block|nil node Block to put in place, or nil when blk holds no code +local function frame_typst_code(blk) + if not blk then + return nil, nil + end + if blk.t == 'CodeBlock' then + return frame_typst_codeblock(blk) + end + if blk.t == 'Div' and is_decorated_codeblock(blk) then + return decorated_window_state(blk), blk + end + return nil, nil +end + +--- Whether a code block carries an annotation marker: a line that ends with a +--- space and "", the shape Quarto reads after a comment opener in any +--- language. Code such as "std::vector<1>" has no space before the marker. +--- @param block pandoc.Block|nil A code block, or Quarto's filename node +--- @return boolean +local function has_annotation_marker(block) + local code = block and (block.t == 'CodeBlock' and block or extract_codeblock(block)) + if not code then + return false + end + for line in (code.text .. '\n'):gmatch('([^\n]*)\n') do + if line:match('%s<%d+>%s*$') then + return true + end + end + return false +end + +--- Walk a list of blocks for Typst and set the window state before each block +--- that Quarto draws through its filename helper. The state block goes before +--- Quarto's node, at the same level, and nothing goes between a block and the +--- block after it, so Quarto still pairs a code block with its annotation list. +--- Inside a layout, the state and the node share one Div, so the layout keeps +--- one cell for them. --- @param blocks pandoc.Blocks|pandoc.List ---- @return pandoc.Blocks processed_blocks ---- @return integer|nil pending_annotation_block_id Block ID if the last block had annotations (for parent consumption) -local function process_typst_blocks(blocks, wrap_codeblocks) - local new_blocks = {} - local pending_annot_block_id = nil +--- @param in_layout boolean|nil Whether the blocks are the cells of a layout +--- @return pandoc.Blocks +local function frame_typst_blocks(blocks, in_layout) + local result = pandoc.Blocks({}) local i = 1 while i <= #blocks do local blk = blocks[i] - - if blk.t == 'CodeBlock' then + local state, node = frame_typst_code(blk) + if node and state and in_layout then + -- In a layout, the state, the node and the annotation list after them + -- share one Div, so the layout keeps one cell for them and Quarto still + -- pairs the block with its list. A list after a block with no + -- annotations keeps its own cell. + local cell = { state, node } local next_blk = blocks[i + 1] - local replacement, consumed_next, annot_id = process_typst_block(blk, next_blk) - local to_insert = (wrap_codeblocks and #replacement > 1) - and { pandoc.Div(replacement) } or replacement - for _, rb in ipairs(to_insert) do - table.insert(new_blocks, rb) + if next_blk and next_blk.t == 'OrderedList' and has_annotation_marker(blk) then + table.insert(cell, next_blk) + i = i + 1 + end + result:insert(pandoc.Div(cell)) + elseif node then + if state then + result:insert(state) end - if consumed_next then - pending_annot_block_id = nil - i = i + 2 + result:insert(node) + elseif blk.t == 'Div' and blk.classes:includes('cell') then + -- Quarto reads the code of an executed cell as the first block of the + -- cell, so the window state of that code goes before the cell. The + -- layout of a cell places its outputs, so the cell is not a layout here. + -- In a layout, the state and the cell share one Div, so the cell keeps + -- one cell of the layout. + local first_state, first_node = frame_typst_code(blk.content[1]) + if first_node then + local rest = blk.content:clone() + rest:remove(1) + blk.content = frame_typst_blocks(rest) + blk.content:insert(1, first_node) else - pending_annot_block_id = annot_id - i = i + 1 + blk.content = frame_typst_blocks(blk.content) end - elseif blk.t == 'Div' and is_decorated_codeblock(blk) then - -- Unwrap Quarto's DecoratedCodeBlock to prevent double filename wrapping. - -- Process the inner CodeBlock directly, replacing the entire Div. - local inner_block = extract_codeblock(blk) - if inner_block then - -- Quarto stores the #| filename: value in its custom node registry, not as - -- a plain Pandoc attribute on the inner CodeBlock. Transfer it here so - -- resolve_window_params sees the explicit filename instead of falling back - -- to auto-filename. - if str.is_empty(inner_block.attributes['filename']) then - local fn = get_decorated_codeblock_filename(blk) - if fn then - inner_block.attributes['filename'] = fn - end - end - local next_blk = blocks[i + 1] - local replacement, consumed_next, annot_id = process_typst_block(inner_block, next_blk) - local to_insert = (wrap_codeblocks and #replacement > 1) - and { pandoc.Div(replacement) } or replacement - for _, rb in ipairs(to_insert) do - table.insert(new_blocks, rb) - end - if consumed_next then - pending_annot_block_id = nil - i = i + 2 - else - pending_annot_block_id = annot_id - i = i + 1 - end + if first_state and in_layout then + result:insert(pandoc.Div({ first_state, blk })) else - -- Fallback: keep the Div as-is if no CodeBlock found. - local processed, inner_pending = process_typst_blocks(blk.content) - blk.content = processed - table.insert(new_blocks, blk) - pending_annot_block_id = inner_pending - i = i + 1 + if first_state then + result:insert(first_state) + end + result:insert(blk) end elseif blk.t == 'Div' then local is_layout = blk.attributes['layout-ncol'] or blk.attributes['layout-nrow'] or blk.attributes['layout'] - local processed, inner_pending = process_typst_blocks(blk.content, is_layout) - blk.content = processed - table.insert(new_blocks, blk) - -- If the Div's last processed block had pending annotations, - -- check if the next sibling is an OrderedList to consume. - if inner_pending then - local next_blk = blocks[i + 1] - if next_blk and code_annotations.is_annotation_ordered_list(next_blk) then - local annot_blocks = code_annotations.ordered_list_to_typst_blocks( - next_blk, CONFIG.typst_wrapper, inner_pending) - for _, ab in ipairs(annot_blocks) do - table.insert(new_blocks, ab) - end - pending_annot_block_id = nil - i = i + 2 - else - pending_annot_block_id = inner_pending - i = i + 1 - end - else - pending_annot_block_id = nil - i = i + 1 - end + blk.content = frame_typst_blocks(blk.content, is_layout) + result:insert(blk) + elseif blk.t == 'BlockQuote' then + blk.content = frame_typst_blocks(blk.content) + result:insert(blk) elseif blk.t == 'BulletList' or blk.t == 'OrderedList' then for j, item in ipairs(blk.content) do - blk.content[j] = process_typst_blocks(pandoc.Blocks(item)) + blk.content[j] = frame_typst_blocks(item) end - table.insert(new_blocks, blk) - pending_annot_block_id = nil - i = i + 1 - elseif blk.t == 'BlockQuote' then - blk.content = process_typst_blocks(blk.content) - table.insert(new_blocks, blk) - pending_annot_block_id = nil - i = i + 1 + result:insert(blk) elseif blk.t == 'DefinitionList' then - for j, def_item in ipairs(blk.content) do + for _, def_item in ipairs(blk.content) do for k, body in ipairs(def_item[2]) do - def_item[2][k] = process_typst_blocks(pandoc.Blocks(body)) + def_item[2][k] = frame_typst_blocks(body) end end - table.insert(new_blocks, blk) - pending_annot_block_id = nil - i = i + 1 + result:insert(blk) else - pending_annot_block_id = nil - table.insert(new_blocks, blk) - i = i + 1 + result:insert(blk) end + i = i + 1 end - return pandoc.Blocks(new_blocks), pending_annot_block_id + return result end --- Walk HTML document blocks and transfer the filename from any DecoratedCodeBlock @@ -1347,28 +1139,29 @@ function Pandoc(doc) return doc end - -- Process code blocks and annotations throughout the document tree. - doc.blocks = process_typst_blocks(doc.blocks) + -- Set the window state before every block Quarto draws with a filename. + doc.blocks = frame_typst_blocks(doc.blocks) - -- Guard: check if the function definition is already present. - local fn_pattern = '#let ' .. CONFIG.typst_wrapper - for _, blk in ipairs(doc.blocks) do + -- A document that defines the window function keeps its own. The filename + -- helper calls it, so the helper goes after that definition, because Typst + -- only sees the names defined before. The colour helpers and the state go at + -- the top, as inline code and every framed block use them. + local fn_pattern = '#let ' .. CONFIG.typst_wrapper .. '(' + local insert_at = 1 + for idx, blk in ipairs(doc.blocks) do if blk.t == 'RawBlock' and blk.format == 'typst' and blk.text:find(fn_pattern, 1, true) then - return doc + insert_at = idx + 1 + break end end - local has_hotfixes = CONFIG.hotfix_code_annotations or CONFIG.hotfix_skylighting - local fn_def = build_typst_function_def(has_hotfixes) + local preamble, window_def = build_typst_function_def(insert_at == 1) if CONFIG.typst_wrapper ~= 'code-window' then - fn_def = fn_def:gsub('code%-window%-annote%-colour', CONFIG.typst_wrapper .. '-annote-colour') - fn_def = fn_def:gsub('code%-window%-circled%-number', CONFIG.typst_wrapper .. '-circled-number') - fn_def = fn_def:gsub('code%-window%-annotation%-item', CONFIG.typst_wrapper .. '-annotation-item') - fn_def = fn_def:gsub('code%-window%-annotated%-content', CONFIG.typst_wrapper .. '-annotated-content') - fn_def = fn_def:gsub('#let code%-window%(', '#let ' .. CONFIG.typst_wrapper .. '(') + window_def = window_def:gsub('code%-window%(', CONFIG.typst_wrapper .. '(') end - table.insert(doc.blocks, 1, pandoc.RawBlock('typst', fn_def)) + table.insert(doc.blocks, insert_at, pandoc.RawBlock('typst', window_def)) + table.insert(doc.blocks, 1, pandoc.RawBlock('typst', preamble)) return doc end @@ -1377,13 +1170,6 @@ end -- MODULE EXPORTS -- ============================================================================ ---- Inject the code-annotations module dependency. ---- Called by main.lua before any filter handlers run. ---- @param mod table The code-annotations module -local function set_code_annotations(mod) - code_annotations = mod -end - --- Inject the schema checker. --- Called by main.lua before any filter handlers run. --- @param mod table The checker built from the vendored validator @@ -1392,7 +1178,6 @@ local function set_checker(mod) end return { - set_code_annotations = set_code_annotations, set_checker = set_checker, Meta = Meta, Pandoc = Pandoc, diff --git a/_extensions/code-window/main.lua b/_extensions/code-window/main.lua index 2f28d44..3db6446 100644 --- a/_extensions/code-window/main.lua +++ b/_extensions/code-window/main.lua @@ -6,7 +6,6 @@ --- Loads all submodules, wires dependencies, and assembles the filter list. local EXTENSION_NAME = 'code-window' -local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) local schema = require(quarto.utils.resolve_path('_vendor/quarto-wizard/schema.lua'):gsub('%.lua$', '')) local check = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/schema-check.lua'):gsub('%.lua$', '')) @@ -28,13 +27,12 @@ local cell_output = require( local language = require( quarto.utils.resolve_path('_modules/language.lua'):gsub('%.lua$', '')) -local code_annotations = require( - quarto.utils.resolve_path('_modules/hotfix/code-annotations.lua'):gsub('%.lua$', '')) +local inline_code = require( + quarto.utils.resolve_path('_modules/inline-code.lua'):gsub('%.lua$', '')) local code_window = require( quarto.utils.resolve_path('code-window.lua'):gsub('%.lua$', '')) -code_window.set_code_annotations(code_annotations) code_window.set_checker(checker) -- ============================================================================ @@ -103,66 +101,28 @@ local function normalise_language(block) return language.CodeBlock(block) end --- ============================================================================ --- SKYLIGHTING HOT-FIX --- ============================================================================ - ---- Load optional skylighting hot-fix module from sibling file. ---- @return table Module table with .filters and .set_wrapper, or empty table -local function load_skylighting_hotfix_module() - local ok, result = pcall(require, - quarto.utils.resolve_path('_modules/hotfix/skylighting-typst-fix.lua'):gsub('%.lua$', '')) - if not ok then - log.log_warning(EXTENSION_NAME, - 'Failed to load optional skylighting hot-fix: ' .. tostring(result)) - return {} - end - if type(result) ~= 'table' then - log.log_warning(EXTENSION_NAME, - 'Skylighting hot-fix did not return a module table.') - return {} - end - return result -end - -- ============================================================================ -- FILTER ASSEMBLY -- ============================================================================ -- Meta runs first because the cell-output pass needs the configuration, and -- that pass runs before the language pass so a marked block is never relabelled. +-- The inline-code box uses the colour helpers that code_window.Pandoc +-- declares, so it runs only where that pass draws chrome. local filters = { { Meta = code_window.Meta }, { Pandoc = mark_cell_output }, { CodeBlock = normalise_language }, { Pandoc = code_window.Pandoc }, { CodeBlock = code_window.CodeBlock }, -} - -local skylighting_mod = load_skylighting_hotfix_module() - --- The hot-fix exists only to serve the chrome, so it asks draws_chrome like --- every other pass that does. Its Skylighting override calls the colour helpers, --- and code_window.Pandoc is what declares them, so asking hotfix_skylighting --- alone left the override in a document that declared none. -for _, subfilter in ipairs(skylighting_mod.filters or {}) do - local wrapped = {} - for element_type, handler in pairs(subfilter) do - wrapped[element_type] = function(...) + { + Pandoc = function(doc) if not code_window.draws_chrome() then return nil end - local cfg = code_window.CONFIG() - if not cfg.hotfix_skylighting then - return nil - end - if skylighting_mod.set_wrapper then - skylighting_mod.set_wrapper(cfg.typst_wrapper) - end - return handler(...) - end - end - table.insert(filters, wrapped) -end + return inline_code.Pandoc(doc) + end, + }, +} return filters diff --git a/docs/examples.qmd b/docs/examples.qmd index 411de5d..0397850 100644 --- a/docs/examples.qmd +++ b/docs/examples.qmd @@ -216,7 +216,7 @@ See [Executed cells](reference.qmd#executed-cells) for the option itself. ## Renaming the Typst function Under Typst the chrome is a call to a function the extension writes into the document. -`wrapper` renames that function and the helper functions beside it: +`wrapper` renames that function: ```{.yaml filename="_quarto.yml"} extensions: @@ -224,48 +224,10 @@ extensions: wrapper: "my-window" ``` -Each block then emits `#my-window(...)` in place of `#code-window(...)`, and the annotation helpers become `my-window-annotation-item` and `my-window-circled-number`. +The document then defines `#let my-window(...)` in place of `#let code-window(...)`, and the filename helper of Quarto calls `my-window`. Rename it when the document already defines a function called `code-window`. The option does nothing in HTML, which draws its chrome from the stylesheet and the script instead. -## The Typst hot-fixes - -The two hot-fixes act on Typst output only. -Each one is on by default. -A boolean turns one off: - -```{.yaml filename="_quarto.yml"} -extensions: - code-window: - hotfix: - code-annotations: true - skylighting: false -``` - -A map adds a version at which the hot-fix switches itself off: - -```{.yaml filename="_quarto.yml"} -extensions: - code-window: - hotfix: - skylighting: - enabled: true - quarto-version: "1.11.0" -``` - -The extension compares that string with the running Quarto. -At or above the named version the hot-fix stays out, whatever `enabled` says. -An upgrade thus retires the hot-fix with no further edit. - -`code-annotations` and `skylighting` are linked. - -`code-annotations` and `skylighting` work together: the first resolves the annotations, and the second supplies the rule that draws the circled marker beside an annotated line. -Turn `skylighting` off, and the numbered list stays under the block while every marker inside it goes, along with the block styling and the inline code backgrounds the same module supplies. - -Every hot-fix follows `enabled`: with the filter off, none of them acts, and the Typst document comes out as Quarto writes it. - -See [Hot-fixes](reference.qmd#hot-fixes) for what each one corrects. - ## Source The repository ships a short, standalone starting point you can copy: [`example.qmd`](https://github.com/mcanouil/quarto-code-window/blob/main/example.qmd). diff --git a/docs/index.qmd b/docs/index.qmd index 2b42bc7..7e6df71 100644 --- a/docs/index.qmd +++ b/docs/index.qmd @@ -62,7 +62,9 @@ See the [Reference](reference.qmd) for every option, and the [Examples](examples HTML formats, and Typst. In HTML the chrome is assembled at page load by a script the extension injects, so the frame needs JavaScript. -Under Typst the chrome is drawn by a wrapper function, and the extension carries hot-fixes for a few Quarto behaviours that Typst output still needs; the [Reference](reference.qmd#hot-fixes) lists them and when they switch themselves off. +In Typst, a wrapper function draws the chrome around the code block that Quarto writes. +Quarto draws the code annotations, and the window frames them. +The [Reference](reference.qmd#how-the-chrome-is-applied) tells how. ## Links diff --git a/docs/reference.qmd b/docs/reference.qmd index 4e42542..7181487 100644 --- a/docs/reference.qmd +++ b/docs/reference.qmd @@ -1,7 +1,7 @@ --- title: "Reference" subtitle: "Every option the code-window extension accepts." -description: "The complete code-window configuration: the document options, the per-block attributes, the window styles, and the Typst hot-fixes." +description: "The complete code-window configuration: the document options, the per-block attributes, and the window styles." --- ## Enabling the filter @@ -32,7 +32,6 @@ extensions: | `collapse` | boolean or string | `false` | Wrap every block in a `
`. `open` renders it expanded, `closed` or `true` collapsed. HTML only. | | `lines-label` | boolean | `true` | Show a chip beside the filename with the highlighted-line spec. | | `wrapper` | string | `code-window` | The Typst function used to draw the chrome. | -| `hotfix` | object | all on | The Typst hot-fixes below. | : Document and project options. {.striped .hover tbl-colwidths="[18,16,12,54]"} @@ -45,7 +44,7 @@ extensions: | Attribute | Type | Description | | --- | --- | --- | -| `code-window-enabled` | boolean | Whether this block gets chrome. Annotations still render when it does not. | +| `code-window-enabled` | boolean | Whether this block gets chrome. Annotations still render when it does not. In Typst, a block with a `filename` then shows the filename bar of Quarto. | | `code-window-no-auto-filename` | boolean | Suppress the derived filename for this block alone. | | `code-window-auto-label` | string | Frame this block with this label, styled as a derived filename. Works when `auto-filename` is `false`. | | `code-window-style` | string | Override the style for this block: `macos`, `windows`, `default`. | @@ -102,7 +101,14 @@ Output named that way is always framed, whatever this option says. In HTML the extension marks blocks and injects a stylesheet and a script. The script runs at page load and builds the title bar, the fold, and the line chip from those marks, so all three need JavaScript. -In Typst the chrome is a call to the `wrapper` function, emitted into the document, and needs nothing at read time. +In Typst, Quarto writes each block that has a filename as a call to its `quarto-code-filename` helper. +The extension replaces this helper with one that calls the `wrapper` function. +A block that gets its name from its language or from `code-window-auto-label` gets the same call, because the extension gives it the filename node of Quarto. +Before each block, the extension sets the style and the line chip of the window in a Typst state. +Quarto draws the code annotations, and the window frames them. +Inside the window, the code block has no outline of its own, so the window border is the only one. +The chrome needs nothing at read time. +This needs Quarto 1.12 or newer. ### Theming variables @@ -127,30 +133,6 @@ To change the title bar colour, set the variable in the theme: } ``` -## Hot-fixes - -Typst output needs a few corrections that Quarto does not yet make itself. -Each is on by default and each can be given a version at which it switches itself off, so an upgrade retires it without a code change. -All of them follow `enabled`, so a document that turns the filter off takes no hot-fix at all. - -| Hot-fix | What it corrects | -| --- | --- | -| `code-annotations` | Code annotations in Typst output. | -| `skylighting` | Block styling and inline code backgrounds in Typst output. | - -: The Typst hot-fixes. {.striped .hover tbl-colwidths="[26,74]"} - -```yaml -extensions: - code-window: - hotfix: - skylighting: - enabled: true - quarto-version: "1.11.0" -``` - -They exist for [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/issues/14170) and will be removed once Quarto covers the same ground. - ## Interactions ::: {.callout-caution} diff --git a/example.qmd b/example.qmd index ebe109c..d1ffec7 100644 --- a/example.qmd +++ b/example.qmd @@ -195,9 +195,7 @@ print("Default style window") ## Code Annotations ::: {.callout-note} -Typst code-annotations support and `filename` attribute handling are temporary hot-fixes. -They will be removed once Quarto natively supports these features (see [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)). -The extension will then focus on **auto-filename** and **code-window-style** features. +In Typst, Quarto 1.12 draws the code annotations and the window frames them (see [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)). ::: Code annotations work standalone and together with code-window styling. @@ -272,21 +270,6 @@ extensions: auto-filename: true style: "macos" wrapper: "code-window" - hotfix: - code-annotations: true - skylighting: true -``` - -Each hotfix accepts either a boolean or a map with `enabled` and `quarto-version` keys for per-hotfix version thresholds: - -```yaml -extensions: - code-window: - hotfix: - code-annotations: - quarto-version: "1.11.0" - skylighting: - enabled: false ``` Override per block with the `code-window-style` attribute: diff --git a/tests/fixtures/annotations-derived.qmd b/tests/fixtures/annotations-derived.qmd new file mode 100644 index 0000000..a537a40 --- /dev/null +++ b/tests/fixtures/annotations-derived.qmd @@ -0,0 +1,66 @@ +--- +title: "Annotations on blocks with a derived name" +filters: + - code-window +format: + typst: + keep-typ: true +extensions: + code-window: + style: macos +--- + +The block below is named after its language. + +```{.python} +x = 1 # <1> +``` + +1. A value. + +The block below names its own label. + +```{.bash code-window-auto-label="Terminal"} +echo labelled +``` + +The first block below sets its own style, and the second block does not. + +```{.python code-window-style="windows"} +styled = True +``` + +```{.python} +plain = True +``` + +The block below highlights one line. + +```{.python code-line-numbers="2"} +first = 1 +second = 2 +``` + +::: {.callout-note} + +## A callout + +```{.python} +a = 1 # <1> +``` + +1. A value in a callout. + +::: + +The div below has the shape an engine gives an executed cell. + +::: {.cell} + +```{.python .cell-code} +b = 1 # <1> +``` + +::: + +1. A value in a cell. diff --git a/tests/fixtures/annotations-explicit.qmd b/tests/fixtures/annotations-explicit.qmd new file mode 100644 index 0000000..9a8ee90 --- /dev/null +++ b/tests/fixtures/annotations-explicit.qmd @@ -0,0 +1,18 @@ +--- +title: "Annotations on a block with a filename" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +Quarto draws the annotations, and the window frames the block. + +```{.python filename="a.py"} +x = 1 # <1> +y = 2 # <2> +``` + +1. The first value. +2. The second value. diff --git a/tests/fixtures/annotations-idiomatic.qmd b/tests/fixtures/annotations-idiomatic.qmd new file mode 100644 index 0000000..5d2d785 --- /dev/null +++ b/tests/fixtures/annotations-idiomatic.qmd @@ -0,0 +1,19 @@ +--- +title: "Annotations with idiomatic highlighting" +filters: + - code-window +format: + typst: + syntax-highlighting: idiomatic + keep-typ: true +--- + +Quarto draws the annotations, and the window frames the block. + +```{.python filename="a.py"} +x = 1 # <1> +y = 2 # <2> +``` + +1. The first value. +2. The second value. diff --git a/tests/fixtures/disabled-explicit.qmd b/tests/fixtures/disabled-explicit.qmd new file mode 100644 index 0000000..1c8faab --- /dev/null +++ b/tests/fixtures/disabled-explicit.qmd @@ -0,0 +1,21 @@ +--- +title: "A block with a filename that refuses the window" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The first block refuses the window, so Quarto draws its own filename bar. +The second block gets the window. + +```{.python filename="off.py" code-window-enabled="false"} +x = 1 # <1> +``` + +1. A value. + +```{.python filename="on.py"} +y = 2 +``` diff --git a/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml b/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml new file mode 100644 index 0000000..6fff7ca --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml @@ -0,0 +1,8 @@ +title: Fake +author: Test +version: 1.0.0 +contributes: + formats: + typst: + template-partials: + - definitions.typ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/definitions.typ b/tests/fixtures/ext-definitions/_extensions/fake/definitions.typ new file mode 100644 index 0000000..a1f86a3 --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/definitions.typ @@ -0,0 +1,257 @@ +// Some definitions presupposed by pandoc's typst output. +#let content-to-string(content) = { + if content.has("text") { + content.text + } else if content.has("children") { + content.children.map(content-to-string).join("") + } else if content.has("body") { + content-to-string(content.body) + } else if content == [ ] { + " " + } +} + +#let horizontalrule = line(start: (25%,0%), end: (75%,0%)) + +#let endnote(num, contents) = [ + #stack(dir: ltr, spacing: 3pt, super[#num], contents) +] + +#show terms.item: it => block(breakable: false)[ + #text(weight: "bold")[#it.term] + #block(inset: (left: 1.5em, top: -0.4em))[#it.description] +] + +// Some quarto-specific definitions. + +// Style native raw code blocks with default inset and radius +// Default fill is a set rule so a later `set block(fill: ...)` (brand or user) wins +#show raw.where(block: true): set block(fill: luma(230)) +#show raw.where(block: true): it => block( + width: 100%, inset: 8pt, radius: 2pt, stroke: 0.5pt + luma(128), it, +) + +#let block_with_new_content(old_block, new_content) = { + let fields = old_block.fields() + let _ = fields.remove("body") + if fields.at("below", default: none) != none { + // TODO: this is a hack because below is a "synthesized element" + // according to the experts in the typst discord... + fields.below = fields.below.abs + } + block.with(..fields)(new_content) +} + +#let empty(v) = { + if type(v) == str { + // two dollar signs here because we're technically inside + // a Pandoc template :grimace: + v.matches(regex("^\\s*$$")).at(0, default: none) != none + } else if type(v) == content { + if v.at("text", default: none) != none { + return empty(v.text) + } + for child in v.at("children", default: ()) { + if not empty(child) { + return false + } + } + return true + } + +} + +// Subfloats +// This is a technique that we adapted from https://github.com/tingerrr/subpar/ +#let quartosubfloatcounter = counter("quartosubfloatcounter") + +#let quarto_super( + kind: str, + caption: none, + label: none, + supplement: str, + position: none, + subcapnumbering: "(a)", + body, +) = { + context { + let figcounter = counter(figure.where(kind: kind)) + let n-super = figcounter.get().first() + 1 + set figure.caption(position: position) + [#figure( + kind: kind, + supplement: supplement, + caption: caption, + { + show figure.where(kind: kind): set figure(numbering: _ => { + let subfloat-idx = quartosubfloatcounter.get().first() + 1 + subfloat-numbering(n-super, subfloat-idx) + }) + show figure.where(kind: kind): set figure.caption(position: position) + show figure.where(kind: kind): set figure(outlined: false) + + show figure: it => { + let num = numbering(subcapnumbering, n-super, quartosubfloatcounter.get().first() + 1) + show figure.caption: it => block({ + num.slice(2) // I don't understand why the numbering contains output that it really shouldn't, but this fixes it shrug? + [ ] + it.body + }) + + quartosubfloatcounter.step() + it + counter(figure.where(kind: it.kind)).update(n => n - 1) + } + + quartosubfloatcounter.update(0) + body + } + )#label] + } +} + +// callout rendering +// this is a figure show rule because callouts are crossreferenceable +#show figure: it => { + if type(it.kind) != str { + return it + } + let kind_match = it.kind.matches(regex("^quarto-callout-(.*)")).at(0, default: none) + if kind_match == none { + return it + } + let kind = kind_match.captures.at(0, default: "other") + kind = upper(kind.first()) + kind.slice(1) + // now we pull apart the callout and reassemble it with the crossref name and counter + + // when we cleanup pandoc's emitted code to avoid spaces this will have to change + let old_callout = it.body.children.at(1).body.children.at(1) + let old_title_block = old_callout.body.children.at(0) + let children = old_title_block.body.body.children + let old_title = if children.len() == 1 { + children.at(0) // no icon: title at index 0 + } else { + children.at(1) // with icon: title at index 1 + } + + // TODO use custom separator if available + // Use the figure's counter display which handles chapter-based numbering + // (when numbering is a function that includes the heading counter) + let callout_num = it.counter.display(it.numbering) + let new_title = if empty(old_title) { + [#kind #callout_num] + } else { + [#kind #callout_num: #old_title] + } + + let new_title_block = block_with_new_content( + old_title_block, + block_with_new_content( + old_title_block.body, + if children.len() == 1 { + new_title // no icon: just the title + } else { + children.at(0) + new_title // with icon: preserve icon block + new title + })) + + align(left, block_with_new_content(old_callout, + block(below: 0pt, new_title_block) + + old_callout.body.children.at(1))) +} + +// 2023-10-09: #fa-icon("fa-info") is not working, so we'll eval "#fa-info()" instead +#let callout(body: [], title: "Callout", background_color: rgb("#dddddd"), icon: none, icon_color: black, body_background_color: white) = { + block( + breakable: false, + fill: background_color, + stroke: (paint: icon_color, thickness: 0.5pt, cap: "round"), + width: 100%, + radius: 2pt, + block( + inset: 1pt, + width: 100%, + below: 0pt, + block( + fill: background_color, + width: 100%, + inset: 8pt)[#if icon != none [#text(icon_color, weight: 900)[#icon] ]#title]) + + if(body != []){ + block( + inset: 1pt, + width: 100%, + block(fill: body_background_color, width: 100%, inset: 8pt, body)) + } + ) +} + +$if(margin-geometry)$ +// Margin layout support using marginalia package +#import "@preview/marginalia:0.3.1" as marginalia: note, notefigure, wideblock + +// Render footnote as margin note using standard footnote counter +// Used via show rule: #show footnote: it => column-sidenote(it.body) +// The footnote element already steps the counter, so we just display it +#let column-sidenote(body) = { + context { + let num = counter(footnote).display("1") + // Superscript mark in text + super(num) + // Content in margin with matching number + note( + alignment: "baseline", + shift: auto, + counter: none, // We display our own number from footnote counter + )[ + #super(num) #body + ] + } +} + +// Note: Margin citations are now emitted directly from Lua as #note() calls +// with #cite(form: "full") + locator text, preserving citation locators. + +// Utility: compute padding for each side based on side parameter +#let side-pad(side, left-amount, right-amount) = { + let l = if side == "both" or side == "left" or side == "inner" { left-amount } else { 0pt } + let r = if side == "both" or side == "right" or side == "outer" { right-amount } else { 0pt } + (left: l, right: r) +} + +// body-outset: extends ~15% into margin area +#let column-body-outset(side: "both", body) = context { + let r = marginalia.get-right() + let out = 0.15 * (r.sep + r.width) + pad(..side-pad(side, -out, -out), body) +} + +// page-inset: wideblock minus small inset from page boundary +#let column-page-inset(side: "both", body) = context { + let l = marginalia.get-left() + let r = marginalia.get-right() + // Inset is a small fraction of the extension area (wideblock stops at far) + let left-inset = 0.15 * l.sep + let right-inset = 0.15 * (r.sep + r.width) + wideblock(side: side)[#pad(..side-pad(side, left-inset, right-inset), body)] +} + +// screen-inset: full width minus `far` distance from edges +#let column-screen-inset(side: "both", body) = context { + let l = marginalia.get-left() + let r = marginalia.get-right() + wideblock(side: side)[#pad(..side-pad(side, l.far, r.far), body)] +} + +// screen-inset-shaded: screen-inset with gray background +#let column-screen-inset-shaded(body) = context { + let l = marginalia.get-left() + wideblock(side: "both")[ + #block(fill: luma(245), width: 100%, inset: (x: l.far, y: 1em), body) + ] +} +$endif$ + +$if(highlighting-definitions)$ +// syntax highlighting functions from skylighting: +$highlighting-definitions$ + +$endif$ diff --git a/tests/fixtures/ext-definitions/_quarto.yml b/tests/fixtures/ext-definitions/_quarto.yml new file mode 100644 index 0000000..b8bae58 --- /dev/null +++ b/tests/fixtures/ext-definitions/_quarto.yml @@ -0,0 +1,2 @@ +project: + type: default diff --git a/tests/fixtures/ext-definitions/index.qmd b/tests/fixtures/ext-definitions/index.qmd new file mode 100644 index 0000000..a35936a --- /dev/null +++ b/tests/fixtures/ext-definitions/index.qmd @@ -0,0 +1,22 @@ +--- +title: "A format that replaces Quarto's definitions" +filters: + - code-window +format: + fake-typst: + keep-typ: true +--- + +The format below replaces the definitions partial with a copy that has no +filename helper. The first block gets the window, and the second block +refuses it. + +```{.python filename="a.py"} +x = 1 # <1> +``` + +1. A value. + +```{.python filename="off.py" code-window-enabled="false"} +y = 2 +``` diff --git a/tests/fixtures/layout-annotations.qmd b/tests/fixtures/layout-annotations.qmd new file mode 100644 index 0000000..e4b6616 --- /dev/null +++ b/tests/fixtures/layout-annotations.qmd @@ -0,0 +1,24 @@ +--- +title: "Annotations in a layout" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The layout below holds an annotated block and its list, then a second block. + +::: {layout-ncol="2"} + +```{.python} +c = 1 # <1> +``` + +1. In the layout. + +```{.r} +d <- 2 +``` + +::: diff --git a/tests/fixtures/layout-cell.qmd b/tests/fixtures/layout-cell.qmd new file mode 100644 index 0000000..ecdf05f --- /dev/null +++ b/tests/fixtures/layout-cell.qmd @@ -0,0 +1,31 @@ +--- +title: "An executed cell with a layout" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The div below has the shape an engine gives an executed cell whose outputs +sit side by side. The code stays in its window, above the outputs. + +::: {.cell layout-ncol="2"} + +```{.python .cell-code} +b = 1 +``` + +::: {.cell-output .cell-output-stdout} + + out1 + +::: + +::: {.cell-output .cell-output-stdout} + + out2 + +::: + +::: diff --git a/tests/fixtures/layout-cells.qmd b/tests/fixtures/layout-cells.qmd new file mode 100644 index 0000000..c8706f0 --- /dev/null +++ b/tests/fixtures/layout-cells.qmd @@ -0,0 +1,31 @@ +--- +title: "A layout of executed cells" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The layout below holds two divs in the shape an engine gives executed cells. +Each cell takes one column, so both windows sit side by side. + +::: {layout-ncol="2"} + +::: {.cell} + +```{.python .cell-code} +a = 1 +``` + +::: + +::: {.cell} + +```{.bash .cell-code} +echo b +``` + +::: + +::: diff --git a/tests/fixtures/layout-list.qmd b/tests/fixtures/layout-list.qmd new file mode 100644 index 0000000..84fc885 --- /dev/null +++ b/tests/fixtures/layout-list.qmd @@ -0,0 +1,34 @@ +--- +title: "A layout with a block and a list" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The layout below holds a block with no annotations and a list of its own. +The list takes the second column. + +::: {layout-ncol="2"} + +```{.python} +a = 1 +``` + +1. A list item. + +::: + +The layout below holds a block whose code ends with `<1>` outside a comment. +Its list takes the second column too. + +::: {layout-ncol="2"} + +```{.cpp} +std::vector<1> +``` + +1. Another item. + +::: diff --git a/tests/fixtures/own-wrapper.qmd b/tests/fixtures/own-wrapper.qmd new file mode 100644 index 0000000..e092b73 --- /dev/null +++ b/tests/fixtures/own-wrapper.qmd @@ -0,0 +1,23 @@ +--- +title: "A document that defines its own window function" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The document defines the window function itself, so the filter keeps it. +The first block and the `inline code` come before that definition. + +```{.python filename="before.py"} +w = 0 +``` + +```{=typst} +#let code-window(content, ..args) = content +``` + +```{.python filename="x.py"} +x = 1 +``` diff --git a/tests/fixtures/state-reset.qmd b/tests/fixtures/state-reset.qmd new file mode 100644 index 0000000..bf683db --- /dev/null +++ b/tests/fixtures/state-reset.qmd @@ -0,0 +1,24 @@ +--- +title: "The window state of a block the filter does not visit" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The first block highlights a line. The block in the note comes after it, and +the filter does not visit notes, so the note block keeps Quarto's own bar. + +```{.python filename="first.py" code-line-numbers="2"} +first = 1 +second = 2 +``` + +Text with a note.[^1] + +[^1]: A note. + + ```{.python filename="note.py"} + n = 1 + ``` diff --git a/tests/run.sh b/tests/run.sh index 91dd4ae..75aa2ae 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -53,12 +53,32 @@ render() { fi } -# Count the code-window calls in a rendered Typst file. The wrapper's own -# definition reads "#let code-window(", so a call is matched at the start of a -# line and with its first argument. +# Count the framed blocks in a rendered Typst file. The filter sets the window +# state on the line before each block it frames, and a framed block is the one +# whose state says the window is on. # $1 rendered file count_typst_windows() { - grep -c '^#code-window(filename' "$1" || true + grep -c '^#_cw-window.update((enabled: true' "$1" || true +} + +# Succeed when the page text of a rendered fixture ends the lines "x = 1" and +# "y = 2" with the numbers of their annotation circles, which is where Quarto +# draws them. Needs pdftotext from poppler. +# $1 fixture base name +circles_on_lines() { + pdftotext -layout "${work_dir}/$1.pdf" "${work_dir}/$1.txt" + grep -Eq 'x = 1 +1$' "${work_dir}/$1.txt" && + grep -Eq 'y = 2 +2$' "${work_dir}/$1.txt" +} + +# Succeed when the first line that matches one pattern comes before the first +# line that matches another. +# $1 rendered file. $2 earlier pattern. $3 later pattern. +comes_before() { + local first second + first="$(grep -n -m 1 -e "$2" "$1" | cut -d: -f1)" + second="$(grep -n -m 1 -e "$3" "$1" | cut -d: -f1)" + [ -n "${first}" ] && [ -n "${second}" ] && [ "${first}" -lt "${second}" ] } # Succeed when one page of page-break-title.txt holds both strings. pdftotext @@ -124,7 +144,7 @@ else "a box(fill: rgb( call in inline-code-only.typ" fi -# A code block goes through the Skylighting override, and the inline code +# A code block goes through Quarto's Skylighting function, and the inline code # keeps its own box in the same document. render inline-code-with-block typst if grep -q '^#Skylighting(' "${work_dir}/inline-code-with-block.typ" && @@ -252,13 +272,13 @@ else fi # ============================================================================ -# The Typst hot-fix stands down with the filter +# The Typst passes stand down with the filter # ============================================================================ -# The hot-fix contributes two passes, and both write Typst that only the +# The framing pass and the inline-code pass both write Typst that only the # filter's own preamble declares. That preamble is not written with the filter -# off, so both passes have to stand down there. The block pass is caught by the -# "_cw-" prefix the colour helpers and the annotation state share. The inline +# off, so both passes have to stand down there. The framing pass is caught by +# the "_cw-" prefix the colour helpers and the window state share. The inline # pass takes a different shape where the theme gives a background colour, and # names no helper at all, so the box itself is the thing to look for. for fixture in filter-disabled filter-disabled-no-language; do @@ -275,10 +295,10 @@ for fixture in filter-disabled filter-disabled-no-language; do fi if grep -q '_cw-' "${work_dir}/${fixture}.typ"; then - report fail "${fixture}: the block pass stands down with the filter" \ + report fail "${fixture}: the framing pass stands down with the filter" \ "no _cw- helper call in ${fixture}.typ" else - report pass "${fixture}: the block pass stands down with the filter" + report pass "${fixture}: the framing pass stands down with the filter" fi if grep -q 'box(fill: rgb(' "${work_dir}/${fixture}.typ"; then @@ -298,11 +318,11 @@ done # disagree with nothing to say so. The wrapper name is the option under test # because it reaches the Typst output word for word. render wrapper-default typst -if grep -q '^#code-window(' "${work_dir}/wrapper-default.typ"; then +if grep -q '^#let code-window(' "${work_dir}/wrapper-default.typ"; then report pass "wrapper-default: the schema default names the wrapper" else report fail "wrapper-default: the schema default names the wrapper" \ - "a #code-window( call in wrapper-default.typ" + "a #let code-window( definition in wrapper-default.typ" fi sed -i.aside 's/default: "code-window"/default: "my-window"/' \ @@ -313,12 +333,12 @@ mv "${work_dir}/_extensions/code-window/_schema.yml.aside" \ # The old name has to be gone as well as the new one present, because a rename # that missed a call site would leave both in the document. -if grep -q '^#my-window(' "${work_dir}/wrapper-default.typ" && - ! grep -q '^#code-window(' "${work_dir}/wrapper-default.typ"; then +if grep -q '^#let my-window(' "${work_dir}/wrapper-default.typ" && + ! grep -q 'code-window(' "${work_dir}/wrapper-default.typ"; then report pass "wrapper-default: a default changed in the schema alone is followed" else report fail "wrapper-default: a default changed in the schema alone is followed" \ - "a #my-window( call and no #code-window( call in wrapper-default.typ" + "a #let my-window( definition and no code-window( in wrapper-default.typ" fi # With no schema to read, the fallback in the Lua answers instead, and the @@ -327,11 +347,11 @@ mv "${work_dir}/_extensions/code-window/_schema.yml" "${work_dir}/schema.yml.asi render wrapper-default typst mv "${work_dir}/schema.yml.aside" "${work_dir}/_extensions/code-window/_schema.yml" -if grep -q '^#code-window(' "${work_dir}/wrapper-default.typ"; then +if grep -q '^#let code-window(' "${work_dir}/wrapper-default.typ"; then report pass "wrapper-default: the fallback answers when the schema cannot be read" else report fail "wrapper-default: the fallback answers when the schema cannot be read" \ - "a #code-window( call in wrapper-default.typ" + "a #let code-window( definition in wrapper-default.typ" fi # ============================================================================ @@ -482,12 +502,12 @@ fi render auto-label-forced typst forced_typst="${work_dir}/auto-label-forced.typ" if [ "$(count_typst_windows "${forced_typst}")" -eq 3 ] && - grep -q '^#code-window(filename: "Terminal", is-auto: true' "${forced_typst}" && - grep -q '^#code-window(filename: "run.sh", is-auto: false' "${forced_typst}"; then + grep -q '^#quarto-code-filename("Terminal")\[' "${forced_typst}" && + grep -q '^#quarto-code-filename("run.sh")\[' "${forced_typst}"; then report pass "auto-label-forced: only the labelled block takes the label in Typst" else report fail "auto-label-forced: only the labelled block takes the label in Typst" \ - "three code-window calls, with one for Terminal with is-auto: true and one for run.sh" + "three windows, with one for Terminal and one for run.sh" fi # The language pass runs for a labelled block and keeps its label, or the @@ -536,7 +556,7 @@ fi render language-not-first typst not_first_typ="${work_dir}/language-not-first.typ" if [ "$(count_typst_windows "${not_first_typ}")" -eq 4 ] && - [ "$(grep -c '^#code-window(filename: "bash"' "${not_first_typ}" || true)" -eq 2 ]; then + [ "$(grep -c '^#quarto-code-filename("bash")\[' "${not_first_typ}" || true)" -eq 2 ]; then report pass "language-not-first: the Typst windows are named after the language" else report fail "language-not-first: the Typst windows are named after the language" \ @@ -554,6 +574,7 @@ cp -R "${work_dir}/_extensions" "${book_dir}/_extensions" render book typst book_typ="${book_dir}/index.typ" if [ "$(count_typst_windows "${book_typ}")" -eq 1 ] && + [ "$(grep -c '^#quarto-code-filename(' "${book_typ}" || true)" -eq 1 ] && ! grep -q 'quarto-title-block' "${book_typ}"; then report pass "book: the chapter title block gets no window" else @@ -561,6 +582,251 @@ else "one window, for the r block, and no quarto-title-block in index.typ" fi +# ============================================================================ +# Quarto draws the annotations inside the window +# ============================================================================ + +# Quarto takes the annotation comments off the block and draws the circles +# itself. The filter replaces Quarto's filename helper with the window, and +# sets the window state on the line before the block, outside Quarto's node. +render annotations-explicit typst +explicit_typ="${work_dir}/annotations-explicit.typ" +if grep -q '^#let quarto-code-filename(' "${explicit_typ}" && + grep -q '^#quarto-code-filename("a.py")\[' "${explicit_typ}" && + comes_before "${explicit_typ}" \ + '^#_cw-window.update((enabled: true, is-auto: false, style: "macos"' \ + '^#quarto-code-filename("a.py")\['; then + report pass "annotations-explicit: the window replaces Quarto's filename helper" +else + report fail "annotations-explicit: the window replaces Quarto's filename helper" \ + "a #let quarto-code-filename( definition, and the window state before #quarto-code-filename(\"a.py\")[" +fi + +if grep -q '#Skylighting(cell-id:' "${explicit_typ}" && + ! grep -q 'code-window-circled-number' "${explicit_typ}" && + ! grep -q 'skylighting-typst-fix override' "${explicit_typ}"; then + report pass "annotations-explicit: Quarto draws the circles" +else + report fail "annotations-explicit: Quarto draws the circles" \ + "a #Skylighting(cell-id: call and no circled-number helper of the filter" +fi + +# The window's own block rules must not reach the circle, or the circle grows +# and wraps below its line. In the page text, the number of the circle then +# ends the line of code it annotates. +if command -v pdftotext >/dev/null 2>&1; then + if circles_on_lines annotations-explicit; then + report pass "annotations-explicit: each circle sits on its line of code" + else + report fail "annotations-explicit: each circle sits on its line of code" \ + "x = 1 and y = 2 each followed by their circle number on the same line" + fi +fi + +# The window draws the border, so it takes Quarto's outline off the code blocks +# inside it, and puts it back after them. +if grep -q 'quarto-code-block-style.update((..outer-code-style, radius: 0pt, stroke: none))' "${explicit_typ}" && + grep -q 'quarto-code-block-style.update(outer-code-style)' "${explicit_typ}"; then + report pass "annotations-explicit: the window removes Quarto's outline inside it" +else + report fail "annotations-explicit: the window removes Quarto's outline inside it" \ + "an update that removes the outline and one that restores it, in the window function" +fi + +# With idiomatic highlighting, Quarto writes a native block and wraps it in +# its own annotation helper, inside the window. +render annotations-idiomatic typst +if grep -q '#quarto-code-annotation(' "${work_dir}/annotations-idiomatic.typ"; then + report pass "annotations-idiomatic: Quarto draws the circles on a native block" +else + report fail "annotations-idiomatic: Quarto draws the circles on a native block" \ + "a #quarto-code-annotation( call in annotations-idiomatic.typ" +fi + +if command -v pdftotext >/dev/null 2>&1; then + if circles_on_lines annotations-idiomatic; then + report pass "annotations-idiomatic: each circle sits on its line of code" + else + report fail "annotations-idiomatic: each circle sits on its line of code" \ + "x = 1 and y = 2 each followed by their circle number on the same line" + fi +fi + +# ============================================================================ +# A block with a derived name goes through Quarto's filename node +# ============================================================================ + +# The filter builds Quarto's filename node for a block named after its +# language or its label, so Quarto still pairs the block with its annotation +# list, in a callout and in an executed cell as well. +render annotations-derived typst +derived_typ="${work_dir}/annotations-derived.typ" +if grep -q '^#quarto-code-filename("python")\[' "${derived_typ}" && + grep -q '^#quarto-code-filename("Terminal")\[' "${derived_typ}"; then + report pass "annotations-derived: a derived name reaches Quarto's filename helper" +else + report fail "annotations-derived: a derived name reaches Quarto's filename helper" \ + "#quarto-code-filename(\"python\")[ and #quarto-code-filename(\"Terminal\")[ calls" +fi + +if [ "$(count_typst_windows "${derived_typ}")" -eq 7 ]; then + report pass "annotations-derived: every block is framed" +else + report fail "annotations-derived: every block is framed" \ + "seven framed blocks in annotations-derived.typ" +fi + +if [ "$(grep -c '#Skylighting(cell-id:' "${derived_typ}" || true)" -eq 3 ] && + [ "$(grep -c '^#quarto-annotation-item("' "${derived_typ}" || true)" -eq 3 ]; then + report pass "annotations-derived: Quarto draws the circles, in a callout and a cell too" +else + report fail "annotations-derived: Quarto draws the circles, in a callout and a cell too" \ + "three #Skylighting(cell-id: calls and three #quarto-annotation-item( calls in annotations-derived.typ" +fi + +# A block with no style of its own takes the document style, not the style of +# the block before it. +if grep '^#_cw-window.update' "${derived_typ}" | grep -q 'is-auto: true, style: "windows"' && + grep '^#_cw-window.update' "${derived_typ}" | grep -A1 'style: "windows"' | + tail -n 1 | grep -q 'style: "macos"'; then + report pass "annotations-derived: a style override stays on its own block" +else + report fail "annotations-derived: a style override stays on its own block" \ + "a windows state followed by a macos state" +fi + +if grep -q 'lines-label: "2"' "${derived_typ}"; then + report pass "annotations-derived: the highlighted lines reach the window" +else + report fail "annotations-derived: the highlighted lines reach the window" \ + "lines-label: \"2\" in the window state" +fi + +# ============================================================================ +# A block that refuses the window keeps Quarto's filename bar +# ============================================================================ + +# The state tells the helper to hand the block to Quarto's own helper, which +# still shows the filename and the circles. +render disabled-explicit typst +disabled_typ="${work_dir}/disabled-explicit.typ" +if grep -q '^#_cw-window.update((enabled: false' "${disabled_typ}" && + grep -q '#Skylighting(cell-id:' "${disabled_typ}" && + [ "$(count_typst_windows "${disabled_typ}")" -eq 1 ]; then + report pass "disabled-explicit: the block goes to Quarto's filename helper" +else + report fail "disabled-explicit: the block goes to Quarto's filename helper" \ + "an enabled: false state, one framed block, and a #Skylighting(cell-id: call" +fi + +if command -v pdftotext >/dev/null 2>&1; then + pdftotext "${work_dir}/disabled-explicit.pdf" "${work_dir}/disabled-explicit.txt" + if grep -q 'off.py' "${work_dir}/disabled-explicit.txt" && + grep -q 'on.py' "${work_dir}/disabled-explicit.txt"; then + report pass "disabled-explicit: both filenames show in the PDF" + else + report fail "disabled-explicit: both filenames show in the PDF" \ + "off.py and on.py in the page text of disabled-explicit.pdf" + fi +fi + +# ============================================================================ +# A format that replaces Quarto's definitions still compiles +# ============================================================================ + +# Quarto adds its own helpers to a format that lacks them, unless the +# document defines one of the same name. The filter defines the filename +# helper, so the document has to compile without Quarto's copy of it. +ext_dir="${work_dir}/ext-definitions" +cp -R "${tests_dir}/fixtures/ext-definitions" "${ext_dir}" +cp -R "${work_dir}/_extensions/code-window" "${ext_dir}/_extensions/code-window" +render ext-definitions fake-typst +if grep -q '#Skylighting(cell-id:' "${ext_dir}/index.typ" && + [ "$(count_typst_windows "${ext_dir}/index.typ")" -eq 1 ] && + [ "$(grep -c '^#quarto-code-filename(' "${ext_dir}/index.typ" || true)" -eq 2 ]; then + report pass "ext-definitions: the document compiles with the window and the circles" +else + report fail "ext-definitions: the document compiles with the window and the circles" \ + "one framed block, two #quarto-code-filename( calls and a #Skylighting(cell-id: call in index.typ" +fi + +# ============================================================================ +# Layouts, notes, and a window function of the document's own +# ============================================================================ + +# The code of an executed cell with a layout stays in its window, and the +# window is not left empty in a cell of the layout. +render layout-cell typst +layout_cell_typ="${work_dir}/layout-cell.typ" +if [ "$(count_typst_windows "${layout_cell_typ}")" -eq 1 ] && + ! grep -A1 '^#quarto-code-filename("python")\[' "${layout_cell_typ}" | grep -qx '\]'; then + report pass "layout-cell: the code of the cell stays in its window" +else + report fail "layout-cell: the code of the cell stays in its window" \ + "one framed block, and no empty #quarto-code-filename(\"python\")[ ] call" +fi + +# In a layout of executed cells, each cell keeps one column of the layout, so +# the two windows share a line of the page. +render layout-cells typst +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/layout-cells.pdf" "${work_dir}/layout-cells.txt" + if grep -Eq 'PYTHON.*BASH' "${work_dir}/layout-cells.txt"; then + report pass "layout-cells: each executed cell takes one column" + else + report fail "layout-cells: each executed cell takes one column" \ + "the PYTHON and BASH title bars on the same line of the page" + fi +fi + +# In a layout, a list after a block with no annotations keeps its own column. +render layout-list typst +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/layout-list.pdf" "${work_dir}/layout-list.txt" + if grep -Eq '^ {20,}1\. A list item' "${work_dir}/layout-list.txt" && + grep -Eq '^ {20,}1\. Another item' "${work_dir}/layout-list.txt"; then + report pass "layout-list: a list with no annotations keeps its own column" + else + report fail "layout-list: a list with no annotations keeps its own column" \ + "both list items in the second column of the page" + fi +fi + +# In a layout, Quarto still pairs an annotated block with its list. +render layout-annotations typst +if [ "$(grep -c '^#quarto-annotation-item("' "${work_dir}/layout-annotations.typ" || true)" -eq 1 ]; then + report pass "layout-annotations: Quarto pairs the block with its list" +else + report fail "layout-annotations: Quarto pairs the block with its list" \ + "one #quarto-annotation-item( call in layout-annotations.typ" +fi + +# The filter does not visit notes, so a block in a note must not take the +# window state of the block before it, here its line chip. +render state-reset typst +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/state-reset.pdf" "${work_dir}/state-reset.txt" + if grep -Eq 'first\.py +2' "${work_dir}/state-reset.txt" && + grep -q 'note\.py' "${work_dir}/state-reset.txt" && + ! grep -Eq 'note\.py +2' "${work_dir}/state-reset.txt"; then + report pass "state-reset: a block the filter does not visit takes no window state" + else + report fail "state-reset: a block the filter does not visit takes no window state" \ + "a 2 chip beside first.py and none beside note.py" + fi +fi + +# A document that defines the window function keeps its own, and still +# compiles, which the render checks. +render own-wrapper typst +if [ "$(grep -c '^#let code-window(' "${work_dir}/own-wrapper.typ" || true)" -eq 1 ] && + grep -q '^#let code-window(content, ..args) = content' "${work_dir}/own-wrapper.typ"; then + report pass "own-wrapper: the document keeps its own window function" +else + report fail "own-wrapper: the document keeps its own window function" \ + "one #let code-window( definition, the one the document wrote" +fi + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}"