Building Quarto Typst Templates: Advanced Patterns (Part 2)

Part 2

This tutorial continues from Part 1, exploring advanced patterns for Quarto Typst extensions. You will learn handler factories, configuration systems, type-safe value conversion, and WCAG-aware badges.

quarto
extensions
typst
lua
pdf
Author
Published

Thursday, the 5th of March, 2026

Feature image for "Quarto + Typst Part 2" blog post. Dark background
with the official Quarto logo (four blue quadrants with wordmark) and
Typst logo (teal wordmark) centered at the top. Below, "Part 2" in serif
font with subtitle "Modern Scientific Publishing". File format pipeline
shows .qmd → .typ → .pdf. Decorative elements include floating code
snippets and gradient orbs in blue and teal.

ImportantQuarto Version

This tutorial was written and tested with Quarto CLI 1.9.29 (pre-release). Some APIs, template syntax, or extension behaviours may differ in earlier stable releases.

TipTL;DR

This tutorial covers advanced patterns for production-quality Quarto Typst extensions. Read Part 1 first for the foundational concepts.

  • Handler factories: eliminate boilerplate by generating handlers with a closure-captured configuration flag.
  • Configuration system: let users extend the extension with their own class-to-function mappings via document metadata.
  • Type-safe value conversion: typst_value maps Lua types to correct Typst syntax (strings, numbers, keywords, units).
  • WCAG-compliant badges: iterative contrast adjustment ensures accessible colour combinations.
  • Shortcodes: explicit syntax for components without a natural markdown representation.

Part 2 (ZIP)

1 Continuing from Part 1

Part 1 introduced the dual-layer architecture for building Quarto Typst templates. Here is a brief recap of the building blocks this tutorial relies on:

  • Lua filters intercept Pandoc AST elements (spans, divs) and generate raw Typst code via pandoc.RawBlock and pandoc.RawInline.
  • Typst rendering functions receive that generated code and produce styled output with access to document context.
  • Wrapper functions in typst-show.typ bridge parse-time (Lua) and render-time (Typst), injecting runtime context such as colour schemes.
  • build_wrapped_content handles the common pattern of wrapping div content with opening and closing Typst function calls, optionally extracting a title from the first heading.

This tutorial builds on those foundations with advanced patterns for production-quality extensions.

Note

Part 1 used filter.lua as the filter filename in its conceptual examples. Part 2 uses typst-markdown.lua, which is the production filename used in the downloadable extension. The role is identical: it is the Pandoc filter entrypoint that loads shared modules and registers the Div and Span handlers. When building your own extension, you can choose any descriptive name; just ensure it matches the filename declared under filters: in _extension.yml.

To keep the article and downloadable zip perfectly aligned, source-backed code blocks below show the full file content from the assets bundle. Each section highlights the exact functions to focus on, so you can scan purposefully instead of reading every line top-to-bottom.

Note

This is Part 2 of a two-part series. If you have not read Part 1, start there first to understand the basic concepts of Lua filters, Typst functions, and wrapper patterns.

The ‘mcanouil’ Quarto extension demonstrates all patterns covered in this tutorial.

2 Handler Factories: Reducing Repetition

As you add components, the same handler structure keeps appearing. Part 1’s panel handler called build_wrapped_content directly. With one or two components that is fine, but each new handler duplicates the same boilerplate:

(conceptual) without factories
local DIV_HANDLERS = {
  ['panel'] = function(div, config)
1    return build_wrapped_content(div, config, true)
  end,
  ['highlight'] = function(div, config)
2    return build_wrapped_content(div, config, false)
  end,
}
1
Extract title from first heading.
2
No title extraction; every new wrapped component repeats this same structure.

The only thing that varies is the extract_title flag. A factory function captures that variation in a closure, eliminating the repetition entirely. A closure is a function that remembers variables from the scope where it was created. When create_wrapped_handler(true) returns a new function, that function carries should_extract_title = true inside it, so every call to the returned handler automatically uses the correct flag without it needing to be passed again. This is necessary because the filter loop (shown below in “Using Factories”) calls each handler as DIV_FACTORY_HANDLERS[class](div, class_config), passing only the div element and the class config; the flag is never passed. By baking the flag into the closure at declaration time, each entry in DIV_FACTORY_HANDLERS becomes a self-contained handler with no extra parameters to manage.

2.1 The Factory Pattern

In this file, focus on create_wrapped_handler and how it forwards to build_wrapped_content; the rest are shared helpers used by multiple components.

