ChaiBuilder Logo

Pipes

A data binding is a data path, and a pipe is a named formatter applied to whatever that path resolves to:

{{blog.publishedAt | date 'long'}}
{{blog.price | currency 'USD' 2}}
{{blog.title | trim | uppercase}}

Pipes are the only transformation a binding can do. There is no JavaScript inside {{ }}, so anything the built-ins do not cover is either shaped in the data provider or added as a pipe you register yourself. Once registered, a pipe is available to every editor on the project in the settings panel, with the same name, arguments, and preview as the built-ins.

This page is the full reference: what each built-in does, the type rules that decide which pipes can follow which, and the registry API.

Two kinds of pipe

Every pipe declares what it returns, and that splits them in two.

Returns Where it can be used
Value pipes a formatted value Any bindable text prop, and inside a visibility condition as long as a boolean pipe finishes the chain
Boolean pipes true or false Conditional visibility only

A boolean pipe in a text field is rejected: {{blog.price | gt 0}} in a heading renders empty, because a visibility test is not a value. The formatter picker in the panel enforces the same split, so it only offers boolean pipes on the Visibility condition.

Syntax

path | name arg arg | name arg
  • Arguments are separated by spaces, and pipes by |.
  • Arguments are literals only: 'quoted strings', numbers, true, false, null. A data path cannot be an argument, so {{blog.price | currency blog.currency}} is invalid.
  • Single or double quotes both work, and \', \", \\, \n, \t escape inside them. A quoted argument may contain |.
  • Pipes run left to right. Each one receives the previous one's output.
  • Limits: 10 pipes per binding, 10 arguments per pipe, 500 characters, one line.

Built-in value pipes

Pipe Accepts Returns Arguments Notes
default any any value literal (required) Substitutes the fallback when the input is null, undefined, "", or an empty array
trim string string - Leading and trailing whitespace
uppercase string string -
lowercase string string -
capitalize string string - First character only. The rest is left as-is, so iPhone SE is not lowercased
join array string separator string (default ,)
number number, string string fractionDigits number Locale formatting. Numeric strings are accepted; anything non-numeric renders empty
money number, string string mask string (required, default $xx), fractionDigits number Formats the number, then replaces the xx token in the mask
currency number, string string currency string (required, default USD), fractionDigits number Locale currency formatting
date date, string, number string style one of short, medium, long, full (default medium), timeZone string (default UTC) Accepts a Date, an ISO string, or a timestamp

Details that decide whether output appears at all:

  • fractionDigits is honoured only as an integer from 0 to 20. Anything else is ignored and the locale default applies, rather than erroring.
  • money requires exactly one xx token: '$xx' and 'xx USD' work, '$' and 'xx-xx' render empty.
  • currency needs a three letter ISO code. 'usd' is fine, 'dollars' renders empty.
  • date renders empty for anything it cannot parse, and formats in UTC unless you pass a timezone. That default is deliberate: a published page is cached and shared, so a server local timezone would leak into the HTML.

Built-in boolean pipes

Only for the Visibility condition. All of them return a real true or false, which is what the visibility rule requires.

Pipe Accepts Arguments True when
equals any value literal (required) The input is exactly the argument. Object.is, so no type coercion: '5' does not equal 5
notEquals any value literal (required) The input is anything else
gt number, string value number (required) Input is greater than the argument
gte number, string value number (required) Input is greater than or equal
lt number, string value number (required) Input is less than
lte number, string value number (required) Input is less than or equal
not any - The input is falsy
truthy any - The input is truthy
empty any - The input is null, undefined, "", or an empty array

The four comparisons convert numeric strings, so '42' | gt 0 is true. Anything that is not a finite number on either side is false rather than an error.

Type rules

Each pipe declares the input types it accepts, and the runtime checks the actual value against that list before calling it. A mismatch invalidates the whole binding, which renders empty in a text prop and hides the block in a visibility condition. Nothing is coerced on your behalf.

The types are string, number, boolean, array, object, date, null, and any.

Two consequences worth internalising:

A missing value has type null. So {{blog.subtitle | uppercase}} on a record with no subtitle is invalid, not empty-then-uppercased, because uppercase accepts only string.

default goes first, not last. default accepts any, so it is the pipe that turns a missing value into something the rest of the chain can work with:

{{blog.subtitle | default 'Read more' | uppercase}}   works
{{blog.subtitle | uppercase | default 'READ MORE'}}   invalid when subtitle is missing

In the panel this is hard to get wrong, because the Add formatter picker only lists pipes that accept the value currently flowing into that position. Hand-typed bindings are where it bites.

Locale

number, money, currency, and date format through Intl using the page's language, falling back to the site's fallback language and then to en. The same binding renders 1,234.50 on the English page and 1.234,50 on the German one, with no per-language authoring.

Custom pipes receive the same locale string, so a pipe that formats anything human-readable should use it rather than hard-coding a format.

Registering a custom pipe

import { registerChaiPipe } from 'chaipro/registry'

registerChaiPipe({
  name: 'readingTime',
  label: 'Reading time',
  description: 'Turns a word count into "N min read".',
  accepts: ['number'],
  returns: 'string',
  args: [{ name: 'wordsPerMinute', label: 'Words per minute', type: 'number', default: 200 }],
  transform: ({ value, args }) => `${Math.ceil(Number(value) / Number(args[0] ?? 200))} min read`,
})

