diff --git a/llm-docs/typst-code-annotations.md b/llm-docs/typst-code-annotations.md new file mode 100644 index 00000000000..c4f7d63b7b4 --- /dev/null +++ b/llm-docs/typst-code-annotations.md @@ -0,0 +1,78 @@ +--- +main_commit: 405161424 +analyzed_date: 2026-10-27 +key_files: + - src/format/typst/format-typst.ts + - src/command/render/filters.ts + - src/resources/filters/quarto-pre/code-annotation.lua + - src/resources/filters/modules/typst-code-annotations.lua + - src/resources/formats/typst/pandoc/quarto/definitions.typ +--- + +# Typst code annotations and filenames + +This document describes how Quarto draws code annotations (the circled numbers) and filename bars in Typst output. +Read it before you change the Pandoc version, the line offsets, or the marker format. + +## Pipeline + +1. `quarto-pre/code-annotation.lua` finds `# <1>` comments in a code block, removes them, and records the line of each annotation. +2. The filter writes the result for the active highlighting mode (next section). +3. The ordered list that follows the code becomes one `#quarto-annotation-item(cell-id, n, [...])` call per item. + The item's own blocks sit between a raw open and a raw close, so `crossref` and citations still run on them. +4. After Pandoc writes the `.typ` file, `src/format/typst/format-typst.ts` patches and merges the output. +5. `src/resources/formats/typst/pandoc/quarto/definitions.typ` holds the Typst helpers. + +## Highlighting modes + +The filter param `syntax-highlighting-mode` (set in `extractTypstFilterParams`) has three values. + +- `skylighting`: Pandoc writes a generated `Skylighting()` function. The Lua filter emits a marker comment before the code block. +- `idiomatic`: Pandoc writes a native Typst `raw` block. The Lua filter wraps the block in `#quarto-code-annotation(annotations, cell-id: ...)[...]`. +- `none`: like `idiomatic`, with no language tag on the block. + +## Marker protocol (`skylighting` mode) + +The Lua filter writes `// quarto-code-annotations: ("": , ...)` as a raw Typst block. +The post-processor merges a marker into the next `#Skylighting(` call, which becomes `#Skylighting(cell-id: "...", annotations: (...), ...)`. +Only these wrappers may sit between the marker and the call: `#block[`, `#figure([`, and `#quarto-code-filename(...)[`. +A marker that does not merge is removed, and Quarto prints a warning with the cell id. + +## Patches to Pandoc's `Skylighting()` function + +`patchSkylightingFunction` changes the generated function with named patches: `signature`, `line counter`, `position counter` and `line rendering`. +The block styling and the brand background (`bgcolor` initialiser only) are applied first. +If one named patch does not apply, the function keeps only the block styling, annotations are off for the document, and Quarto prints a warning with the Pandoc version. +After a Pandoc update, run the `typst/code-annotations/` smoke-all documents and `tests/unit/typst-skylighting-patch.test.ts`. + +## Line offsets + +Two numbers shift the recorded lines, and they differ on purpose. + +- `pendingStartFrom` is the display start from a `startFrom` attribute. Typst numbers the displayed lines. +- `pendingOffset` is the first source line. The generated function and `raw.line` both count source lines from 1. + +`adjustAnnotations` converts the recorded lines to source lines with `lineNo - offset + 1`. +A fenced echo block (`{{python}}`) gets one extra line, because the filter writes the fence line the way `engine_escape` would, and the offset moves by one. + +## Rendering helpers (`definitions.typ`) + +- `quarto-circled-number` draws the circle. It sits inside a `math.equation` that carries the text alternative "Annotation N", because `box`, `text` and `link` do not accept `alt`. The circle sits in a zero-height box, so it overflows its row and adds no height. That box needs `baseline: 0.55em` with a `move(dy: -0.63em)`, which centres the circle on the cap height of the text. With the default baseline, a zero-height box still raises the top of its line by about 0.8pt, and the second annotated row ends up taller than a plain row. +- `quarto-annote-link` links only when the target label exists, so a missing list degrades to an unlinked circle. +- `quarto-code-annotation` is the native-mode renderer. The Skylighting patch contains its own per-line copy of the same layout. Change both together. +- With `.number-lines`, annotated rows use `width: 100% - annote-ln-width` so the circle stays on the code line. +- The default code fill is a `set block(fill: ...)` rule, so a brand or user `set block(fill: ...)` wins. + +## Known limits + +- An extension that replaces `definitions.typ` lacks the annotation helpers. The post-processor defines only the missing ones, before the first block that uses them. +- Code inside a figure with `layout-ncol` (`::: {#fig-a layout-ncol=2}` with a caption) cannot receive its circles, because the layout puts the marker in a grid cell of its own. Quarto prints a warning with the cell id and removes the marker. The list items still render, and the document compiles. +- `hover` and `select` annotation styles use the gutter style. `select` also prints a warning. +- Per-cell `code-annotations` options are not read for any format; only the document-level value counts. +- A filename longer than 40 characters may break after `/`, `_`, `.` and `-`, using zero-width spaces, and a run of more than 30 characters without those may break at any character. The last `.` keeps the extension attached. +- Two copies of the gutter layout exist (the Skylighting patch and `quarto-code-annotation`). + +## Tests + +`tests/docs/smoke-all/typst/code-annotations/`, `tests/docs/smoke-all/typst/code-filename/`, and `tests/unit/typst-skylighting-patch.test.ts`. +The knitr cell documents need a working R graphics device. diff --git a/news/changelog-1.12.md b/news/changelog-1.12.md index 326577f9655..fd96196f5fb 100644 --- a/news/changelog-1.12.md +++ b/news/changelog-1.12.md @@ -1,3 +1,7 @@ All changes included in 1.12: +## Formats +### `typst` + +- ([#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)): Add support for code annotations and filename features in Typst output. Typst renders annotations in the gutter, and `hover` and `select` fall back to that style. Typst code blocks now have a thin outline. (author: @mcanouil) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index a3f8fe4d477..c5f9b9fd3e0 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -25,6 +25,7 @@ import { kFigResponsive, kFormatIdentifier, kHeaderIncludes, + kHighlightStyle, kHtmlMathMethod, kHtmlPreTagProcessing, kHtmlTableProcessing, @@ -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, @@ -944,12 +947,24 @@ async function resolveFilterExtension( return results.flat(); } +const kSyntaxHighlightingMode = "syntax-highlighting-mode"; + 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 highlightingMode = theme === "none" || theme === "idiomatic" + ? theme + : "skylighting"; + return { [kTocIndent]: format.metadata[kTocIndent], [kLogo]: format.metadata[kLogo], [kCssPropertyProcessing]: format.metadata[kCssPropertyProcessing], [kBrandMode]: format.metadata[kBrandMode], [kHtmlPreTagProcessing]: format.metadata[kHtmlPreTagProcessing], + [kSyntaxHighlightingMode]: highlightingMode, }; }; diff --git a/src/format/typst/format-typst.ts b/src/format/typst/format-typst.ts index 71f06570226..0fb15ada8c7 100644 --- a/src/format/typst/format-typst.ts +++ b/src/format/typst/format-typst.ts @@ -5,6 +5,8 @@ */ import { join } from "../../deno_ral/path.ts"; +import { warning } from "../../deno_ral/log.ts"; +import { execProcess } from "../../core/process.ts"; import { RenderServices } from "../../command/render/types.ts"; import { ProjectContext } from "../../project/types.ts"; @@ -33,7 +35,7 @@ import { Metadata, PandocFlags, } from "../../config/types.ts"; -import { formatResourcePath } from "../../core/resources.ts"; +import { formatResourcePath, pandocBinaryPath } from "../../core/resources.ts"; import { createFormat } from "../formats-shared.ts"; import { hasLevelOneHeadings as hasL1Headings } from "../../core/lib/markdown-analysis/level-one-headings.ts"; import { @@ -180,6 +182,178 @@ export function typstFormat(): Format { }); } +const annotatedLineRendering = + `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% - annote-ln-width)[#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% - annote-ln-width)[#ln #h(1fr) #quarto-annote-link(lbl)[#quarto-circled-number(annote-num, color: quarto-annote-color(bgcolor))]] + EndLine() + } + } else { + blocks = blocks + box(width: 100% - annote-ln-width)[#ln #h(1fr) #quarto-circled-number(annote-num, color: quarto-annote-color(bgcolor))] + EndLine() + } + } else { + blocks = blocks + ln + EndLine() + }`; + +export function applyPatch( + source: string, + pattern: string | RegExp, + replacement: string, +): { source: string; applied: boolean } { + const patched = source.replace(pattern, replacement); + return { source: patched, applied: patched !== source }; +} + +// Styles Pandoc's Skylighting function, then adds the annotation patches. +// When one annotation patch misses, the styled function without any +// annotation patch is returned together with the name of the missed patch. +export function patchSkylightingFunction( + fn: string, + brandBgColor?: string, +): { fn: string; failedPatch?: string } { + // Fix block() call: add width, inset, radius, stroke + let styled = fn.replace( + "block(fill: bgcolor, blocks)", + "block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0.5pt + luma(128), blocks)", + ); + + // Override bgcolor with brand monospace-block background-color + if (brandBgColor) { + styled = styled.replace( + /(let bgcolor = )(?:rgb\("[^"]*"\)|none)/, + `$1rgb("${brandBgColor}")`, + ); + } + + const patches: [string, string | RegExp, string][] = [ + [ + "signature", + "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:`) + [ + "line counter", + /if number \{\n\s+lnum = lnum \+ 1\n/, + "lnum = lnum + 1\n annote-pos = annote-pos + 1\n if number {\n", + ], + // 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). + [ + "position counter", + /let lnum = start - 1\n/, + "let lnum = start - 1\n let annote-pos = 0\n let seen-annotes = (:)\n let annote-ln-width = if number { if start + sourcelines.len() > 999 { 30pt } else { 24pt } } else { 0pt }\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). + [ + "line rendering", + "blocks = blocks + ln + EndLine()", + annotatedLineRendering, + ], + ]; + + let annotated = styled; + for (const [name, pattern, replacement] of patches) { + const result = applyPatch(annotated, pattern, replacement); + if (!result.applied) { + return { fn: styled, failedPatch: name }; + } + annotated = result.source; + } + return { fn: annotated }; +} + +async function pandocVersion(): Promise { + const result = await execProcess({ + cmd: pandocBinaryPath(), + args: ["--version"], + stdout: "piped", + }); + return result.stdout?.split("\n")[0]?.split(" ")[1] ?? "(unknown version)"; +} + +const annotationHelperDependencies: [string, string[]][] = [ + ["quarto-annote-link", []], + ["quarto-circled-number", []], + ["quarto-annote-color", []], + ["quarto-code-filename", []], + ["quarto-code-annotation", ["quarto-annote-link", "quarto-circled-number"]], + ["quarto-annotation-item", ["quarto-annote-link", "quarto-circled-number"]], +]; + +function extractLetBlock(source: string, name: string): string | undefined { + const start = source.search(new RegExp(`^#let ${name}\\(`, "m")); + if (start === -1) { + return undefined; + } + const rest = source.slice(start); + const next = rest.slice(1).search(/^(#let |#show|\/\/)/m); + const block = next === -1 ? rest : rest.slice(0, next + 1); + return block.trimEnd(); +} + +// Typst extensions can replace the definitions partial and then lack the +// helpers that code annotations and filenames call. Define only the missing +// ones, before the first line that can use them. +export function injectMissingAnnotationHelpers( + content: string, + definitionsSource: string, +): { content: string; injected: string[] } { + const isDefined = (name: string) => + new RegExp(`^\\s*#let ${name}\\b`, "m").test(content); + const isUsed = (name: string) => + new RegExp(`(?(); + for (const [name, dependencies] of annotationHelperDependencies) { + if (isUsed(name)) { + needed.add(name); + dependencies.forEach((dependency) => needed.add(dependency)); + } + } + + const injected: string[] = []; + const blocks: string[] = []; + for (const [name] of annotationHelperDependencies) { + if (needed.has(name) && !isDefined(name)) { + const block = extractLetBlock(definitionsSource, name); + if (block !== undefined) { + injected.push(name); + blocks.push(block); + } + } + } + if (injected.length === 0) { + return { content, injected }; + } + + // Typst closures capture earlier bindings only, and a `#set` inside a content + // block would scope them, so define the helpers before the first top-level + // `#let` of the file. + let anchor = content.search(/^#let /m); + if (anchor === -1) { + anchor = content.search(/^#show: doc =>/m); + } + if (anchor === -1) { + return { content, injected: [] }; + } + return { + content: content.slice(0, anchor) + blocks.join("\n\n") + "\n\n" + + content.slice(anchor), + injected, + }; +} + // Fix Skylighting code block styling in .typ output (issue #14126). // The Pandoc-generated Skylighting function uses block(fill: bgcolor, blocks) // which lacks width, inset, and radius. This postprocessor matches the entire @@ -187,40 +361,107 @@ 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 the bgcolor +// initialiser only, so line-number colours are left alone. 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: ([^\n]*?) (\([^)]*\))\n(\s*(?:(?:#block\[|#figure\(\[)\s*)*(?:#quarto-code-filename\([^\n]*\)\[\s*)?)#Skylighting\(/g; + return async (output: string) => { - const content = Deno.readTextFileSync(output); + const original = Deno.readTextFileSync(output); + let content = original.replace(/\r\n/g, "\n"); + let changed = content !== original; + let annotationsPatched = true; 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 && annotationMarkerRe.test(content)) { + annotationsPatched = false; + warning( + `Typst code annotations: the Skylighting function was not found in the output of Pandoc ${await pandocVersion()}; annotations are disabled for this document.`, + ); + } + annotationMarkerRe.lastIndex = 0; + if (match) { + const patched = patchSkylightingFunction(match[1], brandBgColor); + if (patched.failedPatch !== undefined) { + annotationsPatched = false; + warning( + `Typst code annotations: patch "${patched.failedPatch}" did not apply to Pandoc ${await pandocVersion()}; annotations are disabled for this document.`, + ); + } + if (patched.fn !== match[1]) { + content = content.replace(match[1], patched.fn); + changed = true; + } } - let fn = match[1]; + // Merge annotation markers into Skylighting call sites, including + // optional #block[ wrappers and #quarto-code-filename(...)[ wrappers. + if (annotationsPatched) { + const merged = content.replace( + annotationMarkerRe, + '$3#Skylighting(cell-id: "$1", annotations: $2, ', + ); + if (merged !== content) { + content = merged; + changed = true; + } + } - // Fix block() call: add width, inset, radius - fn = fn.replace( - "block(fill: bgcolor, blocks)", - "block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks)", + // Strip 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]*\n/gm, + (_marker, cellId: string) => { + if (annotationsPatched) { + warning( + `Typst code annotations: the annotations of cell "${cellId}" could not be attached to its code block and are not shown in the code.`, + ); + } + return ""; + }, ); + if (stripped !== content) { + content = stripped; + changed = true; + } - // Override bgcolor with brand monospace-block background-color - if (brandBgColor) { - fn = fn.replace( - /let bgcolor = rgb\("[^"]*"\)/, - `let bgcolor = rgb("${brandBgColor}")`, - ); + const withHelpers = injectMissingAnnotationHelpers( + content, + Deno.readTextFileSync( + formatResourcePath( + "typst", + join("pandoc", "quarto", "definitions.typ"), + ), + ), + ); + if (withHelpers.injected.length > 0) { + content = withHelpers.content; + changed = true; } - if (fn !== match[1]) { - Deno.writeTextFileSync(output, content.replace(match[1], fn)); + if (changed) { + Deno.writeTextFileSync(output, content); } }; } diff --git a/src/resources/filters/customnodes/decoratedcodeblock.lua b/src/resources/filters/customnodes/decoratedcodeblock.lua index 5aeefb0a55b..936cb437cfb 100644 --- a/src/resources/filters/customnodes/decoratedcodeblock.lua +++ b/src/resources/filters/customnodes/decoratedcodeblock.lua @@ -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) diff --git a/src/resources/filters/modules/constants.lua b/src/resources/filters/modules/constants.lua index 940365a7041..8c99b85843b 100644 --- a/src/resources/filters/modules/constants.lua +++ b/src/resources/filters/modules/constants.lua @@ -33,6 +33,9 @@ local kAsciidocNativeCites = 'use-asciidoc-native-cites' local kShowNotes = 'showNotes' local kProjectResolverIgnore = 'project-resolve-ignore' +local kSyntaxHighlightingMode = 'syntax-highlighting-mode' +local kSyntaxHighlightingSkylighting = 'skylighting' +local kSyntaxHighlightingNone = 'none' local kCodeAnnotationsParam = 'code-annotations' local kDataCodeCellTarget = 'data-code-cell' local kDataCodeCellLines = 'data-code-lines' @@ -202,6 +205,9 @@ return { kAsciidocNativeCites = kAsciidocNativeCites, kShowNotes = kShowNotes, kProjectResolverIgnore = kProjectResolverIgnore, + kSyntaxHighlightingMode = kSyntaxHighlightingMode, + kSyntaxHighlightingSkylighting = kSyntaxHighlightingSkylighting, + kSyntaxHighlightingNone = kSyntaxHighlightingNone, kCodeAnnotationsParam = kCodeAnnotationsParam, kDataCodeCellTarget = kDataCodeCellTarget, kDataCodeCellLines = kDataCodeCellLines, diff --git a/src/resources/filters/modules/typst-code-annotations.lua b/src/resources/filters/modules/typst-code-annotations.lua new file mode 100644 index 00000000000..ca85d139b73 --- /dev/null +++ b/src/resources/filters/modules/typst-code-annotations.lua @@ -0,0 +1,92 @@ +-- 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() + + local function typstEscapeString(s) + return (s:gsub('[\\"]', "\\%0")) + end + + -- 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: " .. typstEscapeString(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, dropLanguage) + local lang = not dropLanguage and codeBlock.attr.classes[1] or "" + local code = codeBlock.text + -- engine_escape runs after this filter and would rewrite an escaped + -- engine block into a markdown block that shows the fence line, so + -- do the same here and shift the keys past the added fence line. + local engine = codeBlock.attr.classes[1] + and codeBlock.attr.classes[1]:match(_quarto.modules.patterns.engine_escape) + if engine then + code = "```" .. engine .. "\n" .. code .. "\n```" + lang = dropLanguage and "" or "markdown" + offset = (offset or 1) - 1 + end + local dict = typstAnnotationsDict(adjustAnnotations(annotations, offset)) + 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: \"" .. typstEscapeString(cellId) .. "\"") or "") + .. ")[" .. fence .. lang .. "\n" .. code .. "\n" .. fence .. "]" + return pandoc.RawBlock("typst", raw) + end + + return { + typstEscapeString = typstEscapeString, + typstAnnotationsDict = typstAnnotationsDict, + typstAnnotationMarker = typstAnnotationMarker, + wrapTypstAnnotatedCode = wrapTypstAnnotatedCode, + } +end + +return _main() diff --git a/src/resources/filters/quarto-post/foldcode.lua b/src/resources/filters/quarto-post/foldcode.lua index 35a9ac50dc5..e0e4cf69fc2 100644 --- a/src/resources/filters/quarto-post/foldcode.lua +++ b/src/resources/filters/quarto-post/foldcode.lua @@ -74,11 +74,14 @@ function fold_code_and_lift_codeblocks() end, CodeBlock = function(block) local folded_block, did_fold = render_folded_block(block) - local need_to_lift = did_fold or block.classes:includes("code-annotation-code") + -- Typst keeps annotated code in the float, next to its annotation marker + local lift_annotated = block.classes:includes("code-annotation-code") + and not _quarto.format.isTypstOutput() + local need_to_lift = did_fold or lift_annotated if need_to_lift then folded_block = _quarto.ast.make_scaffold(pandoc.Div, { folded_block } ) end - if block.classes:includes("code-annotation-code") then + if lift_annotated then prev_annotated_code_block_scaffold = folded_block prev_annotated_code_block = block else diff --git a/src/resources/filters/quarto-pre/code-annotation.lua b/src/resources/filters/quarto-pre/code-annotation.lua index 8a56df4cae3..26b9adb7a01 100644 --- a/src/resources/filters/quarto-pre/code-annotation.lua +++ b/src/resources/filters/quarto-pre/code-annotation.lua @@ -1,6 +1,7 @@ -- code.lua -- Copyright (C) 2020-2022 Posit Software, PBC + local hasAnnotations = false function isAnnotationCell(el) @@ -78,7 +79,7 @@ local function toAnnoteId(number) end local function latexListPlaceholder(number) - return '5CB6E08D-list-annote-' .. number + return '5CB6E08D-list-annote-' .. number end local function toLines(s) @@ -119,7 +120,8 @@ local function resolveCellAnnotes(codeBlockEl, processAnnotation) local outputs = pandoc.List({}) local i = 1 - local offset = codeBlockEl.attr.attributes['startFrom'] or defaultStartLine + -- the injected fence line of a fenced echo block counts as one line before the code + local offset = (tonumber(codeBlockEl.attr.attributes['startFrom']) or 1) + defaultStartLine - 1 for line in toLines(code) do -- Look and annotation @@ -154,7 +156,7 @@ local function resolveCellAnnotes(codeBlockEl, processAnnotation) codeBlockEl.text = outputText hasAnnotations = true end - return codeBlockEl, annotations + return codeBlockEl, annotations, offset elseif lang then return codeBlockEl, {} end @@ -260,11 +262,12 @@ end function processAnnotation(line, annoteNumber, annotationProvider) -- For all other formats, just strip the annotation- the definition list is converted - -- to be based upon line numbers. + -- to be based upon line numbers. local stripped = annotationProvider.stripAnnotation(line, annoteNumber) return stripped end + function code_meta() return { Meta = function(meta) @@ -284,15 +287,25 @@ end -- The actual filter that will look for a code cell and then -- find its annotations, then process the subsequent OL function code_annotations() + local typstAnnotations = require("modules/typst-code-annotations") + local highlightingMode = param(_quarto.modules.constants.kSyntaxHighlightingMode, _quarto.modules.constants.kSyntaxHighlightingSkylighting) + local useSkylighting = highlightingMode == _quarto.modules.constants.kSyntaxHighlightingSkylighting + local dropLanguage = highlightingMode == _quarto.modules.constants.kSyntaxHighlightingNone + -- the localized strings local language = param("language", nil) -- an id counter to provide nice numeric ids to cell local idCounter = 1 + local usedCellIds = {} -- the user request code annotations value local codeAnnotations = param(_quarto.modules.constants.kCodeAnnotationsParam) + if _quarto.format.isTypstOutput() and codeAnnotations == _quarto.modules.constants.kCodeAnnotationStyleSelect then + warn("Typst does not support 'code-annotations: select'; annotations use the gutter style, as with 'hover'.") + end + local requireNonIncremental = PANDOC_WRITER_OPTIONS[_quarto.modules.constants.kIncremental] and ( codeAnnotations == _quarto.modules.constants.kCodeAnnotationStyleSelect or codeAnnotations == _quarto.modules.constants.kCodeAnnotationStyleHover ) @@ -313,11 +326,22 @@ function code_annotations() local pendingAnnotations = nil local pendingCellId = nil local pendingCodeCell = nil - - local clearPending = function() + -- Collected annotation keys are display-based. Two different shifts + -- apply when converting them back to physical source lines for Typst: + -- pendingOffset (startFrom or the fenced-echo start line) for code + -- re-emitted from the pre-time text (native wrap path, whose raw + -- block adds the fence line of an escaped engine block itself), + -- and pendingStartFrom alone for the Skylighting path, whose output + -- still contains the injected fenced-echo line. + local pendingOffset = nil + local pendingStartFrom = nil + + local clearPending = function() pendingAnnotations = nil pendingCellId = nil pendingCodeCell = nil + pendingOffset = nil + pendingStartFrom = nil end local outputBlock = function(block) @@ -326,7 +350,28 @@ function code_annotations() local flushPending = function() if pendingCodeCell then - outputBlock(pendingCodeCell) + local flushedCell = pendingCodeCell + -- No list followed the cell: still render the annotation circles + if _quarto.format.isTypstOutput() + and codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone + and pendingAnnotations and next(pendingAnnotations) ~= nil then + if useSkylighting then + flushedCell = pandoc.Div({ + typstAnnotations.typstAnnotationMarker(pendingAnnotations, pendingCellId, pendingStartFrom), + pendingCodeCell + }) + else + flushedCell = _quarto.ast.walk(pendingCodeCell, { + CodeBlock = function(el) + if el.attr.classes:find('cell-code') or + el.attr.classes:find(_quarto.modules.constants.kDataCodeAnnonationClz) then + return typstAnnotations.wrapTypstAnnotatedCode(el, pendingAnnotations, pendingCellId, pendingOffset, dropLanguage) + end + end + }) + end + end + outputBlock(flushedCell) end clearPending() end @@ -350,6 +395,20 @@ function code_annotations() end end + -- Typst fails on duplicate labels, so make repeated ids of annotated cells unique + local uniqueCellId = function(identifier) + if _quarto.format.isTypstOutput() and usedCellIds[identifier] then + local count = usedCellIds[identifier] + 1 + usedCellIds[identifier] = count + local uniqueId = identifier .. '-' .. tostring(count) + usedCellIds[uniqueId] = 1 + warn("Duplicate code cell id '" .. identifier .. "'; using '" .. uniqueId .. "' for its annotations.") + return uniqueId + end + usedCellIds[identifier] = 1 + return identifier + end + local processCodeCell = function(el, identifier) -- select the process for this format's annotations @@ -361,11 +420,13 @@ function code_annotations() end -- resolve annotations - local resolvedCodeBlock, annotations = resolveCellAnnotes(el, annotationProcessor) + local resolvedCodeBlock, annotations, annotationOffset = resolveCellAnnotes(el, annotationProcessor) if annotations and next(annotations) ~= nil then -- store the annotations and cell info pendingAnnotations = annotations - pendingCellId = identifier + pendingCellId = uniqueCellId(identifier) + pendingOffset = annotationOffset or 1 + pendingStartFrom = tonumber(el.attr.attributes['startFrom']) or 1 -- decorate the cell and return it if codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone then @@ -393,13 +454,13 @@ function code_annotations() local processedAnnotation = false local resolvedBlock = _quarto.ast.walk(block, { CodeBlock = function(el) - if el.attr.classes:find('cell-code') then + if el.attr.classes:find('cell-code') and not el.attr.classes:find(_quarto.modules.constants.kDataCodeAnnonationClz) then local cellId = resolveCellId(el.attr.identifier) local codeCell = processCodeCell(el, cellId) if codeCell then processedAnnotation = true if codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone then - codeCell.attr.identifier = cellId; + codeCell.attr.identifier = pendingCellId; end end return codeCell @@ -414,6 +475,33 @@ function code_annotations() outputBlock(resolvedBlock) end + elseif _quarto.format.isTypstOutput() and is_custom_node(block, "FloatRefTarget") then + -- Typst: a crossref float (e.g. lst-cap listing) wraps its code in a + -- nested Blocks list, so the standalone CodeBlock branch below never + -- sees it at this level and the following OL would stay unprocessed. + -- Process the float's code here and hold onto the float so the OL + -- handler can attach the marker and emit annotation items after it. + flushPending() + local processedAnnotation = false + local resolvedBlock = _quarto.ast.walk(block, { + CodeBlock = function(el) + local cellId = resolveCellId(el.attr.identifier) + local codeCell = processCodeCell(el, cellId) + if codeCell then + processedAnnotation = true + if codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone then + codeCell.attr.identifier = pendingCellId; + end + end + return codeCell + end + }) + if processedAnnotation then + pendingCodeCell = resolvedBlock + else + outputBlock(resolvedBlock or block) + end + elseif block.t == "Div" then local isDecoratedCodeBlock = is_custom_node(block, "DecoratedCodeBlock") if isDecoratedCodeBlock then @@ -421,17 +509,32 @@ function code_annotations() -- output the pending code cell and continue flushPending() - if #block.content == 1 and #block.content[1].content == 1 then + if #block.content == 1 and #block.content[1].content == 1 + and block.content[1].content[1] ~= nil + and block.content[1].content[1].t == "CodeBlock" then -- Find the code block and process that local codeblock = block.content[1].content[1] - + local cellId = resolveCellId(codeblock.attr.identifier) local codeCell = processCodeCell(codeblock, cellId) if codeCell then if codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone then - codeCell.attr.identifier = cellId; + codeCell.attr.identifier = pendingCellId; + end + -- Typst DecoratedCodeBlock: embed annotation data inside the block + -- so the marker stays adjacent to the Skylighting call after rendering + if _quarto.format.isTypstOutput() + and codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone + and pendingAnnotations and next(pendingAnnotations) ~= nil then + if useSkylighting then + block.content[1].content[1] = codeCell + block.content[1].content:insert(1, typstAnnotations.typstAnnotationMarker(pendingAnnotations, pendingCellId, pendingStartFrom)) + else + block.content[1].content[1] = typstAnnotations.wrapTypstAnnotatedCode(codeCell, pendingAnnotations, pendingCellId, pendingOffset, dropLanguage) + end + else + block.content[1].content[1] = codeCell end - block.content[1].content[1] = codeCell outputBlock(block) else outputBlockClearPending(block) @@ -445,7 +548,7 @@ function code_annotations() elseif block.t == 'CodeBlock' then -- don't process code cell output here - we'll get it above -- This processes non-executable code blocks - if not block.attr.classes:find('cell-code') then + if not block.attr.classes:find('cell-code') and not block.attr.classes:find(_quarto.modules.constants.kDataCodeAnnonationClz) then -- If there is a pending code cell and we get here, just -- output the pending code cell and continue @@ -455,9 +558,25 @@ function code_annotations() local codeCell = processCodeCell(block, cellId) if codeCell then if codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone then - codeCell.attr.identifier = cellId; + codeCell.attr.identifier = pendingCellId; + end + -- Typst standalone CodeBlock: emit annotation data alongside + -- the code block. The OL handler will emit annotation items. + if _quarto.format.isTypstOutput() + and codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone + and pendingAnnotations and next(pendingAnnotations) ~= nil then + if useSkylighting then + -- One Div keeps the marker and the code in the same layout cell + outputBlock(pandoc.Div({ + typstAnnotations.typstAnnotationMarker(pendingAnnotations, pendingCellId, pendingStartFrom), + codeCell + })) + else + outputBlock(typstAnnotations.wrapTypstAnnotatedCode(codeCell, pendingAnnotations, pendingCellId, pendingOffset, dropLanguage)) + end + else + outputBlock(codeCell) end - outputBlock(codeCell) else outputBlockClearPending(block) end @@ -465,6 +584,82 @@ function code_annotations() outputBlockClearPending(block) end elseif block.t == 'OrderedList' and pendingAnnotations ~= nil and next(pendingAnnotations) ~= nil then + + -- Typst: emit annotation items as raw Typst blocks + if _quarto.format.isTypstOutput() and codeAnnotations ~= _quarto.modules.constants.kCodeAnnotationStyleNone then + local annotationBlocks = pandoc.List() + for i, v in ipairs(block.content) do + local annotationNumber = block.start + i - 1 + local annoteId = toAnnoteId(annotationNumber) + if pendingAnnotations[annoteId] then + -- Open and close the item with raw Typst around the item's own + -- blocks, so cross-references and citations resolve later. + -- One Div per item keeps the three parts in one layout cell. + local itemBlocks = pandoc.Blocks({pandoc.RawBlock("typst", + "#quarto-annotation-item(\"" .. typstAnnotations.typstEscapeString(pendingCellId or "") .. "\", " .. tostring(annotationNumber) .. ", [")}) + itemBlocks:extend(v) + itemBlocks:insert(pandoc.RawBlock("typst", "])")) + annotationBlocks:insert(pandoc.Div(itemBlocks)) + else + warn("List item " .. tostring(i) .. " has no corresponding annotation in the code cell\n(" .. pandoc.utils.stringify(v) .. ")") + end + end + + if pendingCodeCell ~= nil then + local resolvedCell = _quarto.ast.walk(pendingCodeCell, { + CodeBlock = function(el) + if el.attr.classes:find('cell-code') or + el.attr.classes:find(_quarto.modules.constants.kDataCodeAnnonationClz) then + if useSkylighting then + return nil + else + return typstAnnotations.wrapTypstAnnotatedCode(el, pendingAnnotations, pendingCellId, pendingOffset, dropLanguage) + end + end + end + }) + + local dlDiv = pandoc.Div(annotationBlocks, pandoc.Attr("", {_quarto.modules.constants.kCellAnnotationClass})) + if is_custom_node(resolvedCell, "FloatRefTarget") then + -- Crossref float: put the marker inside the float (adjacent to + -- the code) but emit the annotation items after the float so + -- they don't land inside the captioned figure. The float's + -- content slot may be a bare block rather than a Blocks list. + local custom = _quarto.ast.resolve_custom_data(resolvedCell) or pandoc.Div({}) + if useSkylighting then + local marker = typstAnnotations.typstAnnotationMarker(pendingAnnotations, pendingCellId, pendingStartFrom) + local floatContent = custom.content + if floatContent ~= nil and floatContent.insert ~= nil then + floatContent:insert(1, marker) + else + custom.content = pandoc.Blocks({ marker, floatContent }) + end + end + outputBlock(resolvedCell) + outputBlock(dlDiv) + elseif is_custom_node(resolvedCell) then + local custom = _quarto.ast.resolve_custom_data(resolvedCell) or pandoc.Div({}) + custom.content:insert(2, dlDiv) + if useSkylighting then + custom.content:insert(1, typstAnnotations.typstAnnotationMarker(pendingAnnotations, pendingCellId, pendingStartFrom)) + end + outputBlock(resolvedCell) + else + resolvedCell.content:insert(2, dlDiv) + if useSkylighting then + resolvedCell.content:insert(1, typstAnnotations.typstAnnotationMarker(pendingAnnotations, pendingCellId, pendingStartFrom)) + end + outputBlock(resolvedCell) + end + else + for _, ab in ipairs(annotationBlocks) do + outputBlock(ab) + end + end + clearPending() + + -- Generic handler for all other formats + else -- There are pending annotations, which means this OL is immediately after -- a code cell with annotations. Use to emit a DL describing the code local items = pandoc.List() @@ -493,7 +688,7 @@ function code_annotations() end -- compute the definition for the DD - local definitionContent = v[1].content + local definitionContent = v[1].content local annotationToken = tostring(annotationNumber); -- Only output span for certain formats (HTML) @@ -508,7 +703,7 @@ function code_annotations() {_quarto.modules.constants.kDataCodeCellAnnotation, annotationToken} } definition = pandoc.Span(definitionContent, pandoc.Attr(attribs)) - else + else definition = pandoc.Plain(definitionContent) end @@ -560,6 +755,7 @@ function code_annotations() else flushPending() end + end -- end generic handler else outputBlockClearPending(block) end diff --git a/src/resources/formats/typst/pandoc/quarto/definitions.typ b/src/resources/formats/typst/pandoc/quarto/definitions.typ index f372f20529c..4ed72c7a358 100644 --- a/src/resources/formats/typst/pandoc/quarto/definitions.typ +++ b/src/resources/formats/typst/pandoc/quarto/definitions.typ @@ -24,12 +24,115 @@ // Some quarto-specific definitions. -#show raw.where(block: true): set block( - fill: luma(230), - width: 100%, - inset: 8pt, - radius: 2pt - ) +// Code annotation support +#let quarto-circled-number(n, color: none) = context { + let c = if color != none { color } else { text.fill } + // A zero-height box lets the circle overflow instead of making its row taller. + // The 0.55em baseline offset, undone by the move, stops the box from raising its line. + // The equation wrapper carries the text alternative. + box(height: 0pt, baseline: 0.55em, move(dy: -0.63em, math.equation(alt: "Annotation " + str(n), block: false, circle( + radius: 0.55em, + stroke: 0.5pt + c, + )[#set text(size: 0.7em, fill: c); #align(center + horizon, str(n))]))) +} + +// Link to `target` only if that label exists in the document; otherwise +// render `body` unlinked. Keeps annotation rendering resilient when the +// counterpart (annotation item or code line) is missing. +#let quarto-annote-link(target, body) = context { + if query(label(target)).len() > 0 { link(label(target), body) } else { body } +} + +// Derive a contrasting annotation colour from a background fill. +// Light backgrounds get dark circles; dark backgrounds get light circles. +#let quarto-annote-color(bg) = { + if type(bg) == color { + let comps = bg.components(alpha: false) + let lum = if comps.len() == 1 { + comps.at(0) / 100% + } else { + 0.2126 * comps.at(0) / 100% + 0.7152 * comps.at(1) / 100% + 0.0722 * comps.at(2) / 100% + } + if lum < 0.5 { luma(200) } else { luma(60) } + } else { + luma(60) + } +} + +#let quarto-code-filename(filename, body) = { + // Long names would be clipped: allow breaks after separators (but not after the + // last dot, which keeps the extension attached) first, and + // inside runs of more than 30 characters without a separator + let filename = if type(filename) == str and filename.len() > 40 { + filename + .replace(regex("[/_.-]"), m => m.text + "\u{200b}") + .replace(regex("[.]\u{200b}([^.]*)\\z"), m => "." + m.captures.at(0)) + .replace(regex("[^/_.\u{200b}-]{31,}"), m => m.text.clusters().join("\u{200b}")) + } else { filename } + show raw.where(block: true): it => it + block(width: 100%, radius: 2pt, clip: true, stroke: 0.5pt + luma(128))[ + #set block(spacing: 0pt) + #block(fill: luma(220), width: 100%, inset: (x: 8pt, y: 4pt))[ + #text(size: 0.85em, weight: "bold", fill: luma(30))[#filename]] + #body + ] +} + +#let quarto-code-annotation(annotations, cell-id: "", color: none, body) = { + // 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 (line, num) in annotations { + let key = str(num) + if key not in first-lines or int(line) < int(first-lines.at(key)) { + first-lines.insert(key, line) + } + } + show raw.where(block: true): it => it + show raw.line: it => { + let annote-num = annotations.at(str(it.number), default: none) + if annote-num != none { + 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) #quarto-annote-link(lbl)[#quarto-circled-number(annote-num, color: color)] #label(lbl + "-back")] + } else { + box(width: 100%)[#it #h(1fr) #quarto-annote-link(lbl)[#quarto-circled-number(annote-num, color: color)]] + } + } else { + box(width: 100%)[#it #h(1fr) #quarto-circled-number(annote-num, color: color)] + } + } else { + it + } + } + body +} + +#let quarto-annotation-item(cell-id, n, content) = { + if cell-id != "" { + [#block(above: 0.4em, below: 0.4em)[ + #quarto-annote-link(cell-id + "-annote-" + str(n) + "-back")[#quarto-circled-number(n)] + #h(0.4em) + #content + ] #label(cell-id + "-annote-" + str(n))] + } else { + block(above: 0.4em, below: 0.4em)[ + #quarto-circled-number(n) + #h(0.4em) + #content + ] + } +} + +// Style native raw code blocks with default inset and radius +// Default fill is a set rule so a later `set block(fill: ...)` (brand or user) wins +#show raw.where(block: true): set block(fill: luma(230)) +#show raw.where(block: true): it => block( + width: 100%, inset: 8pt, radius: 2pt, stroke: 0.5pt + luma(128), it, +) #let block_with_new_content(old_block, new_content) = { let fields = old_block.fields() diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-bg/_brand.yml b/tests/docs/smoke-all/typst/code-annotations/brand-bg/_brand.yml new file mode 100644 index 00000000000..8e536e26e17 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-bg/_brand.yml @@ -0,0 +1,3 @@ +typography: + monospace-block: + background-color: "#ffeecc" diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-bg/code-annotations-brand-bg-idiomatic.qmd b/tests/docs/smoke-all/typst/code-annotations/brand-bg/code-annotations-brand-bg-idiomatic.qmd new file mode 100644 index 00000000000..f00daccd59e --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-bg/code-annotations-brand-bg-idiomatic.qmd @@ -0,0 +1,23 @@ +--- +title: Code Annotations (brand background, idiomatic) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#quarto-code-annotation\(\("1": 1\), cell-id: "annotated-cell-1"\)' + - 'set block\(fill: rgb\("#ffeecc"\)\)' + - + - 'fill: luma\(230\), width' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-bg/code-annotations-brand-bg.qmd b/tests/docs/smoke-all/typst/code-annotations/brand-bg/code-annotations-brand-bg.qmd new file mode 100644 index 00000000000..59c1f5173b8 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-bg/code-annotations-brand-bg.qmd @@ -0,0 +1,23 @@ +--- +title: Code Annotations (brand background, Skylighting) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'let bgcolor = rgb\("#ffeecc"\)' + - '#quarto-code-annotation\(|annotations: \("1": 1\)' + - 'set block\(fill: rgb\("#ffeecc"\)\)' + - + - 'fill: luma\(230\), width' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-dark/_brand.yml b/tests/docs/smoke-all/typst/code-annotations/brand-dark/_brand.yml new file mode 100644 index 00000000000..bae1c0e2aff --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-dark/_brand.yml @@ -0,0 +1,8 @@ +color: + palette: + code-bg: "#1e1e2e" + code-fg: "#cdd6f4" +typography: + monospace-block: + color: code-fg + background-color: code-bg diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-dark/code-annotations-brand-dark-filename.qmd b/tests/docs/smoke-all/typst/code-annotations/brand-dark/code-annotations-brand-dark-filename.qmd new file mode 100644 index 00000000000..457e4b54120 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-dark/code-annotations-brand-dark-filename.qmd @@ -0,0 +1,18 @@ +--- +title: Code Filename (dark brand background) +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("example\.py"' + - 'text\(size: 0\.85em, weight: "bold", fill: luma\(30\)\)\[#filename\]' + - [] +--- + +```{.python filename="example.py"} +x = 1 +``` diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-dark/code-annotations-brand-dark-idiomatic.qmd b/tests/docs/smoke-all/typst/code-annotations/brand-dark/code-annotations-brand-dark-idiomatic.qmd new file mode 100644 index 00000000000..df054172040 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-dark/code-annotations-brand-dark-idiomatic.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (dark brand background, idiomatic) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#quarto-code-annotation\(\("1": 1\), cell-id: "annotated-cell-1"\)' + - + - 'color: luma\(60\)' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-nobg-theme/_brand.yml b/tests/docs/smoke-all/typst/code-annotations/brand-nobg-theme/_brand.yml new file mode 100644 index 00000000000..8e536e26e17 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-nobg-theme/_brand.yml @@ -0,0 +1,3 @@ +typography: + monospace-block: + background-color: "#ffeecc" diff --git a/tests/docs/smoke-all/typst/code-annotations/brand-nobg-theme/code-annotations-brand-nobg-theme.qmd b/tests/docs/smoke-all/typst/code-annotations/brand-nobg-theme/code-annotations-brand-nobg-theme.qmd new file mode 100644 index 00000000000..521819ce2cc --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/brand-nobg-theme/code-annotations-brand-nobg-theme.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (brand background, theme without background) +format: + typst: + keep-typ: true + syntax-highlighting: pygments + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'let bgcolor = rgb\("#ffeecc"\)' + - + - 'text\(fill: rgb\("#ffeecc"\), \[ #lnum' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-alt.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-alt.qmd new file mode 100644 index 00000000000..6362af2e630 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-alt.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (text alternative) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'box\(height: 0pt, baseline: 0\.55em, move\(dy: -0\.63em, math\.equation\(alt: "Annotation " \+ str\(n\)' + - 'annotations: \("1": 1\)' + - 'quarto-annotation-item\("annotated-cell-1", 1, \[' + - [] +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-backtick-fence.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-backtick-fence.qmd new file mode 100644 index 00000000000..133763c8042 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-backtick-fence.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (code containing backtick runs) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # Fence must grow beyond the longest backtick run in the code + - '#quarto-code-annotation\(\("1": 1\), cell-id: "annotated-cell-1"\)\[```````python' + - [] +--- + +```{.python} +s = "``````" # <1> +``` + +1. Backticks in string. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-cell-jupyter.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-cell-jupyter.qmd new file mode 100644 index 00000000000..609c41dfc9a --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-cell-jupyter.qmd @@ -0,0 +1,26 @@ +--- +title: Code Annotations (Cell, Jupyter) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - "quarto-circled-number" + - "quarto-annotation-item" + - "cell-id:" + - "annotations:" + - [] +--- + +```{python} +x = 1 # <1> +y = 2 +z = x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-cell-knitr.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-cell-knitr.qmd new file mode 100644 index 00000000000..f2883a45b60 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-cell-knitr.qmd @@ -0,0 +1,26 @@ +--- +title: Code Annotations (Cell, Knitr) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - "quarto-circled-number" + - "quarto-annotation-item" + - "cell-id:" + - "annotations:" + - [] +--- + +```{r} +x <- 1 # <1> +y <- 2 +z <- x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-cellid-escape.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-cellid-escape.qmd new file mode 100644 index 00000000000..e96731690d3 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-cellid-escape.qmd @@ -0,0 +1,21 @@ +--- +title: Code Annotations (cell id with a quote) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'cell-id: "lst-a\\"b"' + - 'quarto-annotation-item\("lst-a\\"b"' + - [] +--- + +```{.python id='lst-a"b'} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing-cell-knitr.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing-cell-knitr.qmd new file mode 100644 index 00000000000..663bde0b410 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing-cell-knitr.qmd @@ -0,0 +1,27 @@ +--- +title: Code Annotations (crossref listing from executable cell) +format: + typst: + keep-typ: true + code-annotations: true +engine: knitr +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell' + - 'quarto-annotation-item\("annotated-cell' + - + - 'quarto-code-annotations:' +--- + +```{r} +#| lst-label: lst-hello +#| lst-cap: "A listing" +print("Hello") # <1> +``` + +1. Print a greeting. + +See @lst-hello. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing-idiomatic.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing-idiomatic.qmd new file mode 100644 index 00000000000..1ae4016193a --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing-idiomatic.qmd @@ -0,0 +1,24 @@ +--- +title: Code Annotations (crossref listing, idiomatic) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#quarto-code-annotation\(' + - '#quarto-annotation-item\("annotated-cell' + - [] +--- + +```{#lst-good .python lst-cap="A listing"} +x = 1 # <1> +``` + +1. Assign x. + +See @lst-good. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing.qmd new file mode 100644 index 00000000000..0520563f0f3 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-crossref-listing.qmd @@ -0,0 +1,24 @@ +--- +title: Code Annotations (crossref listing) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell' + - 'quarto-annotation-item\("annotated-cell' + - + - 'quarto-code-annotations:' +--- + +```{#lst-good .python lst-cap="A listing"} +x = 1 # <1> +``` + +1. Assign x. + +See @lst-good. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-extra-items.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-extra-items.qmd new file mode 100644 index 00000000000..20f6b10961e --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-extra-items.qmd @@ -0,0 +1,21 @@ +--- +title: Code Annotations (list item without annotation) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + printsMessage: + level: INFO + regex: 'List item 2 has no corresponding annotation' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. +2. No annotation for this item. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-fenced-echo-idiomatic.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-fenced-echo-idiomatic.qmd new file mode 100644 index 00000000000..f47b7111362 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-fenced-echo-idiomatic.qmd @@ -0,0 +1,25 @@ +--- +title: Code Annotations (fenced echo, idiomatic) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # Wrapped raw content keeps the fence line, so the code is line 2 + - '#quarto-code-annotation\(\("2": 1\), cell-id: "annotated-cell-1"\)' + - '```markdown\n```\{r\}\nx <- 1' + - '#quarto-annotation-item\("annotated-cell-1"' + - + - '\{\{r\}\}' +--- + +```{{r}} +x <- 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-fenced-echo.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-fenced-echo.qmd new file mode 100644 index 00000000000..cc573696dbd --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-fenced-echo.qmd @@ -0,0 +1,24 @@ +--- +title: Code Annotations (fenced echo display block) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # Skylighting output keeps the injected fence line, so the + # annotated code line is physical line 2 + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("2": 1\)' + - '#quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' +--- + +```{{r}} +x <- 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-fig-div.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-fig-div.qmd new file mode 100644 index 00000000000..6b5e72c0942 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-fig-div.qmd @@ -0,0 +1,27 @@ +--- +title: Code Annotations (code in a figure div) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - 'quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' +--- + +::: {#fig-code} +```{.python} +z = 1 # <1> +``` + +A caption. +::: + +1. Assign z. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-highlight-idiomatic.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-highlight-idiomatic.qmd new file mode 100644 index 00000000000..21e48da6b3d --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-highlight-idiomatic.qmd @@ -0,0 +1,24 @@ +--- +title: Code Annotations (syntax-highlighting idiomatic) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#quarto-code-annotation\(\("1": 1\), cell-id: "annotated-cell-1"\)' + - '```python' + - '#quarto-annotation-item\("annotated-cell-1"' + - + - '#Skylighting\(' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-highlight-none.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-highlight-none.qmd new file mode 100644 index 00000000000..4c0374ed8d8 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-highlight-none.qmd @@ -0,0 +1,24 @@ +--- +title: Code Annotations (syntax-highlighting none) +format: + typst: + keep-typ: true + syntax-highlighting: none + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#quarto-code-annotation\(\("1": 1\), cell-id: "annotated-cell-1"\)' + - '#quarto-annotation-item\("annotated-cell-1"' + - + - '#Skylighting\(' + - '```python' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-hover.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-hover.qmd new file mode 100644 index 00000000000..a8910ee4e02 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-hover.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (hover) +format: + typst: + keep-typ: true + code-annotations: hover +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - 'quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-light-dark-theme.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-light-dark-theme.qmd new file mode 100644 index 00000000000..90ad8361a0a --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-light-dark-theme.qmd @@ -0,0 +1,25 @@ +--- +title: Code Annotations (light/dark highlighting object) +format: + typst: + keep-typ: true + code-annotations: true +syntax-highlighting: + light: github + dark: github-dark +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell' + - 'quarto-annotation-item\("annotated-cell' + - + - '#quarto-code-annotation\(' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-list-position.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-list-position.qmd new file mode 100644 index 00000000000..e93b2a34354 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-list-position.qmd @@ -0,0 +1,28 @@ +--- +title: Code Annotations (list between code and output) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'annotations: \("1": 1\)[\s\S]*quarto-annotation-item\("annotated-cell-1"[\s\S]*OUTPUT-MARKER' + - [] +--- + +::: {.cell} +```{.python .cell-code} +x = 1 # <1> +``` + +::: {.cell-output .cell-output-stdout} +``` +OUTPUT-MARKER +``` +::: +::: + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id-colon.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id-colon.qmd new file mode 100644 index 00000000000..98bd5251d20 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id-colon.qmd @@ -0,0 +1,23 @@ +--- +title: Code Annotations (identifier with colon) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "lst-a:b"' + - 'quarto-annotation-item\("lst-a:b"' + - '#label\(lbl \+ "-back"\)' + - + - 'quarto-code-annotations:' +--- + +```{#lst-a:b .python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id-space.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id-space.qmd new file mode 100644 index 00000000000..7608f6ab35d --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id-space.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (identifier with a space) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "a b", annotations: \("1": 1\)' + - 'quarto-annotation-item\("a b"' + - + - 'quarto-code-annotations:' +--- + +```{.python id='a b'} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id.qmd new file mode 100644 index 00000000000..f5eca13121f --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-listing-id.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (identifier with dot) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "lst-a\.b"' + - 'quarto-annotation-item\("lst-a\.b"' + - + - 'quarto-code-annotations:' +--- + +```{#lst-a.b .python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-multiline.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-multiline.qmd new file mode 100644 index 00000000000..46e8a77ecb8 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-multiline.qmd @@ -0,0 +1,26 @@ +--- +title: Code Annotations (one annotation spanning multiple lines) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # Annotation 1 spans lines 1-2; back-label dedup must keep this compiling + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1, "2": 1, "3": 2\)' + - '#quarto-annotation-item\("annotated-cell-1", 1' + - '#quarto-annotation-item\("annotated-cell-1", 2' + - [] +--- + +```{.python} +x = 1 # <1> +y = 2 # <1> +z = 3 # <2> +``` + +1. Assign. +2. Last. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-native.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-native.qmd new file mode 100644 index 00000000000..e17504b4b79 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-native.qmd @@ -0,0 +1,27 @@ +--- +title: Code Annotations (Native) +format: + typst: + keep-typ: true + code-annotations: true + syntax-highlighting: idiomatic +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # Call sites, not the helper definitions inlined from definitions.typ + - '#quarto-code-annotation\(\("1": 1, "3": 2\), cell-id: "annotated-cell-1"\)' + - '#quarto-annotation-item\("annotated-cell-1", 1' + - 'width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), it,' + - [] +--- + +```python +x = 1 # <1> +y = 2 +z = x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-no-list.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-no-list.qmd new file mode 100644 index 00000000000..e611f763dc1 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-no-list.qmd @@ -0,0 +1,21 @@ +--- +title: Code Annotations (no annotation list) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'annotations: \("1": 1\)' + - + - 'quarto-annotation-item\("' +--- + +```{.python} +x = 1 # <1> +``` + +Prose without an ordered list; the circle renders unlinked. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-none.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-none.qmd new file mode 100644 index 00000000000..d5b39ac7358 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-none.qmd @@ -0,0 +1,24 @@ +--- +title: Code Annotations (None) +format: + typst: + keep-typ: true + code-annotations: none +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - [] + - + - '#quarto-annotation-item\(' + - 'annotations: \("' +--- + +```python +x = 1 # <1> +y = 2 +z = x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-number-lines-width.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-number-lines-width.qmd new file mode 100644 index 00000000000..9e2ec769ba5 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-number-lines-width.qmd @@ -0,0 +1,22 @@ +--- +title: Code Annotations (number-lines row width) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'box\(width: 100% - annote-ln-width\)' + - 'annotations: \("1": 1\)' + - + - 'box\(width: 100%\)\[#ln' +--- + +```{.python .number-lines} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-select.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-select.qmd new file mode 100644 index 00000000000..1dd32b7a09e --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-select.qmd @@ -0,0 +1,23 @@ +--- +title: Code Annotations (select) +format: + typst: + keep-typ: true + code-annotations: select +_quarto: + tests: + typst: + printsMessage: + level: INFO + regex: 'code-annotations: select.*gutter' + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - [] +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-startfrom-number-lines.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-startfrom-number-lines.qmd new file mode 100644 index 00000000000..7f2506736bd --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-startfrom-number-lines.qmd @@ -0,0 +1,21 @@ +--- +title: Code Annotations (startFrom with line numbers) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'annotations: \("1": 1\)' + - 'start: 5' + - [] +--- + +```{.python .number-lines startFrom="5"} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-startfrom.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-startfrom.qmd new file mode 100644 index 00000000000..0fad9a3902b --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-startfrom.qmd @@ -0,0 +1,21 @@ +--- +title: Code Annotations (startFrom without line numbers) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'annotations: \("1": 1\)' + - 'quarto-annotation-item\(' + - [] +--- + +```{.python startFrom="5"} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations-unmerged.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations-unmerged.qmd new file mode 100644 index 00000000000..a41528ac324 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations-unmerged.qmd @@ -0,0 +1,33 @@ +--- +title: Code Annotations (marker that cannot merge) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + printsMessage: + level: WARN + regex: 'cell "annotated-cell-1" could not be attached' + ensureTypstFileRegexMatches: + - + - 'quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' +--- + +::: {#fig-a layout-ncol=2} +```{.python} +z = 1 # <1> +``` + +```{.python} +w = 2 +``` + +Caption +::: + +1. Assign z. diff --git a/tests/docs/smoke-all/typst/code-annotations/code-annotations.qmd b/tests/docs/smoke-all/typst/code-annotations/code-annotations.qmd new file mode 100644 index 00000000000..927c58d8f19 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/code-annotations.qmd @@ -0,0 +1,26 @@ +--- +title: Code Annotations (Skylighting) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # Call sites, not the helper definitions inlined from definitions.typ + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1, "3": 2\)' + - '#quarto-annotation-item\("annotated-cell-1", 1' + - '#quarto-annotation-item\("annotated-cell-1", 2' + - [] +--- + +```python +x = 1 # <1> +y = 2 +z = x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e01-callout.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e01-callout.qmd new file mode 100644 index 00000000000..899a94537ff --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e01-callout.qmd @@ -0,0 +1,26 @@ +--- +title: Callout +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +::: {.callout-note} +## Note +```{.python} +z = 1 # <1> +``` + +1. Assign z. +::: diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e01-columns.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e01-columns.qmd new file mode 100644 index 00000000000..211d5000b15 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e01-columns.qmd @@ -0,0 +1,31 @@ +--- +title: Columns +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +::: {.columns} +::: {.column width="50%"} +```{.python} +z = 1 # <1> +``` + +1. Assign z. +::: + +::: {.column width="50%"} +text +::: +::: diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e01-layout.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e01-layout.qmd new file mode 100644 index 00000000000..6a963a90c00 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e01-layout.qmd @@ -0,0 +1,30 @@ +--- +title: Layout cell +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - 'quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +::: {layout-ncol=2} +```{.python} +z = 1 # <1> +``` + +1. Assign z. + +```{.python} +w = 2 +``` +::: diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e01-listing-div.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e01-listing-div.qmd new file mode 100644 index 00000000000..b0906f05fba --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e01-listing-div.qmd @@ -0,0 +1,26 @@ +--- +title: Listing div +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - '#figure' + - 'quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +::: {#lst-x lst-cap="A listing"} +```{.python} +z = 1 # <1> +``` +::: + +1. Assign z. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e01-tabset.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e01-tabset.qmd new file mode 100644 index 00000000000..1714026b06c --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e01-tabset.qmd @@ -0,0 +1,34 @@ +--- +title: Tabset +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - 'Skylighting\(cell-id: "annotated-cell-2", annotations: \("1": 1\)' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +::: {.panel-tabset} +## A +```{.python} +z = 1 # <1> +``` + +1. Assign z. + +## B +```{.python} +w = 2 # <1> +``` + +1. Assign w. +::: diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e02-unknown-lang.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e02-unknown-lang.qmd new file mode 100644 index 00000000000..e794135dc82 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e02-unknown-lang.qmd @@ -0,0 +1,29 @@ +--- +title: Unknown language +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'quarto-annotation-item\("annotated-cell-1"' + - 'quarto-annotation-item\("annotated-cell-2"' + - + - 'quarto-code-annotations:' +--- + +```{.foobar} +x = 1 # <1> +``` + +1. Assign x. + +``` +y = 2 # <1> +``` + +1. Assign y. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e03-duplicate-id-unannotated.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e03-duplicate-id-unannotated.qmd new file mode 100644 index 00000000000..61e59b0d6a5 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e03-duplicate-id-unannotated.qmd @@ -0,0 +1,34 @@ +--- +title: Duplicate ids on blocks without annotations +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + printsMessage: + level: INFO + regex: 'Duplicate code cell id' + negate: true + ensureTypstFileRegexMatches: + - + - 'cell-id: "lst-b", annotations' + - + - 'cell-id: "lst-b-2"' +--- + +```{#lst-a .python} +a = 1 +``` + +```{#lst-a .python} +b = 2 +``` + +```{#lst-b .python} +c = 3 # <1> +``` + +1. Annotated. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e03-two-cells.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e03-two-cells.qmd new file mode 100644 index 00000000000..90aab1563ab --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e03-two-cells.qmd @@ -0,0 +1,34 @@ +--- +title: Two cells +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'cell-id: "annotated-cell-1", annotations: \("1": 1, "2": 2\)' + - 'cell-id: "annotated-cell-2", annotations: \("1": 1, "2": 2\)' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +```{.python} +x = 1 # <1> +y = 2 # <2> +``` + +1. First. +2. Second. + +```{.python} +a = 1 # <1> +b = 2 # <2> +``` + +1. Third. +2. Fourth. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e04-comment-chars.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e04-comment-chars.qmd new file mode 100644 index 00000000000..22bbb8f4565 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e04-comment-chars.qmd @@ -0,0 +1,50 @@ +--- +title: Comment characters +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - 'cell-id: "annotated-cell-5", annotations: \("1": 1\)' + - + - 'quarto-code-annotations:' + - '<1>' + - '/\* <1>' + - '-- <' +--- + +```{.c} +int x = 1; /* <1> */ +``` + +1. C. + +```{.js} +let x = 1; // <1> +``` + +1. JS. + +```{.sql} +SELECT 1; -- <1> +``` + +1. SQL. + +```{.matlab} +x = 1; % <1> +``` + +1. MATLAB. + +```{.bash} +echo hi # <1> +``` + +1. Bash. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e08-line-numbers-highlight.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e08-line-numbers-highlight.qmd new file mode 100644 index 00000000000..c59b7d236b7 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e08-line-numbers-highlight.qmd @@ -0,0 +1,26 @@ +--- +title: Highlight lines with annotations +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'annotations: \("1": 1, "3": 2\)' + - + - 'quarto-code-annotations:' + - '# <' +--- + +```{.python code-line-numbers="|1,3|2"} +a = 1 # <1> +b = 2 +c = 3 # <2> +``` + +1. One. +2. Three. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e09-no-list.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e09-no-list.qmd new file mode 100644 index 00000000000..d91edf3f3ed --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e09-no-list.qmd @@ -0,0 +1,31 @@ +--- +title: Annotated cell without a list +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("1": 1\)' + - + - 'quarto-code-annotations:' + - '# <1>' +--- + +::: {.cell} +```{.python .cell-code} +x = 1 # <1> +``` + +::: {.cell-output .cell-output-stdout} +``` +1 +``` +::: +::: + +Plain paragraph, no list. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e10-crlf.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e10-crlf.qmd new file mode 100644 index 00000000000..61873cfcd9c --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e10-crlf.qmd @@ -0,0 +1,23 @@ +--- +title: CRLF +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'annotations: \("1": 1\)' + - + - '\r' + - '# <1>' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e11-false.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e11-false.qmd new file mode 100644 index 00000000000..82178f175be --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e11-false.qmd @@ -0,0 +1,25 @@ +--- +title: code-annotations false +format: + typst: + keep-typ: true + code-annotations: false +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(' + - '# <1>' + - + - 'quarto-code-annotations:' + - 'quarto-annotation-item\("' + - 'annotations: \("1"' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e11-none.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e11-none.qmd new file mode 100644 index 00000000000..7338707433f --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e11-none.qmd @@ -0,0 +1,24 @@ +--- +title: code-annotations none +format: + typst: + keep-typ: true + code-annotations: none +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(' + - + - 'quarto-code-annotations:' + - 'quarto-annotation-item\("' + - 'annotations: \("1"' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e12-fenced-startfrom.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e12-fenced-startfrom.qmd new file mode 100644 index 00000000000..9bea592f347 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e12-fenced-startfrom.qmd @@ -0,0 +1,23 @@ +--- +title: Fenced echo with startFrom +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'Skylighting\(cell-id: "annotated-cell-1", annotations: \("2": 1\)' + - 'quarto-annotation-item\("annotated-cell-1"' + - + - 'quarto-code-annotations:' +--- + +```{{python}} {startFrom="5"} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e12-startfrom-combos.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e12-startfrom-combos.qmd new file mode 100644 index 00000000000..d72e6d0dcf3 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e12-startfrom-combos.qmd @@ -0,0 +1,30 @@ +--- +title: startFrom with fenced echo and filename +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("a\.py"' + - 'annotations: \("1": 1\)' + - 'annotations: \("2": 1\)' + - + - 'quarto-code-annotations:' +--- + +```{.python startFrom="5" filename="a.py"} +x = 1 # <1> +``` + +1. Assign x. + +```{{python}} +y = 2 # <1> +``` + +1. Assign y. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e13-rich-items.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e13-rich-items.qmd new file mode 100644 index 00000000000..48f07858e3c --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e13-rich-items.qmd @@ -0,0 +1,33 @@ +--- +title: Rich annotation items +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'quarto-annotation-item\("annotated-cell-1", 1, \[' + - 'First paragraph' + - 'Second paragraph' + - 'nested' + - 'code span' + - 'https://example\.org' + - 'x\^2' + - + - 'quarto-code-annotations:' +--- + +```{.python} +x = 1 # <1> +``` + +1. First paragraph with `code span` and [a link](https://example.org). + + Second paragraph with math $$x^2$$. + + - nested item one + - nested item two diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e14-footnote.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e14-footnote.qmd new file mode 100644 index 00000000000..4b2eec568b5 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e14-footnote.qmd @@ -0,0 +1,25 @@ +--- +title: Footnote in an annotation item +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - '#footnote\[' + - 'quarto-annotation-item\("annotated-cell-1", 1' + - + - 'quarto-code-annotations:' +--- + +```{.python} +x = 1 # <1> +``` + +1. Assign x.[^1] + +[^1]: The footnote text. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e17-native-number-lines.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e17-native-number-lines.qmd new file mode 100644 index 00000000000..ede2adb4007 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e17-native-number-lines.qmd @@ -0,0 +1,26 @@ +--- +title: Native mode with number-lines and startFrom +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic + code-annotations: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - '#quarto-code-annotation\(\("1": 1, "2": 2\), cell-id: "lst-n"\)' + - + - 'quarto-code-annotations:' + - '# <' +--- + +```{#lst-n .python .number-lines startFrom="5"} +x = 1 # <1> +y = 2 # <2> +``` + +1. First. +2. Second. diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e20-duplicate-id-containers.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e20-duplicate-id-containers.qmd new file mode 100644 index 00000000000..392905123b3 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e20-duplicate-id-containers.qmd @@ -0,0 +1,37 @@ +--- +title: Duplicate cell ids in separate containers +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + printsMessage: + level: INFO + regex: "Duplicate code cell id 'lst-b'" + ensureTypstFileRegexMatches: + - + - 'cell-id: "lst-b", annotations' + - 'cell-id: "lst-b-2", annotations' + - 'quarto-annotation-item\("lst-b-2"' + - + - 'quarto-code-annotations:' +--- + +::: {.callout-note} +```{#lst-b .python} +x = 1 # <1> +``` + +1. First. +::: + +::: {.callout-tip} +```{#lst-b .python} +y = 2 # <1> +``` + +1. Second. +::: diff --git a/tests/docs/smoke-all/typst/code-annotations/edge/e20-duplicate-id.qmd b/tests/docs/smoke-all/typst/code-annotations/edge/e20-duplicate-id.qmd new file mode 100644 index 00000000000..6ffc65bf0ab --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/edge/e20-duplicate-id.qmd @@ -0,0 +1,33 @@ +--- +title: Duplicate cell ids +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + noErrors: default + printsMessage: + level: INFO + regex: "Duplicate code cell id 'lst-a'" + ensureTypstFileRegexMatches: + - + - 'cell-id: "lst-a", annotations' + - 'cell-id: "lst-a-2", annotations' + - 'quarto-annotation-item\("lst-a-2"' + - + - 'quarto-code-annotations:' +--- + +```{#lst-a .python} +x = 1 # <1> +``` + +1. First. + +```{#lst-a .python} +y = 2 # <1> +``` + +1. Second. diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/_extension.yml b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/_extension.yml new file mode 100644 index 00000000000..3dd92e6f88a --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/_extension.yml @@ -0,0 +1,14 @@ +title: Fake +author: Test +version: 1.0.0 +contributes: + formats: + typst: + template-partials: + - numbering.typ + - definitions.typ + - typst-template.typ + - page.typ + - typst-show.typ + - notes.typ + - biblio.typ diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/biblio.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/biblio.typ new file mode 100644 index 00000000000..2c8f0ebae8a --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/biblio.typ @@ -0,0 +1,18 @@ +$if(citations)$ +$if(csl)$ + +#set bibliography(style: "$csl$") +$elseif(bibliographystyle)$ + +#set bibliography(style: "$bibliographystyle$") +$endif$ +$if(bibliography)$ +$-- Suppress bibliography display when citation-location: margin (consistent with HTML behavior) +$-- Full citations appear in margins; bibliography is loaded but not displayed +$if(suppress-bibliography)$ +#show bibliography: none +$endif$ + +#bibliography(($for(bibliography)$"$bibliography$"$sep$,$endfor$)) +$endif$ +$endif$ diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/definitions.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/definitions.typ new file mode 100644 index 00000000000..a1f86a32f12 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/definitions.typ @@ -0,0 +1,257 @@ +// Some definitions presupposed by pandoc's typst output. +#let content-to-string(content) = { + if content.has("text") { + content.text + } else if content.has("children") { + content.children.map(content-to-string).join("") + } else if content.has("body") { + content-to-string(content.body) + } else if content == [ ] { + " " + } +} + +#let horizontalrule = line(start: (25%,0%), end: (75%,0%)) + +#let endnote(num, contents) = [ + #stack(dir: ltr, spacing: 3pt, super[#num], contents) +] + +#show terms.item: it => block(breakable: false)[ + #text(weight: "bold")[#it.term] + #block(inset: (left: 1.5em, top: -0.4em))[#it.description] +] + +// Some quarto-specific definitions. + +// Style native raw code blocks with default inset and radius +// Default fill is a set rule so a later `set block(fill: ...)` (brand or user) wins +#show raw.where(block: true): set block(fill: luma(230)) +#show raw.where(block: true): it => block( + width: 100%, inset: 8pt, radius: 2pt, stroke: 0.5pt + luma(128), it, +) + +#let block_with_new_content(old_block, new_content) = { + let fields = old_block.fields() + let _ = fields.remove("body") + if fields.at("below", default: none) != none { + // TODO: this is a hack because below is a "synthesized element" + // according to the experts in the typst discord... + fields.below = fields.below.abs + } + block.with(..fields)(new_content) +} + +#let empty(v) = { + if type(v) == str { + // two dollar signs here because we're technically inside + // a Pandoc template :grimace: + v.matches(regex("^\\s*$$")).at(0, default: none) != none + } else if type(v) == content { + if v.at("text", default: none) != none { + return empty(v.text) + } + for child in v.at("children", default: ()) { + if not empty(child) { + return false + } + } + return true + } + +} + +// Subfloats +// This is a technique that we adapted from https://github.com/tingerrr/subpar/ +#let quartosubfloatcounter = counter("quartosubfloatcounter") + +#let quarto_super( + kind: str, + caption: none, + label: none, + supplement: str, + position: none, + subcapnumbering: "(a)", + body, +) = { + context { + let figcounter = counter(figure.where(kind: kind)) + let n-super = figcounter.get().first() + 1 + set figure.caption(position: position) + [#figure( + kind: kind, + supplement: supplement, + caption: caption, + { + show figure.where(kind: kind): set figure(numbering: _ => { + let subfloat-idx = quartosubfloatcounter.get().first() + 1 + subfloat-numbering(n-super, subfloat-idx) + }) + show figure.where(kind: kind): set figure.caption(position: position) + show figure.where(kind: kind): set figure(outlined: false) + + show figure: it => { + let num = numbering(subcapnumbering, n-super, quartosubfloatcounter.get().first() + 1) + show figure.caption: it => block({ + num.slice(2) // I don't understand why the numbering contains output that it really shouldn't, but this fixes it shrug? + [ ] + it.body + }) + + quartosubfloatcounter.step() + it + counter(figure.where(kind: it.kind)).update(n => n - 1) + } + + quartosubfloatcounter.update(0) + body + } + )#label] + } +} + +// callout rendering +// this is a figure show rule because callouts are crossreferenceable +#show figure: it => { + if type(it.kind) != str { + return it + } + let kind_match = it.kind.matches(regex("^quarto-callout-(.*)")).at(0, default: none) + if kind_match == none { + return it + } + let kind = kind_match.captures.at(0, default: "other") + kind = upper(kind.first()) + kind.slice(1) + // now we pull apart the callout and reassemble it with the crossref name and counter + + // when we cleanup pandoc's emitted code to avoid spaces this will have to change + let old_callout = it.body.children.at(1).body.children.at(1) + let old_title_block = old_callout.body.children.at(0) + let children = old_title_block.body.body.children + let old_title = if children.len() == 1 { + children.at(0) // no icon: title at index 0 + } else { + children.at(1) // with icon: title at index 1 + } + + // TODO use custom separator if available + // Use the figure's counter display which handles chapter-based numbering + // (when numbering is a function that includes the heading counter) + let callout_num = it.counter.display(it.numbering) + let new_title = if empty(old_title) { + [#kind #callout_num] + } else { + [#kind #callout_num: #old_title] + } + + let new_title_block = block_with_new_content( + old_title_block, + block_with_new_content( + old_title_block.body, + if children.len() == 1 { + new_title // no icon: just the title + } else { + children.at(0) + new_title // with icon: preserve icon block + new title + })) + + align(left, block_with_new_content(old_callout, + block(below: 0pt, new_title_block) + + old_callout.body.children.at(1))) +} + +// 2023-10-09: #fa-icon("fa-info") is not working, so we'll eval "#fa-info()" instead +#let callout(body: [], title: "Callout", background_color: rgb("#dddddd"), icon: none, icon_color: black, body_background_color: white) = { + block( + breakable: false, + fill: background_color, + stroke: (paint: icon_color, thickness: 0.5pt, cap: "round"), + width: 100%, + radius: 2pt, + block( + inset: 1pt, + width: 100%, + below: 0pt, + block( + fill: background_color, + width: 100%, + inset: 8pt)[#if icon != none [#text(icon_color, weight: 900)[#icon] ]#title]) + + if(body != []){ + block( + inset: 1pt, + width: 100%, + block(fill: body_background_color, width: 100%, inset: 8pt, body)) + } + ) +} + +$if(margin-geometry)$ +// Margin layout support using marginalia package +#import "@preview/marginalia:0.3.1" as marginalia: note, notefigure, wideblock + +// Render footnote as margin note using standard footnote counter +// Used via show rule: #show footnote: it => column-sidenote(it.body) +// The footnote element already steps the counter, so we just display it +#let column-sidenote(body) = { + context { + let num = counter(footnote).display("1") + // Superscript mark in text + super(num) + // Content in margin with matching number + note( + alignment: "baseline", + shift: auto, + counter: none, // We display our own number from footnote counter + )[ + #super(num) #body + ] + } +} + +// Note: Margin citations are now emitted directly from Lua as #note() calls +// with #cite(form: "full") + locator text, preserving citation locators. + +// Utility: compute padding for each side based on side parameter +#let side-pad(side, left-amount, right-amount) = { + let l = if side == "both" or side == "left" or side == "inner" { left-amount } else { 0pt } + let r = if side == "both" or side == "right" or side == "outer" { right-amount } else { 0pt } + (left: l, right: r) +} + +// body-outset: extends ~15% into margin area +#let column-body-outset(side: "both", body) = context { + let r = marginalia.get-right() + let out = 0.15 * (r.sep + r.width) + pad(..side-pad(side, -out, -out), body) +} + +// page-inset: wideblock minus small inset from page boundary +#let column-page-inset(side: "both", body) = context { + let l = marginalia.get-left() + let r = marginalia.get-right() + // Inset is a small fraction of the extension area (wideblock stops at far) + let left-inset = 0.15 * l.sep + let right-inset = 0.15 * (r.sep + r.width) + wideblock(side: side)[#pad(..side-pad(side, left-inset, right-inset), body)] +} + +// screen-inset: full width minus `far` distance from edges +#let column-screen-inset(side: "both", body) = context { + let l = marginalia.get-left() + let r = marginalia.get-right() + wideblock(side: side)[#pad(..side-pad(side, l.far, r.far), body)] +} + +// screen-inset-shaded: screen-inset with gray background +#let column-screen-inset-shaded(body) = context { + let l = marginalia.get-left() + wideblock(side: "both")[ + #block(fill: luma(245), width: 100%, inset: (x: l.far, y: 1em), body) + ] +} +$endif$ + +$if(highlighting-definitions)$ +// syntax highlighting functions from skylighting: +$highlighting-definitions$ + +$endif$ diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/notes.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/notes.typ new file mode 100644 index 00000000000..99f136c498e --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/notes.typ @@ -0,0 +1,10 @@ +$if(notes)$ +#v(1em) +#block[ +#horizontalrule +#set text(size: .88em) +#v(3pt) // otherwise first note marker is swallowed, bug? + +$notes$ +] +$endif$ diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/numbering.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/numbering.typ new file mode 100644 index 00000000000..e65286bee6a --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/numbering.typ @@ -0,0 +1,23 @@ +// Simple numbering for non-book documents +#let equation-numbering = "(1)" +#let callout-numbering = "1" +#let subfloat-numbering(n-super, subfloat-idx) = { + numbering("1a", n-super, subfloat-idx) +} + +// Theorem configuration for theorion +// Simple numbering for non-book documents (no heading inheritance) +#let theorem-inherited-levels = 0 + +// Theorem numbering format (can be overridden by extensions for appendix support) +// This function returns the numbering pattern to use +#let theorem-numbering(loc) = "1.1" + +// Default theorem render function +#let theorem-render(prefix: none, title: "", full-title: auto, body) = { + if full-title != "" and full-title != auto and full-title != none { + strong[#full-title.] + h(0.5em) + } + body +} diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/page.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/page.typ new file mode 100644 index 00000000000..cee094b4003 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/page.typ @@ -0,0 +1,34 @@ +#set page( + paper: $if(papersize)$"$papersize$"$else$"us-letter"$endif$, +$if(margin-geometry)$ + // Margins handled by marginalia.setup below +$elseif(margin)$ + margin: ($for(margin/pairs)$$margin.key$: $margin.value$,$endfor$), +$else$ + margin: (x: 1.25in, y: 1.25in), +$endif$ + numbering: $if(page-numbering)$"$page-numbering$"$else$none$endif$, + columns: $if(columns)$$columns$$else$1$endif$, +) +$if(logo)$ +#set page(background: align($logo.location$, box(inset: $logo.inset$, image("$logo.path$", width: $logo.width$$if(logo.alt)$, alt: "$logo.alt$"$endif$)))) +$endif$ +$if(margin-geometry)$ +// Configure marginalia page geometry (functions defined in definitions.typ) +#show: marginalia.setup.with( + inner: ( + far: $margin-geometry.inner.far$, + width: $margin-geometry.inner.width$, + sep: $margin-geometry.inner.separation$, + ), + outer: ( + far: $margin-geometry.outer.far$, + width: $margin-geometry.outer.width$, + sep: $margin-geometry.outer.separation$, + ), + top: $if(margin.top)$$margin.top$$else$1.25in$endif$, + bottom: $if(margin.bottom)$$margin.bottom$$else$1.25in$endif$, + book: false, + clearance: $margin-geometry.clearance$, +) +$endif$ diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/template.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/template.typ new file mode 100644 index 00000000000..0c9d99e137f --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/template.typ @@ -0,0 +1,28 @@ +$numbering.typ()$ + +$definitions.typ()$ + +$typst-template.typ()$ + +$for(header-includes)$ +$header-includes$ +$endfor$ + +$page.typ()$ + +$typst-show.typ()$ + +$for(include-before)$ +$include-before$ +$endfor$ + +$body$ + +$notes.typ()$ + +$biblio.typ()$ + +$for(include-after)$ +$include-after$ +$endfor$ + diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/typst-show.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/typst-show.typ new file mode 100644 index 00000000000..72fa761f832 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/typst-show.typ @@ -0,0 +1,109 @@ +#show: doc => article( +$if(title)$ + title: [$title$], +$endif$ +$if(subtitle)$ + subtitle: [$subtitle$], +$endif$ +$if(by-author)$ + authors: ( +$for(by-author)$ +$if(it.name.literal)$ + ( name: [$it.name.literal$], + affiliation: [$for(it.affiliations)$$it.name$$sep$, $endfor$], + email: [$it.email$] ), +$endif$ +$endfor$ + ), +$endif$ +$if(date)$ + date: [$date$], +$endif$ +$if(lang)$ + lang: "$lang$", +$endif$ +$if(region)$ + region: "$region$", +$endif$ +$if(abstract)$ + abstract: [$abstract$], + abstract-title: "$labels.abstract$", +$endif$ +$if(mainfont)$ + font: ("$mainfont$",), +$elseif(brand.typography.base.family)$ + font: $brand.typography.base.family$, +$endif$ +$if(fontsize)$ + fontsize: $fontsize$, +$elseif(brand.typography.base.size)$ + fontsize: $brand.typography.base.size$, +$endif$ +$if(title)$ +$if(brand.typography.headings.family)$ + heading-family: $brand.typography.headings.family$, +$elseif(mainfont)$ + heading-family: ("$mainfont$",), +$endif$ +$if(brand.typography.headings.weight)$ + heading-weight: $brand.typography.headings.weight$, +$endif$ +$if(brand.typography.headings.style)$ + heading-style: "$brand.typography.headings.style$", +$endif$ +$if(brand.typography.headings.color)$ + heading-color: $brand.typography.headings.color$, +$endif$ +$if(brand.typography.headings.line-height)$ + heading-line-height: $brand.typography.headings.line-height$, +$endif$ +$endif$ +$if(section-numbering)$ + sectionnumbering: "$section-numbering$", +$endif$ +$if(mathfont)$ + mathfont: ($for(mathfont)$"$mathfont$",$endfor$), +$endif$ +$if(codefont)$ + codefont: ($for(codefont)$"$codefont$",$endfor$), +$elseif(brand.typography.monospace.family)$ + codefont: $brand.typography.monospace.family$, +$endif$ +$if(linestretch)$ + linestretch: $linestretch$, +$endif$ +$if(thanks)$ + thanks: [$thanks$], +$endif$ +$if(linkcolor)$ + linkcolor: [$linkcolor$], +$endif$ +$if(citecolor)$ + citecolor: [$citecolor$], +$endif$ +$if(filecolor)$ + filecolor: [$filecolor$], +$endif$ +$if(keywords)$ + keywords: ($for(keywords)$"$keywords$",$endfor$), +$endif$ +$if(toc)$ + toc: $toc$, +$endif$ +$if(toc-title)$ + toc_title: [$toc-title$], +$endif$ +$if(toc-indent)$ + toc_indent: $toc-indent$, +$endif$ + toc_depth: $toc-depth$, +$if(lof)$ + lof: true, + lof-title: [$if(crossref.lof-title)$$crossref.lof-title$$else$$quarto.language.crossref-lof-title$$endif$], +$endif$ +$if(lot)$ + lot: true, + lot-title: [$if(crossref.lot-title)$$crossref.lot-title$$else$$quarto.language.crossref-lot-title$$endif$], +$endif$ + doc, +) diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/typst-template.typ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/typst-template.typ new file mode 100644 index 00000000000..e5ccc37f146 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/_extensions/fake/typst-template.typ @@ -0,0 +1,160 @@ + +#let article( + title: none, + subtitle: none, + authors: none, + keywords: (), + date: none, + abstract-title: none, + abstract: none, + thanks: none, + cols: 1, + lang: "en", + region: "US", + font: none, + fontsize: 11pt, + title-size: 1.5em, + subtitle-size: 1.25em, + heading-family: none, + heading-weight: "bold", + heading-style: "normal", + heading-color: black, + heading-line-height: 0.65em, + mathfont: none, + codefont: none, + linestretch: 1, + sectionnumbering: none, + linkcolor: none, + citecolor: none, + filecolor: none, + toc: false, + toc_title: none, + toc_depth: none, + toc_indent: 1.5em, + lof: false, + lof-title: none, + lot: false, + lot-title: none, + doc, +) = { + // Set document metadata for PDF accessibility + set document(title: title, keywords: keywords) + set document( + author: authors.map(author => content-to-string(author.name)).join(", ", last: " & "), + ) if authors != none and authors != () + set par( + justify: true, + leading: linestretch * 0.65em + ) + set text(lang: lang, + region: region, + size: fontsize) + set text(font: font) if font != none + show math.equation: set text(font: mathfont) if mathfont != none + show raw: set text(font: codefont) if codefont != none + + set heading(numbering: sectionnumbering) + + show link: set text(fill: rgb(content-to-string(linkcolor))) if linkcolor != none + show ref: set text(fill: rgb(content-to-string(citecolor))) if citecolor != none + show link: this => { + if filecolor != none and type(this.dest) == label { + text(this, fill: rgb(content-to-string(filecolor))) + } else { + text(this) + } + } + + let has-title-block = title != none or (authors != none and authors != ()) or date != none or abstract != none + if has-title-block { + place( + top, + float: true, + scope: "parent", + clearance: 4mm, + block(below: 1em, width: 100%)[ + + #if title != none { + align(center, block(inset: 2em)[ + #set par(leading: heading-line-height) if heading-line-height != none + #set text(font: heading-family) if heading-family != none + #set text(weight: heading-weight) + #set text(style: heading-style) if heading-style != "normal" + #set text(fill: heading-color) if heading-color != black + + #text(size: title-size)[#title #if thanks != none { + footnote(thanks, numbering: "*") + counter(footnote).update(n => n - 1) + }] + #(if subtitle != none { + parbreak() + text(size: subtitle-size)[#subtitle] + }) + ]) + } + + #if authors != none and authors != () { + let count = authors.len() + let ncols = calc.min(count, 3) + grid( + columns: (1fr,) * ncols, + row-gutter: 1.5em, + ..authors.map(author => + align(center)[ + #author.name \ + #author.affiliation \ + #author.email + ] + ) + ) + } + + #if date != none { + align(center)[#block(inset: 1em)[ + #date + ]] + } + + #if abstract != none { + block(inset: 2em)[ + #text(weight: "semibold")[#abstract-title] #h(1em) #abstract + ] + } + ] + ) + } + + if toc { + let title = if toc_title == none { + auto + } else { + toc_title + } + block(above: 0em, below: 2em)[ + #outline( + title: title, + depth: toc_depth, + indent: toc_indent + ); + ] + } + + if lof { + block(above: 0em, below: 2em)[ + #outline(title: lof-title, target: figure.where(kind: "quarto-float-fig")); + ] + } + + if lot { + block(above: 0em, below: 2em)[ + #outline(title: lot-title, target: figure.where(kind: "quarto-float-tbl")); + ] + } + + doc +} + +#set table( + inset: 6pt, + stroke: none +) diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/ext-replaces-definitions-margin.qmd b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/ext-replaces-definitions-margin.qmd new file mode 100644 index 00000000000..2cfe1703147 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/ext-replaces-definitions-margin.qmd @@ -0,0 +1,25 @@ +--- +title: Extension that replaces definitions.typ, annotated block in the margin +keep-typ: true +format: + fake-typst: + code-annotations: true + syntax-highlighting: idiomatic +_quarto: + tests: + fake-typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - '#let quarto-annotation-item\(' + - 'quarto-annotation-item\("annotated-cell-1"' + - [] +--- + +::: {.column-margin} +```{.python filename="m.py"} +x = 1 # <1> +``` + +1. Assign x. +::: diff --git a/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/ext-replaces-definitions.qmd b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/ext-replaces-definitions.qmd new file mode 100644 index 00000000000..a9695b98a04 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/ext-replaces-definitions/ext-replaces-definitions.qmd @@ -0,0 +1,24 @@ +--- +title: Extension that replaces definitions.typ +keep-typ: true +format: + fake-typst: + code-annotations: true +_quarto: + tests: + fake-typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - '#let quarto-code-filename\(' + - '#let quarto-annote-link\(' + - '#let quarto-annotation-item\(' + - 'quarto-code-filename\("a\.py"' + - [] +--- + +```{.python filename="a.py"} +x = 1 # <1> +``` + +1. Assign x. diff --git a/tests/docs/smoke-all/typst/code-annotations/xref/code-annotations-xref-item.qmd b/tests/docs/smoke-all/typst/code-annotations/xref/code-annotations-xref-item.qmd new file mode 100644 index 00000000000..dd2e91a3285 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/xref/code-annotations-xref-item.qmd @@ -0,0 +1,27 @@ +--- +title: Code Annotations (cross-reference and citation in an item) +bibliography: refs.bib +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-annotation-item\("annotated-cell-1", 1, \[\s*Compare with #ref\(, supplement: \[Figure\]\)' + - 'quarto-annotation-item\("annotated-cell-1", 2, \[\s*See @knuth' + - + - '#cite\(' +--- + +```{.python} +x = 1 # <1> +y = 2 # <2> +``` + +1. Compare with @fig-x. +2. See [@knuth]. + +![A figure](fig.png){#fig-x} diff --git a/tests/docs/smoke-all/typst/code-annotations/xref/fig.png b/tests/docs/smoke-all/typst/code-annotations/xref/fig.png new file mode 100644 index 00000000000..13946b87ee1 Binary files /dev/null and b/tests/docs/smoke-all/typst/code-annotations/xref/fig.png differ diff --git a/tests/docs/smoke-all/typst/code-annotations/xref/refs.bib b/tests/docs/smoke-all/typst/code-annotations/xref/refs.bib new file mode 100644 index 00000000000..6abf584f65e --- /dev/null +++ b/tests/docs/smoke-all/typst/code-annotations/xref/refs.bib @@ -0,0 +1,6 @@ +@book{knuth, + author = {Knuth, Donald E.}, + title = {The Art of Computer Programming}, + year = {1968}, + publisher = {Addison-Wesley} +} diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-cell-jupyter.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-cell-jupyter.qmd new file mode 100644 index 00000000000..25add896637 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-cell-jupyter.qmd @@ -0,0 +1,24 @@ +--- +title: Code Filename + Annotation (Cell, Jupyter) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("hello\.py"\)\[\s*#Skylighting\(cell-id:' + - "quarto-annotation-item" + - "cell-id:" + - "annotations:" + - [] +--- + +```{python} +#| filename: "hello.py" +print("Hello, world!") # <1> +``` + +1. Print a greeting. diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-cell-knitr.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-cell-knitr.qmd new file mode 100644 index 00000000000..76372384a9d --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-cell-knitr.qmd @@ -0,0 +1,24 @@ +--- +title: Code Filename + Annotation (Cell, Knitr) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("hello\.R"\)\[\s*#Skylighting\(cell-id:' + - "quarto-annotation-item" + - "cell-id:" + - "annotations:" + - [] +--- + +```{r} +#| filename: "hello.R" +print("Hello, world!") # <1> +``` + +1. Print a greeting. diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-native.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-native.qmd new file mode 100644 index 00000000000..2789e01631d --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation-native.qmd @@ -0,0 +1,27 @@ +--- +title: Code Filename + Annotation (Native) +format: + typst: + keep-typ: true + code-annotations: true + syntax-highlighting: idiomatic +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\(' + - "quarto-code-annotation" + - "cell-id:" + - "quarto-annotation-item" + - [] +--- + +```{.python filename="example.py"} +x = 1 # <1> +y = 2 +z = x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-annotation.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation.qmd new file mode 100644 index 00000000000..2182af5069d --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-annotation.qmd @@ -0,0 +1,26 @@ +--- +title: Code Filename + Annotation (Code Block) +format: + typst: + keep-typ: true + code-annotations: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\(' + - "quarto-annotation-item" + - "cell-id:" + - "annotations:" + - [] +--- + +```{.python filename="example.py"} +x = 1 # <1> +y = 2 +z = x + y # <2> +``` + +1. Assign x. +2. Compute sum. diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-cell-jupyter.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-cell-jupyter.qmd new file mode 100644 index 00000000000..fc839eef4f9 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-cell-jupyter.qmd @@ -0,0 +1,18 @@ +--- +title: Code Filename (Cell, Jupyter) +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\(' + - [] +--- + +```{python} +#| filename: "hello.py" +print("Hello, world!") +``` diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-cell-knitr.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-cell-knitr.qmd new file mode 100644 index 00000000000..867209f7512 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-cell-knitr.qmd @@ -0,0 +1,18 @@ +--- +title: Code Filename (Cell, Knitr) +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\(' + - [] +--- + +```{r} +#| filename: "hello.R" +print("Hello, world!") +``` diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-escape.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-escape.qmd new file mode 100644 index 00000000000..811caa8c0c1 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-escape.qmd @@ -0,0 +1,17 @@ +--- +title: Code Filename (characters needing escaping) +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - '#quarto-code-filename\("C:\\\\code\\\\hello\.R"\)\[' + - [] +--- + +```{.r filename="C:\code\hello.R"} +x <- 1 +``` diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename-native.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename-native.qmd new file mode 100644 index 00000000000..5f6fa3caf8e --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename-native.qmd @@ -0,0 +1,19 @@ +--- +title: Code Filename (Native) +format: + typst: + keep-typ: true + syntax-highlighting: idiomatic +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\(' + - [] +--- + +```{.python filename="example.py"} +def hello(): + print("Hello, world!") +``` diff --git a/tests/docs/smoke-all/typst/code-filename/code-filename.qmd b/tests/docs/smoke-all/typst/code-filename/code-filename.qmd new file mode 100644 index 00000000000..a64c6c5903d --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/code-filename.qmd @@ -0,0 +1,19 @@ +--- +title: Code Filename (Code Block) +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\(' + - 'block\(width: 100%, radius: 2pt, clip: true, stroke: 0\.5pt \+ luma\(128\)\)' + - [] +--- + +```{.python filename="example.py"} +def hello(): + print("Hello, world!") +``` diff --git a/tests/docs/smoke-all/typst/code-filename/edge/e05-listing.qmd b/tests/docs/smoke-all/typst/code-filename/edge/e05-listing.qmd new file mode 100644 index 00000000000..01c7a8485eb --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/edge/e05-listing.qmd @@ -0,0 +1,27 @@ +--- +title: Filename on listings +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + noErrorsOrWarnings: default + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("foo\.py"' + - 'quarto-code-filename\("bar\.py"' + - 'First caption' + - 'Second caption' + - [] +--- + +::: {#lst-1 lst-cap="First caption" filename="foo.py"} +```{.python} +x = 1 +``` +::: + +```{#lst-2 .python lst-cap="Second caption" filename="bar.py"} +y = 2 +``` diff --git a/tests/docs/smoke-all/typst/code-filename/edge/e06-special.qmd b/tests/docs/smoke-all/typst/code-filename/edge/e06-special.qmd new file mode 100644 index 00000000000..a91a36d45cd --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/edge/e06-special.qmd @@ -0,0 +1,48 @@ +--- +title: Filename special characters +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("a\$b\.py"' + - 'quarto-code-filename\("c#d\.py"' + - 'quarto-code-filename\("e\*f_g\.py"' + - 'quarto-code-filename\("123"' + - 'replace\(regex\("\[/_\.-\]"\), m => m\.text \+ "\\u\{200b\}"\)' + - 'replace\(regex\("\[\.\]\\u\{200b\}\(\[\^\.\]\*\)\\\\z"\)' + - 'replace\(regex\("\[\^/_\.\\u\{200b\}-\]\{31,\}"\)' + - [] +--- + +```{.python filename="a$b.py"} +x = 1 +``` + +```{.python filename="c#d.py"} +x = 1 +``` + +```{.python filename="e*f_g.py"} +x = 1 +``` + +```{.python filename="`tick`.py"} +x = 1 +``` + +```{.python filename="say \"hi\".py"} +x = 1 +``` + +```{.python filename="LONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAMELONGNAME.py"} +x = 1 +``` + +```{.python filename=123} +x = 1 +``` diff --git a/tests/docs/smoke-all/typst/code-filename/edge/e07-containers.qmd b/tests/docs/smoke-all/typst/code-filename/edge/e07-containers.qmd new file mode 100644 index 00000000000..624d42d4e09 --- /dev/null +++ b/tests/docs/smoke-all/typst/code-filename/edge/e07-containers.qmd @@ -0,0 +1,37 @@ +--- +title: Filename in containers +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - + - 'quarto-code-filename\("tab\.py"' + - 'quarto-code-filename\("col-long-filename-that-might-overflow-a-narrow-column\.py"' + - 'quarto-code-filename\("fold\.py"' + - [] +--- + +::: {.panel-tabset} +## A +```{.python filename="tab.py"} +x = 1 +``` +::: + +::: {layout-ncol=2} +```{.python filename="col-long-filename-that-might-overflow-a-narrow-column.py"} +y = 2 +``` + +```{.python} +z = 3 +``` +::: + +```{.python filename="fold.py" code-fold="true"} +w = 4 +``` diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block-no-bg/brand-monospace-block-no-bg.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block-no-bg/brand-monospace-block-no-bg.qmd index 3e6bf00c79b..1af7582cd2e 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block-no-bg/brand-monospace-block-no-bg.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block-no-bg/brand-monospace-block-no-bg.qmd @@ -17,9 +17,9 @@ _quarto: # Even without brand bg, Skylighting override uses theme bgcolor # so that width/inset/radius are applied - 'let bgcolor = rgb\("#f1f3f5"\)' - - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks\)' + - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), blocks\)' # No brand background-color show rule (not configured) - - ['^#show raw\.where\(block: true\): set block\(fill:'] + - ['^#show raw\.where\(block: true\): set block\(fill: rgb\('] --- Brand sets monospace-block color, weight, size, and line-height but NOT diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block/brand-monospace-block.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block/brand-monospace-block.qmd index 3cfd409d894..c46e5e50b0f 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block/brand-monospace-block.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-block/brand-monospace-block.qmd @@ -18,7 +18,7 @@ _quarto: - '^#show raw\.where\(block: true\): set par\(leading: 0\.85em\)$' # Quarto-generated Skylighting override with brand bg and proper block styling - 'let bgcolor = rgb\("#1e1e2e"\)' - - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks\)' + - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), blocks\)' # Should NOT have raw fenced blocks - ["```python"] --- diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inheritance/brand-monospace-inheritance.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inheritance/brand-monospace-inheritance.qmd index acdf6d9ade2..c382e9b9659 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inheritance/brand-monospace-inheritance.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inheritance/brand-monospace-inheritance.qmd @@ -18,7 +18,7 @@ _quarto: - '^#show raw\.where\(block: true\): set par\(leading: 0\.75em\)$' # Quarto Skylighting override with inherited brand bg - 'let bgcolor = rgb\("#edf2f7"\)' - - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks\)' + - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), blocks\)' # monospace-inline overrides weight to 700, inherits color, gets its own bg - '^#show raw\.where\(block: false\): set text\(weight: 700, size: 0\.85em, fill: rgb\("#2d3748"\), \)$' - '^#show raw\.where\(block: false\): content => highlight\(fill: rgb\("#fefcbf"\), content\)$' diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inline/brand-monospace-inline.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inline/brand-monospace-inline.qmd index 6076fee15a7..667986b60f8 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inline/brand-monospace-inline.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-inline/brand-monospace-inline.qmd @@ -14,7 +14,7 @@ _quarto: - '^#show raw\.where\(block: false\): set text\(weight: 600, size: 0\.85em, fill: rgb\("#664d03"\), \)$' - '^#show raw\.where\(block: false\): content => highlight\(fill: rgb\("#fff3cd"\), content\)$' # No monospace-block show rules (not configured) - - ['^#show raw\.where\(block: true\): set block\(fill:'] + - ['^#show raw\.where\(block: true\): set block\(fill: rgb\('] --- Inline code like `hello()`{.python} and `x + y`{.r} should get brand monospace-inline styling diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-with-theme/brand-monospace-with-theme.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-with-theme/brand-monospace-with-theme.qmd index 02489365969..48a48ec870d 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-with-theme/brand-monospace-with-theme.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/brand-monospace-with-theme/brand-monospace-with-theme.qmd @@ -18,7 +18,7 @@ _quarto: - '^#show raw\.where\(block: true\): set par\(leading: 0\.65em\)$' # Quarto Skylighting override uses brand bg (not espresso theme bg) - 'let bgcolor = rgb\("#282a36"\)' - - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks\)' + - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), blocks\)' # Brand monospace-inline properties - '^#show raw\.where\(block: false\): set text\(weight: 500, fill: rgb\("#6c3483"\), \)$' - '^#show raw\.where\(block: false\): content => highlight\(fill: rgb\("#e8e0f0"\), content\)$' diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-default.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-default.qmd index de81bcf6460..d6479bf2fa4 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-default.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-default.qmd @@ -14,8 +14,9 @@ _quarto: - "#let EndLine" # Quarto override with proper block styling and arrow theme bgcolor - 'let bgcolor = rgb\("#f1f3f5"\)' - - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks\)' - - ["```python"] + - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), blocks\)' + - + - "```python" --- Default Skylighting (no explicit theme, no brand). This tests the baseline diff --git a/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-line-numbers.qmd b/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-line-numbers.qmd index af2fe7e9f32..86726f83895 100644 --- a/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-line-numbers.qmd +++ b/tests/docs/smoke-all/typst/syntax-highlighting/skylighting-line-numbers.qmd @@ -14,7 +14,7 @@ _quarto: - '#Skylighting\(number: true' - "#KeywordTok" # Quarto override with proper block styling - - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks\)' + - 'block\(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, stroke: 0\.5pt \+ luma\(128\), blocks\)' - ["```python"] --- diff --git a/tests/unit/typst-skylighting-patch.test.ts b/tests/unit/typst-skylighting-patch.test.ts new file mode 100644 index 00000000000..03af72c9997 --- /dev/null +++ b/tests/unit/typst-skylighting-patch.test.ts @@ -0,0 +1,139 @@ +/* + * typst-skylighting-patch.test.ts + * + * Unit tests for patching Pandoc's generated Skylighting function. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert, assertEquals } from "../asserts.ts"; +import { + applyPatch, + injectMissingAnnotationHelpers, + patchSkylightingFunction, +} from "../../src/format/typst/format-typst.ts"; + +const skylightingFn = + `#let Skylighting(fill: none, number: false, start: 1, sourcelines) = { + let blocks = [] + let lnum = start - 1 + let bgcolor = rgb("#f1f3f5") + for ln in sourcelines { + if number { + lnum = lnum + 1 + blocks = blocks + box(text(fill: rgb("#aaaaaa"), [ #lnum ])) + } + blocks = blocks + ln + EndLine() + } + block(fill: bgcolor, blocks) +}`; + +unitTest("typst-skylighting-patch - applyPatch reports a miss", async () => { + const miss = applyPatch("abc", "xyz", "q"); + assertEquals(miss.applied, false); + assertEquals(miss.source, "abc"); + const hit = applyPatch("abc", "b", "q"); + assertEquals(hit.applied, true); + assertEquals(hit.source, "aqc"); +}); + +unitTest("typst-skylighting-patch - all patches apply", async () => { + const result = patchSkylightingFunction(skylightingFn); + assertEquals(result.failedPatch, undefined); + assert(result.fn.includes("annotations: (:)")); +}); + +unitTest( + "typst-skylighting-patch - a missing target keeps the unannotated function", + async () => { + const source = skylightingFn.replace("let lnum = start - 1\n", ""); + const result = patchSkylightingFunction(source); + assert(result.failedPatch !== undefined); + assert(!result.fn.includes("annotations:")); + assert(result.fn.includes("width: 100%, inset: 8pt")); + }, +); + +const definitionsSource = Deno.readTextFileSync( + new URL( + "../../src/resources/formats/typst/pandoc/quarto/definitions.typ", + import.meta.url, + ), +); + +const usingDocument = + `#let Skylighting(fill: none, number: false, start: 1, cell-id: "", annotations: (:), sourcelines) = { + quarto-annote-link(lbl)[#quarto-circled-number(1, color: quarto-annote-color(bgcolor))] +} +#quarto-code-filename("a.py")[ +#Skylighting(([x],)); +] +#quarto-code-annotation(("1": 1), cell-id: "c")[x] +#quarto-annotation-item("c", 1, [Item]) +#show: doc => doc +`; + +unitTest("typst-skylighting-patch - helpers: nothing used", async () => { + const content = "#show: doc => doc\nplain\n"; + const result = injectMissingAnnotationHelpers(content, definitionsSource); + assertEquals(result.content, content); + assertEquals(result.injected, []); +}); + +unitTest("typst-skylighting-patch - helpers: all missing", async () => { + const result = injectMissingAnnotationHelpers( + usingDocument, + definitionsSource, + ); + assertEquals(result.injected.length, 6); + assert( + result.content.indexOf("#let quarto-code-filename(") < + result.content.indexOf("#let Skylighting("), + ); + assert( + result.content.indexOf("#let quarto-annote-link(") < + result.content.indexOf("#let quarto-code-annotation("), + ); +}); + +unitTest("typst-skylighting-patch - helpers: one missing", async () => { + const link = definitionsSource.search(/^#let quarto-annote-link\(/m); + const afterLink = definitionsSource.slice(link + 1).search(/^(#let |#show)/m); + const withoutLink = definitionsSource.slice(0, link) + + definitionsSource.slice(link + 1 + afterLink); + const result = injectMissingAnnotationHelpers( + withoutLink + usingDocument, + definitionsSource, + ); + assertEquals(result.injected, ["quarto-annote-link"]); + assert( + result.content.indexOf("#let quarto-annote-link(") < + result.content.indexOf("#let quarto-code-annotation("), + ); +}); + +unitTest("typst-skylighting-patch - helpers: all present", async () => { + const content = definitionsSource + usingDocument; + const result = injectMissingAnnotationHelpers(content, definitionsSource); + assertEquals(result.content, content); + assertEquals(result.injected, []); +}); + +unitTest( + "typst-skylighting-patch - helpers: injected at top level, not inside a bracket", + async () => { + const content = `#let preamble = 1 +#block[ +#set align(left) +#quarto-annotation-item("c", 1, [Item]) +] +`; + const result = injectMissingAnnotationHelpers(content, definitionsSource); + assert(result.injected.includes("quarto-annotation-item")); + assert( + result.content.indexOf("#let quarto-annotation-item(") < + result.content.indexOf("#let preamble"), + ); + }, +);