_extensions/my-extension/_modules/wrapper.lua
local typst_utils = require(quarto.utils.resolve_path("_modules/typst-utils.lua"):gsub('%.lua$', ''))

local M = {
  attributes_to_table = typst_utils.attributes_to_table,
}

--- Extract first heading as title attribute
function M.extract_first_heading_as_title(el, attrs)
  if not attrs['title'] and #el.content > 0 then
    local first_elem = el.content[1]
    if first_elem.t == 'Header' then
      attrs['title'] = pandoc.utils.stringify(first_elem.content)
      local new_content = {}
      for i = 2, #el.content do
        table.insert(new_content, el.content[i])
      end
      el.content = new_content
    end
  end
end

--- Build Typst block wrappers with optional attributes
function M.build_typst_block_wrappers(config, attrs)
  local has_attributes = next(attrs) ~= nil

  if has_attributes or config.arguments then
    local attr_string = typst_utils.build_attribute_string(attrs)
    return string.format('#%s(%s)[', config.wrapper, attr_string), ']'
  else
    return string.format('#%s[', config.wrapper), ']'
  end
end

--- Build wrapped content for a div
function M.build_wrapped_content(div, config, should_extract_title)
  local attrs = typst_utils.attributes_to_table(div)
  if should_extract_title then
    M.extract_first_heading_as_title(div, attrs)
  end

  local opening, closing = M.build_typst_block_wrappers(config, attrs)
  local result = { pandoc.RawBlock('typst', opening) }
  for _, item in ipairs(div.content) do
    table.insert(result, item)
  end
  table.insert(result, pandoc.RawBlock('typst', closing))
  return result
end

--- Factory: create handler for wrapped content components
1function M.create_wrapped_handler(should_extract_title)
2  return function(div, config)
    return M.build_wrapped_content(div, config, should_extract_title)
  end
end

return M
1
should_extract_title is captured in the returned closure; each call to create_wrapped_handler produces a distinct handler locked to that flag value.
2
The returned function takes div (the Pandoc div element passed by the dispatcher) and config (the mapping entry for this class, e.g., { wrapper='my-panel', arguments=true }); this signature matches exactly what the dispatcher passes when it calls DIV_FACTORY_HANDLERS[class](div, class_config).

2.2 Using Factories

These factories let you declare handlers concisely. DIV_FACTORY_HANDLERS is where each class opts into title extraction or content-only wrapping; DIV_HANDLERS is reserved for components that need custom extraction logic.

_extensions/my-extension/typst-markdown.lua (handler tables excerpt)
local DIV_HANDLERS = {
1  ['quote-card'] = quote_card.process_div,
}

local DIV_FACTORY_HANDLERS = {
2  ['panel'] = wrapper.create_wrapped_handler(true),
3  ['highlight'] = wrapper.create_wrapped_handler(false),
}
1
Custom handler: handles component-specific extraction logic (author from heading).
2
Factory handler with title extraction enabled; the first heading inside the div becomes the title argument.
3
Factory handler without title extraction; content passes through unchanged.

The dispatcher checks each div class in priority order: custom handlers first, factory handlers second, and the default wrapper last.

_extensions/my-extension/typst-markdown.lua (Div filter excerpt)
for _, class in ipairs(div.classes) do
  local class_config = merged_div_config[class]
  if class_config then
1    if DIV_HANDLERS[class] then
      return DIV_HANDLERS[class](div, class_config)
    end

2    if DIV_FACTORY_HANDLERS[class] then
      return DIV_FACTORY_HANDLERS[class](div, class_config)
    end

3    return wrapper.build_wrapped_content(div, class_config, false)
  end
end
1
Custom handlers run first, before factory or default logic.
2
Factory-generated handlers run next.
3
Default fallback: simple content wrap, no title extraction. User-defined mappings added via YAML always reach this default branch; they cannot opt into title extraction or custom logic without a corresponding factory or custom handler in the extension code.

Handler factories encapsulate common patterns. Use create_wrapped_handler(true) for components with titles and create_wrapped_handler(false) for content-only wrappers.

2.3 The Span Dispatcher

Inline spans (badges and custom highlights) use a simpler dispatcher than divs. There are no factory handlers or custom handlers for spans: every inline class goes through the same lookup-and-format loop.

_extensions/my-extension/typst-markdown.lua (Span filter excerpt)
1for _, class in ipairs(span.classes) do
  local class_config = merged_span_config[class]
  if class_config then