Editors then get Reading time in the formatter picker on any number field, with a Words per minute input and a live preview, and {{blog.wordCount | readingTime 200}} renders on the page.

Definition fields

Field Required Purpose
name yes The identifier used in bindings. Must match ^[a-z][A-Za-z0-9]*$, so camelCase starting lowercase. An invalid name throws at registration
label yes What the panel shows in the picker and on the pipe card
description no One line shown under the label in the panel, and given to the AI assistant
accepts no Input types this pipe handles. Omitted or ['any'] means anything. This is both the runtime check and what the picker filters on
returns no Output type. 'boolean' marks the pipe as visibility-only
args no Argument schema. Drives both parsing and the panel controls
transform yes The function. Must be synchronous

Argument fields

Field Purpose
name Identifier, used in validation messages
label Control label in the panel. Falls back to name
type string, number, boolean, select, or literal. Checked when the binding is parsed
required A binding missing a required argument is invalid
default Prefilled when the editor adds the pipe
options For select: the allowed { label, value } pairs. A value outside the list is rejected

Arguments are positional. Required ones must come before optional ones, since an editor cannot skip a slot.

type: 'select' is worth reaching for whenever the set of valid arguments is closed, because it turns a free text field into a dropdown and makes a wrong value impossible rather than merely invalid:

registerChaiPipe({
  name: 'truncate',
  label: 'Truncate',
  accepts: ['string'],
  returns: 'string',
  args: [
    { name: 'length', label: 'Max characters', type: 'number', required: true, default: 80 },
    {
      name: 'ellipsis',
      label: 'Ellipsis',
      type: 'select',
      default: '...',
      options: [
        { label: 'Three dots', value: '...' },
        { label: 'Unicode ellipsis', value: '…' },
        { label: 'None', value: '' },
      ],
    },
  ],
  transform: ({ value, args }) => {
    const text = String(value ?? '')
    const max = Number(args[0])
    return text.length > max ? `${text.slice(0, max)}${args[1] ?? '...'}` : text
  },
})

What transform receives

transform: ({ value, args, locale, propertyKey }) => unknown
Field What it is
value The output of the previous pipe, or the resolved data path for the first pipe
args The parsed literal arguments, in order
locale The page's language, already resolved through the fallback chain
propertyKey The block prop being bound, for example content or image. Rarely needed

Rules the runtime enforces

  • Synchronous only. Returning a Promise makes the binding render empty and logs a warning. Bindings resolve inside the render pass, so there is no place to await. Data that needs fetching belongs in a data provider.
  • Pure. A transform runs once per bound property per render, on the server, for every page and every repeater item. Do not mutate its input or reach for module state.
  • Throwing fails closed. An exception is caught, the binding renders empty, and a warning is logged once per pipe in development. A bad pipe never takes a page down.
  • Registering the same name twice replaces the earlier definition, which keeps hot reload from stacking duplicates. Last registration wins.
  • Output is escaped like any other bound value. A pipe that returns markup is HTML-escaped in normal props, and sanitized in the rich text, HTML, and icon props. A pipe cannot be used to inject script.

Where registration has to run

The registry is an in-memory map, per process, so it has to be populated before anything renders, on both the builder side and the public side. Register pipes wherever the project already registers custom blocks, at module scope rather than inside a component, so a cold serverless start populates the registry before it serves its first request.

Registering only on the public side is the failure worth watching for: pages render correctly, but editors never see the pipe in the panel and every binding using it shows as invalid in the settings panel.

The registry helpers are exported alongside the rest:

Export Use
registerChaiPipe(definition) Add or replace a pipe
getRegisteredChaiPipes() Every registered pipe, built-ins included

Custom pipes and the AI assistant

The registered pipes are injected into the AI page and block editing prompts, described by name, accepted and returned types, arguments, and description. A pipe you register is therefore something the assistant can reach for on its own, which makes description worth writing as an instruction rather than a restatement of the label: "Turns a word count into 'N min read'" tells the assistant when to use it, "Reading time pipe" does not.

Gotchas

A boolean pipe in a text field renders empty. It is not a formatting bug. Move the condition to Visibility.

accepts is a promise you have to keep. It gates which values reach your transform, so declaring ['any'] on a pipe that calls string methods pushes the failure into the transform and turns a clear "does not accept number" message into a silently empty binding.

A pipe cannot see the page data. It receives one value and its literal arguments, nothing else. Anything needing two fields together is a computed field on the provider.

Renaming a pipe breaks existing pages. The name is stored inside every binding that uses it, and an unknown pipe is invalid. Treat pipe names as public API once editors have used them; register the old name as an alias if you must rename.

Warnings are development only. Invalid bindings and failing pipes log to the server console with NODE_ENV other than production, once per unique expression. In production they fail silently, so upgrades want a check on the pages themselves.

Verify

  1. The pipe appears in the Add formatter picker on a field of the type it accepts, and does not appear on other types.
  2. Its arguments render as the controls you expect, and the panel Preview updates as you change them.
  3. The published page shows the same output as the preview.
  4. On a record where the source field is missing, the block behaves the way you intended, not just the way it happened to on the record you built against.

© ChaiBuilder. All rights reserved.