Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

## Unreleased

No user-facing changes.
### New Features

- feat: Add bidirectional linking to Typst code annotations.
- feat: Add upstream-compatible Quarto code annotation functions for forward compatibility with quarto-dev/quarto-cli#14170.

## 0.16.1 (2026-02-21)

Expand Down
10 changes: 6 additions & 4 deletions _extensions/mcanouil/_modules/code-window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ function M.process_typst(block, config)
-- Even without a code-window, wrap with annotated-code() if annotations exist
local annot_dict = block.attributes['data-code-annotations']
if annot_dict then
local cell_id = block.attributes['data-cell-id'] or ''
local lang = ''
if block.classes and #block.classes > 0 then
lang = block.classes[1]
Expand All @@ -154,8 +155,8 @@ function M.process_typst(block, config)
local fence_len = math.max(3, max_consecutive_backticks(code_content) + 1)
local fence = string.rep('`', fence_len)
local typst_code = string.format(
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode))[%s%s\n%s\n%s]',
annot_dict, fence, lang, code_content, fence
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode), cell-id: "%s")[%s%s\n%s\n%s]',
annot_dict, cell_id, fence, lang, code_content, fence
)
return pandoc.RawBlock('typst', typst_code)
end
Expand All @@ -176,14 +177,15 @@ function M.process_typst(block, config)

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

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

-- Wrap with annotated-code() if annotations are present
if annot_dict then
raw_code = string.format(
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode))[%s]',
annot_dict, raw_code
'#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode), cell-id: "%s")[%s]',
annot_dict, cell_id, raw_code
)
end

