Skip to main content
The language of Tassyir’s declarative storefront blocks: the HTML an app adds to a merchant’s store pages, written as a template in a subset of Liquid with an optional stylesheet. This package validates a block (every error with a code, a line, a column and a fix) and renders it. The Tassyir API checks uploads with it, the store builder previews with it, the storefront renders with it, and the tassyir CLI uses it for tassyir block preview: one code, the same answer everywhere. Not live yet. Storefront blocks open with the Tassyir app marketplace. Until then the API refuses [[storefront.blocks]] in a manifest. The language is published now so that you can write and test blocks. It has no dependency and reads no file, clock, network or randomness. It never uses Intl. The same template and data give the same bytes in Node and in every browser. ESM and CommonJS, named exports only.

Install

Example

A block that shows a product’s rating, read from the app’s data:
Rendered (byte for byte, the README’s spec checks it):
The full language (objects, tags, filters, HTML and CSS rules) is in the storefront blocks guide.

What you get back

validateBlock, compileBlockTemplate and renderBlock never throw. They return { ok: true, … } or { ok: false, errors }. Each error has this shape:
Errors are sorted by position, at most 50 per file (the last one says how many more there were). The same input always gives the same errors in the same order.

Limits

All in BLOCK_LIMITS, the same everywhere: Render time is counted in steps, never measured with a clock: every text run, output, tag, loop iteration and condition costs a step, and every filter and comparison one more per 256 characters or items it touches. The same template and data always finish, or always stop at the same step. The heaviest render the budget allows takes a few milliseconds.

How it stays safe

A block runs on the page that holds the store’s order form, so the language is built to make anything else impossible:
  • Every output is escaped. There is no raw filter and no way to turn escaping off. Only Tassyir’s own stars filter makes markup, from fixed parts.
  • No prototype, method or getter is reachable. The data a render is given is copied into maps and frozen arrays; a template reads own keys and indexes only.
  • The HTML is rebuilt, not filtered. Every render’s output is parsed and written out again from an allowlist (BLOCK_HTML_ALLOWLIST): allowed elements and attributes only, every value checked and quoted, every text escaped, comments and raw-text elements removed, SVG shapes only inside <svg>. The browser parses that canonical HTML, never the template’s. Sanitising the output again leaves it unchanged.
  • Only declared data reaches the template. A compiled template keeps its declaration’s app data keys and setting keys, and a render passes the template only those, through every alias ({% assign d = app.data.store %}).
  • Execution is bounded by counting, never by a clock, so the bound holds on every machine and cannot be raced.
  • The box is the host’s. This package decides what the HTML and the stylesheet may say; it cannot keep a block’s pixels inside its section. The page that renders a block must contain it: contain: paint (and isolation: isolate) on the block’s section. The stylesheet check refuses position: fixed in every spelling, but position: absolute, transforms and the page’s own utility classes are ordinary CSS that only containment keeps in the box.
  • Nothing differs between machines: no Intl, no clock, no randomness, no locale method, no Unicode property table.
Why not a Liquid engine with features switched off: an engine brings its tags (file includes among them), its filters (the clock, locale formatting) and its options to keep switched off release after release. Its time limit is a wall clock checked between renders, not inside a loop. This package has only what blocks need, so there is nothing to switch off. The proof is in the specs: a seeded corpus of more than 1,600 templates and data planted with known sanitiser bypasses (tag and attribute smuggling, raw-text and SVG/MathML parser switches, javascript: and data: URLs in their encodings, character-reference tricks, bidi and zero-width characters, CSS url() and expression(), deep nesting, huge loops), each rendered and parsed by a spec-compliant HTML parser. Not one forbidden element, attribute or URL comes out. Every limit stops its runaway case. The browser bundle renders the corpus to the same bytes as Node.

API

Constants: BLOCK_LIMITS, BLOCK_ERROR_CODES, BLOCK_FILTERS, BLOCK_HTML_ALLOWLIST, BLOCK_ROLES, BLOCK_NAMED_ENTITIES, STOREFRONT_THEME_VARIABLES, BLOCK_PRODUCT_FIELDS, BLOCK_STORE_FIELDS, BLOCK_FORLOOP_FIELDS, BLOCK_OBJECTS, BLOCK_DATA_OWNERS, IMAGE_URL_WIDTHS, IMAGE_CDN_ORIGIN, RENDER_STEP_COSTS, BLOCK_DIALECT_VERSION.

Checking it in real browsers

The specs run the browser bundle in an empty JavaScript context. To run it in real engines (local, needs Chrome; macOS’s JavaScriptCore when present):
It renders the corpus from the built package in Node, headless Chrome and JavaScriptCore, compares the bytes, and checks every rendered block with Chrome’s own HTML parser.