diff --git a/.gitignore b/.gitignore index 809b28f..a823054 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ docs/_sidebar-api.yml docs/api/*.qmd *.html .quarto/ - /.luarc.json +/example.typ +/example-typst.pdf diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b70328..284eda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ ### New Features - feat: add multiple window decoration styles for code blocks (`macos`, `windows`, `default`). -- feat: add `skylighting-fix` option to enable or disable the Skylighting hot-fix for Typst output (enabled by default). +- feat: add Typst code-annotations hot-fix with annotation markers, circled numbers, and bidirectional linking. +- feat: add `hotfix.quarto-version` threshold to auto-disable temporary hot-fixes when Quarto reaches a specified version. + +### Bug Fixes + +- fix: HTML-escape auto-generated filename in code block headers to prevent XSS. +- fix: skylighting hot-fix now respects custom `wrapper` name instead of hardcoding `code-window-circled-number`. ### Style @@ -13,6 +19,10 @@ ### Refactoring +- refactor: consolidate `skylighting-fix` option into nested `hotfix` configuration key with `code-annotations` and `skylighting` toggles. +- refactor: introduce `main.lua` entry point for filter assembly and dependency wiring. +- refactor: move hotfix modules (`code-annotations.lua`, `skylighting-typst-fix.lua`) into `_modules/hotfix/`. +- refactor: split Typst function definitions so annotation helpers are only injected when at least one hot-fix is active. - refactor: update Typst processing to return block sandwich. - refactor: use utility functions for code-window extension. diff --git a/README.md b/README.md index 70e9de4..4f08470 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,30 @@ extensions: auto-filename: true style: "macos" wrapper: "code-window" - skylighting-fix: true + hotfix: + quarto-version: ~ + code-annotations: true + skylighting: true ``` ### Options -| Option | Type | Default | Description | -| ----------------- | ------- | --------------- | ----------------------------------------------------------------------------------- | -| `enabled` | boolean | `true` | Enable or disable the code-window filter. | -| `auto-filename` | boolean | `true` | Automatically generate filename labels from the code block language. | -| `style` | string | `"macos"` | Window decoration style: `"macos"`, `"windows"`, or `"default"`. | -| `wrapper` | string | `"code-window"` | Typst wrapper function name for code-window rendering. | -| `skylighting-fix` | boolean | `true` | Enable or disable the Skylighting hot-fix for Typst output (block and inline code). | +| Option | Type | Default | Description | +| --------------- | ------- | --------------- | -------------------------------------------------------------------- | +| `enabled` | boolean | `true` | Enable or disable the code-window filter. | +| `auto-filename` | boolean | `true` | Automatically generate filename labels from the code block language. | +| `style` | string | `"macos"` | Window decoration style: `"macos"`, `"windows"`, or `"default"`. | +| `wrapper` | string | `"code-window"` | Typst wrapper function name for code-window rendering. | + +### Hotfix Options + +These options are **temporary** and will be removed in a future version (see [Temporary hot-fixes](#temporary-hot-fixes-typst)). + +| Option | Type | Default | Description | +| ------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------ | +| `hotfix.quarto-version` | string | _unset_ | Quarto version at or above which all hot-fixes are automatically disabled. | +| `hotfix.code-annotations` | boolean | `true` | Enable the code-annotations hot-fix for Typst output. | +| `hotfix.skylighting` | boolean | `true` | Enable the Skylighting hot-fix for Typst output (overrides block styling and inline code). | ### Styles @@ -87,17 +99,32 @@ print("Windows style for this block only") ``` ```` -### Typst Skylighting Hot-fix (Integrated) +### Temporary Hot-fixes (Typst) + +The extension includes two temporary hot-fixes for Typst output that compensate for missing Quarto/Pandoc features. +Both will be removed once [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170) is released. +After that, the extension will focus solely on **auto-filename** and **code-window-style** features. + +- **`hotfix.code-annotations`**: processes code annotation markers for Typst, since Quarto does not yet support `code-annotations` in Typst output. + The `filename` attribute for code blocks will also become natively supported. +- **`hotfix.skylighting`**: overrides Pandoc's Skylighting output for Typst to fix block and inline code styling. -`code-window` loads its Typst skylighting hot-fix internally from `_extensions/code-window/skylighting-typst-fix.lua`, so no second filter entry is required. -Set `skylighting-fix: false` to disable the hot-fix without removing the file. +Set `hotfix.quarto-version` to automatically disable both hot-fixes once you update Quarto to the version that includes native support: -This keeps the hot-fix separated from `code-window.lua` for easy future removal while preserving combined behaviour. +```yaml +extensions: + code-window: + hotfix: + quarto-version: "1.10.0" +``` Future removal playbook: -1. Remove the skylighting loader call in `_extensions/code-window/code-window.lua`. -2. Delete `_extensions/code-window/skylighting-typst-fix.lua`. +1. Delete `hotfix` parsing from `code-window.lua` (`HOTFIX_DEFAULTS`, hotfix section in `Meta`). +2. Remove the `hotfix` section from `_schema.yml`. +3. Remove the skylighting guard and loader in `main.lua`. +4. Remove annotation processing from `code-window.lua`. +5. Delete `_modules/hotfix/` directory entirely. ## Example diff --git a/_extensions/code-window/_extension.yml b/_extensions/code-window/_extension.yml index 06d9591..f31399a 100644 --- a/_extensions/code-window/_extension.yml +++ b/_extensions/code-window/_extension.yml @@ -1,8 +1,8 @@ title: Code Window author: Mickaël Canouil version: 0.2.0 -quarto-required: ">=1.9.23" +quarto-required: ">=1.9.36" contributes: filters: - at: pre-quarto - path: code-window.lua + path: main.lua diff --git a/_extensions/code-window/_modules/hotfix/code-annotations.lua b/_extensions/code-window/_modules/hotfix/code-annotations.lua new file mode 100644 index 0000000..3eafe71 --- /dev/null +++ b/_extensions/code-window/_modules/hotfix/code-annotations.lua @@ -0,0 +1,200 @@ +--- @module code-annotations +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @brief Code annotation detection, stripping, and Typst rendering helpers. +--- Scans CodeBlock elements for inline annotation markers (e.g. # <1>, // <2>) +--- and provides utilities for converting annotations to Typst output. + +-- ============================================================================ +-- LANGUAGE COMMENT CHARACTERS +-- ============================================================================ + +--- Map of language identifiers to their single-line comment prefix. +--- @type table +local LANG_COMMENT_CHARS = { + r = '#', + python = '#', + lua = '--', + javascript = '//', + typescript = '//', + go = '//', + rust = '//', + bash = '#', + sh = '#', + zsh = '#', + fish = '#', + c = '//', + cpp = '//', + cxx = '//', + cc = '//', + cs = '//', + java = '//', + scala = '//', + kotlin = '//', + swift = '//', + objc = '//', + php = '//', + ruby = '#', + perl = '#', + julia = '#', + haskell = '--', + elm = '--', + clojure = ';', + scheme = ';', + lisp = ';', + racket = ';', + erlang = '%%', + elixir = '#', + fortran = '!', + matlab = '%%', + ada = '--', + sql = '--', + plsql = '--', + tsql = '--', + mysql = '--', + sqlite = '--', + postgresql = '--', + vb = "'", + vbnet = "'", + fsharp = '//', + stata = '//', + yaml = '#', + toml = '#', + make = '#', + cmake = '#', + dockerfile = '#', + powershell = '#', + nix = '#', + zig = '//', + dart = '//', + groovy = '//', + d = '//', + nim = '#', + crystal = '#', + v = '//', + odin = '//', + mojo = '#', +} + +-- ============================================================================ +-- ANNOTATION RESOLUTION +-- ============================================================================ + +--- Escape a string for use in a Lua pattern. +--- @param s string +--- @return string +local function escape_pattern(s) + return s:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1') +end + +--- Resolve annotations in a CodeBlock element. +--- Scans each line for a trailing annotation marker (e.g. # <1>) using the +--- language's comment prefix. Strips the marker from the code text and returns +--- the cleaned text along with an annotations table. +--- @param block pandoc.CodeBlock +--- @return string cleaned_text The code with annotation markers removed +--- @return table|nil annotations Maps line numbers (int) to annotation numbers (int), or nil if none found +local function resolve_annotations(block) + if not block.classes or #block.classes == 0 then + return block.text, nil + end + + local lang = block.classes[1]:lower() + local comment = LANG_COMMENT_CHARS[lang] + if not comment then + return block.text, nil + end + + local escaped_comment = escape_pattern(comment) + local pattern = '^(.-)%s*' .. escaped_comment .. '%s*<%s*(%d+)%s*>%s*$' + + local annotations = {} + local lines = {} + local found = false + + local line_num = 0 + for line in (block.text .. '\n'):gmatch('([^\n]*)\n') do + line_num = line_num + 1 + local content, annot_num = line:match(pattern) + if annot_num then + found = true + annotations[line_num] = tonumber(annot_num) + table.insert(lines, content) + else + table.insert(lines, line) + end + end + + if not found then + return block.text, nil + end + + return table.concat(lines, '\n'), annotations +end + +-- ============================================================================ +-- TYPST CONVERSION HELPERS +-- ============================================================================ + +--- Convert an annotations table to a Typst dictionary literal. +--- Keys are stringified line numbers, values are annotation numbers. +--- Example output: (1: 2, 3: 1) +--- @param annotations table Line number to annotation number mapping +--- @return string Typst dictionary literal +local function annotations_to_typst_dict(annotations) + local pairs_list = {} + local keys = {} + for k in pairs(annotations) do + table.insert(keys, k) + end + table.sort(keys) + for _, line_num in ipairs(keys) do + table.insert(pairs_list, + string.format('"%d": %d', line_num, annotations[line_num])) + end + return '(' .. table.concat(pairs_list, ', ') .. ')' +end + +--- Check whether a block is an OrderedList that looks like an annotation list. +--- Annotation lists are OrderedLists immediately following a code block, +--- where each item corresponds to an annotation number. +--- @param block pandoc.Block +--- @return boolean +local function is_annotation_ordered_list(block) + return block and block.t == 'OrderedList' +end + +--- Convert an OrderedList to Typst annotation item RawBlocks. +--- Each list item becomes a #code-window-annotation-item(block-id, n)[...] call. +--- @param ol pandoc.OrderedList The ordered list to convert +--- @param wrapper_prefix string Prefix for the Typst function name +--- @param block_id integer Unique block identifier for bidirectional linking +--- @return pandoc.List List of RawBlock elements +local function ordered_list_to_typst_blocks(ol, wrapper_prefix, block_id) + local blocks = {} + local start = ol.listAttributes and ol.listAttributes.start or 1 + for i, item in ipairs(ol.content) do + local annot_num = start + i - 1 + local content_blocks = pandoc.Blocks(item) + local rendered = pandoc.write(pandoc.Pandoc(content_blocks), 'typst') + rendered = rendered:gsub('%s+$', '') + table.insert(blocks, pandoc.RawBlock('typst', string.format( + '#%s-annotation-item(%d, %d)[%s]', + wrapper_prefix, block_id, annot_num, rendered + ))) + end + return blocks +end + +-- ============================================================================ +-- MODULE EXPORTS +-- ============================================================================ + +return { + LANG_COMMENT_CHARS = LANG_COMMENT_CHARS, + resolve_annotations = resolve_annotations, + annotations_to_typst_dict = annotations_to_typst_dict, + is_annotation_ordered_list = is_annotation_ordered_list, + ordered_list_to_typst_blocks = ordered_list_to_typst_blocks, +} diff --git a/_extensions/code-window/skylighting-typst-fix.lua b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua similarity index 56% rename from _extensions/code-window/skylighting-typst-fix.lua rename to _extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua index c683ee3..5dec9f5 100644 --- a/_extensions/code-window/skylighting-typst-fix.lua +++ b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua @@ -9,6 +9,15 @@ --- the Skylighting function with better block styling and adds inline code --- background support for Typst output. +local _wrapper_prefix = 'code-window' + +--- Set the wrapper prefix for Typst function name generation. +--- Called by main.lua before each handler invocation. +--- @param prefix string The wrapper prefix (e.g. 'code-window' or 'my-window') +local function set_wrapper(prefix) + _wrapper_prefix = prefix +end + --- Build a Skylighting override with improved block styling. --- Pandoc 3.8+ generates correct bgcolor but the block call lacks width, --- inset, radius, and stroke properties. The fill parameter is also ignored. @@ -21,6 +30,7 @@ local function build_skylighting_override() local bg = hm['background-color'] if not bg or type(bg) ~= 'string' then return nil end + local circled = _wrapper_prefix .. '-circled-number' return string.format([==[ // skylighting-typst-fix override #let Skylighting( @@ -30,31 +40,66 @@ local function build_skylighting_override() sourcelines, ) = { let bgcolor = if fill != none { fill } else { rgb("%s") } - let blocks = [] - let lnum = start - 1 let has-gutter = start + sourcelines.len() > 999 - for ln in sourcelines { - if number { + context { + let annot-data = _cw-annotations.get() + let blocks = [] + let lnum = start - 1 + let seen-annotes = (:) + + for ln in sourcelines { lnum = lnum + 1 - blocks = blocks + box( - width: if has-gutter { 30pt } else { 24pt }, - text([ #lnum ]), - ) + if number { + blocks = blocks + box( + width: if has-gutter { 30pt } else { 24pt }, + text([ #lnum ]), + ) + } + + if annot-data != none { + let annot-num = annot-data.annotations.at(str(lnum), default: none) + if annot-num != none { + let lbl-prefix = "cw-" + str(annot-data.block-id) + "-" + if str(annot-num) not in seen-annotes { + seen-annotes.insert(str(annot-num), true) + blocks = blocks + box(width: 100%%)[ + #ln + #h(1fr) + #link(label(lbl-prefix + "item-" + str(annot-num)))[ + #%s(annot-num, bg-colour: annot-data.bg-colour) + ] + #label(lbl-prefix + "line-" + str(annot-num)) + ] + } else { + blocks = blocks + box(width: 100%%)[ + #ln + #h(1fr) + #link(label(lbl-prefix + "item-" + str(annot-num)))[ + #%s(annot-num, bg-colour: annot-data.bg-colour) + ] + ] + } + } else { + blocks = blocks + ln + } + } else { + blocks = blocks + ln + } + blocks = blocks + EndLine() } - blocks = blocks + ln + EndLine() - } - block( - fill: bgcolor, - width: 100%%, - inset: 8pt, - radius: 2pt, - stroke: none, - blocks, - ) + block( + fill: bgcolor, + width: 100%%, + inset: 8pt, + radius: 2pt, + stroke: 0.5pt + luma(200), + blocks, + ) + } } -]==], bg) +]==], bg, circled, circled) end --- Process inline Code for Typst format. @@ -91,6 +136,7 @@ local function process_typst_inline(el) end --- Inject Skylighting override at the start of the document. +--- Always injected when code-window is enabled to support annotation markers. function Pandoc(doc) if not quarto.doc.is_format('typst') then return doc @@ -109,7 +155,17 @@ function Pandoc(doc) return doc end - table.insert(doc.blocks, 1, pandoc.RawBlock('typst', override)) + -- Insert after the code-window function definitions so Skylighting can + -- reference _cw-annotations and the wrapper-prefixed circled-number function. + local insert_pos = 1 + for idx, blk in ipairs(doc.blocks) do + if blk.t == 'RawBlock' and blk.format == 'typst' + and blk.text:find('_cw%-annotations') then + insert_pos = idx + 1 + break + end + end + table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', override)) return doc end @@ -122,6 +178,9 @@ function Code(el) end return { - { Pandoc = Pandoc }, - { Code = Code }, + set_wrapper = set_wrapper, + filters = { + { Pandoc = Pandoc }, + { Code = Code }, + }, } diff --git a/_extensions/code-window/_schema.yml b/_extensions/code-window/_schema.yml index 4bd6ae9..27a0c37 100644 --- a/_extensions/code-window/_schema.yml +++ b/_extensions/code-window/_schema.yml @@ -18,7 +18,29 @@ options: type: string default: "code-window" description: "Typst wrapper function name for code-window rendering." - skylighting-fix: - type: boolean - default: true - description: "Enable or disable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background)." + hotfix: + type: object + description: "Temporary hot-fixes for Typst output. These will be removed when Quarto natively supports the corresponding features (see quarto-dev/quarto-cli#14170)." + properties: + quarto-version: + type: string + description: "Quarto version at or above which all hot-fixes are automatically disabled. Leave unset to use individual toggles." + code-annotations: + type: boolean + default: true + description: "Enable the code-annotations hot-fix for Typst output." + skylighting: + type: boolean + default: true + description: "Enable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background)." + +attributes: + CodeBlock: + code-window-enabled: + type: boolean + default: true + description: "Whether to apply window chrome to this specific code block. Set to false to disable. Annotations still render when disabled." + code-window-style: + type: string + enum: ["default", "macos", "windows"] + description: "Override the global window decoration style for this specific code block." diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 84a58eb..e7ca9fe 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -2,7 +2,6 @@ --- @license MIT --- @copyright 2026 Mickaël Canouil --- @author Mickaël Canouil ---- @version 0.1.0 --- @brief Code block window decorations with multiple styles --- @description Adds window chrome (macOS traffic lights, Windows title bar --- buttons, or plain filename) to code blocks in HTML, Reveal.js, and Typst @@ -14,6 +13,7 @@ local EXTENSION_NAME = 'code-window' local utils = require(quarto.utils.resolve_path('_modules/utils.lua'):gsub('%.lua$', '')) +local code_annotations = nil -- ============================================================================ -- DEFAULTS AND STATE @@ -24,7 +24,8 @@ local utils = require(quarto.utils.resolve_path('_modules/utils.lua'):gsub('%.lu --- @field auto_filename boolean Whether to auto-generate filename from language --- @field style string Window decoration style ('macos', 'windows', 'default') --- @field typst_wrapper string Typst wrapper function name ---- @field skylighting_fix boolean Whether to apply the Skylighting hot-fix for Typst +--- @field hotfix_code_annotations boolean Whether to apply the code-annotations hot-fix for Typst +--- @field hotfix_skylighting boolean Whether to apply the Skylighting hot-fix for Typst local VALID_STYLES = { ['default'] = true, ['macos'] = true, ['windows'] = true } @@ -33,11 +34,17 @@ local DEFAULTS = { ['auto-filename'] = 'true', ['style'] = 'macos', ['wrapper'] = 'code-window', - ['skylighting-fix'] = 'true', +} + +local HOTFIX_DEFAULTS = { + ['code-annotations'] = true, + ['skylighting'] = true, } local CURRENT_FORMAT = nil local CONFIG = nil +local TYPST_BG_COLOUR = nil +local ANNOTATION_BLOCK_COUNTER = 0 -- ============================================================================ -- LANGUAGE DETECTION @@ -94,10 +101,92 @@ end -- TYPST FUNCTION DEFINITION -- ============================================================================ ---- Typst function definition for code-window rendering. ---- Injected once at the start of the document body. -local TYPST_FUNCTION_DEF = [==[ -#let code-window(content, filename: none, is-auto: false, style: "macos") = { +--- Typst annotation helper functions (state, colour, circled numbers, annotation items). +--- Only injected when at least one hot-fix is active. +local TYPST_ANNOTATION_DEF = [==[ +// code-window: annotation state passed to Skylighting via Typst state +#let _cw-annotations = state("cw-annotations", none) + +// Derive a contrasting annotation colour from a background fill. +// Light backgrounds get dark circles; dark backgrounds get light circles. +// Uses ITU-R BT.709 luminance coefficients, matching quarto-cli PR #14170. +#let code-window-annote-colour(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 code-window-circled-number(n, bg-colour: none) = { + let c = if bg-colour != none { code-window-annote-colour(bg-colour) } else { luma(120) } + box(baseline: 20%, circle( + radius: 4.5pt, + stroke: 0.5pt + c, + )[#set text(size: 5.5pt, fill: c); #align(center + horizon, str(n))]) +} + +#let code-window-annotation-item(block-id, n, content) = { + let lbl-prefix = "cw-" + str(block-id) + "-" + [#block(above: 0.4em, below: 0.4em)[ + #link(label(lbl-prefix + "line-" + str(n)))[ + #code-window-circled-number(n) + ] + #h(0.4em) + #content + ] #label(lbl-prefix + "item-" + str(n))] +} + +#let code-window-annotated-content(content, annotations: (:), bg-colour: none, block-id: 0) = { + if annotations.len() > 0 { + _cw-annotations.update((annotations: annotations, bg-colour: bg-colour, block-id: block-id)) + content + _cw-annotations.update(none) + } else { + content + } +} +]==] + +--- Typst code-window body content template. +--- Uses annotation wrapper when hot-fixes are active, plain content otherwise. +local TYPST_CONTENT_WITH_ANNOTATIONS = [==[ + code-window-annotated-content( + content, + annotations: annotations, + bg-colour: bg-colour, + block-id: block-id, + ) +]==] + +local TYPST_CONTENT_PLAIN = [==[ + content +]==] + +--- Build the complete Typst code-window function definition. +--- @param has_hotfixes boolean Whether at least one hot-fix is active +--- @return string Typst function definition(s) +local function build_typst_function_def(has_hotfixes) + local content_block = has_hotfixes + and TYPST_CONTENT_WITH_ANNOTATIONS + or TYPST_CONTENT_PLAIN + + local fn_def = string.format([==[ +#let code-window( + content, + filename: none, + is-auto: false, + style: "macos", + annotations: (:), + bg-colour: none, + block-id: 0, +) = { let border-colour = luma(200) let surface-fill = luma(237) let muted-colour = luma(120) @@ -130,13 +219,13 @@ local TYPST_FUNCTION_DEF = [==[ dir: ltr, spacing: 0.8em, // Minimise (horizontal line) - box(width: 0.6em, height: 0.6em, align(horizon, line(length: 100%))), + box(width: 0.6em, height: 0.6em, align(horizon, line(length: 100%%))), // Maximise (square) box(width: 0.6em, height: 0.6em, stroke: 1pt + muted-colour), // Close (x) box(width: 0.6em, height: 0.6em, { - place(line(start: (0%, 0%), end: (100%, 100%))) - place(line(start: (100%, 0%), end: (0%, 100%))) + place(line(start: (0%%, 0%%), end: (100%%, 100%%))) + place(line(start: (100%%, 0%%), end: (0%%, 100%%))) }), ) }, @@ -166,13 +255,13 @@ local TYPST_FUNCTION_DEF = [==[ } block( - width: 100%, + width: 100%%, stroke: 1pt + border-colour, radius: 8pt, clip: true, { block( - width: 100%, + width: 100%%, fill: surface-fill, inset: (x: 1em, y: 0.6em), below: 0pt, @@ -186,7 +275,7 @@ local TYPST_FUNCTION_DEF = [==[ // show raw overrides the document-level raw block styling (fill, radius). { set block( - width: 100%, + width: 100%%, inset: 8pt, radius: 0pt, stroke: none, @@ -195,59 +284,83 @@ local TYPST_FUNCTION_DEF = [==[ ) show raw.where(block: true): set block( fill: none, - width: 100%, + width: 100%%, radius: 0pt, stroke: none, above: 0pt, below: 0pt, ) - content - } +%s } }, ) } -]==] +]==], content_block) + + if has_hotfixes then + return TYPST_ANNOTATION_DEF .. fn_def + end + return fn_def +end -- ============================================================================ -- TYPST PROCESSING -- ============================================================================ ---- Process CodeBlock for Typst format. ---- Returns a block sandwich: opening RawBlock, the original CodeBlock, ---- and a closing RawBlock. Pandoc's own Typst writer handles Skylighting ---- with the document's theme automatically. ---- @param block pandoc.CodeBlock Code block element ---- @return pandoc.List|pandoc.CodeBlock Block list or original block -local function process_typst(block) - local block_style = read_block_style(block) - local explicit_filename = block.attributes['filename'] - local filename = explicit_filename - local is_auto = false +--- Get the next unique block ID for annotation linking. +--- @return integer +local function next_block_id() + ANNOTATION_BLOCK_COUNTER = ANNOTATION_BLOCK_COUNTER + 1 + return ANNOTATION_BLOCK_COUNTER +end - if not filename or filename == '' then - if CONFIG.auto_filename and block.classes and #block.classes > 0 then - filename = block.classes[1] - is_auto = true - end +--- Build the Typst bg-colour parameter string. +--- @return string Empty string or ', bg-colour: rgb("...")' +local function typst_bg_colour_param() + if not TYPST_BG_COLOUR then + return '' end + return string.format(', bg-colour: rgb("%s")', TYPST_BG_COLOUR) +end - if not filename then - return block +--- Build a code-window opening RawBlock for Typst. +--- @param filename string +--- @param is_auto boolean +--- @param style string +--- @param annotations table|nil +--- @param block_id integer +--- @return pandoc.RawBlock +local function typst_code_window_open(filename, is_auto, style, annotations, block_id) + local annot_param = '' + if annotations and next(annotations) then + annot_param = string.format(', annotations: %s, block-id: %d', + code_annotations.annotations_to_typst_dict(annotations), block_id) end - local effective_style = block_style or CONFIG.style + return pandoc.RawBlock('typst', string.format( + '#%s(filename: "%s", is-auto: %s, style: "%s"%s%s)[', + CONFIG.typst_wrapper, + filename:gsub('"', '\\"'), + is_auto and 'true' or 'false', + style, + annot_param, + typst_bg_colour_param() + )) +end - return { - pandoc.RawBlock('typst', string.format( - '#%s(filename: "%s", is-auto: %s, style: "%s")[', - CONFIG.typst_wrapper, - filename:gsub('"', '\\"'), - is_auto and 'true' or 'false', - effective_style - )), - block, - pandoc.RawBlock('typst', ']'), - } +--- Build a standalone annotation wrapper for non-windowed blocks. +--- @param annotations table +--- @param block_id integer +--- @return pandoc.RawBlock opening, pandoc.RawBlock closing +local function typst_annotation_wrapper(annotations, block_id) + local open = pandoc.RawBlock('typst', string.format( + '#%s-annotated-content(annotations: %s, block-id: %d%s)[', + CONFIG.typst_wrapper, + code_annotations.annotations_to_typst_dict(annotations), + block_id, + typst_bg_colour_param() + )) + local close = pandoc.RawBlock('typst', ']') + return open, close end -- ============================================================================ @@ -261,6 +374,15 @@ end --- @param block pandoc.CodeBlock Code block element --- @return pandoc.Div|pandoc.CodeBlock Wrapped block or original local function process_html(block) + -- Per-block opt-out: code-window-enabled="false" skips window chrome. + local block_enabled = block.attributes['code-window-enabled'] + if block_enabled then + block.attributes['code-window-enabled'] = nil + end + if block_enabled == 'false' then + return block + end + local block_style = read_block_style(block) local explicit_filename = block.attributes['filename'] @@ -295,7 +417,7 @@ local function process_html(block) 'html', string.format( '
%s
', - filename + utils.escape_html(filename) ) ) @@ -331,7 +453,7 @@ function Meta(meta) CURRENT_FORMAT = utils.get_quarto_format() local opts = utils.get_options({ extension = EXTENSION_NAME, - keys = { 'enabled', 'auto-filename', 'style', 'wrapper', 'skylighting-fix' }, + keys = { 'enabled', 'auto-filename', 'style', 'wrapper' }, meta = meta, defaults = DEFAULTS, }) @@ -341,14 +463,68 @@ function Meta(meta) string.format('Unknown style "%s", falling back to "macos".', opts['style'])) end + -- Read code-annotations metadata (Quarto standard option). + local annot_meta = meta['code-annotations'] + local annot_value = annot_meta and pandoc.utils.stringify(annot_meta) or '' + local annotations_enabled = annot_value ~= 'none' and annot_value ~= 'false' + + -- Read hotfix sub-table from extensions.code-window.hotfix. + local ext_config = utils.get_extension_config(meta, EXTENSION_NAME) + local hotfix_meta = ext_config and ext_config['hotfix'] or nil + + -- Deprecation check for old flat skylighting-fix key. + if ext_config and ext_config['skylighting-fix'] ~= nil then + utils.log_warning(EXTENSION_NAME, + '"skylighting-fix" is deprecated. Use "hotfix: { skylighting: true/false }" instead.') + end + + -- Parse hotfix options with version-based auto-disable. + local hotfix = {} + local hotfix_version_override = false + if hotfix_meta then + local version_str = hotfix_meta['quarto-version'] + if version_str then + version_str = pandoc.utils.stringify(version_str) + if version_str ~= '' then + local ok, threshold = pcall(pandoc.types.Version, version_str) + if ok and quarto.version >= threshold then + hotfix_version_override = true + end + end + end + end + + for key, default in pairs(HOTFIX_DEFAULTS) do + if hotfix_version_override then + hotfix[key] = false + elseif hotfix_meta and hotfix_meta[key] ~= nil then + hotfix[key] = pandoc.utils.stringify(hotfix_meta[key]) == 'true' + else + hotfix[key] = default + end + end + CONFIG = { enabled = opts['enabled'] == 'true', auto_filename = opts['auto-filename'] == 'true', style = VALID_STYLES[opts['style']] and opts['style'] or 'macos', typst_wrapper = opts['wrapper'], - skylighting_fix = opts['skylighting-fix'] == 'true', + hotfix_code_annotations = hotfix['code-annotations'], + hotfix_skylighting = hotfix['skylighting'], + code_annotations = annotations_enabled, } + -- Cache syntax highlighting background colour for Typst contrast-aware annotations. + if CURRENT_FORMAT == 'typst' then + local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method + if hm then + local bg = hm['background-color'] + if bg and type(bg) == 'string' then + TYPST_BG_COLOUR = bg + end + end + end + if CURRENT_FORMAT == 'html' and CONFIG.enabled then utils.ensure_html_dependency({ name = EXTENSION_NAME, @@ -365,18 +541,13 @@ function Meta(meta) return meta end ---- Process CodeBlock elements. ---- Typst: wraps blocks with RawBlock sandwich. ---- HTML/Reveal.js: wraps blocks with auto-filename Divs. +--- Process CodeBlock elements for HTML/Reveal.js only. +--- Typst processing is handled by the Blocks filter. function CodeBlock(block) if not CURRENT_FORMAT or not CONFIG or not CONFIG.enabled then return block end - if CURRENT_FORMAT == 'typst' then - return process_typst(block) - end - if CURRENT_FORMAT == 'html' then return process_html(block) end @@ -384,12 +555,179 @@ function CodeBlock(block) return block end ---- Inject Typst function definition at the start of the document. +-- ============================================================================ +-- TYPST BLOCKS FILTER +-- ============================================================================ + +--- Determine whether a CodeBlock should get code-window chrome. +--- @param block pandoc.CodeBlock +--- @return string|nil filename +--- @return boolean is_auto +--- @return string|nil block_style +--- @return boolean window_opted_out True when code-window-enabled="false" was set +local function resolve_window_params(block) + -- Per-block opt-out: code-window-enabled="false" skips window chrome. + local block_enabled = block.attributes['code-window-enabled'] + if block_enabled then + block.attributes['code-window-enabled'] = nil + end + if block_enabled == 'false' then + return nil, false, nil, true + end + + local block_style = read_block_style(block) + local explicit_filename = block.attributes['filename'] + local filename = explicit_filename + local is_auto = false + + if not filename or filename == '' then + if CONFIG.auto_filename and block.classes and #block.classes > 0 then + filename = block.classes[1] + is_auto = true + end + end + + return filename, is_auto, block_style, false +end + +--- Process a single CodeBlock for Typst, returning replacement blocks. +--- Handles both code-window wrapping and standalone annotation rendering. +--- @param block pandoc.CodeBlock +--- @param next_block pandoc.Block|nil The block following this CodeBlock +--- @return pandoc.List replacement_blocks Blocks to splice in +--- @return boolean consumed_next Whether the next block was consumed +--- @return integer|nil annotation_block_id Block ID if annotations were found (for parent propagation) +local function process_typst_block(block, next_block) + local filename, is_auto, block_style, window_opted_out = resolve_window_params(block) + local has_window = filename and filename ~= '' + local effective_style = block_style or CONFIG.style + + -- Resolve annotations if enabled and the code-annotations hot-fix is active. + local annotations = nil + local should_handle_annotations = CONFIG.code_annotations and CONFIG.hotfix_code_annotations + + if should_handle_annotations then + local cleaned_text + cleaned_text, annotations = code_annotations.resolve_annotations(block) + if annotations then + block.text = cleaned_text + end + end + + -- Strip filename attribute to prevent Quarto's DecoratedCodeBlock (PR #14170). + if has_window and block.attributes['filename'] then + block.attributes['filename'] = nil + end + + local has_annotations = annotations and next(annotations) + local consumed_next = false + local result = {} + local block_id = has_annotations and next_block_id() or 0 + + if has_window and has_annotations then + table.insert(result, typst_code_window_open( + filename, is_auto, effective_style, annotations, block_id)) + table.insert(result, block) + table.insert(result, pandoc.RawBlock('typst', ']')) + elseif has_window then + table.insert(result, typst_code_window_open( + filename, is_auto, effective_style, nil, 0)) + table.insert(result, block) + table.insert(result, pandoc.RawBlock('typst', ']')) + elseif has_annotations then + local open, close = typst_annotation_wrapper(annotations, block_id) + table.insert(result, open) + table.insert(result, block) + table.insert(result, close) + else + table.insert(result, block) + end + + -- Consume the following OrderedList if it is an annotation list. + if has_annotations + and next_block + and code_annotations.is_annotation_ordered_list(next_block) then + local wrapper_prefix = CONFIG.typst_wrapper + local annot_blocks = code_annotations.ordered_list_to_typst_blocks( + next_block, wrapper_prefix, block_id) + for _, ab in ipairs(annot_blocks) do + table.insert(result, ab) + end + consumed_next = true + end + + local returned_block_id = has_annotations and (not consumed_next) and block_id or nil + return result, consumed_next, returned_block_id +end + +--- Process a flat list of blocks for Typst, handling CodeBlocks and their +--- following OrderedLists. Called recursively on Div contents. +--- @param blocks pandoc.Blocks|pandoc.List +--- @return pandoc.Blocks processed_blocks +--- @return integer|nil pending_annotation_block_id Block ID if the last block had annotations (for parent consumption) +local function process_typst_blocks(blocks) + local new_blocks = {} + local pending_annot_block_id = nil + local i = 1 + while i <= #blocks do + local blk = blocks[i] + + if blk.t == 'CodeBlock' then + local next_blk = blocks[i + 1] + local replacement, consumed_next, annot_id = process_typst_block(blk, next_blk) + for _, rb in ipairs(replacement) do + table.insert(new_blocks, rb) + end + if consumed_next then + pending_annot_block_id = nil + i = i + 2 + else + pending_annot_block_id = annot_id + i = i + 1 + end + elseif blk.t == 'Div' then + local processed, inner_pending = process_typst_blocks(blk.content) + blk.content = processed + table.insert(new_blocks, blk) + -- If the Div's last processed block had pending annotations, + -- check if the next sibling is an OrderedList to consume. + if inner_pending then + local next_blk = blocks[i + 1] + if next_blk and code_annotations.is_annotation_ordered_list(next_blk) then + local annot_blocks = code_annotations.ordered_list_to_typst_blocks( + next_blk, CONFIG.typst_wrapper, inner_pending) + for _, ab in ipairs(annot_blocks) do + table.insert(new_blocks, ab) + end + pending_annot_block_id = nil + i = i + 2 + else + pending_annot_block_id = inner_pending + i = i + 1 + end + else + pending_annot_block_id = nil + i = i + 1 + end + else + pending_annot_block_id = nil + table.insert(new_blocks, blk) + i = i + 1 + end + end + return pandoc.Blocks(new_blocks), pending_annot_block_id +end + +--- Inject Typst function definition and process code blocks for Typst format. +--- Runs as a Pandoc filter to have full control over the document tree. function Pandoc(doc) if CURRENT_FORMAT ~= 'typst' or not CONFIG or not CONFIG.enabled then return doc end + -- Process code blocks and annotations throughout the document tree. + doc.blocks = process_typst_blocks(doc.blocks) + -- Guard: check if the function definition is already present. local fn_pattern = '#let ' .. CONFIG.typst_wrapper for _, blk in ipairs(doc.blocks) do @@ -399,55 +737,35 @@ function Pandoc(doc) end end - local fn_def = TYPST_FUNCTION_DEF + local has_hotfixes = CONFIG.hotfix_code_annotations or CONFIG.hotfix_skylighting + local fn_def = build_typst_function_def(has_hotfixes) if CONFIG.typst_wrapper ~= 'code-window' then - fn_def = fn_def:gsub('#let code%-window', '#let ' .. CONFIG.typst_wrapper) + fn_def = fn_def:gsub('code%-window%-annote%-colour', CONFIG.typst_wrapper .. '-annote-colour') + fn_def = fn_def:gsub('code%-window%-circled%-number', CONFIG.typst_wrapper .. '-circled-number') + fn_def = fn_def:gsub('code%-window%-annotation%-item', CONFIG.typst_wrapper .. '-annotation-item') + fn_def = fn_def:gsub('code%-window%-annotated%-content', CONFIG.typst_wrapper .. '-annotated-content') + fn_def = fn_def:gsub('#let code%-window%(', '#let ' .. CONFIG.typst_wrapper .. '(') end table.insert(doc.blocks, 1, pandoc.RawBlock('typst', fn_def)) return doc end ---- Load optional skylighting hot-fix subfilters from sibling file. ---- Kept as a single integration seam for easy future removal. ---- @return table List of subfilter tables to append -local function load_skylighting_hotfix_filters() - local ok, result = pcall(require, - quarto.utils.resolve_path('skylighting-typst-fix.lua'):gsub('%.lua$', '')) - if not ok then - utils.log_warning(EXTENSION_NAME, - 'Failed to load optional skylighting hot-fix: ' .. tostring(result)) - return {} - end - if type(result) ~= 'table' then - utils.log_warning(EXTENSION_NAME, - 'Skylighting hot-fix did not return a filter list.') - return {} - end - return result -end - -- ============================================================================ --- FILTER EXPORTS +-- MODULE EXPORTS -- ============================================================================ -local filters = { - { Meta = Meta }, - { Pandoc = Pandoc }, - { CodeBlock = CodeBlock }, -} - -for _, subfilter in ipairs(load_skylighting_hotfix_filters()) do - local wrapped = {} - for element_type, handler in pairs(subfilter) do - wrapped[element_type] = function(...) - if not CONFIG or not CONFIG.skylighting_fix then - return nil - end - return handler(...) - end - end - table.insert(filters, wrapped) +--- Inject the code-annotations module dependency. +--- Called by main.lua before any filter handlers run. +--- @param mod table The code-annotations module +local function set_code_annotations(mod) + code_annotations = mod end -return filters +return { + set_code_annotations = set_code_annotations, + Meta = Meta, + Pandoc = Pandoc, + CodeBlock = CodeBlock, + CONFIG = function() return CONFIG end, +} diff --git a/_extensions/code-window/main.lua b/_extensions/code-window/main.lua new file mode 100644 index 0000000..184418c --- /dev/null +++ b/_extensions/code-window/main.lua @@ -0,0 +1,74 @@ +--- @module main +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @brief Entry point for the code-window extension. +--- Loads all submodules, wires dependencies, and assembles the filter list. + +local EXTENSION_NAME = 'code-window' +local utils = require(quarto.utils.resolve_path('_modules/utils.lua'):gsub('%.lua$', '')) + +-- ============================================================================ +-- LOAD SUBMODULES +-- ============================================================================ + +local code_annotations = require( + quarto.utils.resolve_path('_modules/hotfix/code-annotations.lua'):gsub('%.lua$', '')) + +local code_window = require( + quarto.utils.resolve_path('code-window.lua'):gsub('%.lua$', '')) + +code_window.set_code_annotations(code_annotations) + +-- ============================================================================ +-- SKYLIGHTING HOT-FIX +-- ============================================================================ + +--- Load optional skylighting hot-fix module from sibling file. +--- @return table Module table with .filters and .set_wrapper, or empty table +local function load_skylighting_hotfix_module() + local ok, result = pcall(require, + quarto.utils.resolve_path('_modules/hotfix/skylighting-typst-fix.lua'):gsub('%.lua$', '')) + if not ok then + utils.log_warning(EXTENSION_NAME, + 'Failed to load optional skylighting hot-fix: ' .. tostring(result)) + return {} + end + if type(result) ~= 'table' then + utils.log_warning(EXTENSION_NAME, + 'Skylighting hot-fix did not return a module table.') + return {} + end + return result +end + +-- ============================================================================ +-- FILTER ASSEMBLY +-- ============================================================================ + +local filters = { + { Meta = code_window.Meta }, + { Pandoc = code_window.Pandoc }, + { CodeBlock = code_window.CodeBlock }, +} + +local skylighting_mod = load_skylighting_hotfix_module() + +for _, subfilter in ipairs(skylighting_mod.filters or {}) do + local wrapped = {} + for element_type, handler in pairs(subfilter) do + wrapped[element_type] = function(...) + local cfg = code_window.CONFIG() + if not cfg or not cfg.hotfix_skylighting then + return nil + end + if skylighting_mod.set_wrapper then + skylighting_mod.set_wrapper(cfg.typst_wrapper) + end + return handler(...) + end + end + table.insert(filters, wrapped) +end + +return filters diff --git a/example-typst.pdf b/example-typst.pdf deleted file mode 100644 index 28c3169..0000000 Binary files a/example-typst.pdf and /dev/null differ diff --git a/example.qmd b/example.qmd index 7a50c6c..f4c3d3e 100644 --- a/example.qmd +++ b/example.qmd @@ -9,6 +9,8 @@ filters: - at: pre-quarto path: code-window syntax-highlighting: github-dark +code-annotations: below +toc: true format: html: output-file: index @@ -29,6 +31,8 @@ format-links: - revealjs --- +{{< pagebreak >}} + ## Explicit Filename Code blocks with a `filename` attribute display a window header with the filename. @@ -48,6 +52,8 @@ data <- read.csv("data.csv") summary(data) ``` +{{< pagebreak >}} + ## Auto-Generated Filename With `auto-filename: true` (the default), code blocks without explicit filenames automatically show the language name in small-caps styling. @@ -72,6 +78,8 @@ summary(data) echo "Hello, World!" ``` +{{< pagebreak >}} + ## Plain Code Block Code blocks without a language are not affected by the extension. @@ -81,11 +89,21 @@ This is a plain code block without any language specified. No window decoration is applied here. ``` +{{< pagebreak >}} + ## Disabled Auto-Filename To disable auto-generated filenames, set `auto-filename: false` in the extension configuration. Only code blocks with an explicit `filename` attribute will display window decorations. +```yaml +extensions: + code-window: + auto-filename: false +``` + +{{< pagebreak >}} + ## Window Styles Three decoration styles are available via the `style` option. @@ -116,6 +134,77 @@ Plain filename on the left, no window decorations. print("Default style window") ``` +{{< pagebreak >}} + +## Code Annotations + +::: {.callout-note} +Typst code-annotations support and `filename` attribute handling are temporary hot-fixes. +They will be removed once Quarto natively supports these features (see [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)). +The extension will then focus on **auto-filename** and **code-window-style** features. +::: + +Code annotations work standalone and together with code-window styling. + +### Annotations with Explicit Filename + +```{.python filename="annotated.py"} +import pandas as pd # <1> + +df = pd.read_csv("data.csv") # <2> +summary = df.describe() # <3> +``` + +1. Import the pandas library. +2. Load data from a CSV file. +3. Generate summary statistics. + +### Annotations with Auto-Filename + +```python +def greet(name: str) -> str: # <1> + return f"Hello, {name}!" # <2> + +result = greet("World") # <3> +``` + +1. Define a function with type hints. +2. Use an f-string for interpolation. +3. Call the function and store the result. + +### Annotations Spanning Multiple Lines + +A single annotation number can appear on several consecutive lines. +Only the first occurrence receives a back-label to avoid duplicates. + +```{.python filename="pipeline.py"} +def process(data): # <1> + cleaned = clean(data) # <1> + validated = validate(cleaned) # <1> + result = transform(validated) # <2> + return result # <3> +``` + +1. Multi-step input preparation (cleaning and validation). +2. Apply the main transformation. +3. Return the final result. + +### Annotations without Window Chrome + +Set `code-window-enabled="false"` on a block to disable window chrome while keeping annotations. + +```{.r code-window-enabled="false"} +library(ggplot2) # <1> +ggplot(mtcars) + # <2> + aes(x = mpg, y = hp) + # <3> + geom_point() # <4> +``` + +1. Load the ggplot2 package. +2. Initialise a plot with the mtcars dataset. +3. Map aesthetics. +4. Add a point geometry layer. + ### Configuration Set the global style in the document front matter: @@ -124,6 +213,10 @@ Set the global style in the document front matter: extensions: code-window: style: "macos" + hotfix: + quarto-version: ~ + code-annotations: true + skylighting: true ``` Override per block with the `code-window-style` attribute: diff --git a/example.typ b/example.typ deleted file mode 100644 index e17bdab..0000000 --- a/example.typ +++ /dev/null @@ -1,731 +0,0 @@ -// 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 -} -// 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) -] - -// Use nested show rule to preserve list structure for PDF/UA-1 accessibility -// See: https://github.com/quarto-dev/quarto-cli/pull/13249#discussion_r2678934509 -#show terms: it => { - show terms.item: item => { - set text(weight: "bold") - item.term - block(inset: (left: 1.5em, top: -0.4em))[#item.description] - } - it -} - -// Prevent breaking inside definition items, i.e., keep term and description together. -#show terms.item: set block(breakable: false) - -// Some quarto-specific definitions. - -#show raw.where(block: true): set block( - fill: luma(230), - width: 100%, - inset: 8pt, - radius: 2pt, -) - -#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: 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), - ) - }, - ) -} - - -// syntax highlighting functions from skylighting: -/* Function definitions for syntax highlighting generated by skylighting: */ -#let EndLine() = raw("\n") -#let Skylighting(fill: none, number: false, start: 1, sourcelines) = { - let blocks = [] - let lnum = start - 1 - let bgcolor = rgb("#24292e") - for ln in sourcelines { - if number { - lnum = lnum + 1 - blocks = blocks + box(width: if start + sourcelines.len() > 999 { 30pt } else { 24pt }, text([ #lnum ])) - } - blocks = blocks + ln + EndLine() - } - block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks) -} -#let AlertTok(s) = text(weight: "bold", fill: rgb("#ff5555"), raw(s)) -#let AnnotationTok(s) = text(fill: rgb("#6a737d"), raw(s)) -#let AttributeTok(s) = text(fill: rgb("#f97583"), raw(s)) -#let BaseNTok(s) = text(fill: rgb("#79b8ff"), raw(s)) -#let BuiltInTok(s) = text(fill: rgb("#f97583"), raw(s)) -#let CharTok(s) = text(fill: rgb("#9ecbff"), raw(s)) -#let CommentTok(s) = text(fill: rgb("#6a737d"), raw(s)) -#let CommentVarTok(s) = text(fill: rgb("#6a737d"), raw(s)) -#let ConstantTok(s) = text(fill: rgb("#79b8ff"), raw(s)) -#let ControlFlowTok(s) = text(fill: rgb("#f97583"), raw(s)) -#let DataTypeTok(s) = text(fill: rgb("#f97583"), raw(s)) -#let DecValTok(s) = text(fill: rgb("#79b8ff"), raw(s)) -#let DocumentationTok(s) = text(fill: rgb("#6a737d"), raw(s)) -#let ErrorTok(s) = underline(text(fill: rgb("#ff5555"), raw(s))) -#let ExtensionTok(s) = text(weight: "bold", fill: rgb("#f97583"), raw(s)) -#let FloatTok(s) = text(fill: rgb("#79b8ff"), raw(s)) -#let FunctionTok(s) = text(fill: rgb("#b392f0"), raw(s)) -#let ImportTok(s) = text(fill: rgb("#9ecbff"), raw(s)) -#let InformationTok(s) = text(fill: rgb("#6a737d"), raw(s)) -#let KeywordTok(s) = text(fill: rgb("#f97583"), raw(s)) -#let NormalTok(s) = text(fill: rgb("#e1e4e8"), raw(s)) -#let OperatorTok(s) = text(fill: rgb("#e1e4e8"), raw(s)) -#let OtherTok(s) = text(fill: rgb("#b392f0"), raw(s)) -#let PreprocessorTok(s) = text(fill: rgb("#f97583"), raw(s)) -#let RegionMarkerTok(s) = text(fill: rgb("#6a737d"), raw(s)) -#let SpecialCharTok(s) = text(fill: rgb("#79b8ff"), raw(s)) -#let SpecialStringTok(s) = text(fill: rgb("#9ecbff"), raw(s)) -#let StringTok(s) = text(fill: rgb("#9ecbff"), raw(s)) -#let VariableTok(s) = text(fill: rgb("#ffab70"), raw(s)) -#let VerbatimStringTok(s) = text(fill: rgb("#9ecbff"), raw(s)) -#let WarningTok(s) = text(fill: rgb("#ff5555"), raw(s)) - - - -#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, - 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) - } - } - - 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: toc_title, - depth: toc_depth, - indent: toc_indent, - ); - ] - } - - doc -} - -#set table( - inset: 6pt, - stroke: none, -) -#let brand-color = (:) -#let brand-color-background = (:) -#let brand-logo = (:) - -#set page( - paper: "a4", - margin: (x: 2.5cm, y: 2.5cm), - numbering: "1", - columns: 1, -) - -#show: doc => article( - title: [Code Window], - subtitle: [Quarto Extension], - authors: ( - (name: [Mickaël CANOUIL, #emph[Ph.D.]], affiliation: [], email: []), - ), - toc_title: [Table of contents], - toc_depth: 3, - doc, -) -#show figure.where(kind: "quarto-float-lst"): set align(start) - -// skylighting-typst-fix override -#let Skylighting( - fill: none, - number: false, - start: 1, - sourcelines, -) = { - let bgcolor = if fill != none { fill } else { rgb("#24292e") } - let blocks = [] - let lnum = start - 1 - let has-gutter = start + sourcelines.len() > 999 - - for ln in sourcelines { - if number { - lnum = lnum + 1 - blocks = ( - blocks - + box( - width: if has-gutter { 30pt } else { 24pt }, - text([ #lnum ]), - ) - ) - } - blocks = blocks + ln + EndLine() - } - - block( - fill: bgcolor, - width: 100%, - inset: 8pt, - radius: 2pt, - stroke: none, - blocks, - ) -} -#let code-window(content, filename: none, is-auto: false, style: "macos") = { - let border-colour = luma(200) - let surface-fill = luma(237) - let muted-colour = luma(120) - - let filename-label = if filename != none { - text( - size: if is-auto { 0.7em } else { 0.85em }, - weight: 500, - fill: muted-colour, - if is-auto { upper(filename) } else { filename }, - ) - } - - let traffic-lights = box( - inset: (right: 0.5em), - stack( - dir: ltr, - spacing: 0.425em, - circle(radius: 0.425em, fill: rgb("#ff5f56"), stroke: none), - circle(radius: 0.425em, fill: rgb("#ffbd2e"), stroke: none), - circle(radius: 0.425em, fill: rgb("#27c93f"), stroke: none), - ), - ) - - let window-buttons = box( - inset: (left: 0.5em), - { - set line(stroke: 1pt + muted-colour) - stack( - dir: ltr, - spacing: 0.8em, - // Minimise (horizontal line) - box(width: 0.6em, height: 0.6em, align(horizon, line(length: 100%))), - // Maximise (square) - box(width: 0.6em, height: 0.6em, stroke: 1pt + muted-colour), - // Close (x) - box(width: 0.6em, height: 0.6em, { - place(line(start: (0%, 0%), end: (100%, 100%))) - place(line(start: (100%, 0%), end: (0%, 100%))) - }), - ) - }, - ) - - let title-bar = if style == "macos" { - grid( - columns: (auto, 1fr), - align: (left + horizon, right + horizon), - gutter: 0.5em, - stroke: 0pt, - traffic-lights, filename-label, - ) - } else if style == "windows" { - grid( - columns: (1fr, auto), - align: (left + horizon, right + horizon), - gutter: 0.5em, - stroke: 0pt, - filename-label, window-buttons, - ) - } else { - // default: plain filename, left-aligned - filename-label - } - - block( - width: 100%, - stroke: 1pt + border-colour, - radius: 8pt, - clip: true, - { - block( - width: 100%, - fill: surface-fill, - inset: (x: 1em, y: 0.6em), - below: 0pt, - radius: 0pt, - stroke: (bottom: 1pt + border-colour), - sticky: true, - title-bar, - ) - // Strip code block chrome so content fills flush against the window body. - // set block() provides defaults for Skylighting blocks (explicit fill preserved). - // show raw overrides the document-level raw block styling (fill, radius). - { - set block( - width: 100%, - inset: 8pt, - radius: 0pt, - stroke: none, - above: 0pt, - below: 0pt, - ) - show raw.where(block: true): set block( - fill: none, - width: 100%, - radius: 0pt, - stroke: none, - above: 0pt, - below: 0pt, - ) - content - } - }, - ) -} -#figure( - [ - #code-window(filename: "fibonacci.py", is-auto: false, style: "macos")[ - #Skylighting(( - [#KeywordTok("def");#NormalTok(" fibonacci(n: ");#BuiltInTok("int");#NormalTok(") ");#OperatorTok( - "->", - );#NormalTok(" ");#BuiltInTok("int");#NormalTok(":");], - [#NormalTok(" ");#CommentTok("\"\"\"Calculate the nth Fibonacci number.\"\"\"");], - [#NormalTok(" ");#ControlFlowTok("if");#NormalTok(" n ");#OperatorTok("<=");#NormalTok(" ");#DecValTok( - "1", - );#NormalTok(":");], - [#NormalTok(" ");#ControlFlowTok("return");#NormalTok(" n");], - [#NormalTok(" ");#ControlFlowTok("return");#NormalTok(" fibonacci(n ");#OperatorTok("-");#NormalTok( - " ", - );#DecValTok("1");#NormalTok(") ");#OperatorTok("+");#NormalTok(" fibonacci(n ");#OperatorTok("-");#NormalTok( - " ", - );#DecValTok("2");#NormalTok(")");], - )); - ] - ], - caption: figure.caption( - position: top, - [ - This is code - ], - ), - kind: "quarto-float-lst", - supplement: "Listing", -) - - - -= Explicit Filename - -Code blocks with a #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("filename");] attribute display a window header with the filename. The decoration style depends on the #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("style");] option (default: #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("\"macos\"");]). - -#code-window(filename: "fibonacci.py", is-auto: false, style: "macos")[ - #Skylighting(( - [#KeywordTok("def");#NormalTok(" fibonacci(n: ");#BuiltInTok("int");#NormalTok(") ");#OperatorTok("->");#NormalTok( - " ", - );#BuiltInTok("int");#NormalTok(":");], - [#NormalTok(" ");#CommentTok("\"\"\"Calculate the nth Fibonacci number.\"\"\"");], - [#NormalTok(" ");#ControlFlowTok("if");#NormalTok(" n ");#OperatorTok("<=");#NormalTok(" ");#DecValTok( - "1", - );#NormalTok(":");], - [#NormalTok(" ");#ControlFlowTok("return");#NormalTok(" n");], - [#NormalTok(" ");#ControlFlowTok("return");#NormalTok(" fibonacci(n ");#OperatorTok("-");#NormalTok( - " ", - );#DecValTok("1");#NormalTok(") ");#OperatorTok("+");#NormalTok(" fibonacci(n ");#OperatorTok("-");#NormalTok( - " ", - );#DecValTok("2");#NormalTok(")");], - )); -] -#code-window(filename: "analysis.R", is-auto: false, style: "macos")[ - #Skylighting(( - [#CommentTok("# Load data and create summary");], - [#NormalTok("data ");#OtherTok("<-");#NormalTok(" ");#FunctionTok("read.csv");#NormalTok("(");#StringTok( - "\"data.csv\"", - );#NormalTok(")");], - [#FunctionTok("summary");#NormalTok("(data)");], - )); -] -= Auto-Generated Filename - -With #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("auto-filename: true");] (the default), code blocks without explicit filenames automatically show the language name in small-caps styling. - -#code-window(filename: "python", is-auto: true, style: "macos")[ - #Skylighting(( - [#KeywordTok("def");#NormalTok(" greet(name: ");#BuiltInTok("str");#NormalTok(") ");#OperatorTok("->");#NormalTok( - " ", - );#BuiltInTok("str");#NormalTok(":");], - [#NormalTok(" ");#CommentTok("\"\"\"Return a greeting message.\"\"\"");], - [#NormalTok(" ");#ControlFlowTok("return");#NormalTok(" ");#SpecialStringTok("f\"Hello, ");#SpecialCharTok( - "{", - );#NormalTok("name");#SpecialCharTok("}");#SpecialStringTok("!\"");], - )); -] -#code-window(filename: "r", is-auto: true, style: "macos")[ - #Skylighting(( - [#CommentTok("# Create sample data");], - [#NormalTok("data ");#OtherTok("<-");#NormalTok(" ");#FunctionTok("data.frame");#NormalTok("(");], - [#NormalTok(" ");#AttributeTok("x =");#NormalTok(" ");#DecValTok("1");#SpecialCharTok(":");#DecValTok( - "10", - );#NormalTok(",");], - [#NormalTok(" ");#AttributeTok("y =");#NormalTok(" ");#FunctionTok("rnorm");#NormalTok("(");#DecValTok( - "10", - );#NormalTok(")");], - [#NormalTok(")");], - [#FunctionTok("summary");#NormalTok("(data)");], - )); -] -#code-window(filename: "bash", is-auto: true, style: "macos")[ - #Skylighting(([#CommentTok("#!/bin/bash");], [#BuiltInTok("echo");#NormalTok(" ");#StringTok("\"Hello, World!\"");])); -] -= Plain Code Block - -Code blocks without a language are not affected by the extension. - -#Skylighting(( - [#NormalTok("This is a plain code block without any language specified.");], - [#NormalTok("No window decoration is applied here.");], -)); -= Disabled Auto-Filename - -To disable auto-generated filenames, set #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("auto-filename: false");] in the extension configuration. Only code blocks with an explicit #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("filename");] attribute will display window decorations. - -= Window Styles - -Three decoration styles are available via the #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("style");] option. The global style can be set in the document configuration. Individual blocks can override the style with the #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("code-window-style");] attribute. - -== macOS Style (default) - -Traffic light buttons on the left, filename on the right. - -#code-window(filename: "macos-example.py", is-auto: false, style: "macos")[ - #Skylighting(([#BuiltInTok("print");#NormalTok("(");#StringTok("\"macOS style window\"");#NormalTok(")");],)); -] -== Windows Style - -Minimise, maximise, and close buttons on the right, filename on the left. - -#code-window(filename: "windows-example.py", is-auto: false, style: "windows")[ - #Skylighting(([#BuiltInTok("print");#NormalTok("(");#StringTok("\"Windows style window\"");#NormalTok(")");],)); -] -== Default Style - -Plain filename on the left, no window decorations. - -#code-window(filename: "default-example.py", is-auto: false, style: "default")[ - #Skylighting(([#BuiltInTok("print");#NormalTok("(");#StringTok("\"Default style window\"");#NormalTok(")");],)); -] -== Configuration - -Set the global style in the document front matter: - -#code-window(filename: "yaml", is-auto: true, style: "macos")[ - #Skylighting(( - [#FunctionTok("extensions");#KeywordTok(":");], - [#AttributeTok(" ");#FunctionTok("code-window");#KeywordTok(":");], - [#AttributeTok(" ");#FunctionTok("style");#KeywordTok(":");#AttributeTok(" ");#StringTok("\"macos\"");], - )); -] -Override per block with the #box(fill: rgb("#24292e"), inset: (x: 3pt, y: 0pt), outset: (y: 3pt), radius: 2pt, stroke: none)[#NormalTok("code-window-style");] attribute: - -#code-window(filename: "markdown", is-auto: true, style: "macos")[ - #Skylighting(( - [#InformationTok("```{.python filename=\"example.py\" code-window-style=\"windows\"}");], - [#BuiltInTok("print");#NormalTok("(");#StringTok("\"Windows style for this block only\"");#NormalTok(")");], - [#InformationTok("```");], - )); -]