FAQ from data
One FAQ dataset for the whole site, filtered per page by a frame. Pages with questions get the block and FAQPage schema, pages without them get nothing at all.
The questions do not live in the pages. They are one dataset, faq, where every item names the page it belongs to. A frame article__faq filters the list by the current page’s slug and prints the questions together with their JSON-LD; the article shell includes that frame in one place. Adding a question is a data write, not an edit of twenty pages.
1. Store the questions
Each item carries page — the slug of the page it belongs to — plus whatever fields the markup reads; here q for the question, a for the answer and n for the order. The dataset has no fixed schema: the fields you send are the fields the repeater gets as item.<field>.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/faq" -d '{
"blog_id": 233,
"lang": "en",
"dry_run": true,
"items": [
{ "page": "pricing", "n": 1, "q": "Is there a free plan?", "a": "Yes, up to three sites." },
{ "page": "pricing", "n": 2, "q": "Can I cancel any time?", "a": "Yes, from the dashboard." },
{ "page": "setup", "n": 1, "q": "Do I need a developer?", "a": "No." }
]
}'
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 233,
"dataset": {
"name": "faq",
"lang": "en",
"items": 3,
"bytes": 231,
"fields": [
{ "field": "page", "types": ["string"], "in_items": 3 },
{ "field": "n", "types": ["number"], "in_items": 3 },
{ "field": "q", "types": ["string"], "in_items": 3 },
{ "field": "a", "types": ["string"], "in_items": 3 }
],
"origin": "site",
"option": "onpress_dataset_faq__en",
"…": "…"
},
"warnings": [],
"items": ["…"]
}
fields is what the items really contain — check it against the markup before you write the frame. Drop dry_run to store. PUT replaces the whole list; to add or change one question use PATCH /data/faq with ops (append, merge, remove), described on Data.
2. Add the other languages
With lang the dataset is stored per language, next to the others, in its own option (onpress_dataset_faq__ru). A repeater on a Russian page reads the ru variant; a language without a variant falls back to the shared dataset — the one stored without lang — and if there is none, the list is empty.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/faq" -d '{
"blog_id": 233,
"lang": "ru",
"items": [
{ "page": "pricing", "n": 1, "q": "Есть бесплатный тариф?", "a": "Да, до трёх сайтов." }
]
}'
page is compared with the slug of the page being printed. When translations share the slug (/pricing/ and /ru/pricing/), the same value works in every variant; when a translation has its own slug, its variant must use that slug.
3. The article__faq frame
op/repeat walks the list, match keeps the items whose page equals the current page’s slug, and "empty":"frame" makes the whole frame print nothing when no item is left — no heading over an empty list. op/faq-schema prints FAQPage JSON-LD from the same source with the same filter, and prints nothing on an empty list.
<section class="article__faq">
<h2 class="article__faq-title">FAQ</h2>
<!-- wp:op/repeat {"source":"faq","match":{"page":"page.slug"},"empty":"frame"} -->
<details class="article__faq-item">
<summary data-op-bind='{"text":"item.q"}'></summary>
<div data-op-bind='{"html":"item.a"}'></div>
</details>
<!-- /wp:op/repeat -->
<!-- wp:op/faq-schema {"source":"faq","match":{"page":"page.slug"}} /-->
</section>
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/frames" -d "$(jq -n --rawfile html faq.html \
'{blog_id: 233, slug: "article__faq", name: "Article: FAQ", html: $html, dry_run: true}')"
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 233,
"frame": { "id": "624581e9e9cba426", "slug": "article__faq", "block": "article", "element": "faq", "bytes": 427, "…": "…" },
"inserts": { "op/repeat": 1, "op/faq-schema": 1 },
"inserts_total": 2,
"sources": ["faq"],
"frame_refs": [],
"binds": 2,
"renders_on_request": true,
"live_reasons": ["inserts: op/faq-schema", "op/repeat match uses page.* tokens"],
"has_doctype": false,
"has_body": false,
"warnings": []
}
page.slug in match makes the frame page-dependent, so it has no baked copy and is printed on every request — each page gets its own list. sources: ["faq"] is how a later write to the dataset finds this frame.
4. Put it into the shell
The shell includes the frame once, after the content. On a page with questions it prints the block; on a page without them the op/frame leaves an empty place and the shell itself prints as usual.
<main class="article">
<h1 class="article__title" data-op-bind='{"text":"page.title"}'></h1>
<div class="article__body"><!-- wp:op/content /--></div>
<!-- wp:op/frame {"ref":"article__faq"} /-->
</main>
Upload the shell with PUT /frames/article — the full markup, with the header and footer from the shell recipe.
5. Check what a page will get
POST /data/resolve resolves the source exactly as the printer will. Pass the page and the same match; the language is taken from the page unless you pass lang.
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/resolve" -d '{"blog_id": 235, "source": "faq", "page": 6412, "match": {"page": "page.slug"}}'
{
"success": true,
"blog_id": 235,
"name": "faq",
"origin": "site",
"contextual": true,
"option": "onpress_dataset_faq__en",
"lang": "en",
"page_id": 6412,
"match": { "page": "pomelli-workflow" },
"match_total": 42,
"total": 6,
"items": [
{ "page": "pomelli-workflow", "n": 1, "q": "Is Pomelli still free?", "a": "Yes. Pomelli is a free beta from Google Labs. …" },
"…"
]
}
match shows the value page.slug resolved to, match_total the size of the list before filtering, total after it, option which variant was read. The same call for a page without questions:
{
"success": true,
"blog_id": 235,
"name": "faq",
"option": "onpress_dataset_faq__en",
"lang": "en",
"page_id": 6409,
"match": { "page": "pomelli-en" },
"match_total": 42,
"total": 0,
"items": [],
"…": "…"
}
total: 0 means "empty":"frame" fires and that page shows no FAQ block and no schema.
Check
Open a page listed in the dataset: the questions are there, and the page source has a FAQPage JSON-LD script. Open a page that is not in the dataset: no FAQ heading, no schema. Open the translation: the questions come from its language variant. Without page, POST /data/resolve answers an empty list with needs_page: true — that is the missing parameter, not missing data.