Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ docs/_sidebar-api.yml
docs/api/*.qmd
*.html
.quarto/

/.luarc.json
/example.typ
/example-typst.pdf
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@
### 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

- style: adjust padding and height for title bar in code window.

### 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.

Expand Down
55 changes: 41 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions _extensions/code-window/_extension.yml
Original file line number Diff line number Diff line change
@@ -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
200 changes: 200 additions & 0 deletions _extensions/code-window/_modules/hotfix/code-annotations.lua
Original file line number Diff line number Diff line change
@@ -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<string, string>
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<int, int> 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,
}
Loading