Expand Down
62 changes: 46 additions & 16 deletions _extensions/mcanouil/filters/typst-code-annotation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ local LANG_COMMENT_CHARS = {

local DEFAULT_COMMENT = { "#" }

--- Counter for generating unique cell IDs across the document.
local cell_id_counter = 0

--- Generate a unique cell ID for annotation linking.
--- @return string Unique cell identifier, e.g. "cell-annote-1"
local function next_cell_id()
cell_id_counter = cell_id_counter + 1
return 'cell-annote-' .. tostring(cell_id_counter)
end

-- ============================================================================
-- ANNOTATION DETECTION
-- ============================================================================
Expand Down Expand Up @@ -219,37 +229,44 @@ local function annotations_to_typst_dict(annotations)
return '(' .. table.concat(parts, ', ') .. ')'
end

--- Store annotation data on a CodeBlock as a custom attribute.
--- The code-window module reads this attribute to wrap the code with
--- Store annotation data and cell ID on a CodeBlock as custom attributes.
--- The code-window module reads these attributes to wrap the code with
--- annotated-code().
--- @param code_block pandoc.CodeBlock
--- @param annotations table
--- @param cell_id string Unique cell identifier for bidirectional linking
--- @return pandoc.CodeBlock
local function tag_code_block(code_block, annotations)
local function tag_code_block(code_block, annotations, cell_id)
code_block.attributes['data-code-annotations'] = annotations_to_typst_dict(annotations)
code_block.attributes['data-cell-id'] = cell_id
return code_block
end

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

for i, item in ipairs(ol.content) do
local annotation_number = ol.start + i - 1
if annotations[annotation_number] then
local content_inlines = item[1].content or pandoc.Inlines(item[1])
-- Wrap content in Typst content brackets: #annotation-item(N, [content], colours)
-- Wrap content in Typst content brackets:
-- #annotation-item(N, [content], colours, cell-id: "cell-annote-N")
local block_content = pandoc.Inlines({})
block_content:insert(pandoc.RawInline(
'typst',
'#annotation-item(' .. tostring(annotation_number) .. ', ['
))
block_content:extend(content_inlines)
block_content:insert(pandoc.RawInline('typst', '], ' .. COLOURS_EXPR .. ')'))
block_content:insert(pandoc.RawInline(
'typst',
'], ' .. COLOURS_EXPR .. ', cell-id: "' .. cell_id .. '")'
))
items:insert(pandoc.Plain(block_content))
end
end
Expand All @@ -265,34 +282,40 @@ end
--- @param block pandoc.CodeBlock
--- @return pandoc.CodeBlock|nil Cleaned code block, or nil if no annotations
--- @return table|nil Annotations table
--- @return string|nil Cell ID for bidirectional linking
local function process_code_block(block)
if block.attr.classes:includes('cell-code') then
return nil, nil
return nil, nil, nil
end
local resolved, annotations = resolve_annotations(block)
if annotations then
resolved = tag_code_block(resolved, annotations)
local cell_id = next_cell_id()
resolved = tag_code_block(resolved, annotations, cell_id)
return resolved, annotations, cell_id
end
return resolved, annotations
return resolved, annotations, nil
end

--- Process a cell Div, looking for .cell-code CodeBlocks inside.
--- @param div pandoc.Div
--- @return pandoc.Div|nil Modified div, or nil if no annotations
--- @return table|nil Annotations table
--- @return string|nil Cell ID for bidirectional linking
local function process_cell_div(div)
if not div.attr.classes:includes('cell') then
return nil, nil
return nil, nil, nil
end

local found_annotations = nil
local found_cell_id = nil
local resolved_div = pandoc.walk_block(div, {
CodeBlock = function(el)
if el.attr.classes:includes('cell-code') then
local resolved, annotations = resolve_annotations(el)
if annotations and next(annotations) ~= nil then
found_annotations = annotations
resolved = tag_code_block(resolved, annotations)
found_cell_id = next_cell_id()
resolved = tag_code_block(resolved, annotations, found_cell_id)
return resolved
end
end
Expand All @@ -301,9 +324,9 @@ local function process_cell_div(div)
})

if found_annotations then
return resolved_div, found_annotations
return resolved_div, found_annotations, found_cell_id
end
return nil, nil
return nil, nil, nil
end

-- ============================================================================
Expand Down Expand Up @@ -343,40 +366,47 @@ return {
local outputs = pandoc.Blocks({})
local pending_code = nil
local pending_annotations = nil
local pending_cell_id = nil

local function flush_pending()
if pending_code then
outputs:insert(pending_code)
end
pending_code = nil
pending_annotations = nil
pending_cell_id = nil
end

for _, block in ipairs(blocks) do
if block.t == 'CodeBlock' then
flush_pending()
local resolved, annotations = process_code_block(block)
local resolved, annotations, cell_id = process_code_block(block)
if annotations then
pending_code = resolved
pending_annotations = annotations
pending_cell_id = cell_id
else
outputs:insert(block)
end
elseif block.t == 'Div' and block.attr.classes:includes('cell') then
flush_pending()
local resolved, annotations = process_cell_div(block)
local resolved, annotations, cell_id = process_cell_div(block)
if annotations then
pending_code = resolved
pending_annotations = annotations
pending_cell_id = cell_id
else
outputs:insert(block)
end
elseif block.t == 'OrderedList' and pending_annotations then
local annotation_blocks = build_annotation_list(block, pending_annotations)
local annotation_blocks = build_annotation_list(
block, pending_annotations, pending_cell_id or ''
)
outputs:insert(pending_code)
outputs:extend(annotation_blocks)
pending_code = nil
pending_annotations = nil
pending_cell_id = nil
else
flush_pending()
outputs:insert(block)
Expand Down
68 changes: 51 additions & 17 deletions _extensions/mcanouil/typst/partials/libs/code-annotations.typ
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,35 @@
/// Render a circled annotation number using brand colours.
/// @param n Annotation number to display
/// @param colours Colour dictionary (must contain foreground key)
/// @param cell-id Optional cell identifier for bidirectional linking (default: "")
/// @return Inline box with circled number
#let circled-number(n, colours) = {
box(baseline: 15%, circle(
#let circled-number(n, colours, cell-id: "") = {
let marker = box(baseline: 15%, circle(
radius: 0.55em,
stroke: 0.5pt + colours.foreground,
)[#set text(size: 0.7em); #align(center + horizon, str(n))])
marker
}

/// Wrap a raw code block with annotation markers overlaid at the right edge
/// of specified lines.
/// @param code Raw code block content
/// of specified lines. Supports bidirectional linking when cell-id is provided.
/// @param annotations Dictionary mapping annotation numbers to line numbers
/// @param colours Colour dictionary
/// @param cell-id Optional cell identifier for bidirectional linking (default: "")
/// @param code Raw code block content
/// @return Block with code and overlaid annotation markers
#let annotated-code(annotations, colours, code) = {
#let annotated-code(annotations, colours, cell-id: "", code) = {
// Build a set of first-line positions per annotation number so that
// back-labels are only emitted once (avoiding duplicate labels when
// one annotation spans multiple lines).
let first-lines = (:)
for (num-str, line-num) in annotations {
let key = str(num-str)
if key not in first-lines or line-num < int(first-lines.at(key)) {
first-lines.insert(key, str(line-num))
}
}

show raw.line: it => {
// Check whether this line has an annotation
// Keys are strings ("1", "2", ...), values are line numbers (int)
Expand All @@ -36,12 +50,22 @@
}
}
if annote-num != none {
// Line with annotation marker right-aligned, preserving natural line spacing
box(width: 100%)[
#it
#h(1fr)
#circled-number(annote-num, colours)
]
if cell-id != "" {
let lbl = cell-id + "-annote-" + str(annote-num)
let is-first = first-lines.at(str(annote-num), default: none) == str(it.number)
if is-first {
box(width: 100%)[#it #h(1fr) #link(label(lbl))[#circled-number(annote-num, colours)] #label(lbl + "-back")]
} else {
box(width: 100%)[#it #h(1fr) #link(label(lbl))[#circled-number(annote-num, colours)]]
}
} else {
// No cell-id: simple marker without linking
box(width: 100%)[
#it
#h(1fr)
#circled-number(annote-num, colours)
]
}
} else {
it
}
Expand All @@ -50,14 +74,24 @@
}

/// Render a single annotation list item with circled number inline.
/// Supports bidirectional linking when cell-id is provided.
/// @param n Annotation number
/// @param content Description content
/// @param colours Colour dictionary
/// @param cell-id Optional cell identifier for bidirectional linking (default: "")
/// @return Block with circled number and description on the same line
#let annotation-item(n, content, colours) = {
block(above: 0.4em, below: 0.4em)[
#circled-number(n, colours)
#h(0.4em)
#content
]
#let annotation-item(n, content, colours, cell-id: "") = {
if cell-id != "" {
[#block(above: 0.4em, below: 0.4em)[
#link(label(cell-id + "-annote-" + str(n) + "-back"))[#circled-number(n, colours)]
#h(0.4em)
#content
] #label(cell-id + "-annote-" + str(n))]
} else {
block(above: 0.4em, below: 0.4em)[
#circled-number(n, colours)
#h(0.4em)
#content
]
}
}
Loading