33--- @copyright 2026 Mickaël Canouil
44--- @author Mickaël Canouil
55--- @version 0.1.0
6- --- @brief macOS-style code block window decorations
7- --- @description Adds macOS-style window chrome (traffic lights and filename bar)
8- --- to code blocks in HTML, Reveal.js, and Typst formats.
9- --- Registered at pre-quarto to process all formats in a single pass.
6+ --- @brief Code block window decorations with multiple styles
7+ --- @description Adds window chrome (macOS traffic lights, Windows title bar
8+ --- buttons, or plain filename) to code blocks in HTML, Reveal.js, and Typst
9+ --- formats. Registered at pre-quarto to process all formats in a single pass.
1010
1111-- ============================================================================
1212-- EXTENSION NAME
@@ -21,11 +21,15 @@ local EXTENSION_NAME = 'code-window'
2121--- @class CodeWindowConfig
2222--- @field enabled boolean Whether code-window styling is enabled
2323--- @field auto_filename boolean Whether to auto-generate filename from language
24+ --- @field style string Window decoration style (' macos' , ' windows' , ' default' )
2425--- @field typst_wrapper string Typst wrapper function name
2526
27+ local VALID_STYLES = { [' default' ] = true , [' macos' ] = true , [' windows' ] = true }
28+
2629local DEFAULT_CONFIG = {
2730 enabled = true ,
2831 auto_filename = true ,
32+ style = ' macos' ,
2933 typst_wrapper = ' code-window' ,
3034}
3135
@@ -65,6 +69,7 @@ local function get_config(meta)
6569 local config = {
6670 enabled = DEFAULT_CONFIG .enabled ,
6771 auto_filename = DEFAULT_CONFIG .auto_filename ,
72+ style = DEFAULT_CONFIG .style ,
6873 typst_wrapper = DEFAULT_CONFIG .typst_wrapper ,
6974 }
7075
@@ -79,6 +84,17 @@ local function get_config(meta)
7984 if ext_config [' auto-filename' ] ~= nil then
8085 config .auto_filename = pandoc .utils .stringify (ext_config [' auto-filename' ]) == ' true'
8186 end
87+ if ext_config .style ~= nil then
88+ local style_val = pandoc .utils .stringify (ext_config .style )
89+ if VALID_STYLES [style_val ] then
90+ config .style = style_val
91+ else
92+ io.stderr :write (string.format (
93+ ' [code-window] warning: unknown style "%s", falling back to "macos".\n ' ,
94+ style_val
95+ ))
96+ end
97+ end
8298 if ext_config .wrapper ~= nil then
8399 config .typst_wrapper = pandoc .utils .stringify (ext_config .wrapper )
84100 end
@@ -114,18 +130,106 @@ local function is_known_language(lang)
114130 return is_known
115131end
116132
133+ -- ============================================================================
134+ -- BLOCK-LEVEL STYLE OVERRIDE
135+ -- ============================================================================
136+
137+ --- Read the block-level style override from code-window-style attribute.
138+ --- Returns the validated style value or nil.
139+ --- Strips the attribute from the block.
140+ --- @param block pandoc.CodeBlock Code block element
141+ --- @return string | nil Style override value
142+ local function read_block_style (block )
143+ local block_style = block .attributes [' code-window-style' ]
144+ if not block_style or block_style == ' ' then
145+ return nil
146+ end
147+ block .attributes [' code-window-style' ] = nil
148+ if VALID_STYLES [block_style ] then
149+ return block_style
150+ end
151+ io.stderr :write (string.format (
152+ ' [code-window] warning: unknown block style "%s", using configured default.\n ' ,
153+ block_style
154+ ))
155+ return nil
156+ end
157+
117158-- ============================================================================
118159-- TYPST FUNCTION DEFINITION
119160-- ============================================================================
120161
121162--- Typst function definition for code-window rendering.
122163--- Injected once at the start of the document body.
123164local TYPST_FUNCTION_DEF = [==[
124- #let code-window(content, filename: none, is-auto: false) = {
165+ #let code-window(content, filename: none, is-auto: false, style: "macos" ) = {
125166 let border-colour = luma(200)
126167 let surface-fill = luma(237)
127168 let muted-colour = luma(120)
128169
170+ let filename-label = if filename != none {
171+ text(
172+ size: if is-auto { 0.7em } else { 0.85em },
173+ weight: 500,
174+ fill: muted-colour,
175+ if is-auto { upper(filename) } else { filename },
176+ )
177+ }
178+
179+ let traffic-lights = box(
180+ inset: (right: 0.5em),
181+ stack(
182+ dir: ltr,
183+ spacing: 0.425em,
184+ circle(radius: 0.425em, fill: rgb("#ff5f56"), stroke: none),
185+ circle(radius: 0.425em, fill: rgb("#ffbd2e"), stroke: none),
186+ circle(radius: 0.425em, fill: rgb("#27c93f"), stroke: none),
187+ ),
188+ )
189+
190+ let window-buttons = box(
191+ inset: (left: 0.5em),
192+ {
193+ set line(stroke: 1pt + muted-colour)
194+ stack(
195+ dir: ltr,
196+ spacing: 0.8em,
197+ // Minimise (horizontal line)
198+ box(width: 0.6em, height: 0.6em, align(horizon, line(length: 100%))),
199+ // Maximise (square)
200+ box(width: 0.6em, height: 0.6em, stroke: 1pt + muted-colour),
201+ // Close (x)
202+ box(width: 0.6em, height: 0.6em, {
203+ place(line(start: (0%, 0%), end: (100%, 100%)))
204+ place(line(start: (100%, 0%), end: (0%, 100%)))
205+ }),
206+ )
207+ },
208+ )
209+
210+ let title-bar = if style == "macos" {
211+ grid(
212+ columns: (auto, 1fr),
213+ align: (left + horizon, right + horizon),
214+ gutter: 0.5em,
215+ stroke: 0pt,
216+ traffic-lights,
217+ filename-label,
218+ )
219+ } else if style == "windows" {
220+ grid(
221+ columns: (1fr, auto),
222+ align: (left + horizon, right + horizon),
223+ gutter: 0.5em,
224+ stroke: 0pt,
225+ filename-label,
226+ window-buttons,
227+ )
228+ } else {
229+ // default: plain filename, left-aligned
230+ filename-label
231+ }
232+
129233 block(
130234 width: 100%,
131235 stroke: 1pt + border-colour,
@@ -140,32 +244,7 @@ local TYPST_FUNCTION_DEF = [==[
140244 radius: 0pt,
141245 stroke: (bottom: 1pt + border-colour),
142246 sticky: true,
143- {
144- grid(
145- columns: (auto, 1fr),
146- align: (left + horizon, right + horizon),
147- gutter: 0.5em,
148- stroke: 0pt,
149- box(
150- inset: (right: 0.5em),
151- stack(
152- dir: ltr,
153- spacing: 0.425em,
154- circle(radius: 0.425em, fill: rgb("#ff5f56"), stroke: none),
155- circle(radius: 0.425em, fill: rgb("#ffbd2e"), stroke: none),
156- circle(radius: 0.425em, fill: rgb("#27c93f"), stroke: none),
157- ),
158- ),
159- if filename != none {
160- text(
161- size: if is-auto { 0.7em } else { 0.85em },
162- weight: 500,
163- fill: muted-colour,
164- if is-auto { upper(filename) } else { filename },
165- )
166- },
167- )
168- },
247+ title-bar,
169248 )
170249 // Strip code block chrome so content fills flush against the window body.
171250 // set block() provides defaults for Skylighting blocks (explicit fill preserved).
@@ -204,6 +283,7 @@ local TYPST_FUNCTION_DEF = [==[
204283--- @param block pandoc.CodeBlock Code block element
205284--- @return pandoc.RawBlock | pandoc.CodeBlock Transformed or original block
206285local function process_typst (block )
286+ local block_style = read_block_style (block )
207287 local explicit_filename = block .attributes [' filename' ]
208288 local filename = explicit_filename
209289 local is_auto = false
@@ -219,6 +299,8 @@ local function process_typst(block)
219299 return block
220300 end
221301
302+ local effective_style = block_style or CONFIG .style
303+
222304 -- Render through Pandoc's Typst writer to preserve syntax highlighting.
223305 -- Pass highlight_method from the document's writer options so the
224306 -- user's chosen theme (e.g. github-dark) is respected.
@@ -232,10 +314,11 @@ local function process_typst(block)
232314 rendered = rendered :gsub (' %s+$' , ' ' )
233315
234316 local typst_code = string.format (
235- ' #%s(filename: "%s", is-auto: %s)[\n %s\n ]' ,
317+ ' #%s(filename: "%s", is-auto: %s, style: "%s" )[\n %s\n ]' ,
236318 CONFIG .typst_wrapper ,
237319 filename :gsub (' "' , ' \\ "' ),
238320 is_auto and ' true' or ' false' ,
321+ effective_style ,
239322 rendered
240323 )
241324
@@ -246,15 +329,23 @@ end
246329-- HTML PROCESSING
247330-- ============================================================================
248331
249- --- Process CodeBlock for HTML/Reveal.js formats (auto-filename only).
250- --- Blocks with explicit filenames are handled by Quarto; our CSS styles them.
332+ --- Process CodeBlock for HTML/Reveal.js formats.
333+ --- Explicit-filename blocks are returned for Quarto to wrap; a marker class
334+ --- is added when a block-level style override is present.
335+ --- Auto-filename blocks are wrapped directly with the style class.
251336--- @param block pandoc.CodeBlock Code block element
252337--- @return pandoc.Div | pandoc.CodeBlock Wrapped block or original
253338local function process_html (block )
254- -- Blocks with explicit filename are returned unchanged so Quarto
255- -- creates the .code-with-filename Div between passes; our CSS styles it.
339+ local block_style = read_block_style (block )
256340 local explicit_filename = block .attributes [' filename' ]
341+
257342 if explicit_filename and explicit_filename ~= ' ' then
343+ -- Let Quarto create the .code-with-filename wrapper.
344+ -- Add a marker class for block-level style override; the injected JS
345+ -- reads it and promotes it to the wrapper div.
346+ if block_style then
347+ table.insert (block .classes , ' cw-style-' .. block_style )
348+ end
258349 return block
259350 end
260351
@@ -267,6 +358,7 @@ local function process_html(block)
267358 end
268359
269360 local filename = block .classes [1 ]
361+ local effective_style = block_style or CONFIG .style
270362
271363 -- Normalise unknown languages to 'default' so Pandoc renders them
272364 -- with sourceCode wrapper, copy button, and consistent styling.
@@ -284,27 +376,49 @@ local function process_html(block)
284376
285377 return pandoc .Div (
286378 { filename_header , block },
287- pandoc .Attr (' ' , { ' code-with-filename' , ' code-window-auto' })
379+ pandoc .Attr (' ' , { ' code-with-filename' , ' code-window-' .. effective_style , ' code-window- auto' })
288380 )
289381end
290382
291383-- ============================================================================
292384-- FILTER FUNCTIONS
293385-- ============================================================================
294386
295- --- Load configuration and inject CSS dependency.
387+ --- Generate a JS snippet that adds the configured default style class
388+ --- to Quarto-created .code-with-filename wrappers (explicit filenames)
389+ --- and promotes block-level cw-style-* marker classes.
390+ --- @param default_style string The configured default style
391+ --- @return string JavaScript code
392+ local function make_style_js (default_style )
393+ return string.format ([=[
394+ document.addEventListener("DOMContentLoaded",function(){
395+ document.querySelectorAll(".code-with-filename").forEach(function(el){
396+ if(/\bcode-window-(macos|windows|default)\b/.test(el.className))return;
397+ var c=el.querySelector('[class*="cw-style-"]');
398+ if(c){var m=c.className.match(/cw-style-(\w+)/);if(m){el.classList.add("code-window-"+m[1]);return;}}
399+ el.classList.add("code-window-%s");
400+ });
401+ });]=] , default_style )
402+ end
403+
404+ --- Load configuration and inject CSS/JS dependencies.
296405function Meta (meta )
297406 CURRENT_FORMAT = get_format ()
298407 CONFIG = get_config (meta )
299408
300- -- Inject CSS for HTML/Reveal.js (idempotent by name across passes)
409+ -- Inject CSS and JS for HTML/Reveal.js (idempotent by name across passes)
301410 if (CURRENT_FORMAT == ' html' or CURRENT_FORMAT == ' revealjs' )
302411 and CONFIG and CONFIG .enabled then
303412 quarto .doc .add_html_dependency ({
304413 name = EXTENSION_NAME ,
305414 version = ' 0.1.0' ,
306415 stylesheets = { ' style.css' },
307416 })
417+ quarto .doc .add_html_dependency ({
418+ name = EXTENSION_NAME .. ' -style-init' ,
419+ version = ' 0.1.0' ,
420+ head = ' <script>' .. make_style_js (CONFIG .style ) .. ' </script>' ,
421+ })
308422 end
309423
310424 return meta
0 commit comments