An accessible WordPress FAQ block that writes its own SEO structured data — content and schema can never drift apart.
| ⚛️ React editor UI | Add, reorder, and remove Q&A pairs with undo/redo |
| ♿ Accessible by default | Native disclosure elements, full keyboard support, zero front-end JS |
| 🤖 AI-search ready | Auto-generated schema.org FAQPage JSON-LD on every render |
| 🔒 Escaped twice | Editor constraints + server-side wp_kses() allowlist |
| 🎨 Theme-agnostic | Inherits any theme's colors and type, dark mode included |
A WordPress block that treats an FAQ as what it really is: content for two audiences at once — the human reading the page, and the search engines and AI answer engines deciding whether to surface it.
Editors manage question/answer pairs in a clean repeater UI. The block renders an accessible, dependency-free accordion on the front end and automatically emits a schema.org FAQPage JSON-LD graph generated from the same content — so the structured data can never drift out of sync with what visitors see.
Built with the standard WordPress toolchain (@wordpress/scripts), block API v3, and a dynamic render callback in modern, namespaced, strictly-typed PHP.
One-click demo in WordPress Playground →
A temporary WordPress site spins up in your browser with the plugin installed and a demo FAQ page already published. Nothing to install. ~15s first load.
Most FAQ schema implementations fail in one of two ways:
- Decoupled schema — JSON-LD is hand-maintained in an SEO plugin field or a theme snippet, separate from the visible content. The moment an editor rewrites an answer, the page is publishing stale (or worse, mismatched) structured data, which violates Google's guideline that schema must reflect visible page content.
- Inaccessible accordions — div/span click handlers with no keyboard support, no disclosure semantics, and broken screen-reader output.
This block solves both with one architectural decision: the block attributes are the single source of truth, and the server renders both the visible accordion and the JSON-LD from that one source on every request.
Dynamic block, not static save. The block declares "render": "file:./render.php" in block.json and its save() returns null. Rendering server-side means: (a) JSON-LD is always generated from current content, (b) markup improvements ship to all existing posts without block deprecations or content migrations, and (c) escaping happens at output time in PHP, where WordPress's escaping API lives.
Native <details>/<summary>, zero front-end JavaScript. Disclosure widgets are a solved problem at the platform level. Using the native elements gets keyboard operability, screen-reader announcements ("collapsed"/"expanded"), and even in-page find support for free — no ARIA to hand-maintain, no JS bundle to ship, no hydration cost. The disclosure marker is styled, not replaced, and the rotation animation respects prefers-reduced-motion.
Escaping as a contract, enforced twice. Answers allow a small set of inline formats (bold, italic, link, code). The editor constrains this via RichText's allowedFormats; the server enforces it via wp_kses() with an explicit allowlist — because editor-side constraints are UX, not security. Questions and the JSON-LD payload are stripped to plain text per Google's structured-data guidelines, and the graph is encoded with wp_json_encode(), never string concatenation.
Immutable state transformations in the editor. Every repeater operation (add / update / reorder / remove) is a pure function returning a new array, keeping undo/redo and collaborative editing predictable.
Theme-agnostic styling. Front-end CSS asserts structure only — dividers, spacing, the marker — using currentColor and color-mix() so the block inherits any theme's palette, dark mode included. Colors, spacing, and typography are exposed through block.json supports so editors adjust them with core controls instead of custom ones.
An emitSchema escape hatch. If another block or plugin already emits FAQPage schema on the page, editors can toggle this block's JSON-LD off from the inspector — duplicate FAQPage graphs on one URL is itself a structured-data error.
- Disclosure semantics come from native elements; state is announced by the platform.
:focus-visibleoutlines are styled, never removed.- Editor repeater items are labeled groups (
Question 1,Question 2…) so screen-reader users can orient in long lists; reorder/remove controls are real buttons with discernible labels. - Animation is a single transform transition, disabled under
prefers-reduced-motion.
npm install
npm run start # develop with live rebuild
npm run build # production build (also copies render.php via --webpack-copy-php)
npm run lint:js
npm run lint:css
npm run plugin-zipRequires WordPress 6.5+ and PHP 8.0+.
answer-ready-faq.php Plugin bootstrap — metadata-driven block registration
src/faq-block/
block.json Single source of truth: attributes, supports, assets
index.js Registration (edit only; dynamic block, save() → null)
edit.js Repeater editor UI (immutable state helpers)
render.php Server render: accordion markup + FAQPage JSON-LD
style.scss Front-end structure-only styles
editor.scss Editor-only repeater styles
build/ Compiled output (generated; not committed)
- Unit tests for the render callback (WP_Mock) and E2E coverage for the repeater (Playwright)
- Optional "single item open at a time" progressive enhancement via a small
viewScriptusing thenameattribute on<details> HowTosibling block sharing the same architecture
Built by Nora G. (PlayPlayAI)
- Portfolio: playplayai.com
- LinkedIn: linkedin.com/in/ngenetti
- GitHub: github.com/NORARAE
GPL-2.0-or-later.