Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d607d11
feat: add unified quarto-code-block wrapper to Typst definitions
mcanouil Mar 6, 2026
b3e04ce
feat: pass syntax-highlighting boolean to Typst filter params
mcanouil Mar 6, 2026
e50c0bd
feat: add Typst code annotation processing to Lua filter
mcanouil Mar 6, 2026
6ce0416
feat: extend skylightingPostProcessor for code annotations
mcanouil Mar 6, 2026
80b2fcf
feat: add Typst renderer for DecoratedCodeBlock filename bar
mcanouil Mar 6, 2026
651075e
test: add Typst code annotation and filename bar test documents
mcanouil Mar 6, 2026
b5c6da8
refactor: split quarto-code-block into quarto-code-filename and quart…
mcanouil Mar 6, 2026
9e800ff
test: expand Typst code annotation and filename test coverage
mcanouil Mar 6, 2026
8241543
fix: harden Typst code annotation and filename escaping
mcanouil Mar 6, 2026
53f0ede
Merge branch 'main' into feat/typst-annotation-filename
mcanouil Mar 6, 2026
9416ad1
test: tweak test files
mcanouil Mar 6, 2026
78a54f8
fix: merge parent block from code cell with annotation marker regex f…
mcanouil Mar 6, 2026
c0f1448
fix: improve semantic structure by linking code and annotation
mcanouil Mar 7, 2026
ac932e9
test: update tests with semantic links
mcanouil Mar 7, 2026
5c8b344
fix: ensure back-labels are emitted only once
mcanouil Mar 7, 2026
994a878
test: update block styling to include stroke in monospace tests
mcanouil Mar 7, 2026
6efcd12
test: update block styling to include stroke in brand monospace block
mcanouil Mar 8, 2026
da9fb87
fix: escape newline, carriage return, and tab characters in filename
mcanouil Mar 9, 2026
f9a5d50
Merge branch 'main' into feat/typst-annotation-filename
mcanouil Mar 9, 2026
3015d59
Merge branch 'main' into feat/typst-annotation-filename
mcanouil Mar 24, 2026
422e053
fix: improve filename escaping in DecoratedCodeBlock renderer
mcanouil Mar 24, 2026
ae392ad
refactor: extract Typst annotation helpers into require() module
mcanouil Mar 24, 2026
87698cd
fix: move Typst annotation require() inside function scope
mcanouil Mar 24, 2026
12823b5
docs: update changelog for Typst annotation and filename features
mcanouil Mar 24, 2026
f7fd416
Merge branch 'main' into feat/typst-annotation-filename
mcanouil Apr 13, 2026
b997760
Merge branch 'main' into feat/typst-annotation-filename
mcanouil Apr 23, 2026
63d2a79
Merge branch 'main' into feat/typst-annotation-filename
cderv Jul 22, 2026
3300cc2
fix(typst): qualify constants refs left bare after main merge
cderv Jul 23, 2026
f3222b3
revert: remove strok for consistency with other formats
mcanouil Jul 23, 2026
b154d27
Merge remote-tracking branch 'origin/main' into pr/mcanouil/14170
mcanouil Jul 27, 2026
94b048e
fix(typst): make code annotations robust to id, offset, and label mis…
mcanouil Jul 27, 2026
f440dbc
fix(typst): remove leftover stroke on native code blocks
mcanouil Jul 27, 2026
206cc84
fix(typst): support code annotations inside crossref listings
mcanouil Jul 27, 2026
66c4f64
fix(typst): keep Skylighting path for light/dark highlighting themes
mcanouil Jul 27, 2026
34b479d
Merge branch 'main' into feat/typst-annotation-filename
mcanouil Jul 27, 2026
94e7b58
fix(typst): adjust Skylighting annotation keys by startFrom only
mcanouil Jul 27, 2026
f7126b4
test(typst): cover annotation and filename edge cases
mcanouil Jul 27, 2026
a63e76e
docs: move changelog entry to 1.11
mcanouil Jul 27, 2026
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
4 changes: 4 additions & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
All changes included in 1.11:

## Formats

### `typst`

