agentleFS
carlosuhartcarlosuhart/seo-audit-skills/skills/schema-markup/SKILL.md

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

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.
  • semrushSkill

    internal-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

  • xfstudioSkill

    product-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

  • LangChainSkill

    content-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

  • ukunal91appSkill

    revops

    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

  • VercelSkill

    site-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

  • alaraakcasizSkill

    technical-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

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
  1. Distinctive:

    Must be crawlable and publicly accessible

    line 104
  2. Distinctive:

    Remove dead services: Google+, defunct social profiles

    line 108
  3. Distinctive:

    Do not duplicate the same URL twice

    line 109
  4. Distinctive:

    Do not promise FAQ rich results in Google Search — they no longer exist.

    line 192
  5. Distinctive:

    Never report FAQPage as a schema issue or error.

    line 193
  6. Very specific:

    Add this inside a Product or LocalBusiness schema.

    line 285
  7. Distinctive:

    Only use AggregateRating if the reviews are visible to users.

    line 295
  8. Distinctive:

    If not visible: use a generic verified value or remove the field

    line 313
  9. Distinctive:

    Fix: Remove Google+ URLs. Deduplicate.

    line 466
  10. Very specific:

    Fix (WordPress output buffer): Remove the url field when its value contains "Array":

    line 485
  11. Very specific:

    Fix (output buffer): Add || empty($json) to the guard condition:

    line 586
  12. Very specific:

    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 …

    line 606
  13. Very specific:

    Fix: Check and capitalize any top-level @type that should be Organization.

    line 687
  14. Very specific:

    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 …

    line 896
  15. Very specific:

    Add @id and url fields once the author page is live and returns 200

    line 917
  16. Very specific:

    When a plugin generates schema bugs that cannot be fixed through plugin settings (wrong @type capitalization, serialization bugs, missing …

    line 958
  17. Distinctive:

    If the fix is available in plugin settings — prefer that (no maintenance burden)

    line 1016
  18. Distinctive:

    If the site uses a page cache that caches before WordPress runs (Varnish, Cloudflare cache with HTML caching) — the fix will not apply to …

    line 1017
  19. Very specific:

    Use curl to fetch the HTML and extract all <script type="application/ld+json"> blocks:

    line 1137

“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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

ToolWhat it validatesWhen to use
validator.schema.orgSchema.org specification compliance — correct types, required properties, enum valuesAlways — primary validation
Rich Results TestWhether Google can generate a rich result from this page's schemaAfter schema is spec-valid, to check Google eligibility
GSC > EnhancementsErrors Google encounters on crawled pages at scaleProduction 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 typeRecommended schema
Homepage (company)Organization or LocalBusiness
Homepage (personal brand)Person
Blog postArticle (or BlogPosting for informal blogs)
Service pageService within a WebPage
About pageAboutPage + Person or Organization
Contact pageContactPage
FAQ sectionFAQPage
BreadcrumbsBreadcrumbList

For e-commerce (WooCommerce, PrestaShop)

Page typeRecommended schema
Product pageProduct + Offer + optionally AggregateRating
Category pageItemList (optional, no rich result)
Checkout / cartNo schema needed
Homepage (shop)Organization

For medical / health sites

Page typeRecommended schema
Medical condition articleMedicalWebPage + MedicalCondition
Treatment pageMedicalWebPage with specialty
Doctor profilePhysician (subtype of MedicalBusiness)
Clinic homepageMedicalBusiness

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 content
  • BlogPosting — informal blog post, no advantage over Article in practice
  • NewsArticle — 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 (or ratingCount), bestRating are 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:

  • ratingValue and reviewCount in AggregateRating — verify the actual score and count visible on the page
  • price and priceValidUntil in Offer — verify the current price in the product/service page
  • openingHours in LocalBusiness — verify against the actual hours shown on the site
  • telephone and address — verify they match what is displayed publicly
  • description claims ("guaranteed", "certified", "free") — verify they appear in the visible content

Process:

  1. WebFetch the affected URL before finalizing the schema
  2. Confirm the value matches what is visible to users
  3. 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:

  1. Nunca "limpies" _wp_old_slug. WordPress guarda ahí el slug anterior a propósito: es lo que alimenta _wp_old_slug_redirect y sirve el 301 desde la URL antigua. Limpiarlo rompe redirecciones que funcionan. Exclúyelo explícitamente de cualquier barrido sobre postmeta.
  2. 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.
  3. 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. Si update_post_meta devuelve false, 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 — on MedicalWebPage (the page covers this specialty)
  • specialty — on MedicalBusiness (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 \n stored in post_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, \t with 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-LDAntes (decodificaba en bucle)Ahora (decodifica 1 vez)
&amp;&& (sin cambios)
&amp;amp;&&amp; ← roto
&amp;#10004;✔&#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;amp; o &amp;#... 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;amp; Grill"

// Correcto — escapado simple (un solo nivel), sigue funcionando igual
"name": "Bar &amp; 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:

  1. Extraer los bloques JSON-LD con el método curl+python de este documento (ver "Diagnostic commands" más abajo)
  2. Buscar literalmente &amp;amp; y &amp;# dentro de los bloques <script type="application/ld+json"> — es el patrón exacto que señala SEO Südwest para detectar el problema
  3. 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
  4. 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": "..." }  ← invalid

Fix: 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" }  ← wrong

An 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 @id and url if the author archive page does not exist or returns 404
  • jobTitle and description are still read by Google for E-E-A-T
  • sameAs with LinkedIn remains valid without a @id
  • Add @id and url fields once the author page is live and returns 200

CMS implementation

WordPress + Yoast SEO

Yoast generates automatically:

  • WebSite with SearchAction
  • Organization or Person (configured in SEO > Search Appearance > Knowledge Graph)
  • Article / BlogPosting on posts
  • WebPage on pages
  • BreadcrumbList

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, JobPosting natively
  • Documented bug: datePublished = 1970-01-01 (see bugs section)
  • Documented bug: lowercase @type in 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 curl that 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:

MethodWhen to use
functions.phpChild theme present and actively maintained
Code Snippets pluginNo 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 + Offer automatically from product data
  • AggregateRating if 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 report

Common validation errors:

ErrorCauseFix
Missing required fieldRequired property absentAdd the property
Invalid enum valueText instead of URL for enum fieldsUse full schema.org URL
Type not foundLowercase @type (Rank Math bug)Capitalize: "Person" not "person"
Invalid URL formatRelative URL in @id or itemUse absolute URLs with protocol
No values providedEmpty 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;amp; / &amp;#... left literal) — Google change confirmed 21-Aug-2026

Diagnostic 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
"
done

Download now (ZIP)

Does 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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.