$npx -y skills add mralaminahamed/wp-dev-skills --skill wp-structured-dataUse when emitting JSON-LD structured data (schema.org) from a WordPress theme or plugin — FAQPage, HowTo, ItemList, Recipe, Event, Product, Review — especially when the data lives in post meta / custom fields the SEO plugin can't see, OR when you must avoid duplicating the schema
| 1 | # WordPress Structured Data (JSON-LD) |
| 2 | |
| 3 | > **Model note:** Building a single schema type is mechanical (`haiku`). Reach for `sonnet`/`opus` only when reconciling a complex graph against an existing SEO-plugin graph. |
| 4 | |
| 5 | Emit schema.org JSON-LD from theme/plugin code for content an SEO plugin can't generate on its own — without duplicating what the SEO plugin already ships. |
| 6 | |
| 7 | ## When to use |
| 8 | |
| 9 | - Content lives in **post meta / custom fields** (e.g. an FAQ repeater) and is *stripped from the rendered content*, so Rank Math/Yoast can't detect it. |
| 10 | - You render a **custom section** (table of contents, related items, steps) that warrants `ItemList` / `HowTo`. |
| 11 | - You need a schema type the SEO plugin doesn't offer on that template. |
| 12 | |
| 13 | ## Rule 0 — never duplicate the SEO plugin's graph |
| 14 | |
| 15 | Most sites already run Rank Math, Yoast, or SEOPress. They emit a `@graph` with `WebSite`, `WebPage`, `Organization`, `Person`, `BreadcrumbList`, and an article type (`Article`/`BlogPosting`/`Product`). **Never** re-emit those — duplicate/competing nodes confuse parsers and can suppress rich results. |
| 16 | |
| 17 | **Always** check first what is already on the page: |
| 18 | |
| 19 | ```bash |
| 20 | # View source, then list the @type values in every ld+json block |
| 21 | curl -s "<url>" | grep -A99 'application/ld+json' |
| 22 | ``` |
| 23 | |
| 24 | Or in the browser console: |
| 25 | |
| 26 | ```js |
| 27 | [...document.querySelectorAll('script[type="application/ld+json"]')] |
| 28 | .flatMap(s => { const j = JSON.parse(s.textContent); return (j['@graph']||[j]).map(n => n['@type']); }); |
| 29 | ``` |
| 30 | |
| 31 | Only add types the existing graph is **missing** (commonly `FAQPage`, `HowTo`, `Recipe`, custom `ItemList`). |
| 32 | |
| 33 | ## Pattern — build in PHP, print on `wp_head` |
| 34 | |
| 35 | ```php |
| 36 | add_action( 'wp_head', function () { |
| 37 | if ( ! is_singular( 'post' ) ) { |
| 38 | return; |
| 39 | } |
| 40 | |
| 41 | $faqs = get_post_meta( get_the_ID(), 'my_faqs', true ); // stripped from content |
| 42 | if ( empty( $faqs ) || ! is_array( $faqs ) ) { |
| 43 | return; |
| 44 | } |
| 45 | |
| 46 | $questions = array(); |
| 47 | foreach ( $faqs as $faq ) { |
| 48 | if ( empty( $faq['question'] ) ) { |
| 49 | continue; |
| 50 | } |
| 51 | $questions[] = array( |
| 52 | '@type' => 'Question', |
| 53 | 'name' => wp_strip_all_tags( $faq['question'] ), |
| 54 | 'acceptedAnswer' => array( |
| 55 | '@type' => 'Answer', |
| 56 | 'text' => wp_kses_post( wpautop( $faq['answer'] ?? '' ) ), |
| 57 | ), |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | if ( ! $questions ) { |
| 62 | return; |
| 63 | } |
| 64 | |
| 65 | $schema = array( |
| 66 | '@context' => 'https://schema.org', |
| 67 | '@type' => 'FAQPage', |
| 68 | 'mainEntity' => $questions, |
| 69 | ); |
| 70 | |
| 71 | echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>' . "\n"; |
| 72 | } ); |
| 73 | ``` |
| 74 | |
| 75 | ### Key points |
| 76 | |
| 77 | - **Build a PHP array, then `wp_json_encode()`** — never hand-concatenate JSON. `wp_json_encode()` escapes for the `<script>` context; output is safe to print as-is. (PHPCS's `EscapeOutput` sniff doesn't recognise it as an escaper; suppress it via a centralized `phpcs.xml.dist` exclude scoped to the file rather than scattering inline ignores.) |
| 78 | - **`@type` text:** `wp_strip_all_tags()` for short names/titles; `wp_kses_post()` for answer/description bodies that may carry HTML. |
| 79 | - **head vs footer is irrelevant to Google** — it parses JSON-LD anywhere. Use `wp_head` to sit alongside the SEO plugin's graph (convention), or `wp_footer` if you need late data; both work. |
| 80 | - **Gate to the right context** (`is_singular()`, post type, a feature toggle) so schema only appears where the content does. |
| 81 | - **Mirror visible content.** Schema must reflect what users actually see on the page (Google's structured-data policy). Don't emit FAQ schema for FAQs you don't render. |
| 82 | |
| 83 | ## Common types worth emitting from code |
| 84 | |
| 85 | | Type | Use for | Usually missing from SEO plugins? | |
| 86 | |------|---------|-----------------------------------| |
| 87 | | `FAQPage` | Q&A stored in meta / repeater | Yes, wh |