Skip to content

Writing your own shortcuts

The system shortcuts cover the built-in styling capabilities. On top of them you define your own vocabulary in shortcuts.yaml — names that mean something in your book. Instead of repeating ::: {.font family="eb-garamond"} throughout the manuscript, define garamond once and write ::: garamond. When the design changes, you edit one place.

This is where you unleash your inner developer. A shortcut is a styling macro: you compose new constructs from the ones that already exist — chain them, inject body content, even gate a shortcut's body with ifdef — instead of asking for new engine features. Complexity lives in shortcuts.yaml; your prose stays clean. Keystone won't run arbitrary code, but composing shortcuts is about as close as it gets to scripting your Markdown.

Where shortcuts live

User shortcuts go in shortcuts.yaml at the project root. They layer on top of the system shortcuts: defining a name that already exists shadows the system version; deleting your entry restores it. The file is optional — without it, the system shortcuts are all you have, which is plenty to start.

Anatomy

A shortcut maps a name to a handler (or to another shortcut), optionally exposing attributes and injecting content. Four keys are allowed:

garamond:
  class: font            # what this routes to (required)
  interface:             # attributes the author may set
    family:
      bind: class.family
      default: eb-garamond
  • class — the target: a handler, a system shortcut, or another user shortcut. Required.
  • interface — the attributes your shortcut accepts and where each routes.
  • body — Markdown injected into the element (see Body injection).
  • content — names the body slot that receives author content.

Any other top-level key is an error.

Use it like any shortcut:

::: garamond
This paragraph renders in EB Garamond.
:::

A word in [EB Garamond]{.garamond} inline.

The interface

Each interface entry binds a name the author writes to a target the value lands on. The target is always where.attribute:

  • class.X routes to the outer handler — whatever class resolves to. class is a symlink to the shortcut's own class.
  • name.X routes to a div named name inside the shortcut's body.

Every interface attribute is optional. A default sets the value used when the author omits it; without one, the attribute is applied only when the author supplies it — and the underlying handler falls back to whatever default it defines:

garamond:
  class: font
  interface:
    family:
      bind: class.family
      default: eb-garamond
    size:
      bind: class.size        # no default — applied only if the author sets it

The author overrides any interface attribute inline:

::: {.garamond family="libertine"}
This renders in Libertine instead of EB Garamond.
:::

An inline attribute that doesn't match an interface name produces a build warning — typo protection.

Decoupling names

Because the interface renames, two handlers that use the same internal attribute can be exposed under distinct author-facing names — no collision:

italic-center:
  class: align
  interface:
    alignment:
      bind: class.style       # the align handler's "style"
      default: center
    font-style:
      bind: font.style        # the font div's "style"
      default: italic
  body: |
    ::: font
    :::

The author writes alignment and font-style; neither is the literal style that both handlers use internally.

Choosing a terminal class

A chain ultimately has to resolve to a handler. Two cover most needs:

  • container — pure grouping, no styling. Use it when the shortcut exists only to inject a body or nest other shortcuts.
  • font — when the shortcut applies a typeface or size.

The other system shortcuts (align, aside, figure, quote, …) are available as terminal classes when you need their specific behavior.

Chains

A shortcut's class can point at another shortcut, forming a chain. A chain bind (class.X) requires the parent to expose X:

small-garamond:
  class: garamond           # builds on the garamond above
  interface:
    size:
      bind: class.size
      default: small