2    local content = pandoc.utils.stringify(span.content)
    local attrs = typst_utils.attributes_to_table(span)
3    local attr_string = typst_utils.build_attribute_string(attrs)

    local typst_code
    if attr_string ~= '' then
4      typst_code = string.format('#%s(%s)[%s]', class_config.wrapper, attr_string, content)
    else
      typst_code = string.format('#%s[%s]', class_config.wrapper, content)
    end

5    return pandoc.RawInline('typst', typst_code)
  end
end
1
Spans can carry multiple classes (e.g., [text]{.badge .highlight}); span.classes preserves declaration order (left to right as written in the markdown), so iterating finds the first mapped class and return exits immediately, ensuring only one handler fires per span.
2
Span content is flattened to plain text; inline elements cannot preserve nested structure the way block divs can.
3
Build the attribute string from any key-value pairs on the span (e.g., colour="success" becomes colour: "success").
4
If attributes are present, generate #wrapper(attr: value)[content]; otherwise use the shorter #wrapper[content] form.
5
Return RawInline (not RawBlock) so the result sits inline within the surrounding paragraph, not on its own line.

2.4 Choosing Your Approach

When building a new component, consider:

  • No handler (default): Simple wrapper around content; just add a mapping in document YAML.
  • create_wrapped_handler(true): Wraps content and extracts title from first heading (e.g., panels with titles).
  • create_wrapped_handler(false): Wraps content without title extraction (e.g., simple content wrappers).
  • Custom handler: Complex data extraction from nested elements (e.g., quote cards with author attribution).
  • Shortcode: No natural markdown syntax; uses explicit {{< shortcode >}} syntax.

3 The Configuration System

A key design goal for reusable extensions is extensibility. Users should be able to add their own components or override built-in ones without modifying the extension’s source code. The configuration system enables this through metadata-driven element mappings.

3.1 Built-in Mappings

The extension defines default mappings for all its components. get_builtin_mappings() is the canonical baseline before any user metadata overrides are merged.

_extensions/my-extension/_modules/config.lua (built-in mappings excerpt)
--- Return default div/span mappings for all components
function M.get_builtin_mappings()
  return {
    div = {
1      ['highlight'] = { wrapper = 'my-highlight', arguments = false },
2      ['panel'] = { wrapper = 'my-panel', arguments = true },
      ['quote-card'] = { wrapper = 'quote-card', arguments = true },
    },
    span = {
      ['badge'] = { wrapper = 'my-badge', arguments = true },
    },
  }
end
1
arguments = false: parameter list generated only when attributes are present.
2
arguments = true: parameter list always generated, even when empty, because the Typst function always expects named arguments.

Each mapping specifies:

  • wrapper: The Typst function name to call.
  • arguments: Whether to always pass attributes (even if empty).

3.2 User Configuration via Metadata

Users can extend or override mappings through document metadata:

(conceptual) document.qmd
---
title: "My Document"
extensions:
1  typst-markdown:
    divs:
2      my-callout: my-custom-callout
3      my-box:
        function: my-custom-box
        arguments: true
    spans:
      my-highlight: my-text-highlight
---
1
User mappings live under the extensions.typst-markdown namespace.
2
Simple format: class name maps directly to a Typst function name.
3
Detailed format: allows setting arguments: true to always generate a parameter list. Both function: and wrapper: are accepted as the key name in the detailed format and are true aliases; wrapper: matches the internal Lua variable name, whilst function: is more descriptive in a YAML context, so function: is preferred. Use either the simple or detailed format for each class; they are not combined: a class entry is either a bare string or an object, not both.
Important

The YAML mapping tells the Lua dispatcher which Typst function to call, but you must also provide that function’s implementation. The extension has no built-in my-custom-box or my-custom-callout; those names are placeholders you define yourself. Add the Typst function to your document’s Typst preamble via include-in-header:

(conceptual) document.qmd front matter
include-in-header:
  - text: |
      #let my-custom-box(content) = {
        block(fill: luma(240), radius: 4pt, inset: 1em, content)
      }

Alternatively, place the function in a .typ file and include it the same way. The function signature must match the arguments the dispatcher will generate based on your arguments setting.

Note

If you want another real-world comparison, christopherkenny/typst-function uses a similar metadata-driven mapping idea with different naming and module structure. Comparing both implementations is useful for understanding which parts are pattern-level versus project-specific choices.

3.3 Loading User Configuration

