Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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.10.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
All changes included in 1.10:

## 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 @@ -51,10 +51,13 @@ import {
kResourcePath,
kShortcodes,
kTblColwidths,
kHighlightStyle,
kSyntaxHighlighting,
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;
const skylighting =
typeof theme === "string" && 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,
};
};
106 changes: 88 additions & 18 deletions src/format/typst/format-typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,40 +187,110 @@ 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
const annotationMarkerRe =
/\/\/ quarto-code-annotations: ([\w-]*) (\([^)]*\))\n(\s*(?:#block\[\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, stroke: 0.5pt + luma(200), 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 (always track position)
fn = fn.replace(
/if number \{\n\s+lnum = lnum \+ 1\n/,
"lnum = lnum + 1\n if number {\n",
);

// Initialise 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 lnum = start - 1\n/,
"let lnum = start - 1\n let seen-annotes = (:)\n",
);

// Override bgcolor with brand monospace-block background-color
if (brandBgColor) {
// Add annotation rendering per line (derive circle colour from bgcolor)
fn = fn.replace(
/let bgcolor = rgb\("[^"]*"\)/,
`let bgcolor = rgb("${brandBgColor}")`,
"blocks = blocks + ln + EndLine()",
`let annote-num = annotations.at(str(lnum), 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) #link(label(lbl))[#quarto-circled-number(annote-num, color: quarto-annote-color(bgcolor))] #label(lbl + "-back")] + EndLine()
} else {
blocks = blocks + box(width: 100%)[#ln #h(1fr) #link(label(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;
}

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 @@ -200,6 +201,7 @@ return {
kAsciidocNativeCites = kAsciidocNativeCites,
kShowNotes = kShowNotes,
kProjectResolverIgnore = kProjectResolverIgnore,
kSyntaxHighlighting = kSyntaxHighlighting,
kCodeAnnotationsParam = kCodeAnnotationsParam,
kDataCodeCellTarget = kDataCodeCellTarget,
kDataCodeCellLines = kDataCodeCellLines,
Expand Down
69 changes: 69 additions & 0 deletions src/resources/filters/modules/typst-code-annotations.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
-- 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

-- Skylighting mode: emit a Typst comment that the TS post-processor
-- will merge into the Skylighting call site.
local function typstAnnotationMarker(annotations, cellId)
local dict = typstAnnotationsDict(annotations)
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)
local startFrom = tonumber(codeBlock.attr.attributes['startFrom']) or 1
local adjustedAnnotations = {}
for annoteId, lineNumbers in pairs(annotations) do
local adjusted = pandoc.List({})
for _, lineNo in ipairs(lineNumbers) do
adjusted:insert(lineNo - startFrom + 1)
end
adjustedAnnotations[annoteId] = adjusted
end
local dict = typstAnnotationsDict(adjustedAnnotations)
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