- ([#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)): Add support for code annotations and filename features in Typst output. (author: @mcanouil)
11 changes: 11 additions & 0 deletions src/command/render/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
kFigResponsive,
kFormatIdentifier,
kHeaderIncludes,
kHighlightStyle,
kHtmlMathMethod,
kHtmlPreTagProcessing,
kHtmlTableProcessing,
Expand All @@ -50,11 +51,13 @@ import {
kRemoveHidden,
kResourcePath,
kShortcodes,
kSyntaxHighlighting,
kTblColwidths,
kTocTitleDocument,
kUnrollMarkdownCells,
kUseRsvgConvert,
} from "../../config/constants.ts";
import { kDefaultHighlightStyle } from "./constants.ts";
import { PandocOptions } from "./types.ts";
import {
Format,
Expand Down Expand Up @@ -945,11 +948,19 @@ async function resolveFilterExtension(
}

const extractTypstFilterParams = (format: Format) => {
const theme = format.pandoc[kSyntaxHighlighting] ||
format.pandoc[kHighlightStyle] ||
kDefaultHighlightStyle;
// Object themes (light/dark) resolve to a single .theme file for Typst
// (resolveTextHighlightStyle), so Skylighting is active for them too.
const skylighting = theme !== "none" && theme !== "idiomatic";

return {
[kTocIndent]: format.metadata[kTocIndent],
[kLogo]: format.metadata[kLogo],
[kCssPropertyProcessing]: format.metadata[kCssPropertyProcessing],
[kBrandMode]: format.metadata[kBrandMode],
[kHtmlPreTagProcessing]: format.metadata[kHtmlPreTagProcessing],
[kSyntaxHighlighting]: skylighting,
};
};
125 changes: 107 additions & 18 deletions src/format/typst/format-typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,40 +187,129 @@ export function typstFormat(): Format {
// When brand provides a monospace-block background-color, also overrides the
// bgcolor value. This is a temporary workaround until the fix is upstreamed
// to the Skylighting library.
//
// Additionally patches the Skylighting function for code annotation support:
// adds an annotations parameter, moves line tracking outside the if-number
// block, adds per-line annotation rendering, and routes output through
// quarto-code-block(). Also merges annotation comment markers from the Lua
// filter into Skylighting call sites.
//
// Upstream compatibility: a PR to skylighting-format-typst
// (fix/typst-skylighting-block-style) adds block styling upstream. Once merged
// and picked up by Pandoc, the block styling patch becomes a no-op (the
// replace target won't match). The brand color regex targets rgb("...") which
// works with both current and future upstream bgcolor init patterns.
function skylightingPostProcessor(brandBgColor?: string) {
// Match the entire #let Skylighting(...) = { ... } function.
// The signature is stable and generated by Skylighting's Typst backend.
const skylightingFnRe =
/(#let Skylighting\(fill: none, number: false, start: 1, sourcelines\) = \{[\s\S]*?\n\})/;

// Annotation markers emitted by the Lua filter as Typst comments.
// Between the marker and the Skylighting call there may be #block[ (cell
// divs), #figure([ (crossref floats), and #quarto-code-filename( wrappers.
const annotationMarkerRe =
/\/\/ quarto-code-annotations: (\S*) (\([^)]*\))\n(\s*(?:(?:#block\[|#figure\(\[)\s*)*(?:#quarto-code-filename\([^\n]*\)\[\s*)?)#Skylighting\(/g;

return async (output: string) => {
const content = Deno.readTextFileSync(output);
let content = Deno.readTextFileSync(output).replace(/\r\n/g, "\n");
let changed = false;

const match = skylightingFnRe.exec(content);
if (!match) {
// No Skylighting function found — document may not have code blocks,
// or upstream changed the function signature. Nothing to patch.
return;
}
if (match) {
let fn = match[1];

let fn = match[1];
// Fix block() call: add width, inset, radius, stroke
fn = fn.replace(
"block(fill: bgcolor, blocks)",
"block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks)",
);

// Fix block() call: add width, inset, radius
fn = fn.replace(
"block(fill: bgcolor, blocks)",
"block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks)",
);
// Override bgcolor with brand monospace-block background-color
if (brandBgColor) {
fn = fn.replace(
/rgb\("[^"]*"\)/,
`rgb("${brandBgColor}")`,
);
}

// Add cell-id and annotations parameters to function signature
fn = fn.replace(
"start: 1, sourcelines)",
'start: 1, cell-id: "", annotations: (:), sourcelines)',
);

// Move lnum increment outside if-number block and track a 1-based
// annotation position independent of `start` (annotation dict keys
// are always 1-based, whether or not Pandoc passes `start:`)
fn = fn.replace(
/if number \{\n\s+lnum = lnum \+ 1\n/,
"lnum = lnum + 1\n annote-pos = annote-pos + 1\n if number {\n",
);

// Override bgcolor with brand monospace-block background-color
if (brandBgColor) {
// Initialise the annotation position counter and a dictionary to
// track which annotation numbers have already emitted a back-label
// (avoids duplicate labels when one annotation spans multiple lines).
fn = fn.replace(
/let bgcolor = rgb\("[^"]*"\)/,
`let bgcolor = rgb("${brandBgColor}")`,
/let lnum = start - 1\n/,
"let lnum = start - 1\n let annote-pos = 0\n let seen-annotes = (:)\n",
);

// Add annotation rendering per line (derive circle colour from bgcolor).
// quarto-annote-link degrades to an unlinked circle when the annotation
// item label is missing (e.g. no trailing ordered list).
fn = fn.replace(
"blocks = blocks + ln + EndLine()",
`let annote-num = annotations.at(str(annote-pos), default: none)
if annote-num != none {
if cell-id != "" {
let lbl = cell-id + "-annote-" + str(annote-num)
if str(annote-num) not in seen-annotes {
seen-annotes.insert(str(annote-num), true)
blocks = blocks + box(width: 100%)[#ln #h(1fr) #quarto-annote-link(lbl)[#quarto-circled-number(annote-num, color: quarto-annote-color(bgcolor))] #label(lbl + "-back")] + EndLine()
} else {
blocks = blocks + box(width: 100%)[#ln #h(1fr) #quarto-annote-link(lbl)[#quarto-circled-number(annote-num, color: quarto-annote-color(bgcolor))]] + EndLine()
}
} else {
blocks = blocks + box(width: 100%)[#ln #h(1fr) #quarto-circled-number(annote-num, color: quarto-annote-color(bgcolor))] + EndLine()
}
} else {
blocks = blocks + ln + EndLine()
}`,
);

if (fn !== match[1]) {
content = content.replace(match[1], fn);
changed = true;
}
}

// Merge annotation markers into Skylighting call sites, including
// optional #block[ wrappers and #quarto-code-filename(...)[ wrappers.
const merged = content.replace(
annotationMarkerRe,
'$3#Skylighting(cell-id: "$1", annotations: $2, ',
);
if (merged !== content) {
content = merged;
changed = true;
}

// Strip any markers that could not be merged (e.g. the code block was
// wrapped in output the merge regex does not recognise) so they don't
// leak into keep-typ output. Rendering degrades gracefully via
// quarto-annote-link.
const stripped = content.replace(
/^[^\S\n]*\/\/ quarto-code-annotations:[^\n]*\n/gm,
"",
);
if (stripped !== content) {
content = stripped;
changed = true;
}

if (fn !== match[1]) {
Deno.writeTextFileSync(output, content.replace(match[1], fn));
if (changed) {
Deno.writeTextFileSync(output, content);
}
};
}
Expand Down
30 changes: 30 additions & 0 deletions src/resources/filters/customnodes/decoratedcodeblock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,33 @@ _quarto.ast.add_renderer("DecoratedCodeBlock",

return pandoc.Div(blocks, pandoc.Attr("", classes))
end)

-- typst renderer
_quarto.ast.add_renderer("DecoratedCodeBlock",
function(_)
return _quarto.format.isTypstOutput()
end,
function(node)
if node.filename == nil then
return _quarto.ast.walk(quarto.utils.as_blocks(node.code_block), {
CodeBlock = render_folded_block
})
end
local el = node.code_block
local rendered = _quarto.ast.walk(quarto.utils.as_blocks(el), {
CodeBlock = render_folded_block
}) or pandoc.Blocks({})
local blocks = pandoc.Blocks({})
local escaped = node.filename:gsub('[\\"\n\r\t]', {
['\\'] = '\\\\',
['"'] = '\\"',
['\n'] = '\\n',
['\r'] = '\\r',
['\t'] = '\\t',
})
blocks:insert(pandoc.RawBlock("typst",
'#quarto-code-filename("' .. escaped .. '")['))
blocks:extend(rendered)
blocks:insert(pandoc.RawBlock("typst", "]"))
return pandoc.Div(blocks)
end)
2 changes: 2 additions & 0 deletions src/resources/filters/modules/constants.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ local kAsciidocNativeCites = 'use-asciidoc-native-cites'
local kShowNotes = 'showNotes'
local kProjectResolverIgnore = 'project-resolve-ignore'

local kSyntaxHighlighting = 'syntax-highlighting'
local kCodeAnnotationsParam = 'code-annotations'
local kDataCodeCellTarget = 'data-code-cell'
local kDataCodeCellLines = 'data-code-lines'
Expand Down Expand Up @@ -201,6 +202,7 @@ return {
kAsciidocNativeCites = kAsciidocNativeCites,
kShowNotes = kShowNotes,
kProjectResolverIgnore = kProjectResolverIgnore,
kSyntaxHighlighting = kSyntaxHighlighting,
kCodeAnnotationsParam = kCodeAnnotationsParam,
kDataCodeCellTarget = kDataCodeCellTarget,
kDataCodeCellLines = kDataCodeCellLines,
Expand Down
77 changes: 77 additions & 0 deletions src/resources/filters/modules/typst-code-annotations.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
-- typst-code-annotations.lua
-- Copyright (C) 2026 Posit Software, PBC

-- Typst annotation helpers for code blocks.
-- Loaded via require() so locals stay out of the bundled main.lua scope.

local function _main()

-- Convert annotations table to a flat Typst dictionary string.
-- Keys are line positions (as strings), values are annotation numbers.
local function typstAnnotationsDict(annotations)
local entries = {}
for annoteId, lineNumbers in pairs(annotations) do
local num = annoteId:match("annote%-(%d+)")
if num then
for _, lineNo in ipairs(lineNumbers) do
table.insert(entries, {pos = lineNo, annoteNum = tonumber(num)})
end
end
end
table.sort(entries, function(a, b) return a.pos < b.pos end)
local parts = {}
for _, e in ipairs(entries) do
table.insert(parts, '"' .. tostring(e.pos) .. '": ' .. tostring(e.annoteNum))
end
return '(' .. table.concat(parts, ', ') .. ')'
end

-- Annotation line numbers are collected display-based (shifted by the
-- startFrom attribute or the fenced-echo start line). Typst-side rendering
-- always counts source lines from 1, so shift keys back with the same
-- offset used at collection time.
local function adjustAnnotations(annotations, offset)
offset = offset or 1
local adjusted = {}
for annoteId, lineNumbers in pairs(annotations) do
local list = pandoc.List({})
for _, lineNo in ipairs(lineNumbers) do
list:insert(lineNo - offset + 1)
end
adjusted[annoteId] = list
end
return adjusted
end

-- Skylighting mode: emit a Typst comment that the TS post-processor
-- will merge into the Skylighting call site.
local function typstAnnotationMarker(annotations, cellId, offset)
local dict = typstAnnotationsDict(adjustAnnotations(annotations, offset))
return pandoc.RawBlock("typst", "// quarto-code-annotations: " .. (cellId or "") .. " " .. dict)
end

-- Native/none mode: wrap a CodeBlock in #quarto-code-annotation(annotations)[...].
-- raw.line numbers always start at 1 regardless of startFrom, so adjust keys.
local function wrapTypstAnnotatedCode(codeBlock, annotations, cellId, offset)
local dict = typstAnnotationsDict(adjustAnnotations(annotations, offset))
local lang = codeBlock.attr.classes[1] or ""
local code = codeBlock.text
local maxBackticks = 2
for seq in code:gmatch("`+") do
maxBackticks = math.max(maxBackticks, #seq)
end
local fence = string.rep("`", maxBackticks + 1)
local raw = "#quarto-code-annotation(" .. dict
.. (cellId and cellId ~= "" and (", cell-id: \"" .. cellId .. "\"") or "")
.. ")[" .. fence .. lang .. "\n" .. code .. "\n" .. fence .. "]"
return pandoc.RawBlock("typst", raw)
end

return {
typstAnnotationsDict = typstAnnotationsDict,
typstAnnotationMarker = typstAnnotationMarker,
wrapTypstAnnotatedCode = wrapTypstAnnotatedCode,
}
end

return _main()
Loading
Loading