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}"