Bindings
A binding sets a value inside a tag of a frame, an href, a src or the element text, from a token such as site.login_url or page.title. Here is the syntax, the full dictionary and what happens when a token has no value.
Why bindings exist
An insert drops a piece of markup into the flow, but it cannot reach inside a tag. Yet the value that has to change often lives exactly there: the href of the login button, the src of the logo, the text of a heading, the date in datetime. So the tag is marked with the reserved attribute data-op-bind, and at print time the attributes are rewritten by core WP_HTML_Tag_Processor, the streaming HTML rewriter of WordPress, without regular expressions and without parsing the whole document. The name follows the core Block Bindings API, which does the same for attributes of registered blocks.
The attribute itself stays in the output. For the browser it is an ordinary data attribute that breaks nothing, and it shows what was bound. Printing always starts from the source, so nothing accumulates.
Syntax
The value of data-op-bind is a JSON object: the key is the attribute to set, the value is a token.
<a class="site-footer__login" href="/wp-login.php" data-op-bind='{"href":"site.login_url"}'>Log in</a>
<img class="site-header__logo-img" alt="" data-op-bind='{"src":"site.logo_url","alt":"site.name"}'>
Single quotes around the attribute are mandatory. The JSON is full of double quotes; in a double-quoted attribute it breaks at the first of them and the whole binding is silently lost. For the same reason a token or a literal must not contain a double quote. POST /frames warns about double-quoted data-op-bind in warnings, but saves the frame anyway, so read the warnings:
"warnings": ["data-op-bind is written with double quotes: JSON inside an attribute must be kept in single quotes, otherwise the attribute breaks at the first double quote of the value"]
Two keys are not attributes but the content of the element:
| Key | What it sets |
|---|---|
text | the content of the element, HTML-escaped |
html | the content of the element, inserted as is; use it for values that are markup, such as an FAQ answer |
| any other key | the attribute of that name, href, src, alt, datetime, data-page-title… |
text and html replace whatever the element contains in the source. They cannot be used on void elements (img, input, meta and the like); the print report then says не удалось подставить содержимое элемента #N.
Token forms
| Form | Example | Value |
|---|---|---|
| dictionary token | site.home_url, page.title | from the dictionary below |
raw:<value> | raw:/promo/ | the literal after raw:, as is |
item.<field> | item.q, item.prev.title, item.children.0.url | a field of the current item inside op/repeat, nested through dots |
option.<name> | option.blogdescription | a scalar option of the current site; a missing option gives an empty string |
item.* exists only inside a repeater: the repeater substitutes the fields of each item itself. Scalars are turned into strings, true into "1", false into ""; an array, an object, null or a missing field has no value. Outside a repeater an item.* token is skipped quietly. How lists get into a repeater is in Data.
The same tokens work as values of match on op/repeat, so a filter and a binding mean the same thing by page.slug.
Token dictionary
The dictionary is built from the site being printed, in the language being printed. A token that has no value in the current situation is simply absent; see Unresolved tokens below.
site
| Token | Value |
|---|---|
site.home_url, site.url | home address with a trailing slash |
site.site_url | WordPress address |
site.name, site.description | site title and tagline |
site.charset | charset, UTF-8 |
site.login_url | login page |
site.logout_url | logout link back to home; contains a nonce |
site.admin_url | admin area |
site.host | host of the home address |
site.blog_id | network site id |
site.lang | current language slug from Polylang, otherwise the site locale |
site.year | current year |
site.logo_url | the logo, in the theme’s order: custom logo, the header settings image, the site icon; absent when there is none |
frame
| Token | Value |
|---|---|
frame.id | id of the frame being printed at the top level; nested frames see the same value |
frame.url | its publish path, empty for a shell |
frame.type | legacy type from an old registry entry, otherwise empty |
page
Present only when a page is being printed. Any page.* token in a frame makes it render on request, see below.
| Token | Value |
|---|---|
page.id, page.url, page.title | the page |
page.slug, page.status, page.type | its slug, status and post type |
page.parent_id, page.parent_title, page.parent_url | the parent; absent on a top-level page |
page.date, page.date_modified | publication and modification dates, formatted by the site’s date format |
page.date_iso, page.date_modified_iso | the same dates in ISO 8601, for datetime of time |
page.excerpt | the excerpt; when empty, core generates it from the text |
page.description | the excerpt as written, empty when there is none; use it for a lead, so it does not repeat the first paragraph |
page.author_id, page.author_name | author id and display name |
page.author_url | author archive; only when safe, see Privacy |
page.author_avatar | avatar URL; only a local one, see Privacy |
page.thumbnail_id, page.thumbnail_url, page.thumbnail_alt | featured image; absent without one |
page.categories, page.tags | names of all categories or tags, comma-separated |
page.category_name, page.category_slug, page.category_url | the first category, for a label above the title |
For the full list of terms as links there is the insert op/terms, for the image as picture the insert op/featured-image.
header and footer
Values of the old theme’s header and footer settings: the tech posts Header and Footer, in the language of the page. A frame reads the same place as the theme, so a frame header and a theme header never disagree. Flags are "1" or empty.
| Token | Value |
|---|---|
header.button_title, header.button_url, header.button_target, header.button_icon | the header button |
header.show_button, header.show_logo | flags |
footer.description, footer.copyright | footer text; 2026 in the copyright is replaced with the year |
footer.cta, footer.cta_head, footer.cta_subhead | the call-to-action block: flag, heading, subheading |
footer.cta_button_title, footer.cta_button_url, footer.cta_button_target, footer.cta_button_icon | its button |
footer.show_logo, footer.just_copyrights | flags |
Privacy of author tokens
Two tokens publish more than they seem to, so their default is restrictive.
page.author_url would publish the login of the account. WordPress addresses author archives by user_nicename, which for most accounts is the login or derived from it (john.smith becomes john-smith, collisions get -2). The id form /?author=5 does not help: core redirects it to the nicename form as soon as the author has a published post. So the token is given only when the nicename is set deliberately and does not reveal the login.
page.author_avatar from Gravatar carries a hash of the author’s email, which can be checked against a list of known addresses. So only a local avatar, on the site’s own host, is given.
When a token is withheld, it is listed in unresolved, and the print report explains why in warnings, once per token. You see it in bake.response of POST /frames/{ref}/assign, which prints the frame for that page. page.author_name is given always, but if the account has no display name WordPress keeps the login there, and the report says so: set “Display name publicly as” in the profile. To publish these values anyway, set the tokens yourself with the filter below, deliberately and on one site.
When bindings are substituted
Bindings are substituted when the frame is printed. The page’s frame is printed by the theme on every request, so its bindings are always fresh: a new title or a new date is visible at once, with no rebake. A baked copy, used by a frame published at url, holds the values of the moment it was baked.
A page.* token in any data-op-bind of a frame, or in the match of its repeater, makes the frame page-dependent: renders_on_request: true, no baked copy, and the reason in the upload report:
"renders_on_request": true,
"live_reasons": ["data-op-bind uses page.* tokens"]
A frame that nests such a frame through op/frame renders on request too. site.*, header.* and footer.* do not have this effect: the real header of site 235 binds only site.* tokens and is baked.
On a page drawn by a frame the bindings pass runs over everything the frame printed, including the page text from op/content. On a page without a frame data-op-bind in the content is not processed; wrap such markup in a frame and insert it with op/frame.
Unresolved tokens
A token without a value does not break anything and does not blank anything out. The attribute keeps the value written in the source, and an element with text or html keeps its source content. So write a sensible fallback into the source, as the header below does with href="/" and the text Pomelli.
The print report collects such tokens in unresolved: a typo in a token name, site.logo_url on a site without a logo, page.parent_title on a top-level page, page.author_url withheld for privacy. It also warns about broken JSON in the attribute (битый JSON в data-op-bind: …, the tag is skipped) and about a pair that is not two strings (пара атрибут/токен должна быть строками).
Extending the dictionary
The dictionary goes through the filter onpress/frames/bind_values before use. It receives the array of values and the print context (frame_id, frame_url, page_id) and may add tokens or replace existing ones.
add_filter('onpress/frames/bind_values', function (array $values, array $context): array {
$values['shop.phone'] = get_option('shop_phone', '');
$page_id = (int) ($context['page_id'] ?? 0);
if ($page_id > 0) {
$values['shop.price'] = (string) get_post_meta($page_id, 'price', true);
}
return $values;
}, 10, 2);
The namespaces site., page., frame., header., footer., item. and option., and the prefix raw:, belong to the plugin: add your tokens under a namespace of your own (shop. above) so they do not collide with tokens added later. Replacing a plugin token through the filter is a deliberate override, which is exactly how page.author_url is switched back on for one site. A custom token that depends on the page does not make the frame render on request by itself; only page.* does, so keep such tokens in frames that are already page-dependent.
Example: header and footer of site 235
The header site__header. Three bindings, all site.*, so the frame is baked; the insert op/lang-switcher adds the languages:
<div class="site-header site-header--pomelli">
<div class="site-header__brand">
<a class="site-header__logo" href="/" data-op-bind='{"href":"site.home_url"}'>
<img class="site-header__logo-img" width="24" height="24" alt="" decoding="async" data-op-bind='{"src":"site.logo_url","alt":"site.name"}'>
<span class="site-header__logo-text" data-op-bind='{"text":"site.name"}'>Pomelli</span>
</a>
</div>
<div class="site-header__actions">
<div class="site-header__lang"><!-- wp:op/lang-switcher {"class":"oplang","show":"name","display":"dropdown"} /--></div>
</div>
</div>
The footer site__footer, shortened: item.* bindings inside repeaters over the datasets footer and footer_legal (with English and Russian variants), html for the text that contains markup, and site.login_url on the login icon:
<footer class="site-footer site-footer--pomelli js-footer">
<div class="site-footer__brand">
<!-- wp:op/repeat {"source":"footer"} -->
<div class="site-footer__about">
<div class="site-footer__brand-name" data-op-bind='{"text":"item.brand"}'></div>
<div class="site-footer__disclaimer" data-op-bind='{"html":"item.about"}'></div>
</div>
<!-- /wp:op/repeat -->
</div>
<div class="site-footer__columns"><!-- wp:op/footer-columns /--></div>
<div class="site-footer__copyright"><!-- wp:op/copyright {"text":"© {year} AI Marketing"} /--></div>
<nav class="site-footer__legal" aria-label="Legal">
<!-- wp:op/repeat {"source":"footer_legal"} -->
<a class="site-footer__legal-link" data-op-bind='{"text":"item.text","href":"item.url"}'></a>
<!-- /wp:op/repeat -->
</nav>
<a class="site-footer__login" href="/wp-login.php" aria-label="Login" data-op-bind='{"href":"site.login_url"}'>…</a>
</footer>
And the article shell binds the page itself, which is why article renders on request:
<h1 class="opdoc__title" data-op-bind='{"text":"page.title"}'></h1>
<div class="opdoc__body" data-op-bind='{"data-page-title":"page.title"}'><!-- wp:op/content /--></div>
A shell with these frames step by step: Shell with header and footer.