The configuration module parses user settings from metadata. Two helpers keep the logic simple and robust:

  • meta_get(...) reads metadata maps safely, whether keys are plain strings or Pandoc metadata objects.
  • parse_mapping(...) normalises both supported user formats into one internal shape ({wrapper=..., arguments=...}).
_extensions/my-extension/_modules/config.lua (user config excerpt)
local function meta_get(meta_map, wanted_key)
  if type(meta_map) ~= 'table' then return nil end
  if meta_map[wanted_key] ~= nil then return meta_map[wanted_key] end
  -- Pandoc meta maps may use non-string key objects; normalise via stringify.
  for key, value in pairs(meta_map) do
    if pandoc.utils.stringify(key) == wanted_key then return value end
  end
  return nil
end

--- Parse simple or detailed config format
local function parse_mapping(config)
1  if type(config) == 'string' then
    return { wrapper = config, arguments = false }
  end

2  local fn_name = meta_get(config, 'function') or meta_get(config, 'wrapper')
  if fn_name then
    local arg_value = meta_get(config, 'arguments')
    return {
      wrapper = pandoc.utils.stringify(fn_name),
      arguments = arg_value and pandoc.utils.stringify(arg_value) == 'true' or false,
    }
  end

  -- Simple form: `custom-highlight: my-custom-highlight`.
3  local simple_fn = pandoc.utils.stringify(config)
  if simple_fn ~= '' then
    return { wrapper = simple_fn, arguments = false }
  end

  return nil
end
1
Simple string mapping: my-callout: my-custom-callout.
2
Detailed mapping: both function and wrapper are accepted as the key name, normalised to wrapper internally; arguments = arg_value and ... or false is the Lua ternary idiom (condition and x or y) since Lua has no ?: operator, and the value is stringified before comparison because Pandoc delivers YAML booleans as metadata strings, not Lua booleans.
3
Fallback: Pandoc may wrap a plain string in a metadata object; stringify unwraps it.

3.4 Merging Configurations

User configuration overrides built-in defaults. merge_configurations(...) performs a simple two-pass merge: built-ins first, then user mappings which overwrite any matching key.

_extensions/my-extension/_modules/config.lua (merge excerpt)
--- Merge configurations: user overrides built-in
function M.merge_configurations(builtin, user)
  local merged = {}
1  for class, cfg in pairs(builtin) do
    merged[class] = cfg
  end
2  for class, cfg in pairs(user) do
    merged[class] = cfg
  end
  return merged
end
1
Copy all built-in mappings first.
2
User mappings overwrite built-in ones with the same key; new keys are added as extensions.

4 Value Conversion and Type Safety

When passing values from Lua to Typst, type conversion matters. Strings need quoting. Numbers should not. Booleans map to Typst’s true/false. Getting this wrong produces syntax errors or unexpected behaviour.

4.1 The typst_value Function

The typst-utils module provides a typst_value function that handles conversion.

Focus on typst_value(...): it is the single conversion gate between Lua values and valid Typst syntax.

_extensions/my-extension/_modules/typst-utils.lua
local M = {}

--- Convert a Lua value to Typst syntax
1function M.typst_value(value)
  if value == nil then
    return 'none'
  elseif type(value) == 'boolean' then
2    return value and 'true' or 'false'
  elseif type(value) == 'number' then
    return tostring(value)
  elseif type(value) == 'string' then
3    if value == 'none' or value == 'auto' or value == 'true' or value == 'false' then
      return value
    end
4    if value:match('^%-?%d+%.?%d*[a-z%%]+$') then
      return value
    end
5    return '"' .. value:gsub('"', '\\"') .. '"'
  else
    return '"' .. tostring(value):gsub('"', '\\"') .. '"'
  end
end

--- Convert element attributes to a plain Lua table
function M.attributes_to_table(element)
  local attrs = {}
  for key, value in pairs(element.attributes) do
    attrs[key] = value
  end
  return attrs
end

--- Build attribute string for Typst function calls
function M.build_attribute_string(attrs)
  local parts = {}
  for key, value in pairs(attrs) do
    local typst_key = key
    local typst_val = M.typst_value(value)
    table.insert(parts, string.format('%s: %s', typst_key, typst_val))
  end
  return table.concat(parts, ', ')
end

