Repository navigation
Expressions
Generated from docs/expressions.md in the repository. Edit it there; changes made on the wiki are overwritten.
Design strings bind to data with {{ … }} placeholders: "Hello, {{ github.name }}",
"{{ stats.commits | compact }}", a layer's if and repeat.each. @readmint/expr compiles such a string once and
evaluates it against the data as often as needed. It never runs host code, never reaches a prototype, stays within
fixed step and size limits, and gives the same result in Node and in the browser.
A string is compiled as literal text with placeholders in it.
- A string that is exactly one placeholder (
"{{ count }}", nothing before or after it, not even a space) is an expression: it evaluates to the value with its type (number, boolean, string, null, array, object). Use this form for numeric, colour and boolean properties,ifandrepeat.each. -
Any other string (
"{{ a }} x {{ b }}"," {{ a }}","plain") is text: it evaluates to a string with each placeholder written into it: a number as JavaScript'sString(n)(1.5,-0as0,1e+21), a boolean astrueorfalse,nullas nothing. An array or an object in text is atypeerror. - The closing
}}is found by the scanner, so}}inside a string literal ({{ "}}" }}) does not end the placeholder. A}}outside any placeholder is literal text. A literal{{is written{{ "{{" }}. An unclosed{{and an empty placeholder aresyntaxerrors. - Results are plain text, not XML-escaped: the renderer escapes them where it writes SVG.
| form | syntax |
|---|---|
| number |
12, 1.5, 2e3, 1.5E-3 (no leading ., no sign: -1 is unary minus) |
| string |
'…' or "…", escapes \\ \' \" \n \t \u{1F600} (1 to 6 hex digits) |
| literal |
true, false, null
|
| array | [a, b, c] |
| name |
[A-Za-z_][A-Za-z0-9_]*; - joins a name only before a letter or _
|
| property |
a.b, a["key"], a[0], a[i]
|
| filter |
value | name, value | name(arg, …) (at most 4 arguments) |
| operators |
+ - * / %, == != < <= > >=, && || !, test ? a : b, ( … )
|
Precedence from low to high: |, ? :, ||, &&, == !=, < <= > >=, + -, * / %, unary ! -, then .name
and [key]. Binary operators group to the left, ? : to the right. Parentheses, brackets and filter arguments may
contain pipes: (name | upper) + "!". Nothing else can be called, nothing assigned, and there are no object literals
and no user-defined functions.
The hyphen rule keeps ids usable as names: props.my-label and repo-index are one name each, x-1 is x - 1.
a-b is one name, so a subtraction of two names needs spaces (a - b); an unknown hyphenated name says so.
-
Names. A name is looked up in the locals first (the
repeatitem and index, the names a layer'sletbinds, componentprops, which canvas bindings see as the props of the root instance, see design-format.md, andpalettein a design that declares one), then among the own top-level keys of the data. An unknown name is anunknown-nameerror. -
Palette. In a design with a
palette, the namepaletteis an object of the active mode's tokens, visible to every binding of the design (canvas, layers, components):{{ palette.accent }},{{ palette["bg-muted"] }}. A token no mode defines is a designunknown-referenceissue when it is written aspalette.<token>orpalette["<token>"]; a computed key (palette[name]) that names no token readsnull(design-format.md). -
Local bindings. A layer's
letbinds names in key order, each entry reading the entries before it (design-format.md):"let": { "n": "{{ github.contributions.total }}", "half": "{{ n / 2 }}" }lets the layer and its subtree write{{ half }}. An entry that reads itself or a later entry ("a": "{{ b }}", "b": "{{ 1 }}") is a renderbindingissue at the entry, found from the names the entry reads before anything is evaluated; it never falls back to an outer local or a data key of that name. Aletname may not be a top-level data key either (bindingat the entry, messagelet "x" hides the data name "x"; rename the let name or the data namespace), andprops,true,falseandnullare reserved. -
Missing data is
null. A missing property, an index out of range and any property ofnullread asnull, so{{ github.repo.owner.name }}on partial data isnull, not an error. Arrays have only integer indices (list[0];list.lengthisnull, use| length), and strings, numbers and booleans have no properties. -
Only data is read. A read takes an own data property of a plain object (prototype
Object.prototypeornull) or an item of an array.__proto__,constructor,toStringand every other inherited member read asnull; a getter is never invoked. A value that is not JSON (a function, class instance,Map,Date, getter,undefined,NaN,Infinity) is aninvalid-dataerror where it is read. Data must be plain JSON values (aProxyis the caller's own code and is not detected). -
Arithmetic.
+adds two numbers or joins two strings;- * / %take numbers. Any other mix is atypeerror, a result that is not finite (1 / 0) isnot-finite. -
Comparison.
==and!=compare strings, numbers, booleans andnullstrictly (1 == "1"is false); anything may be compared withnull, but two arrays or objects are atypeerror.< <= > >=compare two numbers or two strings (by UTF-16 code unit). -
Truthiness.
false,null,0,""and the empty array are falsy, everything else truthy (also{}and"0").!gives a boolean;a && banda || bgive one of their operands (name || "anonymous") and skip the other side. -
Filters receive the value on their left. Every filter except
defaultpassesnullthrough unchanged, so{{ user.nickname | upper | default("-") }}is-whenuserhas nonickname(an unknown root name such as{{ nickname }}stays anunknown-nameerror).
| filter | input | result |
|---|---|---|
number(decimals=0, locale?) |
number |
1,234.5 with the locale's separators; decimals 0 to 20 |
compact(decimals=1, locale?) |
number |
1.2K, 3.4M, 1B, 2T; trailing zeros dropped, 999950 is 1M
|
percent(decimals=0, locale?) |
number |
0.25 is 25% (25 % in de, fr, es, cs) |
round(decimals=0) |
number | a number, rounded half away from zero (2.5 is 3, -2.5 is -3) |
floor |
number | the next integer down |
ceil |
number | the next integer up |
abs |
number | the absolute value |
sqrt |
number | the square root; a negative number is a not-finite error |
min(b), min, min(key)
|
number or array | the smaller of the value and b; of an array, its smallest item (of key) |
max(b), max, max(key)
|
number or array | the larger of the value and b; of an array, its largest item (of key) |
clamp(low, high) |
number | the value within [low, high]; low > high is an argument error |
date(pattern?, locale?) |
ISO 8601 string or ms | the date in UTC written with pattern, else the locale's default pattern |
upper |
string | upper case by a fixed 1:1 table (see Determinism) |
lower |
string | lower case by a fixed 1:1 table (see Determinism) |
trim |
string | without leading and trailing white space (a fixed set, see Determinism) |
truncate(length, suffix="…") |
string | at most length code points, the suffix included when it was cut |
padStart(width, char=" ") |
string or number | padded on the left to width code points with char (exactly one code point) |
default(value) |
anything |
value when the input is null, else the input |
length |
string or array | code points of a string, items of an array |
join(separator=", ") |
array | the items written as in text, separated; arrays and objects in it are a type error |
first |
array or string | the first item (null when empty) or code point |
last |
array or string | the last item (null when empty) or code point |
exclude(key, values) |
array | the items whose key (the item itself for a null key) is strictly equal to no item of values
|
sum, sum(key)
|
array | the sum of its numbers (of key), added left to right; 0 when empty |
textWidth(font, size, letterSpacing=0) |
string | the width in pixels the text takes drawn with font asset font at size (see below) |
truncateWidth(width, font, size, letterSpacing=0) |
string | the text when it fits width pixels, else its longest start of whole clusters (minus the whitespace it ends on) plus … that does; "" when … does not fit |
min and max of an array give null when it is empty; an item that is not a number is a type error. With a string
key (days | max("count"), a null key means none) every item must be an object and its own property key a number:
an item that is not an object, or a missing or non-number property, is a type error, and the key is read like .key
(never a getter or a prototype). A key that is not a string is an argument error, as is a number without b. The key
is charged once, on top of one step per item.
sum reads its items (with key, their property) exactly like min and max and has the same type and argument
errors; a sum past the number range is not-finite.
exclude drops the items whose key value equals a value of the array values by type and value, case included:
github.languages | exclude("name", ["TypeScript"]) leaves out TypeScript, "1" does not match 1, and a null key
compares the items themselves. With a key, an item that is not an object is a type error, a missing property reads
null, and an item whose key value is an array or object is kept. A values that is not an array, or one holding an
array or object, is an argument error. The result is a new array in the order of the input. Both filters charge one
step per item, exclude one more per value, and each string compared or looked up costs its length as in text filters.
Arguments are expressions; a null argument means its default. Decimals, lengths and widths must be integers. Number
filters round with toFixed (exact on the binary value: 1.005 | number(2) is 1.00); from 1e21 on a number is
written as String(n) (1e+21). -0 and values that round to zero are written without a sign.
textWidth and truncateWidth measure a text as the renderer draws it, so a design can size or place something by
the drawn width of a bound text instead of guessing from length. The renderer gives the scope its measurer (see API);
without one both are unknown-filter errors. That is so wherever an expression is evaluated before or outside a
render: the src binding of a remote image, which the CLI and the Action evaluate to fetch the image, cannot measure
(the image is refused with image-binding, saying the filter needs a renderer). font is the id of a font asset of the design (any other value, an
unknown id included, is an argument error), size is above 0 and at most 1000, letterSpacing from -100 to 100.
The width is that of a text layer with this font, size and letter spacing and no width: its fallbacks apply, a
character no font has takes no space, a line break starts a new line and the widest line counts. A layer's
fontWeight is not applied: name the font asset of the weight that is drawn. A width scales with the size when the
letter spacing scales too, so 840 / (name | textWidth("display", 1, -0.01)) is the size at which a name drawn with
letterSpacing -0.01 × size fills 840 pixels. truncateWidth cuts between clusters (never inside an emoji sequence
or a ligature) and finds the cut by bisection; where negative letter spacing or kerning makes a shorter start wider,
the cut it finds fits but may not be the longest that would. A text of more than 10000 UTF-16 code units is
too-large. Each measurement is charged the steps of its text, truncateWidth once per text it lays out (the whole
text, the ellipsis, one per bit of the text's length); the renderer also counts the shaping against its own bound.
Measuring draws nothing: it embeds no font and never warns missing-glyph. truncateWidth cuts one line; a text that
wraps is capped at a number of lines by the text layer's maxLines (design format), which
cuts its last drawn line the same way.
The scope's locale (createScope(data, { locale }), default en) applies to number, compact, percent and
date; a locale argument overrides it. A tag is matched case-insensitively as a whole, then by its language subtag
(de-AT uses de); anything else is unknown-locale. The tables are fixed in the package and never come from Intl
or the host, because ICU data differs between Node and browsers.
A design states its locale once with the root property locale (en, de, fr, es or cs, exactly these lowercase
tags); the renderer hands it to the scope, so every expression of the design formats with it. The first of these wins:
- the filter's
localeargument, - the render option
locale(the CLI--locale), an explicit choice of the caller for one render, - the design's
locale, -
en.
The design takes no region tags (cs-CZ is not valid there): a region table can later be added as a new value without
changing the meaning of a design that is valid today. supportedLocales from @readmint/expr lists the tags.
| locale | decimal | group | grouping from | percent | compact suffixes (K, M, B, T) | default date |
|---|---|---|---|---|---|---|
| en | . |
, |
4 digits | 25% |
K M B T
|
MMM d, yyyy |
| de | , |
. |
4 digits | 25 % |
Tsd. Mio. Mrd. Bio.
|
dd.MM.yyyy |
| fr | , |
U+202F (narrow) | 4 digits | 25 % |
k M Md Bn
|
d MMM yyyy |
| es | , |
. |
5 digits | 25 % |
mil M mil M B
|
d MMM yyyy |
| cs | , |
U+00A0 (no-break) | 4 digits | 25 % |
tis. mil. mld. bil.
|
d. M. yyyy |
The space before % and before a compact suffix is U+00A0 NO-BREAK SPACE. Separators, grouping, the percent pattern,
month and weekday names (wide and abbreviated, format context: Czech months in the genitive, 5. ledna), AM/PM
markers and the default date pattern are Unicode CLDR 48 values. The compact suffixes are Readmint's own: CLDR's short
forms, except German thousands (Tsd., CLDR writes 1234) and Spanish billions (mil M, CLDR writes 1234 M). The
CLDR data is used under the Unicode License v3; its copyright and permission notice is in packages/expr/src/locales.ts and packages/expr/LICENSE-CLDR.
The Spanish AM/PM markers (a. m., p. m.) hold U+202F NARROW NO-BREAK SPACE.
date takes an ISO 8601 string or an integer of epoch milliseconds, and works in UTC only: no clock, no host time
zone, no Date.parse.
- Accepted text:
YYYY-MM-DD, optionally followed byTHH:mm,THH:mm:ssorTHH:mm:ss.f(1 to 9 fraction digits, milliseconds kept) and thenZ,+HH:mm,-HH:mmor nothing (UTC). A date alone is midnight UTC; an offset is converted to UTC (2024-03-05T00:30+01:00is March 4). - Years 0000 to 9999 of the proleptic Gregorian calendar, before and after any offset; 29 February only in leap years.
Anything else is
invalid-date.
| pattern | output | pattern | output |
|---|---|---|---|
yyyy, yy
|
2024, 24
|
H, HH
|
hour 0-23: 9, 09
|
M, MM
|
3, 03
|
h, hh
|
hour 1-12: 9, 09
|
MMM, MMMM
|
Mar, March (locale) |
a |
AM / PM (locale) |
d, dd
|
5, 05
|
m, mm
|
minute: 7, 07
|
EEE, EEEE
|
Tue, Tuesday (locale) |
s, ss
|
second: 9, 09
|
Text in single quotes is literal ('at' HH:mm), '' is a quote, and any other character that is not an ASCII letter
is copied. Any other ASCII letter, or another run length of these letters, is an argument error, so a future field
cannot silently change an existing pattern. A pattern has at most 64 characters.
| limit | value | error code |
|---|---|---|
| source | 10000 code points per compiled string (as the format counts), before scanning | too-long |
| nesting | 64 levels of parentheses, brackets, unary operators, ? : and filter arguments |
too-deep |
| steps | 100000 per evaluation, or a shared budget (see API) | budget |
| strings | 100000 UTF-16 code units for any string produced | too-large |
| arrays | 10000 items in an array literal and in a repeat
|
too-large |
| filter args | 4 per call | argument |
| locals | 256 in a scope a let extends (repeat names, props and let names together) |
too-large |
| measured text | 10000 UTF-16 code units for textWidth and truncateWidth
|
too-large |
| error excerpt | 64 characters of the source |
A step is one expression node, plus one per 16 characters or one per item a filter or operator processes: string
+, ==, != and < <= > >=, a string used as an index, literal text, and every filter that reads a string or
an array. Work is
charged and sizes are checked before the work is done, so a hostile expression stops early. Copying a scope is charged
too, for every copy: a let one step per local it copies plus one per name it binds, a repeat item one per local
plus two (the item and the index), and the renderer's component props scope one per local plus one. Long flat chains
(a + b + …, a.b.c…, a | f | g …) are evaluated by loops; only nesting counts against the depth limit.
Compiling and evaluating never throw; they return { ok: false, error }:
interface ExpressionError {
code: ExpressionErrorCode;
message: string; // ends with "at column <start + 1>"
start: number; // 0-based UTF-16 offsets into the compiled string; end exclusive
end: number;
excerpt: string; // the {{ }} placeholder around start, at most 64 characters, "…" where cut
}Messages name types, operators, filters and names from the design, never a value from the data. In the excerpt,
control characters, the line and paragraph separators, the twelve Bidi_Control characters and lone surrogates are
written as \u{…}.
| code | meaning |
|---|---|
syntax |
the placeholder is not valid: unexpected token, unclosed {{ or string, bad escape |
too-long |
the string is longer than 10000 characters |
too-deep |
nesting deeper than 64 levels |
unknown-name |
neither a local nor the data has this name |
unknown-filter |
no filter has this name, or textWidth / truncateWidth without a measurer (outside a render) |
argument |
a filter got too many or too few arguments, or one of the wrong kind or range |
type |
an operator or filter got a value of the wrong type |
not-finite |
arithmetic gave an infinite or undefined number, or sqrt of a negative number |
too-large |
a string, an array literal or a repeat would pass its size limit, or a scope its 256 locals |
budget |
the evaluation took more steps than its budget |
unknown-locale |
the locale is not one of en, de, fr, es, cs |
invalid-date |
not an ISO 8601 date or epoch milliseconds in the years 0000 to 9999 |
invalid-data |
the data holds something that is not a JSON value where it was read |
import {
compileTemplate,
createScope,
createBudget,
evaluateCondition,
expandRepeat,
ExpressionLimits,
toText,
} from "@readmint/expr";
const template = compileTemplate("{{ github.followers | compact }} followers"); // ExprResult<Template>
if (!template.ok) report(template.error);
const budget = createBudget(); // optional: one budget shared by a whole render
const scope = createScope(data, { locale: "de", budget }); // data: a plain JSON object
createScope(data, { measurer }); // a TextMeasurer: what textWidth and truncateWidth measure with (the renderer's)
template.value.evaluate(scope); // ExprResult<ExprValue>
template.value.kind; // "expression" (exactly one placeholder) or "text"
evaluateCondition(ifTemplate, scope); // ExprResult<boolean>, for a layer's `if`
expandRepeat(eachTemplate, scope, { as: "repo", index: "i" }); // ExprResult<Scope[]>, one child scope per item
scope.with({ props: { label: "Stars" } }); // a child scope with locals, e.g. component props
scope.extend(["n", "half"], (name, inner) => valueOf(name, inner)); // ExprResult<Scope>, names bound in order
scope.localCount; // the locals the scope holds: what copying them into a child scope costs
template.value.names; // ReadonlySet<string>: the names the placeholders read, e.g. {"n"} for "{{ n.total / 2 }}"
toText(value); // string | undefined: a value written as a placeholder in text is; undefined for an array or object-
ExprResult<T>is{ ok: true, value: T } | { ok: false, error: ExpressionError };ExprValueis a JSON value. - A
Templateis frozen and reusable; compile each design string once. -
expandRepeatdefaultsastoitemandindextoindex(0-based); both must be names an expression can read (repo-index, notrepo-1,1stortrue) and differ, elseargument;nullrepeats nothing, anything but an array is atypeerror, more than 10000 itemstoo-large. With a shared budget, items x (scope.localCount+ 2) steps are charged before any child scope is made (budget). Child scopes keep the outer locals and data. -
scope.extend(names, valueOf)(alet) copies the locals once and binds each name tovalueOf(name, inner), whereinneralready holds the names before it and is valid only during that call. Before anything is evaluated, the existing locals plus the new names are checked againstExpressionLimits.maxLocals(256; more failtoo-large), then that many steps are charged to the budget (budget); either error has an empty excerpt. WhenvalueOfthrows, the copy is dropped. - A
TextMeasurerhaswidth(text, font, size, letterSpacing)(pixels) andtruncate(text, width, font, size, letterSpacing)(the cut text); both returnundefinedfor a font id the design does not have. Child scopes keep it. A scope built by hand may leavemeasurerout. - Without a shared budget each
evaluategets a fresh one of 100000 steps; withcreateBudget(n)passed tocreateScope, every evaluation in that scope and its children draws from the samensteps, and an exhausted budget stays exhausted.createBudgetthrows aRangeErrorfor a negative or fractionaln: that is a caller bug, never design input.
The same template, data and locale give the same value in Node and in every browser: no Intl, no host locale or time
zone, no clock, no randomness; numbers are written by toFixed and String(n), both exactly specified, and strings
are compared by code unit. sqrt is Math.sqrt, which ECMA-262 defines as the exact square root rounded to the
nearest double (unlike Math.exp or Math.sin, which it leaves implementation-approximated), so every conforming
engine gives the same bits; the corpus pins the results in Node and Chromium. min and max of an array reduce with
Math.min / Math.max, so -0 is below 0 in any order. upper and lower never use toUpperCase /
toLowerCase, whose mappings change with the engine's Unicode version: they use a fixed table of the simple (one to
one) Unicode case mappings for Basic Latin, Latin-1 Supplement, Latin Extended-A, Greek and Coptic, and Cyrillic, and
leave every other character unchanged. Nothing expands: ß stays ß, İ lowers to i, and Σ always lowers to
σ. trim removes a fixed set: tab, line feed, vertical tab, form feed, carriage return, space, U+00A0, U+1680,
U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and U+FEFF. A shared corpus of expressions is checked in Node
and in headless Chromium. textWidth and truncateWidth give what the renderer's measurer gives: it adds the
font's advances in a fixed order, the same numbers in Node and every browser (renderer).
Home · Getting started · Examples · Source
See it first
Start here
Guides
Reference
Contributors