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.

A shortcut is a styling macro: you compose new constructs from the ones that already exist — chain them, give them a template, even gate a template's content with ifdef — instead of asking for new engine features. Complexity lives in shortcuts.yaml; your prose stays clean. Keystone won't run arbitrary code — shortcuts compose existing constructs, nothing more.

Where shortcuts live

Your shortcuts go in shortcuts.yaml at the project root, layered over the system shortcuts. Defining a system shortcut's name shadows it (see Shadowing a system shortcut); deleting your entry restores it. The file is optional — missing, empty or only comments, the system shortcuts are all you have, which is plenty to start.

The build checks the whole file before it reads a chapter, and stops on anything it can't take, naming the key that caused it — see What gets rejected.

Anatomy

A shortcut names another shortcut as its class, and may add an interface and templates. These keys are allowed:

garamond:
  class: font            # the shortcut this builds on (required)
  interface:             # attributes the author may set
    family:
      bind: class.family
      default: eb-garamond
  • class — the shortcut this one builds on: a system shortcut or another of yours. Required on a new shortcut.
  • interface — the attributes your shortcut accepts and where each routes.
  • body — the template for a div use (see Templates).
  • span — the template for a span use: one paragraph of inline Markdown.
  • content — names the template element that receives author content.
  • classes — native classes the element also takes (see Headings).

Any other top-level key stops the build.

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 element the shortcut becomes — through the shortcut its class names, which must expose X.
  • key.X routes to every element of the shortcut's templates whose first class is key — a template you wrote, so the shortcut needs one.

A default sets the value used when the author omits the attribute; without one, the attribute applies only when the author writes it, and the handler underneath falls back to its own default. A value reaches the attribute as text: 3 arrives as "3", true as "true".

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 no interface entry takes is ignored with a warning — typo protection.

Required attributes

Mark an attribute required: true when the shortcut is meaningless without it and there's nothing sensible to default to. If the author omits it, the build stops naming the shortcut and the field:

spacer:
  class: vspace
  interface:
    size:
      bind: class.size
      required: true      # no default — the author must choose a size

A default already supplies the value, so required: true beside it never fires; one definition setting both warns.

Some handlers require an attribute of their own — vspace needs a size, set needs a mark. A shortcut over one must guarantee that attribute reaches it: an entry routing to it with a default, a const or required: true. The system shortcuts already do, and your shortcut inherits their entries; the build stops when nothing in a chain or template guarantees one.

Fixed values

Some values are what a shortcut means. print-only keeps content for PDF, so its symbol is always latex. A const fixes the value:

print-only:
  class: ifdef
  interface:
    symbol:
      bind: class.symbol
      const: latex

A const reaches the attribute as a default does, and nothing sets it: an author writing the attribute is refused, and so is a template handing the shortcut a value for it, or a chain or shadow writing any field of the entry. It stands alone in its entry — beside default or required it would say two things. A shortcut may fix an entry it inherits: print-only fixes the symbol that ifdef requires, as the system's latex-only does.

Accepted values

A system shortcut's attributes accept set values — a vspace size, an aside type, a family your fonts provide — which the reference lists. An entry routing to one inherits what it accepts, through chains, shadows and templates. A value is judged where it's written. A default, a const or a value a template writes is judged when the build compiles your shortcuts, and one its entry doesn't accept stops the build, naming the key:

ERROR: shortcuts.yaml has defaults and consts their entries don't accept
  garamond.interface.family.default: eb-garmond
  Their entries take a font the registry holds.
  Valid: biolinum, dejavu-mono, dejavu-sans, dejavu-serif, eb-garamond,
         fourier-ornaments, imfell-flowers-1, imfell-flowers-2, latin-modern,
         latin-modern-math, latin-modern-mono, libertine, noto-mono,
         source-code-pro, tex-gyre-adventor, tex-gyre-bonum, tex-gyre-cursor,
         tex-gyre-heros, tex-gyre-pagella, tex-gyre-schola, tex-gyre-termes
  Name a bundled font, or one fonts/fonts-registry.yaml declares.
  See https://keystone.knight-owl.dev/shortcuts/writing-shortcuts/#an-accepted-value

A value written at a use is judged as the chapter builds: one its entry doesn't accept is refused, and the entry's default stands in where it has one.

An entry narrows what it inherits with accepts, a list of values or a rule:

emphasis:
  class: font
  interface:
    style:
      bind: class.style
      accepts: [italic, bold-italic]
Rule Accepts Options
integer an integer in decimal digits min, max
css-length a CSS length keywords: words it takes besides; percent: true adds a whole percentage to 100
hex-color a #rrggbb color —
nonblank text holding something besides whitespace —
symbols one or more build symbols, separated by spaces —

A rule alone is its name, accepts: hex-color; with options, a map: accepts: { integer: { min: 2, max: 3 } }.

