schema-markup
Skill6 starsChanged 13 days agoMITView on GitHub
Schema.org JSON-LD for SEO: type selection, CMS implementation (Yoast, Rank Math, WooCommerce, PrestaShop), documented bugs, validation and rich result eligibility. Use when auditing or implementing structured data or diagnosing rich results in GSC.
61 other projects use a version of this.
Adopted by
Most recently taken up by
- oivanfelipe/claude-skillstheir own version, first seen Nov 8, 2026
- darkweb19/skillstheir own version, first seen Nov 8, 2026
- yht-4403/jc-agent-skillstheir own version, first seen Nov 8, 2026
- insight68/Skillstheir own version, first seen Nov 8, 2026
- ftzi/livespectheir own version, first seen Nov 8, 2026
- About-Intelligence/awesome-marketing-skillstheir own version, first seen Nov 8, 2026
Used with
Projects with it also use internal-link-audit, product-marketing-context and 10 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
semrushSkillinternal-link-audit
Audit internal linking to find orphan pages, broken internal links, crawl-depth problems, and weak anchor distribution, then plan the hub-and-spoke structure that fixes them. Use when the user asks about internal links, orphan pages, site architecture, crawl depth, or how to pass authority to priority pages.
Also in 3 projects that use this
xfstudioSkillproduct-marketing-context
When the user wants to create or update their product marketing context document. Also use when the user mentions 'product context,' 'marketing context,' 'set up context,' 'positioning,' or wants to avoid repeating foundational information across marketing tasks. Creates `.claude/product-marketing-context.md` that other marketing skills reference.
Also in 19 projects that use this
LangChainSkillcontent-brief
Turn a vague or multi-asset design ask into a brief. Pull what is already known, ask at most two questions with reasons, decide the rest and say what you decided.
Also in 5 projects that use this
ukunal91appSkillrevops
When the user wants help with revenue operations, lead lifecycle management, or marketing-to-sales handoff processes. Also use when the user mentions 'RevOps,' 'revenue operations,' 'lead scoring,' 'lead routing,' 'MQL,' 'SQL,' 'pipeline stages,' 'deal desk,' 'CRM automation,' 'marketing-to-sales handoff,' 'data hygiene,' 'leads aren't getting to sales,' 'pipeline management,' 'lead qualification,' or 'when should marketing hand off to sales.' Use this for anything involving the systems and processes that connect marketing to revenue. For cold outreach emails, see cold-email. For email drip campaigns, see email-sequence. For pricing decisions, see pricing-strategy.
Also in 14 projects that use this
VercelSkillsite-architecture
Use when planning or restructuring what pages a site has and how they connect: hierarchy, navigation, URL patterns, breadcrumbs, and internal linking. Not for XML sitemaps, which are in seo-audit.
Also in 16 projects that use this
alaraakcasizSkilltechnical-seo-audit
Technical and on-page SEO audit of any website — a client site, your own site, a competitor, or a staging build before a CMS migration. Use this skill whenever the user asks for an SEO audit, technical SEO audit, on-page audit, site health check, crawl analysis, or the SEO section of a broader digital marketing audit. Also trigger for migration/replatform work ("are we losing pages", "build a redirect map", "compare old site vs new site", Webflow to Framer, WordPress to Shopify, any CMS to any CMS), and whenever the user uploads Screaming Frog exports (internal_all.csv, image_details.csv, All Inlinks), a GSC Pages export, or a sitemap and wants them analysed. Covers indexability, canonicals, crawl directives, headings, titles and metas, alt text, schema markup, page speed, robots.txt, llms.txt, internal linking, page-inventory diffing and redirect mapping.
Also in 3 projects that use this
Other versions61 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 61
- TheCraigHewitt/seomachine★ 7.5ksame name
- LeoYeAI/openclaw-marketing-skills★ 1ksame name
- OpenClaudia/openclaudia-skills★ 731same name
- boraoztunc/skills★ 454same name
- mysticaltech/marketingskills★ 352same name
- textura-agency/next16-claude-starter★ 141same name
- b1rdmania/ghostclaw★ 91same name
- akii-technologies-ltd/akii-seo-ai-search-optimizer★ 79same name
- robertbstillwell/marketing-skills★ 26same name
- ukunal91app/Marketing-Skills-by-Corey-Haines★ 26same name
51 more not listed
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository40 other files it gives its agents.
- accesibilidad-wcagSkill
- cache-headersSkill
- canonicalSkill
- ccpaSkill
- core-web-vitalsSkill
- ga4-ai-trafficSkill
- ga4-analysisSkill
- geo-ai-discoverabilitySkill
- google-tag-managerSkill
- hreflangSkill
- image-optimizationSkill
- javascript-seoSkill
- ley-25326Skill
- ley-datos-chileSkill
- lfpdpppSkill
- lgpdSkill
- mineria-de-impresionesSkill
- nfadpSkill
- on-page-fundamentalsSkill
- prestashop-seoSkill
- privacidadSkill
- redirectsSkill
- rgpdSkill
- robots-txtSkill
- screaming-frogSkill
- semrushSkill
- seo-educacionSkill
- seo-gsc-diagnosticsSkill
- seo-interlinkingSkill
- seo-negativoSkill
- seo-quoteSkill
- se-rankingSkill
- sitemapSkill
- ssl-httpsSkill
- third-party-scriptsSkill
- uk-gdprSkill
- ux-heuristicaSkill
- wordpress-diviSkill
- wordpress-elementorSkill
- wordpress-hidden-errorsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
19 rules. 0 are common, 10 are distinctive, and 9 are very specific to this project.
0
Common
Most of the 60 edited versions we read keep these.
10
Distinctive
This author's own choices. Worth a look.
9
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 104
Must be crawlable and publicly accessible
- Distinctive: line 108
Remove dead services: Google+, defunct social profiles
- Distinctive: line 109
Do not duplicate the same URL twice
- Distinctive: line 192
Do not promise FAQ rich results in Google Search — they no longer exist.
- Distinctive: line 193
Never report FAQPage as a schema issue or error.
- Very specific: line 285
Add this inside a Product or LocalBusiness schema.
- Distinctive: line 295
Only use AggregateRating if the reviews are visible to users.
- Distinctive: line 313
If not visible: use a generic verified value or remove the field
- Distinctive: line 466
Fix: Remove Google+ URLs. Deduplicate.
- Very specific: line 485
Fix (WordPress output buffer): Remove the url field when its value contains "Array":
- Very specific: line 586
Fix (output buffer): Add || empty($json) to the guard condition:
- Very specific: line 606
When fixing either from a shell, write the script to a file and build the backslash with chr(92): heredocs and -c strings silently eat …
- Very specific: line 687
Fix: Check and capitalize any top-level @type that should be Organization.
- Very specific: line 896
When a WordPress author user exists but the public author archive page hasn't been created yet (or returns 404), using a @id with a …
- Very specific: line 917
Add @id and url fields once the author page is live and returns 200
- Very specific: line 958
When a plugin generates schema bugs that cannot be fixed through plugin settings (wrong @type capitalization, serialization bugs, missing …
- Distinctive: line 1016
If the fix is available in plugin settings — prefer that (no maintenance burden)
- Distinctive: line 1017
If the site uses a page cache that caches before WordPress runs (Varnish, Cloudflare cache with HTML caching) — the fix will not apply to …
- Very specific: line 1137
Use curl to fetch the HTML and extract all <script type="application/ld+json"> blocks:
“Common” is measured: kept by at least 6 in 10 of the 60 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Schema Markup (JSON-LD) — Technical SEO
All schema referenced here uses JSON-LD format — the only format Google recommends for new implementations. Validator: https://validator.schema.org Rich Results Test: https://search.google.com/test/rich-results
Validator.schema.org vs Rich Results Test — not the same
| Tool | What it validates | When to use |
| validator.schema.org | Schema.org specification compliance — correct types, required properties, enum values | Always — primary validation |
| Rich Results Test | Whether Google can generate a rich result from this page's schema | After schema is spec-valid, to check Google eligibility |
| GSC > Enhancements | Errors Google encounters on crawled pages at scale | Production monitoring |
Rule: A schema that passes the Rich Results Test does not mean it is spec-correct. A schema that passes validator.schema.org does not mean it will generate a rich result. Both tools serve different purposes.
Schema type selection
For WordPress/blog sites
| Page type | Recommended schema |
| Homepage (company) | Organization or LocalBusiness |
| Homepage (personal brand) | Person |
| Blog post | Article (or BlogPosting for informal blogs) |
| Service page | Service within a WebPage |
| About page | AboutPage + Person or Organization |
| Contact page | ContactPage |
| FAQ section | FAQPage |
| Breadcrumbs | BreadcrumbList |
For e-commerce (WooCommerce, PrestaShop)
| Page type | Recommended schema |
| Product page | Product + Offer + optionally AggregateRating |
| Category page | ItemList (optional, no rich result) |
| Checkout / cart | No schema needed |
| Homepage (shop) | Organization |
For medical / health sites
| Page type | Recommended schema |
| Medical condition article | MedicalWebPage + MedicalCondition |
| Treatment page | MedicalWebPage with specialty |
| Doctor profile | Physician (subtype of MedicalBusiness) |
| Clinic homepage | MedicalBusiness |
Core schema types — implementation reference
Organization / LocalBusiness
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "https://domain.com/#organization",
"name": "Business Name",
"url": "https://domain.com",
"logo": {
"@type": "ImageObject",
"url": "https://domain.com/logo.png",
"width": 300,
"height": 150
},
"address": {
"@type": "PostalAddress",
"streetAddress": "Street Name 123",
"addressLocality": "City",
"postalCode": "28001",
"addressCountry": "ES"
},
"telephone": "+34 900 000 000",
"sameAs": [
"https://www.facebook.com/businessname",
"https://www.instagram.com/businessname",
"https://www.linkedin.com/company/businessname"
]
}Logo requirements for rich results:
- Minimum: 112×112px (Google requirement for Knowledge Panel eligibility)
- 60×60px is invalid for rich result eligibility — do not use
- Recommended: square or near-square, transparent background (PNG)
- Must be crawlable and publicly accessible
sameAs validation:
- Each URL must be publicly accessible (200 status)
- Remove dead services: Google+, defunct social profiles
- Do not duplicate the same URL twice
- Limit to authoritative profiles: official social media, Wikipedia, Wikidata
Article / BlogPosting
{
"@context": "https://schema.org",
"@type": "Article",
"@id": "https://domain.com/post-slug/#article",
"headline": "Article Title",
"description": "Meta description text",
"image": "https://domain.com/featured-image.jpg",
"datePublished": "2026-01-15T10:00:00+01:00",
"dateModified": "2026-03-20T14:30:00+01:00",
"author": {
"@type": "Person",
"@id": "https://domain.com/author/name/#person",
"name": "Author Name",
"url": "https://domain.com/author/name/"
},
"publisher": {
"@type": "Organization",
"@id": "https://domain.com/#organization",
"name": "Site Name",
"logo": {
"@type": "ImageObject",
"url": "https://domain.com/logo.png"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://domain.com/post-slug/"
}
}Article vs BlogPosting vs NewsArticle:
Article— generic, recommended for most blog/editorial contentBlogPosting— informal blog post, no advantage over Article in practiceNewsArticle— for news publishers registered with Google News
FAQPage
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Question text here?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Answer text here. Can include basic HTML."
}
},
{
"@type": "Question",
"name": "Second question?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Second answer."
}
}
]
}FAQPage and rich results (May 7, 2026 update): Google retired FAQ rich results completely on May 7, 2026. FAQPage schema no longer generates accordion dropdowns in Google Search for any site type (including government and health sites, which had been the last exceptions since the 2023 partial restriction).
However, FAQPage is still worth implementing because:
- Bing and Yahoo still render FAQ rich results for all sites
- AI tools (ChatGPT, Perplexity, AI Overviews, AI Mode) extract Q&A pairs from FAQPage for citation and answer generation
- Google reads Q&A pairs for semantic understanding and topical authority
- E-E-A-T signal: structured, clear answers support content quality signals
Recommendation: Maintain and add FAQPage implementations. Do not remove them. Do not promise FAQ rich results in Google Search — they no longer exist. Never report FAQPage as a schema issue or error.
BreadcrumbList
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://domain.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Category Name",
"item": "https://domain.com/category/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Page Title"
}
]
}Last item: The last ListItem does not need an item property (it is the current page).
Duplicate BreadcrumbList (Yoast + Schema Pro / Elementor): A common issue in WordPress sites using two schema-generating plugins simultaneously. Google may show one or both, or neither if they conflict. Fix: disable breadcrumb schema in one plugin. Yoast handles it well by default.
Product + Offer (WooCommerce / PrestaShop)
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Product Name",
"description": "Product description text",
"image": [
"https://domain.com/product-image-1.jpg",
"https://domain.com/product-image-2.jpg"
],
"sku": "SKU-123",
"brand": {
"@type": "Brand",
"name": "Brand Name"
},
"offers": {
"@type": "Offer",
"url": "https://domain.com/product-slug/",
"priceCurrency": "EUR",
"price": "29.99",
"priceValidUntil": "2026-12-31",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"seller": {
"@type": "Organization",
"name": "Store Name"
}
}
}availability must use full schema.org enum URL:
"https://schema.org/InStock"— correct"InStock"— accepted by Google but not spec-valid"in stock"— invalid
price format: String, not number. "29.99" not 29.99.
priceValidUntil: Required for rich result eligibility when price is present.
Without it, Google may not show the price in the rich result.
AggregateRating
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.7",
"reviewCount": "124",
"bestRating": "5",
"worstRating": "1"
}Add this inside a Product or LocalBusiness schema.
Requirements for Google to show star ratings:
ratingValue,reviewCount(orratingCount),bestRatingare required- The ratings must come from actual users — not self-assigned
- WooCommerce: requires a reviews plugin that exports to JSON-LD
- PrestaShop: requires the native reviews module with schema export enabled
AggregateRating on pages without visible reviews: Google can penalize sites that show schema ratings but do not display the actual reviews on the page. Only use AggregateRating if the reviews are visible to users.
Claims verification before publishing schema
Any specific claim included in schema markup must be verified on the live site before writing it. This applies to:
ratingValueandreviewCountin AggregateRating — verify the actual score and count visible on the pagepriceandpriceValidUntilin Offer — verify the current price in the product/service pageopeningHoursin LocalBusiness — verify against the actual hours shown on the sitetelephoneandaddress— verify they match what is displayed publiclydescriptionclaims ("guaranteed", "certified", "free") — verify they appear in the visible content
Process:
- WebFetch the affected URL before finalizing the schema
- Confirm the value matches what is visible to users
- If not visible: use a generic verified value or remove the field
A schema that passes validator.schema.org but contains unverified claims is still incorrect — validation tools check structure, not factual accuracy.
Documented bugs and common errors
Schema Pro guarda un snapshot en postmeta que re-guardar el post NO regenera
El bug más caro de diagnosticar de esta lista, porque desafía la intuición: puedes limpiar el contenido del post, verificarlo, volver a guardarlo, y el schema sigue mostrando los datos viejos indefinidamente.
Causa: Schema Pro (wp-schema-pro) no genera el JSON-LD al vuelo en cada carga. Guarda un
snapshot renderizado en postmeta —wp_schema_pro_optimized_structured_data, más los campos
por plantilla tipo recipe-NNN-ingredients, recipe-NNN-recipe-instructions— y sirve ese
snapshot. Rank Math hace algo equivalente con rank_math_schema_*. Disparar save_post no
lo reconstruye.
Consecuencia práctica: cualquier corrección masiva sobre content.raw (limpiar enlaces de
afiliado inyectados, corregir texto, reparar rutas de imagen rotas) deja el schema intacto con
los datos corruptos. El contenido visible queda limpio y los datos estructurados que lee Google
siguen sucios, sin ninguna señal visible.
Detección — el campo no lo expone la REST API estándar de WordPress, así que hay que leer el JSON-LD del HTML renderizado:
curl -s -A "Mozilla/5.0" https://example.com/post/ \
| grep -o '<script type="application/ld+json"[^>]*>.*\?</script>' \
| grep -i "patron_a_buscar"Para barrer un sitio completo, itera sobre el sitemap o el listado de posts y cachea por lotes: una pasada sobre ~1.100 URLs tarda del orden de 20–30 minutos y conviene poder reanudarla.
Reparación: hay que escribir en postmeta, que tampoco expone la REST API estándar. En WordPress con el plugin Code Snippets instalado se puede registrar una ruta REST temporal que lea y escriba postmeta, usarla una vez y borrar el snippet en el mismo proceso. Alternativa más limpia si el acceso lo permite: borrar la clave de caché para forzar la regeneración.
Tres precauciones aprendidas reparando esto:
- Nunca "limpies"
_wp_old_slug. WordPress guarda ahí el slug anterior a propósito: es lo que alimenta_wp_old_slug_redirecty sirve el 301 desde la URL antigua. Limpiarlo rompe redirecciones que funcionan. Exclúyelo explícitamente de cualquier barrido sobre postmeta. - No resuelvas "la imagen correcta" como "el primer adjunto del post". Si una URL de imagen está corrupta dentro del schema, la cadena corrupta normalmente decodifica al nombre real del archivo — verifica ese candidato con HEAD antes de sustituirlo por otro. Tomar el primer adjunto mete una imagen equivocada que nadie va a notar.
- Los valores serializados de PHP no se parchean con reemplazo de texto. Cambiar una cadena
dentro de un
s:123:"..."invalida el prefijo de longitud. Siupdate_post_metadevuelvefalse, probablemente no escribió nada — verifica antes de asumir que falló por otra razón.
Verificación: tras reparar, la página puede seguir sirviendo el schema viejo por caché.
Comprueba con cache-busting (?nc=$RANDOM) antes de dar el fix por fallido, y fuerza el purgado
del lado del servidor con un touch al post (un POST del mismo contenido dispara save_post,
que sí purga la caché de página aunque no regenere el schema).
datePublished = "1970-01-01" (Rank Math bug)
Cause: Rank Math generates datePublished: "1970-01-01T00:00:00+00:00" for
posts that have no saved modified date in the WordPress database. Typically
affects posts published before Rank Math was installed.
Impact: Google de-prioritizes these articles for re-crawl. In Google News, a 1970 date causes immediate exclusion. In regular search, it signals stale content.
Fix:
- Rank Math: Titles & Meta > Posts > Advanced > Use Modified Date
- Bulk re-save affected posts to write a real modified date to the database
- Or via WP-CLI:
wp post list --post_type=post --fields=ID | xargs -I {} wp post update {} --post_modified="$(date)"
@type capitalization (Rank Math lowercase bug)
Rank Math sometimes generates lowercase @type values in author objects inside Article schema:
"author": {
"@type": "person", ← wrong
"name": "Author Name"
}Correct: "@type": "Person" — capitalized, matching schema.org specification.
Detection: validator.schema.org flags this as a type error.
specialty / relevantSpecialty with text value or wrong enum URL
Field names differ by context:
relevantSpecialty— onMedicalWebPage(the page covers this specialty)specialty— onMedicalBusiness(the business specializes in this)
Both require a full enum URL from the schema.org MedicalSpecialty enum.
// Wrong — text value
"relevantSpecialty": "Physical Therapy"
// Wrong — PhysicalTherapy is a schema.org @type for businesses, NOT a MedicalSpecialty enum value
"relevantSpecialty": "http://schema.org/PhysicalTherapy"
// Correct — Physiotherapy is the valid MedicalSpecialty enum value
"relevantSpecialty": "http://schema.org/Physiotherapy"http://schema.org/PhysicalTherapy is a subtype of LocalBusiness/MedicalBusiness
(i.e. a business type), not a value in the MedicalSpecialty enumeration. Using it as a
specialty or relevantSpecialty value fails validator.schema.org.
Other valid MedicalSpecialty enum values for health clinics:
http://schema.org/Physiotherapy, http://schema.org/Osteopathy,
http://schema.org/Nursing, http://schema.org/Musculoskeletal,
http://schema.org/Neurologic
Full list: https://schema.org/MedicalSpecialty
Logo too small for Knowledge Panel (60×60px)
Google requires a minimum 112×112px logo image for Knowledge Panel eligibility. A 60×60px logo passes the Rich Results Test but is invalid for Knowledge Panel.
Yoast: SEO > Search Appearance > Knowledge Graph > Logo — upload minimum 112×112px.
null fields in Person schema (WordPress author profile)
"author": {
"@type": "Person",
"name": "Author Name",
"email": null, ← noise, not invalid but unnecessary
"gender": null, ← same
"image": null ← same
}Null fields are not spec errors but signal an incomplete author profile. They are generated when WordPress author fields exist in the schema template but have no value. Not worth fixing as a standalone task, but should be noted as indicator of thin author profiles for E-E-A-T purposes.
sameAs with dead services or duplicates
"sameAs": [
"https://plus.google.com/...", ← dead — Google+ shut down 2019
"https://www.facebook.com/page",
"https://www.facebook.com/page" ← duplicate
]Fix: Remove Google+ URLs. Deduplicate. Verify all URLs return 200.
HowToStep.url = "Array,Array,Array..." (WP Recipe Maker serialization bug)
A PHP array serialization bug in certain recipe plugins causes the url field
of HowToStep objects to contain the literal string "Array,Array,Array...".
{
"@type": "HowToStep",
"text": "Mix the ingredients.",
"url": "Array,Array,Array,Array,Array" ← invalid, causes validation error
}Cause: The plugin internally stores step URLs as a PHP array and casts it to string instead of encoding it as JSON. Results in an invalid URL field that fails validator.schema.org.
Fix (WordPress output buffer): Remove the url field when its value contains "Array":
foreach ( $json['recipeInstructions'] as &$step ) {
if ( isset( $step['url'] ) && strpos( (string) $step['url'], 'Array' ) !== false ) {
unset( $step['url'] );
}
}Recipe.description contains full article content
Some recipe plugins dump the entire post_content into Recipe.description
instead of the post excerpt. Results in thousands of characters including
section headers, FAQ text, history notes, and tips.
Impact: validator.schema.org does not flag this, but Google's rich result parser may truncate or ignore an oversized description. It also pollutes AI extraction (ChatGPT, Perplexity) with non-description content.
Fix: Extract the first clean paragraph:
if ( isset( $json['description'] ) && strlen( $json['description'] ) > 500 ) {
$paragraphs = preg_split( '/\r?\n\r?\n+/', $json['description'] );
foreach ( $paragraphs as $p ) {
$p = trim( $p );
if ( strlen( $p ) > 50 ) {
$json['description'] = $p;
break;
}
}
}nutrition.calories missing unit
The schema.org spec requires calories to be a string with a unit, not a bare number.
// Wrong
"nutrition": { "calories": "300" }
// Correct
"nutrition": { "calories": "300 calories" }validator.schema.org accepts both, but Google's structured data documentation
specifies the unit must be included. Fix: append " calories" if the value
contains no letters.
Recipe missing datePublished when Article block has it
On WordPress sites using a recipe plugin alongside Rank Math or Yoast,
the Article block has datePublished but the Recipe block does not.
Both schemas are on the same page.
Fix (WordPress output buffer, cross-block): Capture the date from the
Article block and apply it to the Recipe block. Since Article always precedes
Recipe in the DOM, use a static variable:
static $article_date_published = null;
static $article_date_modified = null;
if ( $json['@type'] === 'Article' ) {
if ( isset( $json['datePublished'] ) ) $article_date_published = $json['datePublished'];
if ( isset( $json['dateModified'] ) ) $article_date_modified = $json['dateModified'];
}
if ( $json['@type'] === 'Recipe' ) {
if ( ! isset( $json['datePublished'] ) && $article_date_published ) {
$json['datePublished'] = $article_date_published;
}
}Alternative (no Article block): Extract from the OG meta tag in the buffer:
if ( preg_match( '/<meta property="og:article:published_time" content="([^"]+)"/', $buffer, $m ) ) {
$json['datePublished'] = str_replace( ' ', 'T', $m[1] ) . '+00:00';
}Note: WordPress may output the date as "2024-06-09 16:40:33" (space, no T, no timezone)
in the OG tag even while Yoast/Rank Math output ISO 8601 in JSON-LD.
datePublished format +0000 (missing colon)
Some plugins generate "2024-01-15T10:00:00+0000" instead of "2024-01-15T10:00:00+00:00".
Both are technically valid ISO 8601 but the colon form is required by RFC 3339
which schema.org uses as its date standard.
Fix:
$json['datePublished'] = preg_replace( '/\+0{4}$/', '+00:00', $json['datePublished'] );Empty [] JSON-LD block
Some plugins generate an empty JSON-LD block ([]) on certain page types.
json_decode('[]', true) returns an empty PHP array — it passes is_array()
and does not trigger a JSON error, so standard checks miss it.
Detection: validator.schema.org reports "No values provided for @type".
Fix (output buffer): Add || empty($json) to the guard condition:
$json = json_decode( $matches[1], true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $json ) || empty( $json ) ) {
return $matches[0]; // return original unchanged
}Inline JSON-LD broken by escaping (content published via API)
Two patterns found when content is created programmatically through the REST API:
- Literal
\nstored inpost_content. The body was saved with escaped newlines (a JSON string not decoded before publishing). The whole post renders as one block with visible\n\n, and every inline JSON-LD block fails to parse. Count backslash-n in the raw content; replace literal\r\n,\n,\twith real characters and re-validate. \'inside JSON strings.\'is not a valid JSON escape (only\",\\,\/,\b,\f,\n,\r,\t,\u). Python-style escaping of apostrophes makes the whole block invalid and Google ignores it silently. Replace with a plain'.
When fixing either from a shell, write the script to a file and build the backslash
with chr(92): heredocs and -c strings silently eat backslashes and the replacement
does nothing. Validate every inline block with a real JSON parser after the fix.
Double-decoding of HTML entities in JSON-LD (Google change, 21 ago 2026)
Confirmado — cambio oficial de Google, anunciado el 21 de agosto de 2026 (comunicado por Google vía LinkedIn, en formato imagen; corroborado independientemente por SEO Südwest: http://www.seo-suedwest.de/10921-wichtige-google-aenderung-bei-der-extraktion-von-json-ld.html). Google cambió cómo decodifica entidades HTML dentro de JSON-LD: ahora las decodifica una sola vez, en lugar de decodificarlas repetidamente hasta resolver todos los niveles de escapado.
Comportamiento antes/después:
| Valor en el JSON-LD | Antes (decodificaba en bucle) | Ahora (decodifica 1 vez) |
& | & | & (sin cambios) |
&amp; | & | & ← roto |
&#10004; | ✔ | ✔ ← roto, no renderiza |
Los valores simplemente escapados siguen funcionando igual. Solo se rompe el contenido con doble escapado (o más niveles).
Síntoma: un campo de texto (name, description, headline, respuestas
FAQ) muestra &amp; o &#... literal en el rich result, en vez del
carácter resuelto. También puede romper URLs con parámetros de tracking que
contienen & doblemente escapado (ej. en item/url de BreadcrumbList o
en Offer.url), impidiendo el matching correcto de la URL.
Causa: el pipeline de generación de schema escapa un valor que ya venía
escapado desde la base de datos (doble encoding). Típico en plugins
WordPress mal configurados o desarrollo custom que aplica
htmlspecialchars()/esc_html() sobre un string que ya contenía entidades,
en lugar de escapar solo el valor crudo.
// Mal — doble escapado, se rompe con el cambio de Google
"name": "Bar &amp; Grill"
// Correcto — escapado simple (un solo nivel), sigue funcionando igual
"name": "Bar & Grill"
// Correcto — carácter UTF-8 plano (recomendado por SEO Südwest)
"name": "Bar & Grill"Quién se ve afectado: principalmente sitios con desarrollo custom o
plugins que generan el JSON-LD "a mano" (concatenación de strings) en lugar
de usar json_encode()/wp_json_encode(), que gestionan el escapado
correctamente por defecto. La mayoría de sitios con Yoast, Rank Math o
Schema Pro no debería verse afectada — estos plugins usan
wp_json_encode() internamente.
Diagnóstico:
- Extraer los bloques JSON-LD con el método curl+python de este documento (ver "Diagnostic commands" más abajo)
- Buscar literalmente
&amp;y&#dentro de los bloques<script type="application/ld+json">— es el patrón exacto que señala SEO Südwest para detectar el problema - Confirmar en GSC > Enhancements si hay caída reciente de rich results en páginas con este patrón, y contrastar la fecha con el 21 de agosto de 2026
- Validar con Rich Results Test qué texto está parseando Google realmente en el campo afectado
Fix: asegurar que el pipeline de generación de schema escape una sola
vez, usando el carácter UTF-8 plano (o, si se necesita escapado JSON, el
escape Unicode de un ampersand: código de escape JSON estándar para U+0026,
no una entidad HTML) en vez de funciones HTML-escape. En PHP: wp_json_encode() o json_encode() con
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES sobre el valor crudo de la
base de datos — nunca sobre un valor que ya pasó por htmlspecialchars() o
esc_html() previamente.
@type lowercase in Organization (not just Person/Article)
Beyond the documented Rank Math bug in author objects, some plugins generate
lowercase @type for the top-level Organization or NewsMediaOrganization:
{ "@type": "organization", "name": "..." } ← invalidFix: Check and capitalize any top-level @type that should be Organization.
@type: "Organization" in author field
Certain plugin configurations map the post author as an Organization
instead of a Person:
"author": { "@type": "Organization", "name": "Author Name" } ← wrongAn author is a Person. Using Organization here fails validation and
breaks E-E-A-T authorship signals.
Character encoding corruption (™ → replacement character)
WordPress sites with a utf8 (not utf8mb4) database connection may store
multi-byte characters (™, ©, emoji) correctly in the DB but corrupt them
during JSON encoding, producing the Unicode replacement character �.
"name": "Brand�" ← should be "Brand™"Fix (output buffer):
array_walk_recursive( $json, function( &$val ) {
if ( is_string( $val ) ) $val = str_replace( "\u{FFFD}", '™', $val );
});For a permanent fix, set define('DB_CHARSET', 'utf8mb4') in wp-config.php
and convert the database tables to utf8mb4.
WebSite schema @id in subdirectory installations (Yoast)
Yoast in a WordPress subdirectory (/en/) generates:
"@id": "https://domain.com/en/#website"Instead of:
"@id": "https://domain.com/#website"This splits the Knowledge Graph entity between the main domain and the subdirectory.
Fix in functions.php of the secondary installation:
add_filter('wpseo_schema_website', function($data) {
$data['@id'] = 'https://domain.com/#website';
$data['url'] = 'https://domain.com/';
return $data;
});MedicalWebPage and MedicalCondition — GSC expectations
MedicalWebPage and MedicalCondition do not generate an enhancement report in GSC.
Google Search Console only shows enhancement reports for schema types that generate rich results (Product, FAQ, HowTo, Review, etc.). Medical schema types are not in this list.
The value of medical schema is:
- Semantic classification — helps Google understand the content is health-related
- E-E-A-T signal — structured authorship and specialty claims
- AI extraction — ChatGPT, Perplexity, and AI Overviews use medical schema to understand medical content and attribute it correctly
- Bing/Yandex — may use it for health-specific rich results
Do not promise GSC enhancement reports when implementing medical schema.
E-E-A-T and schema
Schema supports E-E-A-T signals through structured authorship and entity data.
Author schema for E-E-A-T
For sites where author expertise matters (medical, legal, financial, news):
{
"@type": "Person",
"@id": "https://domain.com/author/name/#person",
"name": "Author Full Name",
"url": "https://domain.com/author/name/",
"jobTitle": "Physiotherapist",
"description": "Short bio establishing expertise",
"image": {
"@type": "ImageObject",
"url": "https://domain.com/author-photo.jpg"
},
"sameAs": [
"https://www.linkedin.com/in/authorname",
"https://orcid.org/0000-0000-0000-0000"
],
"worksFor": {
"@type": "Organization",
"@id": "https://domain.com/#organization"
},
"knowsAbout": ["Physical Therapy", "Rehabilitation", "Sports Medicine"]
}jobTitle and description are the most important E-E-A-T fields.
They establish the author's role and expertise directly in the schema.
sameAs for authors: LinkedIn is the most impactful. ORCID for academic/medical.
Wikidata if the author has a notable profile.
Linking Article to Person (consistent @id)
The same @id for the author must appear in both the Article schema and the
standalone Person schema on the author's archive page:
// On article page:
"author": {
"@type": "Person",
"@id": "https://domain.com/author/name/#person"
}
// On author archive page — full Person entity with same @id:
{
"@type": "Person",
"@id": "https://domain.com/author/name/#person",
"name": "Author Name",
"jobTitle": "...",
...
}Google uses the @id to connect the author entity across pages and build
a consolidated understanding of the author's expertise.
Multi-domain entity consolidation (parent brand + product/satellite sites)
Same underlying principle as author @id consistency, applied to companies that
operate several product domains under one parent brand (a SaaS company with a
marketing site plus a separate domain per product, a group with multiple
sub-brands, etc.). The fix for the fragmentation below generalizes directly.
Symptom: each product domain declares its own independent Organization
block — different name, no @id, its own partial sameAs — instead of all of
them representing the same parent entity. Google/AI systems see N different,
unconnected organizations instead of one entity with N properties, splitting
brand-mention and citation signals across domains instead of consolidating them.
A second, subtler version of the same bug: even after anchoring every
satellite to the parent's @id, one satellite ends up using the legal name
(the registered company name) in the name field instead of the public brand
name — those are different fields with different purposes (name = the brand
Google/users recognize, legalName = the registered entity) and mixing them up
re-fragments the signal even though @id matches.
Fix — replicate the parent's canonical block on every satellite, changing only alternateName:
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://parent-domain.com/#organization",
"name": "Parent Brand Name",
"legalName": "Registered Legal Entity Name",
"alternateName": "Product/Satellite Name",
"url": "https://product-domain.com/",
"sameAs": [
"https://www.linkedin.com/company/parent-brand",
"https://www.instagram.com/parentbrand/",
"https://www.youtube.com/@ParentBrandOfficial",
"https://www.facebook.com/profile.php?id=XXXXXXXXXXXXXXX",
"https://www.wikidata.org/wiki/QXXXXXXX"
]
}Same @id, same name/legalName, same complete sameAs array on every
satellite domain — only alternateName changes to identify which product this
particular embed is for. Never let sameAs grow inconsistently between
satellites (each one accumulating whatever social profiles someone happened to
add) — treat the parent's sameAs list as the single source of truth and copy it
in full to every satellite when auditing more than one.
Known limitation, not fully solved by this pattern: url still varies per
satellite (each domain's own address) while @id/name/legalName stay fixed —
technically the same declared entity ends up with a different url value
depending on which domain serves the block. The textbook-correct alternative is a
separate Organization (parent, one fixed url) plus a distinct
Product/SoftwareApplication entity per satellite (own @id, own url) linked
back via provider/manufacturer. That said, if the parent's own product-page
schema already models the products as sections of one Organization rather than as
separate SoftwareApplication entities, forcing that split only in the satellite's
external-facing Organization block creates a different inconsistency (the
satellite modeling itself as a full separate entity while the parent's content
doesn't) — treat this as an architecture decision to make deliberately for the
whole domain family, not something to silently "fix" on one satellite at a time.
A frequent false lead when auditing sameAs across satellites: a social
profile handle that contains a different sub-brand or parent-entity name than
the one you're auditing (e.g., a YouTube handle literally spelled with the holding
company's name) does not by itself mean that profile belongs to the holding
company rather than the operating brand — verify against the parent's own
already-corrected canonical schema block, not against what the handle string
seems to say.
Person schema when no author archive page exists
When a WordPress author user exists but the public author archive page hasn't been
created yet (or returns 404), using a @id with a non-existent URL is worse than
omitting it — it creates a broken entity reference.
// Pattern: embed full Person inline in Article, without @id or url
"author": {
"@type": "Person",
"name": "Author Full Name",
"jobTitle": "Physiotherapist",
"description": "Short bio establishing expertise — 1-2 sentences",
"sameAs": [
"https://www.linkedin.com/in/authorname"
]
}Rules:
- Omit
@idandurlif the author archive page does not exist or returns 404 jobTitleanddescriptionare still read by Google for E-E-A-TsameAswith LinkedIn remains valid without a@id- Add
@idandurlfields once the author page is live and returns 200
CMS implementation
WordPress + Yoast SEO
Yoast generates automatically:
WebSitewithSearchActionOrganizationorPerson(configured in SEO > Search Appearance > Knowledge Graph)Article/BlogPostingon postsWebPageon pagesBreadcrumbList
Configuration paths:
- Organization schema: SEO > Search Appearance > Knowledge Graph
- Logo: SEO > Search Appearance > Knowledge Graph > Logo (minimum 112×112px)
- Author schema: SEO > Search Appearance > Archives > Author archives
- Social profiles for
sameAs: SEO > Search Appearance > Social
Yoast does NOT generate automatically:
Product+Offer(requires Yoast WooCommerce SEO add-on)FAQPage(must be added via FAQ block in editor)HowTo(must be added via HowTo block)AggregateRating
WordPress + Rank Math
Rank Math generates similar schema to Yoast with some differences:
- Schema builder: Rank Math > Schema > Schema Generator (per post type)
- Supports
Product,Course,Recipe,JobPostingnatively - Documented bug:
datePublished = 1970-01-01(see bugs section) - Documented bug: lowercase
@typein author objects (see bugs section)
Rich Snippet type per post: Each post can have a different schema type set via the Rank Math sidebar > Schema > Schema Type.
WordPress — output buffer fix pattern
When a plugin generates schema bugs that cannot be fixed through plugin settings
(wrong @type capitalization, serialization bugs, missing fields), the cleanest
approach is intercepting the page output buffer and patching the JSON-LD blocks
before they are sent to the browser.
This avoids modifying the plugin, survives plugin updates, and fixes all pages simultaneously.
Pattern (add to functions.php or a code snippets plugin):
add_action( 'template_redirect', function() {
ob_start( function( $buffer ) {
return preg_replace_callback(
'/<script type="application\/ld\+json">(.*?)<\/script>/s',
function( $matches ) use ( $buffer ) {
$json = json_decode( $matches[1], true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $json ) || empty( $json ) ) {
return $matches[0];
}
// Apply fixes here — examples:
// Fix lowercase @type
if ( isset( $json['@type'] ) && $json['@type'] === 'article' ) {
$json['@type'] = 'Article';
}
// Fix author @type
if ( isset( $json['author']['@type'] ) && $json['author']['@type'] === 'person' ) {
$json['author']['@type'] = 'Person';
}
// Remove HowToStep Array bug
if ( isset( $json['recipeInstructions'] ) ) {
foreach ( $json['recipeInstructions'] as &$step ) {
if ( isset( $step['url'] ) && strpos( (string) $step['url'], 'Array' ) !== false ) {
unset( $step['url'] );
}
}
unset( $step );
}
return '<script type="application/ld+json">'
. wp_json_encode( $json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
. '</script>';
},
$buffer
);
});
}, 1 );When to use this pattern:
- Bugs reproducible via
curlthat do not appear in plugin settings - Sites where the plugin cannot be updated (managed hosting, frozen versions)
- Multiple bugs requiring coordinated fixes across the same JSON-LD blocks
When NOT to use it:
- If the fix is available in plugin settings — prefer that (no maintenance burden)
- If the site uses a page cache that caches before WordPress runs (Varnish, Cloudflare cache with HTML caching) — the fix will not apply to cached pages
Deployment options:
| Method | When to use |
functions.php | Child theme present and actively maintained |
| Code Snippets plugin | No child theme; snippets survive theme updates; toggle per snippet in WP admin |
| HFCM (Header Footer Code Manager) | Need to scope the fix to specific post types or page templates |
Must-use plugin (mu-plugins/) | Site-critical fixes that must run before regular plugins load |
Code Snippets is the recommended method for most sites: no file access required,
managed from WP admin, and survives theme changes. The template_redirect hook
works identically regardless of which deployment method is used.
WooCommerce
WooCommerce + Yoast WooCommerce SEO add-on generates:
Product+Offerautomatically from product dataAggregateRatingif reviews are enabled and ratings exist
Without the add-on: WooCommerce generates microdata (not JSON-LD). Google still reads microdata but JSON-LD is preferred.
Schema Pro plugin: Alternative to Yoast WooCommerce SEO for WooCommerce schema. Supports more granular control but risks duplicate BreadcrumbList (see bugs section).
PrestaShop
PrestaShop 8.x generates Product schema natively if enabled in the SEO module.
Path: Catalog > Products > SEO tab > check if schema is being generated
Verify: WebFetch a product page and search for application/ld+json
AggregateRating requires:
- The native reviews module (or third-party) with star ratings
- Module configured to export ratings to JSON-LD
PrestaShop does NOT generate Article, BlogPosting, or FAQPage natively.
These require custom Smarty template implementation.
Validation workflow
1. Implement schema in JSON-LD
2. Validate with validator.schema.org
→ Fix all errors (invalid types, missing required fields, wrong enum values)
→ Note warnings (optional but recommended fields)
3. Test with Rich Results Test (search.google.com/test/rich-results)
→ Confirms Google can render a rich result from this specific page
→ "Eligible" does not guarantee Google will show it — Google decides
4. Monitor GSC > Enhancements
→ Available for: Product, FAQ (health/gov), Article, Sitelinks Searchbox,
Video, Review snippet, Breadcrumb
→ NOT available for: MedicalWebPage, MedicalCondition, Service, LocalBusiness
5. After GSC shows the enhancement, check for errors/warnings in the reportCommon validation errors:
| Error | Cause | Fix |
| Missing required field | Required property absent | Add the property |
| Invalid enum value | Text instead of URL for enum fields | Use full schema.org URL |
| Type not found | Lowercase @type (Rank Math bug) | Capitalize: "Person" not "person" |
| Invalid URL format | Relative URL in @id or item | Use absolute URLs with protocol |
| No values provided | Empty array [] | Remove the property or populate it |
Audit checklist
CRITICAL
[ ] No schema with invalid XML/JSON syntax (validate with validator.schema.org)
[ ] datePublished not "1970-01-01" (Rank Math bug)
[ ] @type capitalized correctly ("Person" not "person")
[ ] specialty using full enum URL (not text) on medical pages
[ ] Product schema present on all product pages (WooCommerce / PrestaShop)
[ ] Offer price, priceCurrency, availability present in Product schema
HIGH
[ ] Organization/LocalBusiness logo ≥ 112×112px
[ ] No duplicate BreadcrumbList (two plugins generating simultaneously)
[ ] sameAs contains no dead URLs (Google+, defunct profiles)
[ ] No duplicate sameAs URLs
[ ] AggregateRating only on pages with visible reviews
[ ] WebSite @id points to root domain (not subdirectory) in all installations
[ ] Article schema on blog posts (datePublished, author, publisher)
MEDIUM
[ ] Author schema with jobTitle and description for E-E-A-T
[ ] Consistent @id for author across Article and Person pages
[ ] FAQPage maintained on relevant pages (semantic value even without rich result)
[ ] priceValidUntil present on Product Offer schema
[ ] AggregateRating has reviewCount, ratingValue, bestRating, worstRating
[ ] MedicalWebPage specialty uses schema.org enum URL
LOW
[ ] No null fields in Person schema (incomplete author profile signal)
[ ] No schema types Google does not use (redundant noise)
[ ] sameAs includes LinkedIn for author profiles (E-E-A-T)
[ ] GSC Enhancements monitored for eligible schema types
[ ] Recipe description is clean (not full article content >500 chars)
[ ] HowToStep.url is a valid URL (not "Array,Array,...")
[ ] nutrition.calories includes unit ("300 calories" not "300")
[ ] Recipe datePublished present
[ ] No empty [] JSON-LD blocks on any page type
[ ] No double-decoded HTML entities in text fields (&amp; / &#... left literal) — Google change confirmed 21-Aug-2026Diagnostic commands — schema extraction
Primary method — curl + python (works on any site):
WebFetch and DataForSEO on_page do not return raw JSON-LD blocks reliably.
Use curl to fetch the HTML and extract all <script type="application/ld+json"> blocks:
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" "https://example.com/page/" \
| python3 -c "
import sys, re, json
html = sys.stdin.read()
blocks = re.findall(r'<script type=\"application/ld\+json\">(.*?)</script>', html, re.DOTALL)
print(f'{len(blocks)} blocks found')
for i, b in enumerate(blocks):
try:
print(f'--- Block {i+1}: @type={json.loads(b).get(\"@type\",\"@graph\")} ---')
print(json.dumps(json.loads(b), indent=2, ensure_ascii=False))
except Exception as e:
print(f'Block {i+1} PARSE ERROR: {e}')
print(repr(b[:200]))
"If Cloudflare blocks the request, add a Referer header:
curl -s -A "Mozilla/5.0 ..." -H "Referer: https://example.com/" "https://example.com/page/"Quick field audit across multiple pages:
for url in \
"https://example.com/" \
"https://example.com/recipe-page/" \
"https://example.com/about/"; do
echo "=== $url ==="
curl -s -A "Mozilla/5.0" "$url" | python3 -c "
import sys, re, json
blocks = re.findall(r'<script type=\"application/ld\+json\">(.*?)</script>', sys.stdin.read(), re.DOTALL)
for b in blocks:
try:
j = json.loads(b)
print(f' {j.get(\"@type\",\"@graph\")}: datePublished={j.get(\"datePublished\",\"–\")} author.@type={j.get(\"author\",{}).get(\"@type\",\"–\")}')
except: pass
"
doneDoes it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

