Skip to content

Commit 76be25e

Browse files
committed
feat: add bidirectional linking to Typst code annotations
Add cell-id generation and Typst label/link support so that clicking a circled annotation number in code jumps to its description and vice versa. Add upstream-compatible Quarto functions (quarto-circled-number, quarto-annote-color, quarto-code-filename, quarto-code-annotation, quarto-annotation-item) to definitions.typ for forward compatibility with quarto-dev/quarto-cli#14170.
1 parent eef1e3d commit 76be25e

5 files changed

Lines changed: 198 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
## Unreleased
44

5-
No user-facing changes.
5+
### New Features
6+
7+
- feat: Add bidirectional linking to Typst code annotations.
8+
- feat: Add upstream-compatible Quarto code annotation functions for forward compatibility with quarto-dev/quarto-cli#14170.
69

710
## 0.16.1 (2026-02-21)
811

_extensions/mcanouil/_modules/code-window.lua

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ function M.process_typst(block, config)
146146
-- Even without a code-window, wrap with annotated-code() if annotations exist
147147
local annot_dict = block.attributes['data-code-annotations']
148148
if annot_dict then
149+
local cell_id = block.attributes['data-cell-id'] or ''
149150
local lang = ''
150151
if block.classes and #block.classes > 0 then
151152
lang = block.classes[1]
@@ -154,8 +155,8 @@ function M.process_typst(block, config)
154155
local fence_len = math.max(3, max_consecutive_backticks(code_content) + 1)
155156
local fence = string.rep('`', fence_len)
156157
local typst_code = string.format(
157-
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode))[%s%s\n%s\n%s]',
158-
annot_dict, fence, lang, code_content, fence
158+
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode), cell-id: "%s")[%s%s\n%s\n%s]',
159+
annot_dict, cell_id, fence, lang, code_content, fence
159160
)
160161
return pandoc.RawBlock('typst', typst_code)
161162
end
@@ -176,14 +177,15 @@ function M.process_typst(block, config)
176177

177178
-- Check for code annotations set by typst-code-annotation filter
178179
local annot_dict = block.attributes['data-code-annotations']
180+
local cell_id = block.attributes['data-cell-id'] or ''
179181

180182
local raw_code = string.format('%s%s\n%s\n%s', fence, lang, code_content, fence)
181183

182184
-- Wrap with annotated-code() if annotations are present
183185
if annot_dict then
184186
raw_code = string.format(
185-
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode))[%s]',
186-
annot_dict, raw_code
187+
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode), cell-id: "%s")[%s]',
188+
annot_dict, cell_id, raw_code
187189
)
188190
end
189191