What you accept must lie within what the entry inherits: accepts: [italic, slanted] on a font style stops the build, since font doesn't take slanted. A shadow's narrowing reaches every shortcut built on the one you shadow, through its class or its template. Narrow font's style and pullquote takes only what you listed; a system default outside it stops the build, naming your narrowing. It reaches no other: epigraph and sym set a face as font does, without being built on it, so narrow each one you mean. A system shortcut's accepts names its handler's domain, as ks-font@style; that form is the system's alone, and yours lists values or names a rule.

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 shortcut's "style"
      default: center
    font-style:
      bind: font.style        # the font element's "style"
      default: italic
  body: |
    ::: font
    :::

The author writes alignment and font-style; neither is the literal style that both handlers use internally. Two entries routed to the same attribute of the same target stop the build: which value landed would be left to chance.

Choosing a terminal class

A chain ends at a handler, through the system shortcut that fronts it. Two cover most needs:

  • container — pure grouping, no styling. Use it when the shortcut exists only for its template, to nest other shortcuts, or to name a heading.
  • font — when the shortcut applies a typeface or size.

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

Chains

A shortcut whose class is another shortcut starts from it: its interface, templates, content and classes come along, and it changes only what it writes.

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

An entry under a name the parent already has replaces it, keeping whatever it leaves out — bind included — so small-garamond writes only the new default. The rest of the parent's interface comes along untouched: small-garamond still takes family.

An entry under a new name bound to class.X takes over the parent's entry X: it renames it, and X leaves the interface. italic-center above renames align's style to alignment, so style isn't an attribute of italic-center. A class.X the parent doesn't expose stops the build — the attribute is private to the parent.

A chain binds into a template only when the template is its own; a template it inherits is reached through the entries its parent exposes.

Shadowing a system shortcut

Defining a system shortcut's name changes that shortcut for your book, everywhere it appears — inside other system shortcuts' templates too, so a shadow of figure changes aligned-figure. Your definition layers over the system's key by key, down to a single entry field:

pullquote:
  interface:
    font-size:
      default: huge          # every pullquote in the book

A shadow may change defaults, templates and the routes into them, and add entries routed into its templates. It keeps the system's class — writing one stops the build — since the entries it inherits bind through it, and it renders every form the system shortcut does, so a template that would take one away stops the build. It keeps every entry the system shortcut exposes and the attribute each routes to, so a take-over under another name is refused; rename in a shortcut of your own, with the system one as its class. The handler's other attributes stay private. A value the system fixes takes no field from a shadow, and a shadow may fix a system default with const for its book.

A layer only overrides: a null anywhere — default: ~, body: ~ — stops the build. To reach the state a default excludes, write the value that names it, such as font's and align's normal.

Templates

A shortcut can carry a template: body for a div use, span for a span use. An empty use takes the template as its content:

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

The empty div becomes a centered ~ ~ ~. A use with content wraps it: the content goes into the template's slot — the element content names, or else its first empty element of the use's form, skipping one whose handler takes no content. A template with no slot renders the same content every time, so a use wrapping text in ornament stops the build rather than drop the text.

A shortcut renders the forms it has templates for: one with only a body is a div, and used as a span it stops the build. With no template, it renders the forms the system shortcut it builds on declares. A span is one paragraph of inline Markdown:

sic:
  class: container
  span: |
    []{.slot} [[sic]]{.font style="italic"}
The sign read [Pubic Library]{.sic}.

The quoted words land in the first empty span, []{.slot}, followed by an italic "[sic]".

The slot can carry the style itself:

ship:
  class: container
  span: |
    HMS []{.font style="italic"}
Darwin sailed on [Beagle]{.ship}.

Beagle lands in the italic span, after an upright "HMS".

An interface binds into every template the shortcut has, so an author writes the same attributes in either form; a route to an element one template lacks stops the build.

Naming the content slot

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

ornamented-quote:
  class: container
  content: font
  body: |
    ::: ornament
    :::
    ::: {.font style=italic}
    :::

Without content, the slot would be the empty ornament, the one from Templates, whose template has no room for text, and the build would stop. Naming font puts the text there, below the ornament.

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

motto:
  class: font
  content: slot
  interface:
    source:
      bind: optional-source.text
  body: |
    ::: quote
    ::: slot
    :::
    ::: optional-source
    :::
    :::
::: {.motto source="Family saying"}
Measure twice, cut once.
:::

Author content flows into slot inside quote, while the attribution stays a sibling sharing the quote's indented width, and renders only when the use sets source.

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.

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 element) — but usually you needn't think about it.

An interface entry whose bind target isn't class routes onto the matching template element. 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 element carries its own style="italic" in the template. A template attribute no entry routes to is private — the author can't change it. An entry routed to it, with no default, makes it overridable while the template's value stays the built-in.

Nesting

Template elements may themselves use shortcuts — divs, spans and headings alike — and they expand recursively, so composition nests naturally:

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

Templates that embed each other in a loop stop the build before any chapter is read.

Headings

A heading names a shortcut as a div does, and keeps its text. set and appendix take one, as their reference shows; a shortcut built on container renders one too, and classes adds the Pandoc-native classes it carries, so a heading style takes a name of your own:

front-matter:
  class: container
  classes: [unnumbered, unlisted]
# Preface {.front-matter}

The heading is unnumbered and unlisted, as # Preface {.unnumbered .unlisted} would be. A heading takes no template, so a shortcut with a body or span renders only those forms. classes takes the native classes Pandoc's writers read — unnumbered, unlisted and mark; smallcaps and underline work only written in the manuscript.

A shortcut built on container adds classes alone. To style a heading's text, put a span inside it:

# [Preface]{.font style=italic}

The heading, its contents entry and its running head take the style; a PDF bookmark stays plain text. A size there enlarges the contents entry and running head too. The look of every heading of a level is the target's.

A heading in a template expands too, its text fixed by the template.

classes works in any form a writer reads the class in. A highlight is mark on a span:

hl:
  class: container
  classes: [mark]
A [crucial]{.hl} word.

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 a fuller version of this composition as aligned-figure, so you rarely write it yourself — it's shown here only to illustrate identifier routing.)

What gets rejected

The build compiles shortcuts.yaml before it reads a chapter, and stops on any mistake below. A message names each key at fault by path, as in pullquote.interface.font-size.default, and links the group holding its rule.

The file

More than one YAML document, a null anywhere, a key written twice, a list or map used as a key, or something other than a map of names to definitions. See Anatomy, and Shadowing a system shortcut for why a null means nothing.

A key

An unknown top-level key, or an unknown key in an entry. A shortcut takes the keys in Anatomy — forms belongs to a system shortcut fronting a handler; an entry takes bind, default, required, const and accepts.

A value

A class, body, span or content that isn't text; a classes or forms that isn't a list of names, or a form other than div, span or heading; an interface or entry that isn't a map; a required that isn't true or false; a default or const that is a list or a map; a const beside a default or required. See The interface and Fixed values.

A class

Missing on a new shortcut, written on a shadow, naming nothing, or leading back to itself through a chain; one in classes that Pandoc's writers don't honor. See Anatomy, Chains, Shadowing a system shortcut and Headings.

A name

One starting with ks-, a ks- class anywhere in the file, or a shortcut named after a Pandoc-native class no system shortcut fronts, which keeps Pandoc's meaning. The ks- prefix is reserved: a handler of your own is fronted by a system shortcut, as Writing a handler shows, and your shortcuts build on that.

A bind

Not target.attribute, or to id or class; a class.X its parent doesn't expose; a template target on a shortcut without templates of its own, or on a chain that inherits one; an entry without bind that inherits none; a route to an element a template lacks; two entries routed to one attribute. See The interface, Decoupling names and Chains.

A shadow

Taking over a system entry, binding a class.X the system shortcut doesn't route, or a template leaving out a form the system shortcut renders. See Shadowing a system shortcut.

A fixed entry

A chain or a shadow writing a field of, or taking over, an entry another layer fixes; a template handing a nested shortcut a value it fixes. See Fixed values.

A template

A span that isn't one paragraph; templates embedding each other in a loop; a content naming no element of its form; a template for a form its chain doesn't declare, or on a shortcut whose handler takes no content. See Templates, Naming the content slot and Nesting.

A template element

Naming two shortcuts, or naming a shortcut that can't render its form, refuses the content it holds, or requires a value nothing supplies — an attribution a use may leave out embeds optional-source rather than source. See Templates and Nesting.

A handler's requirement

An attribute the handler needs that nothing in the chain or its templates guarantees. See Required attributes. In a fork, a system shortcut fronting a handler stops the image build when it declares no forms, or one the handler doesn't render; see Writing a handler.

An accepted value

A default, a const or a template value its entry doesn't accept. An accepts that isn't a name, a list of text or a rule with its options; with an option its rule doesn't take, or set to a value it can't take; naming no rule, or a system source such as ks-font@style; whose keywords name no values; taking values its entry doesn't; or rejecting a value the system writes beneath it. See Accepted values.

What only warns

  • an entry one definition gives both required: true and a default
  • a template handing a nested shortcut an attribute it doesn't take
  • a template class that names nothing, checked as the manuscript's are

In the manuscript

The classes you use in the manuscript are checked as each one expands. A div, span, or heading class that names no shortcut or Pandoc-native style — a typo like .vspcae, or # Preface {.unnumberd} — is reported, quoting the class and suggesting a close match when one exists. The build still finishes (the bogus class is simply unstyled), unless it's a strict build — then it fails rather than ship the mistake. A value its entry doesn't accept is refused the same way. A use stops the build when it sets a required attribute to a value nothing stands in for, names two shortcuts, uses a shortcut in a form it lacks, wraps content in one that takes none, sets an attribute the shortcut fixes, or names a class starting with ks-.

What you can build

A few patterns that fall out of these mechanics:

  • Named typefaces — garamond, chapter-face, caption-face: a font shortcut with the family pinned, so the choice lives in one place.
  • Decorative breaks — ornament, flourish: an align shortcut with the glyphs in its template.
  • Recurring attributions — byline, 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.