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