return M
1
Entry point: dispatches on Lua type; all paths return a string of valid Typst syntax.
2
Ternary pattern: condition and true_value or false_value.
3
Typst keywords (none, auto, true, false) passed through without quotes.
4
Lua pattern matches Typst measurement values like 1em, 2.5pt, 100%.
5
Escape double quotes inside string values to prevent broken Typst syntax.

4.2 Usage Examples

1typst_value(nil)
2typst_value(true)
3typst_value(42)
4typst_value("hello")
5typst_value("1em")
6typst_value('none')
7typst_value('Say "hi"')
1
Returns: none.
2
Returns: true.
3
Returns: 42.
4
Returns: "hello".
5
Returns: 1em (Typst length unit, not quoted).
6
Returns: none (Typst keyword, not quoted).
7
Returns: "Say \"hi\"" (escaped quotes).

4.3 Building Attribute Strings

When generating Typst function calls, attributes need proper formatting. build_attribute_string(...) in typst-utils.lua centralises this so handlers never duplicate the logic.

_extensions/my-extension/_modules/typst-utils.lua (attribute string excerpt)
--- Build attribute string for Typst function calls
function M.build_attribute_string(attrs)
  local parts = {}
1  for key, value in pairs(attrs) do
2    local typst_key = key
    local typst_val = M.typst_value(value)
    table.insert(parts, string.format('%s: %s', typst_key, typst_val))
  end
3  return table.concat(parts, ', ')
end
1
Iterate over all attribute key-value pairs.
2
Attribute keys are passed through unchanged; rename here if your Typst function uses a different parameter name.
3
Join with comma separator for Typst named arguments.

5 WCAG Compliance for Badges

Production-quality badges need accessible colour contrast. The WCAG AA standard requires 4.5:1 contrast ratio for small text.

Note

The PDF document generated in this tutorial is not a fully accessible PDF because of the use of a simplified template.

5.1 Colour Calculation

get-badge-colours calls two helper functions defined earlier in components.typ. get-accent-colour maps semantic names ("success", "warning", "danger", "info") to a fixed palette of Quarto callout colours. ensure-contrast iteratively lightens or darkens a foreground colour until the perceived contrast against the background is sufficient. Both helpers are in the full components.typ file from the zip.

_extensions/my-extension/components.typ (badges excerpt)
// Compute badge colours with dark-mode support
#let get-badge-colours(colour, colours) = {
  let fg-components = colours.foreground.components()
1  let is-dark-mode = fg-components.at(0, default: 0%) > 50%

  let base = if colour == "success" { get-accent-colour("tip") }
    else if colour == "warning" { get-accent-colour("warning") }
    else if colour == "danger" { get-accent-colour("important") }
    else if colour == "info" { get-accent-colour("note") }
2    else { colours.muted }

  let background = if is-dark-mode { base.darken(60%) }
3    else { base.lighten(80%) }

4  let text-colour = ensure-contrast(base, background, min-ratio: 4.5)

5  (background: background, border: base, text: text-colour)
}
1
Detect dark mode: light foreground (> 50% lightness) means the page background is dark.
2
Map semantic colour names to Quarto’s callout theme colours; fall back to muted for unknown names.
3
Darken base colour for dark mode backgrounds; lighten for light mode.
4
Adjust text colour iteratively until the contrast ratio meets the 4.5:1 WCAG AA threshold.
5
Return a dictionary with background, border, and text colours.

5.2 Integrating get-badge-colours in the Component

Use get-badge-colours inside the badge component, then inject runtime colours from the wrapper.

Focus on the handoff: my-badge resolves runtime theme colours once, and simple-badge consumes the resulting dictionary.

_extensions/my-extension/typst-show.typ (badge wrapper excerpt)
// Badge wrapper (WCAG-aware)
1#let my-badge(content, colour: "neutral") = {
  simple-badge(
    content,
    colour: colour,
2    colours: get-colours(mode: effective-brand-mode),
  )
}
1
my-badge is the public function Lua calls; it resolves the colour scheme once and passes it into simple-badge.
2
effective-brand-mode is the Pandoc template variable introduced in Part 1 that Quarto resolves at render time; get-colours(mode: ...) returns the full colour dictionary, and simple-badge delegates palette computation to get-badge-colours.

WCAG AA compliance requires 4.5:1 contrast ratio for small text (below 14pt bold or 18pt regular). Badge text at 0.85em typically falls into this category.

6 Shortcodes: An Alternative Approach

For components without a natural markdown representation, shortcodes provide explicit syntax.

6.1 When to Use Shortcodes

