---
title: "Custom targets — quario"
description: "Building your own quario render target on the public event stream, with the walk driver, the token seam and the band roles."
url: "https://getquario.com/docs/diving-deeper/custom-target"
---

# Custom targets

## Intent

The event stream is the seam every render target consumes, and it’s public. A
target you write consumes it through exactly the contract `@quario/html` and
`@quario/pdf` use, which proves the seam sufficient for complete renderers
rather than merely offering it. Use this when you need an output nothing
ships. Examples are a Markdown digest, a terminal table, a Word document, or a
domain format of your own.

## Design decisions

**The official targets are stream consumers, not insiders.** They hold no
private access. Anything they do, a target you write can do.

**The stream escapes nothing.** Escaping belongs to a target at its own edge,
because what needs escaping depends on the output. A target embedding values
in markup owns that job, and the split to copy is the one the HTML target
makes. Every token is hostile until you escape it, literal template text
included, because a definition is as untrusted as its data.

**Presentation arrives as data, never as markup.** A `style` is the resolved
declaration object, carrying the authored vocabulary names in declaration order
with each value already computed. Mapping those names to your own model is the
work of writing a target.

**The token structure is the typed seam.** A cell whose template is one
interpolation is exactly one value token holding the value itself. That’s how
the spreadsheet target writes a real numeric cell while a markup target writes
text, from the same event. A cell written as styled runs flattens into that same
`tokens` array, with a resolved `style` on the tokens a run covers. A consumer
that has never heard of runs stays correct rather than merely tolerant.

**`compile` runs once per render call, so it must be cheap and stateless.**
Per-render work belongs in the renderer it returns.

**A factory refuses the options it doesn’t understand.** Every official target
closes its key set at the factory call. `hostOptions` is that check, exported for
a target of your own to use. Reading the keys you know and ignoring the rest is
the behaviour this replaced. A host’s typo cost them the option they meant to
set, with no signal at any point. Where your target takes the document
properties the others take, `hostMeta` checks the same `{ title, author,
subject }` contract so a reader learns it once.

**A missing handler isn’t an error, which is what makes the stream additive.**
Events added later reach a target written before them, which skips them. A
consumer with no `split-start` handler still receives the slot items as
ordinary events and renders them stacked. That’s what the band did before
splits existed.

**The engine resolves a row’s box before the stream.** A row isn’t a
container that can hold a box on any surface. The engine lands those
declarations on the row’s cells first. A target never has to decide what a
box on a row means.

**The engine resolves the totals block away entirely.** It folds the block
into each of its rows before either half crosses, so no target learns that a
level above the total rows exists.

**Page bands cross as closures, not events.** The banded walk is
pagination-agnostic, because only a paginating target knows where pages fall. A
target that ignores them never evaluates them at all.

## API walkthrough

### Declaring a target

```ts title="the contract"
interface Target<Name extends string = string, Out = unknown> {
  name: Name
  compile: (stream: ReportEventStream) => (data?: unknown) => Out
}
```

**`name`** is a plain identifier naming what the target is, used in
diagnostics. **`compile`** receives the compiled stream and returns the
renderer that runs per dataset. `render` resolves your output whether the
renderer is async or not.

