Skip to content

Commit b41d14c

Browse files
committed
refactor: consolidate hotfix options and add main.lua entry point
Group temporary hot-fixes (code-annotations and skylighting) under a nested `hotfix` configuration key, replacing the flat `skylighting-fix` option. Add a `hotfix.quarto-version` threshold that auto-disables both fixes when Quarto reaches the specified version. Introduce `main.lua` as the filter entry point that loads submodules, wires dependencies, and assembles the filter list. Move hotfix modules (`code-annotations.lua`, `skylighting-typst-fix.lua`) into `_modules/hotfix/` for easy future removal. Add code-annotations hot-fix for Typst output with annotation markers, circled numbers, and bidirectional linking. Split Typst function definitions so annotation helpers are only injected when at least one hot-fix is active. Fix skylighting-typst-fix wrapper prefix bug: use configurable wrapper name instead of hardcoded `code-window-circled-number`, preventing Typst compilation errors when `wrapper` is customised. Additional fixes: HTML-escape auto-filename in code block headers, remove duplicate `@return` annotations, and improve `code-window-enabled` attribute description.
1 parent 9faa75a commit b41d14c

6 files changed

Lines changed: 799 additions & 126 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
title: Code Window
22
author: Mickaël Canouil
33
version: 0.2.0
4-
quarto-required: ">=1.9.23"
4+
quarto-required: ">=1.9.36"
55
contributes:
66
filters:
77
- at: pre-quarto
8-
path: code-window.lua
8+
path: main.lua
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
--- @module code-annotations
2+
--- @license MIT
3+
--- @copyright 2026 Mickaël Canouil
4+
--- @author Mickaël Canouil
5+
--- @brief Code annotation detection, stripping, and Typst rendering helpers.
6+
--- Scans CodeBlock elements for inline annotation markers (e.g. # <1>, // <2>)
7+
--- and provides utilities for converting annotations to Typst output.
8+
9+
-- ============================================================================
10+
-- LANGUAGE COMMENT CHARACTERS
11+
-- ============================================================================
12+
13+
--- Map of language identifiers to their single-line comment prefix.
14+
--- @type table<string, string>
15+
local LANG_COMMENT_CHARS = {
16+
r = '#',
17+
python = '#',
18+
lua = '--',
19+
javascript = '//',
20+
typescript = '//',
21+
go = '//',
22+
rust = '//',
23+
bash = '#',
24+
sh = '#',
25+
zsh = '#',
26+
fish = '#',
27+
c = '//',
28+
cpp = '//',
29+
cxx = '//',
30+
cc = '//',
31+
cs = '//',
32+
java = '//',
33+
scala = '//',
34+
kotlin = '//',
35+
swift = '//',
36+
objc = '//',
37+
php = '//',
38+
ruby = '#',
39+
perl = '#',
40+
julia = '#',
41+
haskell = '--',
42+
elm = '--',
43+
clojure = ';',
44+
scheme = ';',
45+
lisp = ';',
46+
racket = ';',
47+
erlang = '%%',
48+
elixir = '#',
49+
fortran = '!',
50+
matlab = '%%',
51+
ada = '--',
52+
sql = '--',
53+
plsql = '--',
54+
tsql = '--',
55+
mysql = '--',
56+
sqlite = '--',
57+
postgresql = '--',
58+
vb = "'",
59+
vbnet = "'",
60+
fsharp = '//',
61+
stata = '//',
62+
yaml = '#',
63+
toml = '#',
64+
make = '#',
65+
cmake = '#',
66+
dockerfile = '#',
67+
powershell = '#',
68+
nix = '#',
69+
zig = '//',
70+
dart = '//',
71+
groovy = '//',
72+
d = '//',
73+
nim = '#',
74+
crystal = '#',
75+
v = '//',
76+
odin = '//',
77+
mojo = '#',
78+
}
79+
80+
-- ============================================================================
81+
-- ANNOTATION RESOLUTION
82+
-- ============================================================================
83+
84+
--- Escape a string for use in a Lua pattern.
85+
--- @param s string
86+
--- @return string
87+
local function escape_pattern(s)
88+
return s:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1')
89+
end
90+
91+
--- Resolve annotations in a CodeBlock element.
92+
--- Scans each line for a trailing annotation marker (e.g. # <1>) using the
93+
--- language's comment prefix. Strips the marker from the code text and returns
94+
--- the cleaned text along with an annotations table.
95+
--- @param block pandoc.CodeBlock
96+
--- @return string cleaned_text The code with annotation markers removed
97+
--- @return table|nil annotations Maps line numbers (int) to annotation numbers (int), or nil if none found
98+
local function resolve_annotations(block)
99+
if not block.classes or #block.classes == 0 then
100+
return block.text, nil
101+
end
102+
103+
local lang = block.classes[1]:lower()
104+
local comment = LANG_COMMENT_CHARS[lang]
105+
if not comment then
106+
return block.text, nil
107+
end
108+
109+
local escaped_comment = escape_pattern(comment)
110+
local pattern = '^(.-)%s*' .. escaped_comment .. '%s*<%s*(%d+)%s*>%s*$'
111+
112+
local annotations = {}
113+
local lines = {}
114+
local found = false
115+
116+
local line_num = 0
117+
for line in (block.text .. '\n'):gmatch('([^\n]*)\n') do
118+
line_num = line_num + 1
119+
local content, annot_num = line:match(pattern)
120+
if annot_num then
121+
found = true
122+
annotations[line_num] = tonumber(annot_num)
123+
table.insert(lines, content)
124+
else
125+
table.insert(lines, line)
126+
end
127+
end
128+
129+
if not found then
130+
return block.text, nil
131+
end
132+
133+
return table.concat(lines, '\n'), annotations
134+
end
135+
136+
-- ============================================================================
137+
-- TYPST CONVERSION HELPERS
138+
-- ============================================================================
139+
140+
--- Convert an annotations table to a Typst dictionary literal.
141+
--- Keys are stringified line numbers, values are annotation numbers.
142+
--- Example output: (1: 2, 3: 1)
143+
--- @param annotations table<int, int> Line number to annotation number mapping
144+
--- @return string Typst dictionary literal
145+
local function annotations_to_typst_dict(annotations)
146+
local pairs_list = {}
147+
local keys = {}
148+
for k in pairs(annotations) do
149+
table.insert(keys, k)
150+
end
151+
table.sort(keys)
152+
for _, line_num in ipairs(keys) do
153+
table.insert(pairs_list,
154+
string.format('"%d": %d', line_num, annotations[line_num]))
155+
end
156+
return '(' .. table.concat(pairs_list, ', ') .. ')'
157+
end
158+
159+
--- Check whether a block is an OrderedList that looks like an annotation list.
160+
--- Annotation lists are OrderedLists immediately following a code block,
161+
--- where each item corresponds to an annotation number.
162+
--- @param block pandoc.Block
163+
--- @return boolean
164+
local function is_annotation_ordered_list(block)
165+
return block and block.t == 'OrderedList'
166+
end
167+
168+
--- Convert an OrderedList to Typst annotation item RawBlocks.
169+
--- Each list item becomes a #code-window-annotation-item(block-id, n)[...] call.
170+
--- @param ol pandoc.OrderedList The ordered list to convert
171+
--- @param wrapper_prefix string Prefix for the Typst function name
172+
--- @param block_id integer Unique block identifier for bidirectional linking
173+
--- @return pandoc.List List of RawBlock elements
174+
local function ordered_list_to_typst_blocks(ol, wrapper_prefix, block_id)
175+
local blocks = {}
176+
local start = ol.listAttributes and ol.listAttributes.start or 1
177+
for i, item in ipairs(ol.content) do
178+
local annot_num = start + i - 1
179+
local content_blocks = pandoc.Blocks(item)
180+
local rendered = pandoc.write(pandoc.Pandoc(content_blocks), 'typst')
181+
rendered = rendered:gsub('%s+$', '')
182+
table.insert(blocks, pandoc.RawBlock('typst', string.format(
183+
'#%s-annotation-item(%d, %d)[%s]',
184+
wrapper_prefix, block_id, annot_num, rendered
185+
)))
186+
end
187+
return blocks
188+
end
189+
190+
-- ============================================================================
191+
-- MODULE EXPORTS
192+
-- ============================================================================
193+
194+
return {
195+
LANG_COMMENT_CHARS = LANG_COMMENT_CHARS,
196+
resolve_annotations = resolve_annotations,
197+
annotations_to_typst_dict = annotations_to_typst_dict,
198+
is_annotation_ordered_list = is_annotation_ordered_list,
199+
ordered_list_to_typst_blocks = ordered_list_to_typst_blocks,
200+
}

_extensions/code-window/skylighting-typst-fix.lua renamed to _extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
--- the Skylighting function with better block styling and adds inline code
1010
--- background support for Typst output.
1111

12+
local _wrapper_prefix = 'code-window'
13+
14+
--- Set the wrapper prefix for Typst function name generation.
15+
--- Called by main.lua before each handler invocation.
16+
--- @param prefix string The wrapper prefix (e.g. 'code-window' or 'my-window')
17+
local function set_wrapper(prefix)
18+
_wrapper_prefix = prefix
19+
end
20+
1221
--- Build a Skylighting override with improved block styling.
1322
--- Pandoc 3.8+ generates correct bgcolor but the block call lacks width,
1423
--- inset, radius, and stroke properties. The fill parameter is also ignored.
@@ -21,6 +30,7 @@ local function build_skylighting_override()
2130
local bg = hm['background-color']
2231
if not bg or type(bg) ~= 'string' then return nil end
2332

33+
local circled = _wrapper_prefix .. '-circled-number'
2434
return string.format([==[
2535
// skylighting-typst-fix override
2636
#let Skylighting(
@@ -30,31 +40,66 @@ local function build_skylighting_override()
3040
sourcelines,
3141
) = {
3242
let bgcolor = if fill != none { fill } else { rgb("%s") }
33-
let blocks = []
34-
let lnum = start - 1
3543
let has-gutter = start + sourcelines.len() > 999
3644
37-
for ln in sourcelines {
38-
if number {
45+
context {
46+
let annot-data = _cw-annotations.get()
47+
let blocks = []
48+
let lnum = start - 1
49+
let seen-annotes = (:)
50+
51+
for ln in sourcelines {
3952
lnum = lnum + 1
40-
blocks = blocks + box(
41-
width: if has-gutter { 30pt } else { 24pt },
42-
text([ #lnum ]),
43-
)
53+
if number {
54+
blocks = blocks + box(
55+
width: if has-gutter { 30pt } else { 24pt },
56+
text([ #lnum ]),
57+
)
58+
}
59+
60+
if annot-data != none {
61+
let annot-num = annot-data.annotations.at(str(lnum), default: none)
62+
if annot-num != none {
63+
let lbl-prefix = "cw-" + str(annot-data.block-id) + "-"
64+
if str(annot-num) not in seen-annotes {
65+
seen-annotes.insert(str(annot-num), true)
66+
blocks = blocks + box(width: 100%%)[
67+
#ln
68+
#h(1fr)
69+
#link(label(lbl-prefix + "item-" + str(annot-num)))[
70+
#%s(annot-num, bg-colour: annot-data.bg-colour)
71+
]
72+
#label(lbl-prefix + "line-" + str(annot-num))
73+
]
74+
} else {
75+
blocks = blocks + box(width: 100%%)[
76+
#ln
77+
#h(1fr)
78+
#link(label(lbl-prefix + "item-" + str(annot-num)))[
79+
#%s(annot-num, bg-colour: annot-data.bg-colour)
80+
]
81+
]
82+
}
83+
} else {
84+
blocks = blocks + ln
85+
}
86+
} else {
87+
blocks = blocks + ln
88+
}
89+
blocks = blocks + EndLine()
4490
}
45-
blocks = blocks + ln + EndLine()
46-
}
4791
48-
block(
49-
fill: bgcolor,
50-
width: 100%%,
51-
inset: 8pt,
52-
radius: 2pt,
53-
stroke: none,
54-
blocks,
55-
)
92+
block(
93+
fill: bgcolor,
94+
width: 100%%,
95+
inset: 8pt,
96+
radius: 2pt,
97+
stroke: 0.5pt + luma(200),
98+
blocks,
99+
)
100+
}
56101
}
57-
]==], bg)
102+
]==], bg, circled, circled)
58103
end
59104

60105
--- Process inline Code for Typst format.
@@ -91,6 +136,7 @@ local function process_typst_inline(el)
91136
end
92137

93138
--- Inject Skylighting override at the start of the document.
139+
--- Always injected when code-window is enabled to support annotation markers.
94140
function Pandoc(doc)
95141
if not quarto.doc.is_format('typst') then
96142
return doc
@@ -109,7 +155,17 @@ function Pandoc(doc)
109155
return doc
110156
end
111157

112-
table.insert(doc.blocks, 1, pandoc.RawBlock('typst', override))
158+
-- Insert after the code-window function definitions so Skylighting can
159+
-- reference _cw-annotations and the wrapper-prefixed circled-number function.
160+
local insert_pos = 1
161+
for idx, blk in ipairs(doc.blocks) do
162+
if blk.t == 'RawBlock' and blk.format == 'typst'
163+
and blk.text:find('_cw%-annotations') then
164+
insert_pos = idx + 1
165+
break
166+
end
167+
end
168+
table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', override))
113169
return doc
114170
end
115171

@@ -122,6 +178,9 @@ function Code(el)
122178
end
123179

124180
return {
125-
{ Pandoc = Pandoc },
126-
{ Code = Code },
181+
set_wrapper = set_wrapper,
182+
filters = {
183+
{ Pandoc = Pandoc },
184+
{ Code = Code },
185+
},
127186
}

_extensions/code-window/_schema.yml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,29 @@ options:
1818
type: string
1919
default: "code-window"
2020
description: "Typst wrapper function name for code-window rendering."
21-
skylighting-fix:
22-
type: boolean
23-
default: true
24-
description: "Enable or disable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background)."
21+
hotfix:
22+
type: object
23+
description: "Temporary hot-fixes for Typst output. These will be removed when Quarto natively supports the corresponding features (see quarto-dev/quarto-cli#14170)."
24+
properties:
25+
quarto-version:
26+
type: string
27+
description: "Quarto version at or above which all hot-fixes are automatically disabled. Leave unset to use individual toggles."
28+
code-annotations:
29+
type: boolean
30+
default: true
31+
description: "Enable the code-annotations hot-fix for Typst output."
32+
skylighting:
33+
type: boolean
34+
default: true
35+
description: "Enable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background)."
36+
37+
attributes:
38+
CodeBlock:
39+
code-window-enabled:
40+
type: boolean
41+
default: true
42+
description: "Whether to apply window chrome to this specific code block. Set to false to disable. Annotations still render when disabled."
43+
code-window-style:
44+
type: string
45+
enum: ["default", "macos", "windows"]
46+
description: "Override the global window decoration style for this specific code block."

0 commit comments

Comments
 (0)