Escape
HomeDocsCustom targets

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. Reach for this when you need an output nothing ships. 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. Interpolated values are hostile until you escape them, while literal template text is author-controlled and passes through.

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 validates 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’s folded 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

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.

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 hands the event loop back 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.

breathe() is the same courtesy for a loop of your own, such as a second pass over pages you have 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, 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 the cell’s resolved format opts into the one coercion, reviving a date string under the date kind. Its second argument is that run's resolved format rather than the cell's.

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 fall back to display(). 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.

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 a render isn’t covered by a valid key. 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

Roughly seventy lines, and tested in the engine’s own suite. Tables become pipe tables, items become lines, and the bands flatten to document order.

markdown.js
import { walk } from 'quario'

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

/** @param {unknown} value */
const esc = (value) => String(value ?? '').replace(SPECIAL, '\\$1')

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

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

/** @param {readonly import('quario').EventCell[]} list */
const cells = (list) => '| ' + list.map(inline).join(' | ') + ' |'

/** @returns {import('quario').Target<'markdown', Promise<string>>} */
export function markdown() {
  return {
    name: 'markdown',
    compile: (stream) => async (data) => {
      let out = ''
      const line = (/** @type {string} */ s) => {
        out += s + '\n'
      }
      const record = (/** @type {{ cells: import('quario').EventCell[] }} */ e) =>
        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.

render.js
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.

One stream. Any output you like.

npm install quario
Getting started
© 2026 quario · KvK 61815977