Skip to content
Draft
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
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
24 changes: 24 additions & 0 deletions src/resources/filters/customnodes/decoratedcodeblock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,27 @@ _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('\\', '\\\\'):gsub('"', '\\"')
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
Loading
Loading