Shortcodes work well for:

  • Components with many required parameters.
  • Elements that do not map naturally to divs or spans.
  • Functionality that needs explicit invocation.

6.2 Basic Shortcode Structure

Focus on the returned raw Typst call: shortcode code should stay thin and delegate visual logic to shared Typst wrappers.

_extensions/my-extension/shortcodes.lua
return {
1  ['status-badge'] = function(args, kwargs, meta)
    if not quarto.doc.is_format('typst') then
      return pandoc.Null()
    end

2    local label = pandoc.utils.stringify(kwargs['label'] or 'Status')
    local style = pandoc.utils.stringify(kwargs['style'] or 'info')

    local typst_code = string.format(
3      '#my-badge(colour: "%s")[%s]',
      style,
      label
    )

4    return pandoc.RawInline('typst', typst_code)
  end,
}
1
Shortcode name maps directly to the handler function; args are positional values, kwargs are named.
2
Always stringify metadata values to handle Pandoc’s MetaInlines type; the or default is a plain Lua string, so stringify on it is harmless.
3
The shortcode’s style attribute maps to my-badge’s colour parameter; both name the same semantic concept but follow different naming conventions (HTML attribute names versus Typst parameter names).
4
Return RawInline for inline output; use RawBlock for block-level shortcodes.

6.3 Usage

{{< status-badge label="Hello" style="info" >}}

7 Complete Example: Quote Card

Let us build a complete quote card component from scratch, demonstrating all patterns covered.

7.1 Step 1: Design the Markdown Syntax

::: {.quote-card author="Alan Kay" source="1971"}
The best way to predict the future is to invent it.
:::

Alternatively, support heading-based author attribution:

::: {.quote-card source="1971"}
The best way to predict the future is to invent it.

## Alan Kay

:::

7.2 Step 2: Create the Typst Rendering Function

Focus on render-quote-card(...) and its conditional attribution block; the badge and panel functions already exist from earlier sections.

_extensions/my-extension/components.typ (quote-card excerpt)
#let QUOTE-CARD-RADIUS = 8pt
#let QUOTE-CARD-INSET = 1.5em
#let QUOTE-MARK-SIZE = 3em

// Render a styled quote card
#let render-quote-card(
  content,
  author: none,
  source: none,
  colours: (:),
) = {
  let bg = colours.at("background", default: luma(250))
  let fg = colours.at("foreground", default: luma(50))
  let muted = colours.at("muted", default: luma(128))
  let accent = get-accent-colour("note")

  block(
    width: 100%,
    fill: bg,
    stroke: (left: 4pt + accent), -- <1>
    radius: (right: QUOTE-CARD-RADIUS), -- <2>
    inset: QUOTE-CARD-INSET,
    {
      // Opening quote mark
      place( -- <3>
        top + left,
        dx: -0.5em,
        dy: -0.3em,
        text(size: QUOTE-MARK-SIZE, fill: accent.lighten(60%), sym.quote.l.double),
      )

      // Quote content
      pad(left: 1em, right: 1em)[
        #text(style: "italic", fill: fg, content) -- <4>
      ]

      // Attribution line
      if author != none or source != none { -- <5>
        v(0.8em)
        align(right)[
          #text(fill: muted)[
            #sym.dash.em
            #if author != none { [ #author] }
            #if source != none { [, #emph(source)] }
          ]
        ]
      }
    },
  )
}
  1. Directional stroke: only the left edge has a border.
  2. Directional radius: rounds only the right corners.
  3. place positions the opening quote mark absolutely within the container.
  4. Quote content rendered in italic with the foreground colour.
  5. Attribution line conditionally displayed when author or source is provided.

7.3 Step 3: Create the Wrapper Function

Focus on argument forwarding (..args) and runtime colour injection; this keeps Lua extraction and Typst rendering concerns cleanly separated.

_extensions/my-extension/typst-show.typ (quote-card wrapper excerpt)
// Quote card wrapper
1#let quote-card(content, ..args) = {
  render-quote-card(
    content,
2    colours: get-colours(mode: effective-brand-mode),
3    ..args,
  )
}
1
Wrapper captures all extra named arguments via sink parameter ..args.
2
Injects the runtime colour scheme; Lua cannot know this at parse time.
3
Spread operator forwards author, source, and any other arguments to the rendering function.

7.4 Step 4: Create the Lua Handler

Focus on the last-header extraction branch and the shared wrapper reuse, which avoids reimplementing block wrapper assembly.

