quario
v0.12.0
Published
Tiny, CSP-safe report engine for JavaScript. One JSON document renders to HTML, PDF, XLSX, CSV and Word.
Maintainers
Readme
quario
The report engine. Turns a JSON report definition plus data into a stream of structured render
events. Every render target (@quario/html,
@quario/pdf,
@quario/xlsx,
@quario/csv, or your own) consumes that
stream.
The engine does not escape text, emit HTML, or format pages or cells. Install a target when you want output. Use this package when you want to build a target, or to consume report structure as data.
Contents
- Install
- Quick start
- API
- The scope model
- Options
- Writing a render target
- Content Security Policy
- Documentation
- License
Install
npm install quarioESM-only, Node 22+. Dependencies: xprsn (expressions), sjabloon (templates), padvinder (JSONPath).
Quick start
import { quario, text } from "quario";
const schema = {
data: "$.orders[*]",
aggregates: { total: "sum:[email protected] * @.qty" },
detail: [{ type: "text", value: "{{ @.product }} — {{ @.price * @.qty }}" }],
footer: [{ type: "text", value: "Total {{ $.total }}" }],
};
const report = quario().report(schema);
for (const e of report.stream({ orders: [{ product: "Desk", price: 250, qty: 2 }] })) {
if (e.type === "item") console.log(e.role, text(e.tokens));
}
// detail Desk — 500
// report-footer Total 500report() compiles the definition once into a tree of closures. You render the compiled
report per dataset with report.render(target, data), passing the target and its configuration
at the call, so one compile serves any number of target configurations. Definition errors
surface at compile time, so compile at startup and render in your request path.
API
quario(options?)
Creates a configured instance with host-level controls: { query?, license?, locale?, currency?,
timeZone? } — the query budget, the license key, and the format configuration (en-US, no
default currency, UTC). Returns { report, plan, license }. license settles with this instance's
key verification as { licensed, licensee?, id? }.
report(schema, functions?)
Compiles a report and returns the compiled report. stream(data) is the raw event generator.
render(target, data) takes a self-naming { name, compile } object from a target factory
(e.g. html() from @quario/html) or your own.
A render is async: quario awaits key verification before the target sees its first event. A
malformed target throws synchronously from render, before the first event. Definition problems
throw earlier, at report().
The data pre-pass (select, filter, sort, aggregate) runs when you call the renderer, because a report header may interpolate a report aggregate. Event emission pulls on demand, so a consumer that stops early does not pay for the rest of the walk.
The compiled report carries metadata:
report.names; // free variable names the expressions read, excluding engine anchors
report.functions; // { name, arity } per registry function the definition calls
report.paths; // padvinder's deeply frozen dependency topology for the `data` queryEvents arrive in render order: report-start, report header items, then either the empty
items or the group/detail walk, then footer items, report-end. Group instances bracket their
content with group-start/group-end. A table detail yields table-start, one row per visible
row, one total-row per emitted total row, and table-end.
| Event | Carries |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| report-start | params, resolved report aggregates, optional page band closures, columns, style (the report default), marking, margin, headerHeight, and the instance's locale / currency / timeZone when set |
| item | role, path, tokens, optional style, run |
| image | role, path, bytes, format (png | jpeg), fit, optional alt, style, run |
| split-start | role, slots (each an optional width), optional style. One item or image per slot follows, then split-end |
| split-end | - |
| group-start | name, depth, key, aggregates, path, optional break, breakAfter, reset, columns |
| group-end | name, depth |
| table-start | path, header (cells, optional style), columns (each an optional width) |
| row | cells, optional style, run |
| total-row | cells, optional style |
| table-end | - |
| report-end | - |
The full field semantics are in the event stream reference.
Cells carry tokens. A cell is { tokens, style? }. Each token is either { literal }
(static template text, verbatim) or { value } (one interpolation's pre-format value, the
expression result before any stringification). This is the typed seam: a cell whose template is {{ @.amount }} holds one value token with the
number itself, so a spreadsheet consumer writes a real numeric cell. Total: {{ @.amount }} mixes
a literal and a value, so the cell is text only.
A page-number token names itself. A value token whose interpolation is exactly
{{ page.number }} or {{ page.total }} also carries field, holding that same string. The
value is the number the render used for this page. field says which page value the token stands
for, so a target whose own document format numbers pages can write its own live field there
instead. Anything computed from them carries no field.
Escaping is the consumer's job. A target that embeds values in markup must escape them at its own edge.
report-start.marking carries the evaluation wording when no license covers the render, or while
verification is still settling. Licensed streams omit it. Targets place the marking. They do not
author its wording. columns on report-start / group-start is the declared
page column count when
present. @quario/pdf and @quario/html lay page columns out.
xlsx never will.
text(tokens)
Joins a token stream to display text: literals verbatim, values through display() (a Date
as ISO 8601, nullish as the empty string), run styles ignored. Re-exported from sjabloon so consumers do not hand-roll the join.
typed(tokens, kind?)
Exactly one value token holding a finite number, a boolean, or a valid Date keeps that
pre-stringify value. Anything else, including a lone null, reports undefined and joins to
display text. Passing the cell's resolved format kind opts into the seam's one coercion: under
"date", an RFC 3339 string revives to the Date it names. Spreadsheet consumers use this for
real numeric cells.
@quario/csv is the short form.
styledRuns(tokens)
Groups a cell's tokens into its styled runs, in order — each { style, tokens },
with style null where the tokens carry none and the cell's own applies. Consecutive tokens
with equal styles are one run, which is lossless: equal styles render identically, so the grouping
survives a JSON round trip. Every built-in target reads a cell's runs through this, so a custom
target cannot drift from them.
walk(events, handlers) / breathe()
walk is the delivery driver every official target uses. Pass one render's event iterable and
per-event handlers keyed by type. A missing handler ignores that event. It pulls on demand,
returns the loop between batches, and delivers the opening event before pulling a second, so a
target can settle report-start (page bands, marking) there instead of draining the stream
itself.
breathe() is that return alone. Await it between batches of a loop you own. walk already
calls it for you.
splits(events)
A split reaches the stream as a bracket: split-start, one item or image per slot, split-end.
splits folds each bracket into one split event that carries the opening's fields and the slot
events under items, and passes every other event through, lazily. Read walk(splits(events),
handlers) and register a split handler where you want a split whole, and keep no bracket state of
your own. Every official target reads its splits this way.
Presentation helpers
A target that stringifies imports these rather than restating them, so every surface presents a cell the same way:
display(value)— the scalar ruletext()joins with: aDateas ISO 8601 UTC, nullish as the empty string, everything elseString(value).format(value, style?, options?)— presents a token under the cell's resolvedformatdeclaration (its kind and modifier, and forcurrencythe cell's own code). It answersundefinedwhen the kind does not apply, so the caller falls back todisplay().fractionDigits(style?, options?)— the digit count a resolvedformatdeclaration presents: the kind's own (two fornumberandpercent, a currency's minor units forcurrency) unless the declaration'sdigitsoverrides it. It answersundefinedwhere there is no count.currencyOf(style?, options?)— which code a money cell wears: its own, else the instance's.isReportBand(role)— whether a role names one of the report's own bands rather than a group's.STYLE_NAMES— the closed style vocabulary, in the spec's order, andRUN_STYLE_NAMES— the inline half of it, which is what a styled run may wear.isBoxName(name)— whether a style name is part of the box, a padding or a border part. The spec defines a row's box by this partition, and this function is its one home.
validate(schema, functions?)
Validates a definition without rendering it. It returns every problem as a path-prefixed string. An empty array means valid.
validate({ data: "$.o[*]", sort: [{ by: "[email protected]", dir: "up" }] });
// ['sort[0].dir: unknown sort direction "up"']report() throws on the first problem instead. Validation and compilation share one traversal,
so validate() can never disagree with what report() accepts.
quario().plan(schema, functions?, { targets }?)
The one traversal, whole — for hosts that validate and render in a loop, like an editor. Returns
{ report, problems, anchors, warnings }: the compiled report (null while the document has
problems), every problem structurally as { path, source?, message, diagnostic? } (the message
is exactly validate()'s string, and every problem keeps its own located diagnostic with
start/end offsets, not only the first), anchors, mapping each compiled source's schema path
to the anchors and group handles it reads — the unfiltered complement of names — and
warnings.
A warning is { path, source?, message }: the document declares something nothing will read. It
is not a problem at a lower severity, which is why it has no diagnostic — nothing raised, the
engine decided. Neither warning below locates into an authored source, so none carries a source
today. A warning is never fatal: a document carrying only warnings compiles and
renders, so report is null on problems alone. Two declarations warn today — a currency on
a cell whose format is not "currency", and a table where the author sized every column and the widths
total under 100 — and the list is advisory and deliberately incomplete, so a quiet one is not a
promise that every declaration will be read. validate() returns problems only.
targets checks the document against where it is going. A document may mark declarations it
cannot do without — "required": { "uppercase": true } at its root — and hand plan the
capabilities descriptors of the targets it is meant for. Each required declaration one of them
withdraws, or leaves unread as the page bands are, becomes a problem of its own at
required.<name>, which nulls the report as any other problem does. An approximated one does not:
the target rendered the intent as closely as it can. Pass no descriptors and nothing is checked,
which is what keeps the marking additive. It is never a render-time failure.
import { capabilities as csv } from "@quario/csv";
const { problems } = quario().plan(schema, undefined, { targets: [csv] });
// required.uppercase: the csv target withdraws "uppercase"const { report, problems, anchors, warnings } = quario().plan(schema);
for (const warning of warnings) console.warn(warning.message);
if (report) await report.render(html(), data);
else console.error(problems[0].path, problems[0].message);isDiagnostic(error)
True when a caught value is a located diagnostic: an error xprsn, sjabloon or padvinder minted — thrown by that engine, or re-thrown by quario with the engine original behind it. Authentication tests identity. An error that only matches the shape does not pass.
Location is not what the guard reads: quario's own verdicts on a document and a registered
function's own throw carry a location too, and neither is a diagnostic. Errors a report throws
name the path they failed at — and the offending source, where there is one — while keeping their
original type (SyntaxError, TypeError, RangeError). What a diagnostic adds on top is
metadata an engine vouches for: code, start/end offsets, and, for a query budget in place
of those offsets, limit and actual.
try {
await report.render(html(), data);
} catch (e) {
// Every error names where it failed; a diagnostic also carries an engine's own metadata.
if (isDiagnostic(e)) console.error(e.code, e.start, e.end);
throw e;
}The scope model
| Anchor | Is |
| ------------- | ------------------------------------------------------------------- |
| @ | The current row. Unbound outside detail rows, so @.x throws there |
| $ | The report root: $.input, $.params, and report aggregates |
| <groupName> | A named handle per enclosing group: .key plus its aggregates |
| run.<name> | Running accumulator values on the current detail row |
Each anchor is a distinct object. Absent reads are null, so x == null holds for a missing
field. Reading through a null base still throws, so use ?..
Options
quario({ query: { maxNodes: 10_000, maxDepth: 64, maxResults: 1_000 } }).report(schema, functions);query bounds the JSONPath data selection. Hosts set budgets through this API argument.
Definitions do not carry it. Failures point at data, keep their RangeError type, and
carry code, limit, and actual. Every render starts with fresh counters.
Writing a render target
Read the stream through the public API. Do not reach for engine internals. A complete, tested
Markdown target lives in the repository at example/markdown.js in under 60 lines of code. The built-in
targets use the same public API.
Two rules a target owes its users: escape or neutralize every value token at your own edge, and
map the style vocabulary to your own
formatting model rather than expecting CSS.
The stream is additive. The walk driver skips any event you register no handler for, so a target
that ignores a newer event (for example, example/markdown.js has none for image) keeps rendering
when a schema uses one.
A target that runs a loop of its own can await breathe() between batches to hand the event loop
back, as walk does for the targets that drive through it.
Content Security Policy
Expressions and templates compile to closures. There is no string-to-code path anywhere in this
package, so it runs under a script policy that omits unsafe-eval. The test suite enforces it
under Node's --disallow-code-generation-from-strings flag, a source scan, and a Playwright
harness that loads the published files under a strict CSP.
Documentation
The quario documentation is the reference. The report schema is the normative specification of what a report may declare, and the engine reference is this package's own API.
License
Commercial software with readable source. Evaluation is free, unlimited, and watermarked. Per-developer licenses at getquario.com. See the bundled LICENSE.
Pass your license key in the options. quario verifies it offline:
const q = quario({ license: "quario_..." });
await q.license; // { licensed: true, licensee: "Acme BV", id: "1-ACME" }