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