A single, fully cross-linked Schema.org @graph per page for classic WordPress themes — one PHP file, no plugin, no build step.
Instead of scattering isolated JSON-LD snippets, this emits one <script type="application/ld+json"> block whose nodes reference each other by stable @ids. It also derives a FAQPage node automatically from a Core Accordion block and marks the headline and lead as Speakable for answer engines.
Full walkthrough (German): https://marcelbest.com/webdesign/2026/json-ld-fuer-wordpress-ohne-plugin/ The blog post explains the how and why in detail; this README is the quick start.
One @graph, with nodes chosen by context:
| Context | Nodes |
|---|---|
| Always | WebSite, Person |
| Single post | BlogPosting (with SpeakableSpecification; plus a linked FAQPage when an Accordion is present) |
| Front page | Blog |
| Category / tag / taxonomy archive | CollectionPage |
| Static page | WebPage |
| Any page with a multi-level path | BreadcrumbList |
All nodes are wired together: BlogPosting.isPartOf → WebSite, author/publisher → Person, FAQPage.about → BlogPosting, and so on. No duplicated, orphaned markup.
- Automatic FAQPage from the Accordion block. The Core
core/accordionblock is treated as your FAQ section. Its presence alone produces aFAQPagenode — no extra authoring markup. Parsing is fully server-side (parse_blocks()→render_block()→DOMXPath), so any client-side rewrite of the accordion is irrelevant. - Speakable. Headline and lead are marked as suitable for text-to-speech via CSS selectors, aimed at modern answer engines (Google AI Overviews, Perplexity, ChatGPT Search) and featured-snippet eligibility.
- Data-driven, no empty fields. Optional fields (image, keywords, description, social
sameAs) are only emitted when real data exists. - No dependencies. No SEO plugin required; everything site-specific is overridable via filters.
- A classic theme (PHP
functions.php). Block themes / full site editing are not supported without changes. - PHP 8.0+
- The automatic FAQPage requires the Core Accordion block (WordPress 6.9+).
- Copy
json-ld.phpinto your theme, e.g.inc/json-ld.php. - Require it from your theme's
functions.php:require get_template_directory() . '/inc/json-ld.php';
- Replace the
yourtheme_function prefix, theYOURTHEME_constant prefix and theyourthemetext domain with your own namespace. - Configure your identity via the filters below (at minimum the Person name and social links).
No permalink flush or activation step is needed — output happens on wp_head.
Everything site-specific is a filter. Drop these into functions.php.
// Person / author identity (defaults are derived from the site).
add_filter( 'yourtheme_jsonld_person_name', fn() => 'Jane Doe' );
add_filter( 'yourtheme_jsonld_person_url', fn() => home_url( '/about/' ) );
add_filter( 'yourtheme_jsonld_person_id', fn() => home_url( '/about/#jane-doe' ) );
// Social profiles → Person.sameAs (array of URLs).
add_filter( 'yourtheme_jsonld_person_social', fn() => array(
'https://github.com/janedoe',
'https://www.linkedin.com/in/janedoe',
) );| Filter / constant | Default | Purpose |
|---|---|---|
yourtheme_jsonld_person_name |
site name | Person name |
yourtheme_jsonld_person_url |
home URL | Person url |
yourtheme_jsonld_person_id |
{home}/#person |
Person @id (must be stable) |
yourtheme_jsonld_person_social |
[] |
Array of profile URLs → sameAs |
yourtheme_jsonld_description |
excerpt / term description | Plug in your SEO plugin's description |
yourtheme_jsonld_speakable_selectors |
['.post-header h1', '.post-excerpt'] |
CSS selectors for Speakable — match your theme's markup |
yourtheme_jsonld_faq_block |
accordion |
Block base name parsed for FAQ |
yourtheme_jsonld_blog_url |
posts page or home | URL/@id base for the Blog node |
yourtheme_jsonld_breadcrumb_items |
derived | Adjust breadcrumb trail |
yourtheme_jsonld_remove_hentry |
true |
Strip the legacy hentry microformat class |
yourtheme_jsonld_graph |
full graph | Final escape hatch — modify any node before output |
YOURTHEME_JSONLD_SEPARATOR (constant) |
| |
Separator in page/collection names |
The Person node is the author and publisher of everything, so its name matters. To avoid an empty field it defaults to get_bloginfo('name') (the site name) — but a site name is not necessarily a person's name. On a site called "Acme Blog" the default produces a Person named "Acme Blog", which is semantically wrong. Treat yourtheme_jsonld_person_name (and the rest of the Person identity) as a required setting whenever your site name isn't your own name.
If the publishing entity is a company rather than a person, an Organization node fits better than Person. Swap it via the yourtheme_jsonld_graph filter:
add_filter( 'yourtheme_jsonld_graph', function( $graph ) {
foreach ( $graph as &$node ) {
if ( isset( $node['@type'] ) && 'Person' === $node['@type'] ) {
$node['@type'] = 'Organization';
$node['logo'] = home_url( '/logo.png' );
}
}
return $graph;
} );Speakable points at on-page elements, not meta fields. The defaults (.post-header h1, .post-excerpt) assume the title sits in .post-header and the lead is rendered as a visible .post-excerpt element. Adjust yourtheme_jsonld_speakable_selectors to your theme, and make sure the lead is actually rendered on the page.
add_filter( 'yourtheme_jsonld_description', function( $description ) {
// e.g. Yoast
if ( function_exists( 'YoastSEO' ) ) {
$meta = YoastSEO()->meta->for_current_page()->description;
if ( $meta ) {
return $meta;
}
}
return $description;
} );The Accordion block is treated as your FAQ section by convention — write your FAQ as a Core Accordion and a FAQPage node appears automatically. Each item's heading becomes a Question, its panel the acceptedAnswer (plain text; links are reduced to their text). No Accordion, no FAQ node, no error.
If you use the Accordion block for non-FAQ content too, point yourtheme_jsonld_faq_block at a different block, or return [] from a custom integration.
Note: Valid markup means eligible, not guaranteed. Google shows FAQ rich results in the SERP only for limited accounts — but for LLM/answer-engine parsing the structured FAQ stays valuable regardless.
Check your output with:
GPL-2.0-or-later. See LICENSE.