_extensions/mcanouil/filters/typst-code-annotation.lua

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ local LANG_COMMENT_CHARS = {
9797

9898
local DEFAULT_COMMENT = { "#" }
9999

100+
--- Counter for generating unique cell IDs across the document.
101+
local cell_id_counter = 0
102+
103+
--- Generate a unique cell ID for annotation linking.
104+
--- @return string Unique cell identifier, e.g. "cell-annote-1"
105+
local function next_cell_id()
106+
cell_id_counter = cell_id_counter + 1
107+
return 'cell-annote-' .. tostring(cell_id_counter)
108+
end
109+
100110
-- ============================================================================
101111
-- ANNOTATION DETECTION
102112
-- ============================================================================
@@ -219,37 +229,44 @@ local function annotations_to_typst_dict(annotations)
219229
return '(' .. table.concat(parts, ', ') .. ')'
220230
end
221231

222-
--- Store annotation data on a CodeBlock as a custom attribute.
223-
--- The code-window module reads this attribute to wrap the code with
232+
--- Store annotation data and cell ID on a CodeBlock as custom attributes.
233+
--- The code-window module reads these attributes to wrap the code with
224234
--- annotated-code().
225235
--- @param code_block pandoc.CodeBlock
226236
--- @param annotations table
237+
--- @param cell_id string Unique cell identifier for bidirectional linking
227238
--- @return pandoc.CodeBlock
228-
local function tag_code_block(code_block, annotations)
239+
local function tag_code_block(code_block, annotations, cell_id)
229240
code_block.attributes['data-code-annotations'] = annotations_to_typst_dict(annotations)
241+
code_block.attributes['data-cell-id'] = cell_id
230242
return code_block
231243
end
232244

233245
--- Build annotation list items as raw Typst blocks.
234246
--- Each item renders the circled number inline with the description text.
235247
--- @param ol pandoc.OrderedList
236248
--- @param annotations table
249+
--- @param cell_id string Unique cell identifier for bidirectional linking
237250
--- @return pandoc.Blocks
238-
local function build_annotation_list(ol, annotations)
251+
local function build_annotation_list(ol, annotations, cell_id)
239252
local items = pandoc.Blocks({})
240253

241254
for i, item in ipairs(ol.content) do
242255
local annotation_number = ol.start + i - 1
243256
if annotations[annotation_number] then
244257
local content_inlines = item[1].content or pandoc.Inlines(item[1])
245-
-- Wrap content in Typst content brackets: #annotation-item(N, [content], colours)
258+
-- Wrap content in Typst content brackets:
259+
-- #annotation-item(N, [content], colours, cell-id: "cell-annote-N")
246260
local block_content = pandoc.Inlines({})
247261
block_content:insert(pandoc.RawInline(
248262
'typst',
249263
'#annotation-item(' .. tostring(annotation_number) .. ', ['
250264
))
251265
block_content:extend(content_inlines)
252-
block_content:insert(pandoc.RawInline('typst', '], ' .. COLOURS_EXPR .. ')'))
266+
block_content:insert(pandoc.RawInline(
267+
'typst',
268+
'], ' .. COLOURS_EXPR .. ', cell-id: "' .. cell_id .. '")'
269+
))
253270
items:insert(pandoc.Plain(block_content))
254271
end
255272
end
@@ -265,34 +282,40 @@ end
265282
--- @param block pandoc.CodeBlock
266283
--- @return pandoc.CodeBlock|nil Cleaned code block, or nil if no annotations
267284
--- @return table|nil Annotations table
285+
--- @return string|nil Cell ID for bidirectional linking
268286
local function process_code_block(block)
269287
if block.attr.classes:includes('cell-code') then
270-
return nil, nil
288+
return nil, nil, nil
271289
end
272290
local resolved, annotations = resolve_annotations(block)
273291
if annotations then
274-
resolved = tag_code_block(resolved, annotations)
292+
local cell_id = next_cell_id()
293+
resolved = tag_code_block(resolved, annotations, cell_id)
294+
return resolved, annotations, cell_id
275295
end
276-
return resolved, annotations
296+
return resolved, annotations, nil
277297
end
278298

279299
--- Process a cell Div, looking for .cell-code CodeBlocks inside.
280300
--- @param div pandoc.Div
281301
--- @return pandoc.Div|nil Modified div, or nil if no annotations
282302
--- @return table|nil Annotations table
303+
--- @return string|nil Cell ID for bidirectional linking
283304
local function process_cell_div(div)
284305
if not div.attr.classes:includes('cell') then
285-
return nil, nil
306+
return nil, nil, nil
286307
end
287308

288309
local found_annotations = nil
310+
local found_cell_id = nil
289311
local resolved_div = pandoc.walk_block(div, {
290312
CodeBlock = function(el)
291313
if el.attr.classes:includes('cell-code') then
292314
local resolved, annotations = resolve_annotations(el)
293315
if annotations and next(annotations) ~= nil then
294316
found_annotations = annotations
295-
resolved = tag_code_block(resolved, annotations)
317+
found_cell_id = next_cell_id()
318+
resolved = tag_code_block(resolved, annotations, found_cell_id)
296319
return resolved
297320
end
298321
end
@@ -301,9 +324,9 @@ local function process_cell_div(div)
301324
})
302325

303326
if found_annotations then
304-
return resolved_div, found_annotations
327+
return resolved_div, found_annotations, found_cell_id
305328
end
306-
return nil, nil
329+
return nil, nil, nil
307330
end
308331

309332
-- ============================================================================
@@ -343,40 +366,47 @@ return {
343366
local outputs = pandoc.Blocks({})
344367
local pending_code = nil
345368
local pending_annotations = nil
369+
local pending_cell_id = nil
346370

347371
local function flush_pending()
348372
if pending_code then
349373
outputs:insert(pending_code)
350374
end
351375
pending_code = nil
352376
pending_annotations = nil
377+
pending_cell_id = nil
353378
end
354379

355380
for _, block in ipairs(blocks) do
356381
if block.t == 'CodeBlock' then
357382
flush_pending()
358-
local resolved, annotations = process_code_block(block)
383+
local resolved, annotations, cell_id = process_code_block(block)
359384
if annotations then
360385
pending_code = resolved
361386
pending_annotations = annotations
387+
pending_cell_id = cell_id
362388
else
363389
outputs:insert(block)
364390
end
365391
elseif block.t == 'Div' and block.attr.classes:includes('cell') then
366392
flush_pending()
367-
local resolved, annotations = process_cell_div(block)
393+
local resolved, annotations, cell_id = process_cell_div(block)
368394
if annotations then
369395
pending_code = resolved
370396
pending_annotations = annotations
397+
pending_cell_id = cell_id
371398
else
372399
outputs:insert(block)
373400
end
374401
elseif block.t == 'OrderedList' and pending_annotations then
375-
local annotation_blocks = build_annotation_list(block, pending_annotations)
402+
local annotation_blocks = build_annotation_list(
403+
block, pending_annotations, pending_cell_id or ''
404+
)
376405
outputs:insert(pending_code)
377406
outputs:extend(annotation_blocks)
378407
pending_code = nil
379408
pending_annotations = nil
409+
pending_cell_id = nil
380410
else
381411
flush_pending()
382412
outputs:insert(block)

_extensions/mcanouil/typst/partials/libs/code-annotations.typ

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,35 @@
1111
/// Render a circled annotation number using brand colours.
1212
/// @param n Annotation number to display
1313
/// @param colours Colour dictionary (must contain foreground key)
14+
/// @param cell-id Optional cell identifier for bidirectional linking (default: "")
1415
/// @return Inline box with circled number
15-
#let circled-number(n, colours) = {
16-
box(baseline: 15%, circle(
16+
#let circled-number(n, colours, cell-id: "") = {
17+
let marker = box(baseline: 15%, circle(
1718
radius: 0.55em,
1819
stroke: 0.5pt + colours.foreground,
1920
)[#set text(size: 0.7em); #align(center + horizon, str(n))])
21+
marker
2022
}
2123

2224
/// Wrap a raw code block with annotation markers overlaid at the right edge
23-
/// of specified lines.
24-
/// @param code Raw code block content
25+
/// of specified lines. Supports bidirectional linking when cell-id is provided.
2526
/// @param annotations Dictionary mapping annotation numbers to line numbers
2627
/// @param colours Colour dictionary
28+
/// @param cell-id Optional cell identifier for bidirectional linking (default: "")
29+
/// @param code Raw code block content
2730
/// @return Block with code and overlaid annotation markers
28-
#let annotated-code(annotations, colours, code) = {
31+
#let annotated-code(annotations, colours, cell-id: "", code) = {
32+
// Build a set of first-line positions per annotation number so that
33+
// back-labels are only emitted once (avoiding duplicate labels when
34+
// one annotation spans multiple lines).
35+
let first-lines = (:)
36+
for (num-str, line-num) in annotations {
37+
let key = str(num-str)
38+
if key not in first-lines or line-num < int(first-lines.at(key)) {
39+
first-lines.insert(key, str(line-num))
40+
}
41+
}
42+
2943
show raw.line: it => {
3044
// Check whether this line has an annotation
3145
// Keys are strings ("1", "2", ...), values are line numbers (int)
@@ -36,12 +50,22 @@
3650
}
3751
}
3852
if annote-num != none {
39-
// Line with annotation marker right-aligned, preserving natural line spacing
40-
box(width: 100%)[
41-
#it
42-
#h(1fr)
43-
#circled-number(annote-num, colours)
44-
]
53+
if cell-id != "" {
54+
let lbl = cell-id + "-annote-" + str(annote-num)
55+
let is-first = first-lines.at(str(annote-num), default: none) == str(it.number)
56+
if is-first {
57+
box(width: 100%)[#it #h(1fr) #link(label(lbl))[#circled-number(annote-num, colours)] #label(lbl + "-back")]
58+
} else {
59+
box(width: 100%)[#it #h(1fr) #link(label(lbl))[#circled-number(annote-num, colours)]]
60+
}
61+
} else {
62+
// No cell-id: simple marker without linking
63+
box(width: 100%)[
64+
#it
65+
#h(1fr)
66+
#circled-number(annote-num, colours)
67+
]
68+
}
4569
} else {
4670
it
4771
}
@@ -50,14 +74,24 @@
5074
}
5175

5276
/// Render a single annotation list item with circled number inline.
77+
/// Supports bidirectional linking when cell-id is provided.
5378
/// @param n Annotation number
5479
/// @param content Description content
5580
/// @param colours Colour dictionary
81+
/// @param cell-id Optional cell identifier for bidirectional linking (default: "")
5682
/// @return Block with circled number and description on the same line
57-
#let annotation-item(n, content, colours) = {
58-
block(above: 0.4em, below: 0.4em)[
59-
#circled-number(n, colours)
60-
#h(0.4em)
61-
#content
62-
]
83+
#let annotation-item(n, content, colours, cell-id: "") = {
84+
if cell-id != "" {
85+
[#block(above: 0.4em, below: 0.4em)[
86+
#link(label(cell-id + "-annote-" + str(n) + "-back"))[#circled-number(n, colours)]
87+
#h(0.4em)
88+
#content
89+
] #label(cell-id + "-annote-" + str(n))]
90+
} else {
91+
block(above: 0.4em, below: 0.4em)[
92+
#circled-number(n, colours)
93+
#h(0.4em)
94+
#content
95+
]
96+
}
6397
}

0 commit comments

Comments
 (0)