small-garamond binds size through garamond, which exposes size (bound to font's size). If garamond didn't expose it, this would error — the attribute is private to garamond.

Chains can be any depth and merge first-writer-wins: when the same interface name appears at two levels, the outermost entry wins.

Body injection

A shortcut can carry a body — Markdown parsed and injected when the element is empty:

ornament:
  class: align
  interface:
    style:
      bind: class.style
      default: center
  body: |
    ~ ~ ~
::: ornament
:::

The empty div becomes a centered ~ ~ ~. If the author does supply content, it wins — the body wraps it by dropping the content into the body's first empty div.

Naming the content slot

When the body has several divs and the first empty one isn't where author content should go, name the slot with content:

pullquote:
  class: align
  content: font
  body: |
    ::: rule
    :::
    ::: font
    :::
    ::: rule
    :::

Author content now always lands in the font div; the empty rule divs are left alone. Pointing content at a font div can look odd, but the font div is where the visible text belongs — the rule divs are just decoration.

When fixed elements must surround the author content inside one parent, use the slot placeholder — it receives the content while its siblings stay put:

epigraph:
  class: font
  content: slot
  body: |
    ::: quote
    ::: slot
    :::
    ::: source
    :::
    :::

Here author content flows into slot inside quote, while source remains a sibling sharing the quote's indented width.

Composition across formats

PDF and EPUB nest freely — each level becomes a nested LaTeX environment or a nested HTML element with its classes — so a composition renders faithfully and the order you stack handlers in is mostly cosmetic. These are Keystone's publishable formats, and this is where composition fidelity matters.

DOCX and ODT work differently: a paragraph carries a single style, so in a stacked composition only the style closest to the content survives — deeper layers flatten away. That's fine. DOCX and ODT aren't publication targets; they're collaboration vehicles for handing a draft to an editor, so good-enough styling is the bar, not pixel fidelity. If a particular look there does matter to you, put the style that must survive closest to the content (it's why pullquote slots into its font div) — but usually you needn't think about it.

An interface entry whose bind target isn't class forwards onto the matching body div. This is how one shortcut drives several handlers:

poem-date:
  class: align
  interface:
    style:
      bind: class.style
      default: right
  body: |
    ::: {.font style="italic"}
    :::
::: poem-date
Published 1850.
:::

style routes right to the outer align; the font div carries its own style="italic" baked in. A body attribute with no interface entry pointing at it is private — the author can't change it. Add an interface entry (without a default) to make it overridable while keeping the body value as the built-in.

Nesting

Body content may itself use shortcuts — both divs and spans — and they expand recursively, so composition nests naturally:

poem-footer:
  class: poem-date
  body: |
    ::: small-garamond
    2026
    :::

A depth limit (10 levels) guards against shortcuts that reference each other in a loop.

Cross-references

Most binds set key-value attributes. The identifier target is special — it sets Pandoc's native id, what #id and id= populate. A single-handler shortcut needs nothing here: an #id you write rides through to the element on its own. You bind identifier only when a composed shortcut must carry the id onto an inner element — here, wrapping a figure in centering and forwarding the id inward:

centered-figure:
  class: align
  interface:
    align:
      bind: class.style
      default: center
    identifier:
      bind: figure.identifier
    width:
      bind: figure.width
  body: |
    ::: figure
    :::
::: {.centered-figure #fig-map width="50%"}
![Map of the region.](assets/map.png)
:::

#fig-map lands on the inner figure, where it becomes the cross-reference target. (Keystone ships this exact composition as aligned-figure, so you rarely write it yourself — it's shown here only to illustrate identifier routing.)

What gets rejected

Shortcuts are validated when the build loads them. Common errors:

  • a missing class
  • a name starting with ks- (reserved for handlers)
  • an unknown top-level key (only class, interface, body, content)
  • a bind that isn't class.attribute or bodydiv.attribute form
  • a non-class bind with no body div of that name
  • a chain bind to an attribute the parent doesn't expose
  • content without a body, or naming a slot the body doesn't contain
  • two shortcuts that reference each other in a cycle

The build halts with a message pointing at the offending entry, so mistakes surface immediately rather than producing silently wrong output.

What you can build

A few patterns that fall out of these mechanics:

  • Named typefacesgaramond, chapter-face, caption-face: a font shortcut with the family pinned, so the choice lives in one place.
  • Decorative breaksornament, flourish: an align shortcut with the glyphs in its body.
  • Recurring attributionsbyline, poem-date: compose align over font so a single name carries both placement and style.
  • Running-header marks — a shortcut over set (e.g. poem-title) so you set a mark by meaning, not by repeating mark="…". See Running headers & footers.
  • Edition gates — a shortcut over ifdef (e.g. draft-note) so a recurring symbol reads as intent. See Conditional content.

The guideline: when you find yourself repeating the same attributes, that's a shortcut waiting to be named.