@quario/html
v0.11.1
Published
Tiny, escape-by-default HTML render target for quario. Semantic tables and a stable class contract.
Maintainers
Readme
@quario/html
The HTML render target for quario. Renders a report definition to an HTML fragment with semantic tables, a stable class contract, and every interpolated value escaped.
Contents
Install
npm install quario @quario/htmlThe engine is a peer dependency, installed beside the target. ESM-only, Node 22+, and browser-ready through any standards-based ESM bundler.
Quick start
import { quario } from "quario";
import { html } from "@quario/html";
const schema = {
data: "$.orders[*]",
aggregates: { total: "sum:[email protected] * @.qty" },
groups: [
{
name: "region",
by: "[email protected]",
aggregates: { subtotal: "sum:[email protected] * @.qty" },
header: [{ type: "text", value: "{{ region.key }}", style: { bold: true } }],
footer: [{ type: "text", value: "Subtotal {{ money(region.subtotal) }}" }],
},
],
detail: {
columns: [
{ header: "Product", value: "{{ @.product }}" },
{ header: "Amount", value: "{{ money(@.price * @.qty) }}", style: { align: "right" } },
],
},
footer: [{ type: "text", value: "Grand total {{ money($.total) }}" }],
};
const data = { orders: [{ region: "North", product: "Desk", price: 250, qty: 2 }] };
const report = quario().report(schema, { money: (n) => "$" + n.toFixed(2) });
const page = await report.render(html(), data);<div class="q-report">
<div class="q-group" data-group="region">
<div class="q-item q-group-header" style="font-weight:bold">North</div>
<table class="q-table">
<colgroup><col><col></colgroup>
<thead><tr><th>Product</th><th>Amount</th></tr></thead>
<tbody><tr><td>Desk</td><td style="text-align:right">$500.00</td></tr></tbody>
</table>
<div class="q-item q-group-footer">Subtotal $500.00</div>
</div>
<div class="q-item q-report-footer">Grand total $500.00</div>
</div>The output is a fragment: no <html>, no <head>, no stylesheet of its own — the one
<style> it writes is an @page { margin } rule when the schema declares page.margin. Wrap it
in your own page shell and stylesheet.
API
html(options?)
The target factory takes this target's host options, validates them at the call, and returns
the target you pass to render. The factory refuses an option it does not know. It throws a TypeError at the call for an unknown key. It throws one also for a key with a value of the wrong type.
report() compiles once and report.render(html(), data) resolves the fragment. Compile at
startup and render per request. Definition problems throw at report(), at compile time.
Two options. { paths: true } stamps data-q-path="<schema path>" on each element whose event
carries one (items, images, group containers, the table and its cells), mapping rendered output
back to the definition behind it. Off by default. { fonts } maps a declared family name to the
CSS font-family value it should emit — a custom property, a font stack, a quoted name —
consulted before the three built-in generics. Names match case-insensitively, and the factory
rejects a value holding ; or } when it constructs the target:
html({ fonts: { "Instrument Sans": "var(--font-instrument-sans)", mono: "var(--font-ibm-plex-mono)" } });const report = quario().report(schema, funcs);
const page = await report.render(html(), data);
report.names; // free variable names the expressions read
report.functions; // registry function names the definition calls
report.paths; // the data query's frozen dependency topology
report.stream(data); // the raw event generator, if you want events insteadEngine-level options (query budgets, the license key) live on the instance
(quario({ query, license })). See the engine README.
Rendering is asynchronous and returns the loop between batches, so a large report never blocks the host. Compilation stays synchronous. Render-time failures reject with the same located errors.
Output contract
These classes are the contract host CSS targets. They are stable, and changing them is a breaking change.
| Emits | For |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <div class="q-report"> | The fragment's root, holding every band |
| <a href> | A styled run that declares href, inside that run's own span. The URL is escaped like every other interpolated value, and the engine admitted it against the host's scheme allowlist before this target saw it |
| <div class="q-item q-<role>"> | Every item. Roles: q-report-header, q-empty, q-group-header, q-detail, q-group-footer, q-report-footer |
| <div class="q-group" data-group="name"> | Each group instance, wrapping its header, nested content, and footer. Adds q-break where the group's break turns a page before the instance, and q-break-after where one turns after it |
| <table class="q-table"> | Each table, with a real colgroup, thead, tbody, and a tfoot around the total rows the walk emits. No tfoot when total is absent or every row of it is hidden. A spanning cell carries colspan |
| <div class="q-item q-image q-<role>"> | Each image item, holding one <img>. Its src is a base64 data: URI of the event's bytes, and its width/height are the picture's natural size. fit sets max-width:100% or width:100% on it, paired with height:auto. The target escapes the rendered alt |
| <div class="q-columns" style="column-count:<n>"> | The content between a node's own bands when it declares columns. On the root, that is the body between report header and footer. On a group, it is inside that instance's q-group |
| <div class="q-split q-<role>"> | Each split, carrying inline display:flex. Its slots are <div class="q-slot"> carrying inline display:grid and their share (a slot's valign as align-content), each holding the slot item's ordinary container |
A column width becomes an inline width:<n>% on its <col>. A row's style lands on its <tr>, except the box, which the engine has already resolved onto the cells (each <td> carries box-sizing:border-box). The classes above belong to this
target. The schema never speaks in CSS. Displaying a fragment that contains images under a Content
Security Policy needs img-src data: (see
Trust and CSP).
Style declarations map to inline CSS (bold → font-weight:bold or font-weight:normal, size → font-size:<n>pt,
family: "mono" → font-family:monospace, …). Occupy-a-line and newline-as-break are not inline: they hang off
.q-item and .q-table th, td in @quario/html/style.css, the same way .q-break
honors a leading page break. The class is the hook. The rule is a reference default a host overrides on source order.
A fragment without that sheet still carries the classes and the text, including newlines. It does not occupy or
break until some stylesheet says so. This target supplies no defaults in the markup: no weight or size per band
role, no leading, no padding, no spacing between bands, no borders. An inline style attribute would beat yours
in the cascade and force !important on you. The defaults and the honor rules ship as an ordinary stylesheet
instead. The PDF and XLSX targets carry theirs built in, because their consumers have no stylesheet. An empty
row set still emits the table, its header, and an empty <tbody>. A hidden table cell keeps its <td>, empty.
This target paginates nothing: it ignores schema page bands (page furniture belongs to your print CSS)
and maps each edge a group's break turns to its own class. Page columns are laid out:
a node declaring columns wraps the content between its own bands in the q-columns container
above, and the browser flows it.
The full contract is The HTML target, with each declaration's fate in the support matrix.
Unlicensed marking
An unlicensed render opens the fragment with <div class="q-unlicensed"> holding the wording
from report-start.marking as escaped text. A licensed render emits no badge. The element and its
position are normative. Visual treatment belongs to the host. The shipped stylesheet leaves the
badge unstyled, because a watermark would escape the fragment's box onto the host page.
Escaping
This target escapes every interpolated value. {{{ }}} is a definition error, so no schema syntax
can exempt a data value from escaping. It escapes every generated attribute value too — inline styles,
data- attributes, src, alt and colspan. Class names are constants the target owns and never carry data.
Literal template text passes through verbatim as author-controlled markup. A definition is trusted configuration. Its data is not, and data can never reach the document unescaped.
Printing to PDF
For a browserless, deterministic document, use
@quario/pdf, same schema, same API, a
Uint8Array out. Use the HTML print route below when you want full CSS typography and already
have a browser or Paged-CSS engine in your pipeline.
Host CSS targets these classes:
- Render the fragment and wrap it in a document with two stylesheets inlined, in order:
@quario/html/style.cssbelow, then your own. - The shipped sheet already puts band behavior on the emitted classes:
.q-item { min-height: 1lh; white-space: pre-line }occupies a line and breaks on newlines,.q-group { break-inside: avoid }keeps a group header with its rows,.q-table tr { break-inside: avoid }keeps a row whole,.q-break { break-before: page }and.q-break-after { break-after: page }honor the schema's own break hints, andtheadrepeats per printed page. Yours adds the page geometry in@page, which only you can decide. - Print with a headless browser (
page.pdf()in Playwright or Puppeteer, page numbers via the footer template) or a Paged-CSS engine (WeasyPrint or Prince, with page numbers via@pagemargin boxes).
The reference stylesheet
The package ships the default look as a real stylesheet, @quario/html/style.css: the band-role
weights and sizes, the table's structure (full width, collapsed borders, cell padding, an
unstyled cell pinned to the top of its row), occupy and newline-as-break on .q-item and table
cells, and the four pagination rules above. It carries no look beyond that — no rule under the
header row, no bold totals, no stroke above the report footer — because no other target has one
either. Add them to your own sheet if you want them. Link it, import it through a bundler, or read and inline it.
Plain Node cannot import a .css file, so on the server:
import { readFileSync } from "node:fs";
const css = readFileSync(new URL(import.meta.resolve("@quario/html/style.css")), "utf8");
const page = `<!doctype html><html><head><style>${css}</style></head><body>${fragment}</body></html>`;Its selectors are contract. Its rules are not. It is a reference default meant to be
overridden. Every rule is ordinary specificity, so your own stylesheet loaded after it wins
without !important anywhere. Link nothing at all and you get unstyled markup.
Two things it leaves to you: @page geometry and body type, which only a host can decide, and
any watermark treatment of .q-unlicensed, left as plain text here because position: fixed
would paint over your whole page. example/print.css in the repository is a host's half that does
both, and example/print.js is the complete pipeline.
Documentation
The quario documentation is the reference.
The report schema is the normative
specification of what a report may declare, and
@quario/html 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 once, on the instance. quario verifies it offline:
const q = quario({ license: "quario_..." });
await q.license; // { licensed: true, licensee: "Acme BV", id: "1-ACME" }