Every published target also exports a **`capabilities`** descriptor beside its
factory: `{ target, declarations }`, where each declaration the support matrix
names maps to `resolved`, `approximated`, `withdrawn` or `unread`. It’s keyed
by the name an author writes, and it carries no version of its own. The package
that ships it sets the version. A host gives those descriptors to `plan` to
check a document’s `required` marking. Exporting one from your own target lets
it join that check. [Validating
definitions](https://getquario.com/docs/diving-deeper/validating.md) covers the marking.

### Driving the walk

**`walk(events, handlers)`** dispatches one render’s events to per-event
handlers in stream order and exactly once each. It pulls lazily and returns
control to the event loop between batches, so a long report doesn’t block the host.

Handlers are synchronous. A promise one returns isn’t awaited, so accumulate
into a closure and resolve after the walk.

The stream’s first event reaches its handler before anything pulls a second.
That’s why a target settles what it needs from `report-start` there rather
than pulling the stream itself.

**`splits(events)`** folds a split bracket into one event before the walk sees
it. Read `walk(splits(events), handlers)` and register a `split` handler, which
receives the opening event’s `role`, `path`, `slots` and `style` plus the slot
events in order under `items`. Every other event passes through unchanged, and
lazily. The stream itself doesn’t change. A consumer that doesn’t use
`splits` still gets `split-start`, the slots, and `split-end`. Every built-in
target reads its splits this way now, so yours can too.

**`collapse(stream)`** is the reading a target with nowhere to hide a row wants.
A group may declare `collapsed`, asking that the instance open shut. A surface
that can’t offer a reader a way to open it again has to omit the content.
Read `walk(collapse(events), handlers)` and a collapsed instance arrives as its
own bands alone: the helper drops its content, nested instances whole, and keeps
its brackets. The engine computes aggregates before the stream, so a footer
still totals rows nobody sees. A target that _can_ hide a row and give it back —
a worksheet’s outline levels — reads the stream unwrapped and honours the flag
itself.

**`breathe()`** is the same courtesy for a loop of your own, such as a second
pass over pages you laid out.

### Reading a cell

**`tokens`** is a cell’s rendered value in render order, with `{ literal }` for
each static run and `{ value }` for each interpolation’s pre-format value.

**`text(tokens)`** joins them to display text with literals verbatim and
values through the scalar rule. **`display(value)`** is that rule. It’s exported
so your target and the stream stringify identically. A valid `Date` renders
as ISO 8601 UTC, so a bare date cell produces the same bytes on every
machine.

**`typed(tokens, decl?)`** returns the pre-stringify value when the cell is
exactly one value token holding a finite number, a boolean or a valid `Date`,
and `undefined` otherwise. Passing a resolved `format` enables the one
coercion, reviving a date string under the `date` kind. Pass the resolved
`format` of the token’s run, not the cell’s, because a run may declare its own.

**`styledRuns(tokens)`** groups a cell’s tokens back into its runs, consecutive
tokens with equal styles forming one. The built-in targets read runs through
it, so a custom target that does can’t drift from them.

**`format(value, style, options)`** presents a value for a cell’s resolved
style, returning nothing when the kind doesn’t apply so you use `display()`
instead. **`currencyOf(style, options)`** answers which code a money cell
wears, and **`fractionDigits(style, options)`** how many places it presents.

A value that already went through a registered function arrives formatted,
because the function is part of the expression. Reports written for typed
consumers interpolate the bare value and let the target own presentation.

### Placing a band

**`role`** names the band an item came from, one of `report-header`, `empty`,
`group-header`, `detail`, `group-footer`, `report-footer`, `page-header` and
`page-footer`.

**`isReportBand(role)`** answers whether the role names one of the report’s own
bands rather than a group instance’s. It’s a classification your surface may
map differently, which is why it ships as a helper rather than as a field.

**`group-start`** and **`group-end`** bracket each instance, and both carry
`name` and `depth`, so a target needs no bracket stack. **`split-start`**
and **`split-end`** bracket a split’s slots the same way.

`group-start` carries **`break`** where a page turns before the instance, and
**`breakAfter`** where one turns after it. A target therefore reads the two
edges, never the four positions that asked for them. `group-end` carries
**`hollow: true`** where the instance’s bands emitted nothing between its
brackets — no space, no gap, no page turned by opening it, no outline entry.
The field is absent rather than `false` on an instance that drew, and an
instance holding nothing but hollow instances is hollow too.

A row or item whose visibility is exactly `false` yields no event, while
aggregates and running accumulators still saw the row. A hidden table cell
keeps its position with empty tokens and keeps its style, which describes the
column slot rather than the suppressed content.

### Paginating, or not

When the schema declares page bands, `report-start` carries `page` with
`header` and `footer` functions. Call them once per page with
`{ number, total }` and receive that page’s resolved events, each wearing the
page role.

The closures belong to that render call, since they capture its root scope.
Ignore the field and no page band is ever evaluated, which is the correct
behaviour for an unpaginated target.

### Failing well

**`imageError(path, said, cause)`** mints an image failure named on the item
that asked for the bytes. The engine vouches for an image’s magic numbers and
no further, so anything past that’s your target’s to report. The result takes
`cause`’s class, and it’s not a diagnostic.

**`report-start.marking`** carries the unlicensed wording when no valid key
covers a render. Draw it. It must never depend on a grant your host
might withhold, because a marking that fails open is worse than none.

## A complete target

It’s roughly seventy lines, and the engine’s own suite tests it. Tables become
pipe tables, items become lines, and the bands flatten to document order.

```ts title="markdown.ts"
import { walk } from 'quario'
import type { EventCell, Target, Token } from 'quario'

const SPECIAL = /([\\`*_[\]<>|#])/g

const esc = (value: unknown) => String(value ?? '').replace(SPECIAL, '\\$1')

const markup = (tokens: readonly Token[]) => {
  let out = ''
  for (const token of tokens) out += 'literal' in token ? token.literal : esc(token.value)
  return out
}

const inline = (cell: { tokens: readonly Token[]; style?: Record<string, unknown> }) => {
  const style = cell.style || {}
  let s = markup(cell.tokens)
  if (style.italic) s = '*' + s + '*'
  if (style.bold) s = '**' + s + '**'
  return s
}

const cells = (list: readonly EventCell[]) => '| ' + list.map(inline).join(' | ') + ' |'

export function markdown(): Target<'markdown', Promise<string>> {
  return {
    name: 'markdown',
    compile: (stream) => async (data) => {
      let out = ''
      const line = (s: string) => {
        out += s + '\n'
      }
      const record = (e: { cells: EventCell[] }) => line(cells(e.cells))

      await walk(stream(data), {
        'report-start': (e) => {
          if (e.marking) line(esc(e.marking))
        },
        item: (e) => line(inline(e)),
        'table-start': (e) => {
          line(cells(e.header.cells))
          line('| ' + e.columns.map(() => '---').join(' | ') + ' |')
        },
        row: record,
        'total-row': record,
      })
      return out
    },
  }
}
```

It renders like any official target.

```ts title="render.ts"
const out = await report.render(markdown(), data)
```

Note what it doesn’t read. Page geometry, images, and every style beyond bold
and italic go unhandled, and the report still renders. That’s the
missing-handler rule doing its work.

`format` is among the declarations it ignores, so an amount column reaches the
output as `500` rather than `€500.00`. Reading the kind is one call to
`format(value, cell.style, options)` per value token, with the instance
configuration carried on `report-start` as `locale`, `currency` and
`timeZone`. Whether a target presents or withdraws is its own decision, and
the CSV target withdraws on purpose.

Styled runs are another. The sample reads a token’s value and never its
`style`, so a bolded word inside a sentence arrives unbolded. `styledRuns(tokens)`
is the seam to add it.

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Docs","item":"https://getquario.com/docs"},{"@type":"ListItem","position":3,"name":"Custom targets","item":"https://getquario.com/docs/diving-deeper/custom-target"}]}
```
