Shell with header and footer
Three frames — a header, a footer and an article shell that pulls both in — uploaded, checked with dry_run and put on a page. A later header change reaches every shell by itself.
The look of a page is one frame, its shell. A frame is a template and has no address: the address belongs to the page, which names its shell. The header and the footer are not bound to the page either — the shell pulls them in by name with op/frame {"ref":…}, so there is one header for every shell that uses it. The order matters: upload the parts first, then the shell that references them — an op/frame pointing at a slug that does not exist prints nothing, and the upload does not warn about it.
The markup below is a trimmed version of the frames on the staging site example.onpress.pro (blog 235). Keep each frame in its own file and send it with jq --rawfile: that way the quotes inside the markup need no escaping.
1. Header: site__header
Logo, main menu and language switcher. The logo is filled by bindings: data-op-bind substitutes attributes and text from the token dictionary on every request. op/menu prints the menu assigned to the theme’s header location, op/lang-switcher the site’s Polylang languages.
<header class="site-header">
<a class="site-header__logo" href="/" data-op-bind='{"href":"site.home_url"}'>
<img class="site-header__logo-img" width="24" height="24" alt="" data-op-bind='{"src":"site.logo_url","alt":"site.name"}'>
<span class="site-header__logo-text" data-op-bind='{"text":"site.name"}'>Site</span>
</a>
<nav class="site-header__nav"><!-- wp:op/menu {"place":"header"} /--></nav>
<div class="site-header__lang"><!-- wp:op/lang-switcher {"display":"dropdown","show":"name"} /--></div>
</header>
Keep data-op-bind in single quotes. With double quotes the attribute ends at the first quote inside the JSON, and the upload answers with a warning about exactly that.
Check it first with dry_run: you get the full inspection report and nothing is stored.
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/frames" -d "$(jq -n --rawfile html header.html \
'{blog_id: 233, slug: "site__header", name: "Site: header", html: $html, dry_run: true}')"
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 233,
"frame": {
"id": "6a3e0e2cd2689e45",
"name": "Site: header",
"slug": "site__header",
"block": "site",
"element": "header",
"modifier": null,
"bytes": 527,
"…": "…"
},
"inserts": { "op/menu": 1, "op/lang-switcher": 1 },
"inserts_total": 2,
"sources": [],
"frame_refs": [],
"binds": 3,
"renders_on_request": false,
"has_doctype": false,
"has_body": false,
"warnings": []
}
binds: 3 is the three data-op-bind attributes, inserts what the bake is going to substitute. renders_on_request: false means the header is baked once per language and served from the copy. Send the same body without dry_run to store it; the answer then has "applied": true and a bake field with the result of baking.
2. Footer: site__footer
Footer menus in columns, a copyright line and social links. op/footer-columns reads the menus in the footer_1, footer_2 and footer_3 locations; op/copyright replaces {year} and {name}; op/socials prints the links from the site settings (POST /onpress/v1/settings/socials) and prints nothing when there are none.
<footer class="site-footer">
<div class="site-footer__columns"><!-- wp:op/footer-columns /--></div>
<div class="site-footer__bottom">
<!-- wp:op/copyright {"text":"© {year} Example"} /-->
<!-- wp:op/socials {"place":"footer"} /-->
</div>
</footer>
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/frames" -d "$(jq -n --rawfile html footer.html \
'{blog_id: 233, slug: "site__footer", name: "Site: footer", html: $html, dry_run: true}')"
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 233,
"frame": { "id": "757fadef9689737e", "slug": "site__footer", "block": "site", "element": "footer", "bytes": 263, "…": "…" },
"inserts": { "op/footer-columns": 1, "op/copyright": 1, "op/socials": 1 },
"inserts_total": 3,
"binds": 0,
"renders_on_request": false,
"warnings": [],
"…": "…"
}
3. Shell: article
The shell holds the header, the page title, the page text and the footer. op/content is where the page’s own content is printed; the title comes from a binding to page.title.
<div class="article-layout">
<!-- wp:op/frame {"ref":"site__header"} /-->
<main class="article">
<h1 class="article__title" data-op-bind='{"text":"page.title"}'></h1>
<div class="article__body"><!-- wp:op/content /--></div>
</main>
<!-- wp:op/frame {"ref":"site__footer"} /-->
</div>
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/frames" -d "$(jq -n --rawfile html article.html \
'{blog_id: 233, slug: "article", name: "Article", html: $html, dry_run: true}')"
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 233,
"frame": { "id": "72a69d4c3ab10da1", "slug": "article", "block": "article", "element": null, "bytes": 300, "…": "…" },
"inserts": { "op/frame": 2, "op/content": 1 },
"inserts_total": 3,
"sources": [],
"frame_refs": ["site__header", "site__footer"],
"binds": 1,
"renders_on_request": true,
"live_reasons": ["inserts: op/content", "data-op-bind uses page.* tokens"],
"has_doctype": false,
"has_body": false,
"warnings": []
}
frame_refs is the list of frames the shell depends on — the registry uses it to rebake the shell when the header changes and to rewrite the reference when the header is renamed. renders_on_request: true is expected for a shell: it prints a different page every time, so it has no baked copy, and live_reasons says why.
4. Put a page into the shell
A page has exactly one frame. Either bind an existing page:
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/frames/article/assign" -d '{"blog_id": 235, "page": "/pomelli-workflow/"}'
{
"success": true,
"blog_id": 235,
"frame": { "id": "91399e3637b7aa2b", "slug": "article" },
"page": { "id": 6703, "title": "Pomelli workflow", "url": "/pomelli-workflow/" },
"meta_key": "_onpress_frame",
"cleared": false,
"bake": { "status": "ok" }
}
or set it at publish time with frame: article in the frontmatter of POST /pages/markdown — the response then names it in frame: {id, slug, meta_key}. Both write the same page meta _onpress_frame with the frame’s id. {"page": …, "clear": true} removes the binding; the page is then printed by the theme’s own template.
The page is also what holds the address, so a landing is the same two steps: the markup goes up as a frame, and the page at /promo/ names it — with an empty body when the frame prints the whole document. Binding decides static publishing too: this article shell contains op/content, so it is printed on request and its pages are rendered by PHP; a shell without page-dependent inserts is baked, and its pages are served from files. Nothing is switched anywhere.
5. Change the header later
Replace the header’s markup with PUT /frames/site__header (same body as POST, without dry_run). Every frame that has site__header in its frame_refs is baked again in the same call and listed in rebaked:
{
"success": true,
"applied": true,
"updated": true,
"frame": { "slug": "site__header", "…": "…" },
"bake": { "status": "ok", "…": "…" },
"rebaked": {
"status": "ok",
"total": 1,
"baked": 1,
"frames": [
{ "id": "72a69d4c3ab10da1", "slug": "article", "because": ["nested frames: site__header"], "bake": "ok" }
]
}
}
Only direct includers are picked: a frame that inserts article is not rebaked because of the header. When nothing includes the frame, rebaked is absent. A menu edited in wp-admin is not seen by the API — rebake by hand with POST /frames/rebake {"menus": true}.
Check
GET /frames/site__header?blog_id=233 answers with the markup and "referenced_by": ["article"]. GET /frames?blog_id=233&block=site lists both parts. The bound page opens with the header, its own text and the footer; a page without a frame still uses the theme template.