This handler reuses the same wrapped content pattern from Part 1’s panel, with one twist: instead of extracting the first heading as a title, it looks for a heading at the end of the content as an author attribution line. This kind of component-specific extraction logic is why some components need a custom handler rather than a factory-generated one.

_extensions/my-extension/_modules/quote-card.lua
1local wrapper = require(quarto.utils.resolve_path("_modules/wrapper.lua"):gsub('%.lua$', ''))

local M = {}

--- Process quote card: extract author from last heading or attributes
function M.process_div(div, config)
2  local attrs = wrapper.attributes_to_table(div)

  if not attrs['author'] and #div.content > 0 then
3    local last_elem = div.content[#div.content]
    if last_elem.t == 'Header' then
      attrs['author'] = pandoc.utils.stringify(last_elem.content)
      local new_content = {}
      for i = 1, #div.content - 1 do
        table.insert(new_content, div.content[i])
      end
      div.content = new_content
    end
  end

4  local opening, closing = wrapper.build_typst_block_wrappers(config, attrs)
  local result = { pandoc.RawBlock('typst', opening) }
  for _, item in ipairs(div.content) do
    table.insert(result, item)
  end
  table.insert(result, pandoc.RawBlock('typst', closing))
  return result
end

return M
1
Load the shared wrapper module using Quarto’s path resolution from the extension root.
2
Pandoc’s div.attributes is a metadata map, not a plain Lua table; this conversion produces a standard key-value table that can be iterated and passed to build_attribute_string.
3
Check the last element, unlike panel’s first-heading extraction; this supports the ## Author Name syntax at the end of the div.
4
Delegates to the shared wrapper module; the opening/closing pattern is identical to Part 1’s panels.

7.5 Step 5: Register the Component

Add the quote-card entry to get_builtin_mappings() in config.lua:

_extensions/my-extension/_modules/config.lua (registration excerpt)
function M.get_builtin_mappings()
  return {
    div = {
      ['highlight'] = { wrapper = 'my-highlight', arguments = false },
      ['panel'] = { wrapper = 'my-panel', arguments = true },
1      ['quote-card'] = { wrapper = 'quote-card', arguments = true },
    },
    span = {
      ['badge'] = { wrapper = 'my-badge', arguments = true },
    },
  }
end
1
arguments = true ensures the parameter list is always generated, which is needed because quote-card always passes at least colours: via the wrapper function.

Then register the custom handler in typst-markdown.lua:

_extensions/my-extension/typst-markdown.lua (handler registration excerpt)
local DIV_HANDLERS = {
1  ['quote-card'] = quote_card.process_div,
}
1
DIV_HANDLERS maps class names to custom handler functions; the quote card goes here because it has component-specific extraction logic (last-heading author) that a factory cannot express.

8 Advanced Topics

8.1 Checking Output Format

Filters should only run for Typst output; without this guard, transformation logic leaks into HTML, DOCX, and other targets.

_extensions/my-extension/typst-markdown.lua (format guard excerpt)
Div = function(div)
1  if not quarto.doc.is_format('typst') then
    return div
  end
  -- ... transformation logic
end,
1
quarto.doc.is_format() checks the current render target; returning the element unchanged lets Pandoc handle it normally for non-Typst outputs.

8.2 Module Loading

Load shared modules using Quarto’s path resolution.

The require_local(...) helper keeps imports consistent and reduces repeated path-normalisation boilerplate.

_extensions/my-extension/typst-markdown.lua (module loading excerpt)
1local function require_local(path)
  return require(quarto.utils.resolve_path(path):gsub('%.lua$', ''))
end

2local config      = require_local("_modules/config.lua")
local quote_card  = require_local("_modules/quote-card.lua")
local wrapper     = require_local("_modules/wrapper.lua")
local typst_utils = require_local("_modules/typst-utils.lua")
1
require_local wraps quarto.utils.resolve_path to anchor paths at the extension root; gsub strips .lua because require resolves by module name, not file path.
2
With a top-level typst-markdown.lua, all shared modules live under _modules/; adjust this prefix if your structure differs.

8.3 Filter Ordering

When using multiple filters, order matters. Declare filters in _extension.yml:

_extensions/my-extension/_extension.yml
title: My Extension
version: 2.0.0
contributes:
  formats:
    typst:
      template: template.typ
      template-partials:
        - typst-show.typ
        - components.typ
      filters:
1        - typst-markdown.lua
  shortcodes:
    - shortcodes.lua
1
A single filter entry; a Lua filter file can return a table containing multiple filter tables rather than one flat filter table. Here typst-markdown.lua returns { {Meta=...}, {Div=..., Span=...} }, an array of two filter tables. Pandoc applies each inner table as a separate pass in order; both passes operate on the same document. The first pass runs the Meta handler, which reads configuration from document metadata and stores it in module-level variables. The second pass runs the element handlers (Div, Span), which can then read the already-loaded configuration.

8.4 Debugging Tips

Use quarto.log.warning() to inspect values during development:

(conceptual) debugging logs
1quarto.log.warning('Processing div with classes: ' .. table.concat(div.classes, ', '))
2quarto.log.warning('Attributes: ' .. pandoc.utils.stringify(pandoc.MetaMap(attrs)))
1
table.concat joins the class list into a readable string.
2
Wrap the attributes table in pandoc.MetaMap so stringify can serialise it.

Output appears in the Quarto render log.

example.qmd
---
title: "Part 2 Extension Demo"
format:
  my-extension-typst:
    syntax-highlighting: idiomatic
extensions:
  typst-markdown:
    divs:
      custom-highlight: my-custom-highlight
---

## Badges (WCAG-Aware)

Inline badges with accessible colours:
[Completed]{.badge colour="success"},
[Pending]{.badge colour="warning"},
[Failed]{.badge colour="danger"},
[Information]{.badge colour="info"},
and [Default]{.badge}.

## Highlight (Factory Handler, No Title)

::: {.highlight}
This highlight block uses the factory handler **without** title extraction.
It simply wraps content in a styled container.
:::

## Panel (Factory Handler)

::: {.panel style="info"}

# Status Summary

This panel uses the **info** style.
The first heading is extracted as the title via the factory handler.

:::

## Quote Card (Attribute-Based Author)

::: {.quote-card author="Alan Kay" source="1971"}
The best way to predict the future is to invent it.
:::

## Quote Card (Heading-Based Author)

::: {.quote-card source="1971"}
The best way to predict the future is to invent it.

## Alan Kay

:::

## Shortcode

A live status indicator: {{< status-badge label="Live" style="success" >}}.

## User-Defined Component (Config System)

::: {.custom-highlight}
This block uses a **user-defined mapping** declared in the YAML front matter.
The `custom-highlight` class maps to the `my-custom-highlight` Typst function via the configuration system.
:::

PDF render showing seven sections. Badges (WCAG-Aware): five WCAG-compliant inline badges labelled Completed, Pending, Failed, Information, and Default. Highlight: a light green content block with text explaining the factory handler requires no title extraction. Panel (Factory Handler): a blue info panel titled Status Summary, where the title was extracted from the first heading. Two Quote Card sections: each displays the quote The best way to predict the future is to invent it in italic with a left blue accent border, attributed to Alan Kay, 1971 — one using an attribute-based author, one using a heading-based author. Shortcode: a green Live badge inline in a sentence. User-Defined Component: an orange highlight block demonstrating a user-defined class mapped to a custom Typst function via the configuration system.

9 Conclusion

This tutorial covered advanced patterns for building production-quality Quarto Typst extensions:

  1. Handler factories reduce repetition by generating handlers with configured behaviour.
  2. Configuration systems enable user extensibility through metadata-driven mappings.
  3. Type-safe value conversion prevents syntax errors when passing values between Lua and Typst.
  4. WCAG compliance ensures accessible colour contrast in styled components.
  5. Shortcodes provide explicit syntax for components without natural markdown representation.

You can download the complete code for this part of the tutorial as a zip file containing the extension with all components covered above.

Part 2 (ZIP)

The separation of concerns between Lua (transformation) and Typst (rendering) enables flexibility, testability, and maintainability as your extension grows.

9.1 Further Resources

Back to top

Reuse

Citation

BibTeX citation:
@misc{canouil2026,
  author = {CANOUIL, Mickaël},
  title = {Building {Quarto} {Typst} {Templates:} {Advanced} {Patterns}
    {(Part} 2)},
  date = {2026-03-05},
  url = {https://mickael.canouil.fr/posts/2026-03-05-typst-template-tutorial-part2/},
  langid = {en-GB}
}
For attribution, please cite this work as:
CANOUIL, M. (2026, March 5). Building Quarto Typst Templates: Advanced Patterns (Part 2). Mickael.canouil.fr. https://mickael.canouil.fr/posts/2026-03-05-typst-template-tutorial-part2/