Schema Markup skill for Claude Code

JSON-LD that earns rich results and matches the page. Claude stops pasting generic schema snippets and instead builds one connected JSON-LD graph per template from the same data that renders the page. It skips types Google no longer rewards, such as HowTo and most FAQPage use, escapes output safely in Next.js, and lints every template variant with a bundled script before you run the Rich Results Test.

  • nameschema-markup
  • size212 lines · ≈2,400 tokens
  • files3
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes, embeds, and validates JSON-LD structured data for Google rich results and entity understanding, covering Organization, WebSite, Article, Product and merchant listings, BreadcrumbList, LocalBusiness, SoftwareApplication, Event, VideoObject, and the restricted FAQPage and deprecated HowTo types. Use when adding schema.org markup, when the Rich Results Test or Search Console reports structured-data errors, when product stars or prices are missing from results, or when templating JSON-LD in Next.js, Astro, WordPress, or Shopify.

Try asking

  • Add Product structured data to our Next.js product pages so prices and stars show in Google
  • Search Console says 'Missing field shippingDetails' on 3,000 items. Fix the template
  • Should we add FAQ schema to our blog posts? Audit what markup we already emit

Use it when

  • Adding JSON-LD to page templates for the first time
  • Search Console or the Rich Results Test reports structured-data errors
  • Product prices, ratings, or breadcrumbs are missing from search results
  • A plugin and custom code may be emitting duplicate markup

Skip it when

How to check the result

The bundled extract_jsonld.py exits 0 on each template variant, the Rich Results Test shows zero errors, and every marked-up value is visible on the rendered page.

The common mistake it prevents

Hardcoding a price or rating in JSON-LD that later diverges from the visible page, which invalidates the rich result and can trigger a structured-data manual action.

~/.claude/skills/schema-markup/SKILL.md

--- name: schema-markup description: Writes, embeds, and validates JSON-LD structured data for Google rich results and entity understanding, covering Organization, WebSite,… license: MIT ---

Done well means each template emits one valid JSON-LD graph that describes only what a visitor can see on the page, uses types Google still rewards, passes the Rich Results Test with zero errors, and stays correct when the underlying data changes. Markup that contradicts visible content is a spam-policy risk, not an SEO win.

Ground rules

  • JSON-LD in a <script type="application/ld+json"> is the default. Do not mix in Microdata or RDFa for the same entity.
  • Mark up only content present on the page. Prices, ratings, availability, dates, and authors must match the rendered HTML exactly.
  • Generate markup from the same data source that renders the page. Hand-written, hardcoded JSON-LD drifts within weeks.
  • Structured data makes a page eligible for rich results; it does not guarantee them and is not a direct ranking factor.
  • Never mark up reviews you wrote about yourself (self-serving Review/AggregateRating on your own Organization or LocalBusiness is not eligible for review stars).

Decide what is worth marking up (2025–2026)

TypeRich result todayRecommendation
Organization (home/about)Knowledge panel logo, entity signalsAlways, once, on home or about page
WebSiteSite name in resultsHome page only; name + alternateName + url
BreadcrumbListBreadcrumb trail in resultsEvery page below home
Article / BlogPosting / NewsArticleBetter title, image, date displayEvery editorial page
Product + OfferPrice, availability, ratings; merchant listingsEvery product page with a visible price
LocalBusiness subtypeLocal details, hoursOne per physical location page
SoftwareApplicationRating and priceApp pages with real user ratings
Event, JobPosting, Recipe, VideoObjectDedicated featuresWhen the page genuinely is that thing
ProfilePage, DiscussionForumPostingPerspectives / forum featuresCreator profiles, forum threads
FAQPageOnly for well-known authoritative government and health sites since 2023Do not add expecting a rich result; acceptable only if the FAQ is visible and genuinely useful
HowToRich result removed (2023)Do not add for search features

Google also retired several niche features in 2025 (e.g. Course Info, Claim Review, Estimated Salary, Vehicle Listing, Special Announcement). Before investing in any type, check it is still listed in Google’s Search Gallery. Answer engines and AI Overviews do not require special markup; clear visible content matters more than schema.

Workflow

- [ ] 1. Inventory templates and existing markup
- [ ] 2. Choose types per template from the table
- [ ] 3. Build one @graph per page with stable @id links
- [ ] 4. Generate from page data and embed safely
- [ ] 5. Validate (syntax, Google eligibility, visible-content parity)
- [ ] 6. Monitor Search Console enhancement reports

1. Inventory existing markup

grep -rn "application/ld+json" --include=*.{tsx,jsx,ts,js,astro,vue,svelte,php,liquid,html} . | grep -v node_modules
python3 scripts/extract_jsonld.py https://example.com/products/blue-mug
python3 scripts/extract_jsonld.py dist/blog/post/index.html

The script extracts every JSON-LD block from a URL or local HTML file, reports parse errors, flattens @graph, lists each entity’s @type, and flags missing Google-required properties, relative URLs, non-ISO dates, and FAQPage/HowTo usage. It exits 1 on errors. Plugins (Yoast, Rank Math, Shopify themes) often already emit markup — extend or disable theirs rather than adding a duplicate Product or Organization.

3. Build one connected graph

Use @id URIs (page URL + fragment) so entities reference each other instead of repeating. One Organization node, referenced everywhere as publisher/brand/seller.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://example.com/#org",
      "name": "Example Co",
      "url": "https://example.com/",
      "logo": { "@type": "ImageObject", "url": "https://example.com/logo-512.png" },
      "sameAs": ["https://www.linkedin.com/company/example", "https://github.com/example"]
    },
    {
      "@type": "BlogPosting",
      "@id": "https://example.com/blog/index-bloat/#article",
      "headline": "How we cut index bloat by 60%",
      "image": ["https://example.com/img/index-bloat-1200x675.jpg"],
      "datePublished": "2026-03-02T09:00:00+00:00",
      "dateModified": "2026-04-11T14:20:00+00:00",
      "author": { "@type": "Person", "name": "Dana Ruiz", "url": "https://example.com/authors/dana" },
      "publisher": { "@id": "https://example.com/#org" },
      "mainEntityOfPage": "https://example.com/blog/index-bloat/"
    },
    {
      "@type": "BreadcrumbList",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://example.com/blog/" },
        { "@type": "ListItem", "position": 2, "name": "How we cut index bloat by 60%" }
      ]
    }
  ]
}

Per-type required and recommended properties, plus Product/merchant-listing, LocalBusiness, SoftwareApplication, Event, and VideoObject examples, are in references/types.md. Read it when writing any type beyond the example above.

4. Generate and embed safely

Next.js App Router (server component), escaping < so data cannot break out of the script:

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const product = await getProduct(slug);
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    image: product.images.map((i) => i.url),
    sku: product.sku,
    brand: { "@type": "Brand", name: product.brand },
    offers: {
      "@type": "Offer",
      url: `https://example.com/products/${slug}`,
      price: product.price.toFixed(2),
      priceCurrency: product.currency,
      availability: product.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
    },
  };
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
      />
      {/* page */}
    </>
  );
}

Rules for generated markup:

  • Omit a property when the value is missing. Never emit "", null, 0 ratings, or placeholder images.
  • Absolute URLs everywhere; ISO 8601 dates with timezone; price as a number or numeric string with a . decimal and no currency symbol; currency as ISO 4217.
  • Use full schema.org enum URLs for availability and itemCondition.
  • Emit aggregateRating only when reviewCount ≥ 1 and the rating is visible on the page.
  • Server-render JSON-LD. Google can read JS-injected markup, but rendering delays and other consumers (social, answer engines) often cannot.
  • Keep dateModified honest: change it for meaningful edits, not on every build.

5. Validate

  1. Syntax and schema.org conformance: scripts/extract_jsonld.py and the Schema Markup Validator (validator.schema.org).
  2. Google eligibility: Rich Results Test (search.google.com/test/rich-results) on the live or staged URL, or paste the code. Zero errors required; resolve warnings for properties you can genuinely supply.
  3. Parity: open the page and confirm every marked-up value (price, rating, author, date, breadcrumb labels) is visible and identical.
  4. Test every template variant: out-of-stock product, product without reviews, article without image, multi-variant product.

If validation fails, fix the generator, not the output of one page.

6. Monitor

Search Console → Enhancements / Shopping shows per-type valid, warning, and invalid item counts. After deploy, use “Validate fix” on resolved issues. A sudden drop in valid items usually means a template or data-feed change emitted empty values. Manual actions for “spammy structured data” appear under Security & Manual Actions.

Gotchas

  • Duplicate entities from a theme plus custom code produce two conflicting Product nodes; Google may pick the wrong price.
  • Review stars for Organization/LocalBusiness on your own site will not show.
  • Product variants: use ProductGroup with hasVariant and variesBy, or one Product per variant URL; do not list one price for a range of variant prices.
  • Shipping and returns: supply shippingDetails and hasMerchantReturnPolicy on offers, or configure them at Organization level / in Merchant Center, to avoid merchant-listing warnings.
  • Images must be crawlable (not blocked in robots.txt) and representative; provide multiple aspect ratios (16:9, 4:3, 1:1) for Article images at ≥ 1200 px wide when possible.
  • JSON comments and trailing commas are invalid JSON; one bad block can hide all others in that script tag.
  • Markup on a noindex or non-canonical page does nothing for search.

Output contract

When delivering markup, provide:

### Template: /products/[slug]
- Types: Product (+Offer, AggregateRating when reviews ≥ 1), BreadcrumbList; references #org
- Data source: getProduct() → fields mapped in lib/seo/product-jsonld.ts
- Conditional properties: aggregateRating, gtin13, shippingDetails
- Validation: Rich Results Test 0 errors on in-stock, out-of-stock, no-review variants
- Not added (and why): FAQPage — rich result restricted to gov/health sites

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/types.md 176 lines · 8.0 KB
# Schema types: required and recommended properties

Read this when writing any type beyond the Article example in SKILL.md. "Required" means
Google's documentation requires it for the rich result; "Recommended" improves eligibility
or display. Always re-check Google's Search Gallery for the current list before relying on
a feature — requirements change.

## Quick table

| Type | Required (Google) | Recommended |
|---|---|---|
| Article / BlogPosting / NewsArticle | none | headline, image (multiple ratios), datePublished, dateModified, author (name + url) |
| BreadcrumbList | itemListElement → ListItem with position, name, item (item optional on last) | — |
| Organization | none | name, url, logo, sameAs, description, address, contactPoint, legalName |
| Product (snippet) | name + one of review, aggregateRating, offers | brand, sku, gtin, image, description |
| Product (merchant listing) | name, image, offers with price (and priceCurrency) | availability, itemCondition, shippingDetails, hasMerchantReturnPolicy, gtin, brand |
| AggregateRating | ratingValue, ratingCount or reviewCount, itemReviewed (if not nested) | bestRating, worstRating when scale is not 1–5 |
| LocalBusiness (use subtype) | name, address | geo, telephone, url, openingHoursSpecification, priceRange, image |
| SoftwareApplication | name, offers.price, aggregateRating or review | applicationCategory, operatingSystem |
| Event | name, startDate, location | endDate, eventStatus, eventAttendanceMode, image, offers, organizer, performer, description |
| VideoObject | name, thumbnailUrl, uploadDate | description, duration (ISO 8601), contentUrl, embedUrl |
| JobPosting | title, description, datePosted, hiringOrganization, jobLocation (or applicantLocationRequirements + jobLocationType TELECOMMUTE) | validThrough, employmentType, baseSalary, identifier, directApply |
| Recipe | name, image | author, datePublished, recipeIngredient, recipeInstructions, totalTime, nutrition, aggregateRating |
| ProfilePage | mainEntity (Person or Organization) with name | dateCreated, dateModified, image, sameAs, interactionStatistic |
| DiscussionForumPosting | author, datePublished, text/image/video | headline, url, comment, interactionStatistic |
| FAQPage | mainEntity → Question(name) → acceptedAnswer Answer(text) | Rich result restricted to authoritative government and health sites |

## Product with offer, rating, shipping, and returns

```json
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://example.com/products/blue-mug#product",
  "name": "Blue Stoneware Mug, 350 ml",
  "image": [
    "https://example.com/img/mug-1x1.jpg",
    "https://example.com/img/mug-4x3.jpg",
    "https://example.com/img/mug-16x9.jpg"
  ],
  "description": "Hand-glazed stoneware mug, dishwasher safe.",
  "sku": "MUG-BLU-350",
  "gtin13": "0123456789012",
  "brand": { "@type": "Brand", "name": "Example Co" },
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.6, "reviewCount": 128 },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/products/blue-mug",
    "price": 24.00,
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "seller": { "@id": "https://example.com/#org" },
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": { "@type": "MonetaryAmount", "value": 0, "currency": "USD" },
      "shippingDestination": { "@type": "DefinedRegion", "addressCountry": "US" },
      "deliveryTime": {
        "@type": "ShippingDeliveryTime",
        "handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 1, "unitCode": "DAY" },
        "transitTime": { "@type": "QuantitativeValue", "minValue": 2, "maxValue": 5, "unitCode": "DAY" }
      }
    },
    "hasMerchantReturnPolicy": {
      "@type": "MerchantReturnPolicy",
      "applicableCountry": "US",
      "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
      "merchantReturnDays": 30,
      "returnMethod": "https://schema.org/ReturnByMail",
      "returnFees": "https://schema.org/FreeReturn"
    }
  }
}
```

Variants: wrap in `ProductGroup` with `productGroupID`, `variesBy` (e.g.
`https://schema.org/color`), and `hasVariant` listing each `Product` with its own `sku`,
offer, and URL.

## LocalBusiness

```json
{
  "@context": "https://schema.org",
  "@type": "Dentist",
  "@id": "https://example.com/locations/austin#place",
  "name": "Example Dental Austin",
  "url": "https://example.com/locations/austin",
  "telephone": "+1-512-555-0100",
  "image": "https://example.com/img/austin-office.jpg",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "100 Congress Ave",
    "addressLocality": "Austin",
    "addressRegion": "TX",
    "postalCode": "78701",
    "addressCountry": "US"
  },
  "geo": { "@type": "GeoCoordinates", "latitude": 30.2655, "longitude": -97.7431 },
  "openingHoursSpecification": [
    { "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"], "opens": "08:00", "closes": "17:00" }
  ],
  "parentOrganization": { "@id": "https://example.com/#org" }
}
```

Use the most specific subtype (`Dentist`, `Restaurant`, `AutoRepair`), one node per
location page, and keep name/address/phone identical to the Google Business Profile.

## SoftwareApplication

```json
{
  "@context": "https://schema.org",
  "@type": "SoftwareApplication",
  "name": "Example Notes",
  "operatingSystem": "macOS, Windows, iOS",
  "applicationCategory": "BusinessApplication",
  "offers": { "@type": "Offer", "price": 0, "priceCurrency": "USD" },
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.7, "ratingCount": 2310 }
}
```

Only include `aggregateRating` from real, visible user ratings collected on your site or
a cited source shown on the page.

## Event

```json
{
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Postgres Performance Workshop",
  "startDate": "2026-11-12T17:00:00-05:00",
  "endDate": "2026-11-12T19:00:00-05:00",
  "eventStatus": "https://schema.org/EventScheduled",
  "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
  "location": { "@type": "VirtualLocation", "url": "https://example.com/live/pg-workshop" },
  "organizer": { "@id": "https://example.com/#org" },
  "offers": { "@type": "Offer", "price": 0, "priceCurrency": "USD", "url": "https://example.com/events/pg", "availability": "https://schema.org/InStock", "validFrom": "2026-10-01T00:00:00-05:00" }
}
```

Cancelled or moved events: keep the page, set `eventStatus` to `EventCancelled` or
`EventRescheduled` (with `previousStartDate`), do not delete the markup.

## VideoObject

```json
{
  "@context": "https://schema.org",
  "@type": "VideoObject",
  "name": "Setting up a sitemap index",
  "description": "Splitting a 200k-URL sitemap into an index with lastmod.",
  "thumbnailUrl": ["https://example.com/img/sitemap-video-1280x720.jpg"],
  "uploadDate": "2026-02-10T08:00:00+00:00",
  "duration": "PT6M30S",
  "contentUrl": "https://cdn.example.com/video/sitemap.mp4",
  "embedUrl": "https://example.com/embed/sitemap"
}
```

The video must be the main content of the page and playable without login for video
features. Add `hasPart` `Clip` entries or `SeekToAction` for key moments.

## Common validation errors and fixes

| Error / warning | Fix |
|---|---|
| Missing field "price" / "offers" | Emit `offers` only when a price is shown; otherwise drop Product rich-result expectations |
| Invalid value in field "availability" | Use the full `https://schema.org/InStock` URL |
| Invalid datetime value | ISO 8601 with timezone offset |
| Either "offers", "review", or "aggregateRating" should be specified | Add one real value or accept no product snippet |
| Missing field "shippingDetails" / "hasMerchantReturnPolicy" (warning) | Add on Offer or configure in Merchant Center / Organization |
| Duplicate unique property | Two blocks describe the same entity; remove the plugin or custom duplicate |
| Unparsable structured data | Invalid JSON: trailing comma, unescaped quote, comment, or HTML in a string |
scripts/extract_jsonld.py 172 lines · 7.0 KB
#!/usr/bin/env python3
"""Extract and lint JSON-LD from a URL or local HTML file (stdlib only).

Usage:
  python3 extract_jsonld.py https://example.com/page
  python3 extract_jsonld.py path/to/page.html [--print]

Reports each JSON-LD entity's @type, missing Google-required properties for common
types, relative URLs, non-ISO dates, empty values, and FAQPage/HowTo usage.
Exit codes: 0 = no errors, 1 = errors found, 2 = usage/fetch problem.
This is a first-pass linter. Always confirm with Google's Rich Results Test.
"""
from __future__ import annotations

import json
import re
import sys
import urllib.request
from html.parser import HTMLParser

REQUIRED = {
    "Product": [["name"], ["offers", "review", "aggregateRating"]],
    "Offer": [["price", "priceSpecification"]],
    "BreadcrumbList": [["itemListElement"]],
    "ListItem": [["position"], ["name", "item"]],
    "AggregateRating": [["ratingValue"], ["ratingCount", "reviewCount"]],
    "LocalBusiness": [["name"], ["address"]],
    "SoftwareApplication": [["name"], ["offers"], ["aggregateRating", "review"]],
    "Event": [["name"], ["startDate"], ["location"]],
    "VideoObject": [["name"], ["thumbnailUrl"], ["uploadDate"]],
    "JobPosting": [["title"], ["description"], ["datePosted"], ["hiringOrganization"],
                   ["jobLocation", "applicantLocationRequirements"]],
    "Recipe": [["name"], ["image"]],
    "Question": [["name"], ["acceptedAnswer"]],
}
RECOMMENDED = {
    "Article": ["headline", "image", "datePublished", "author"],
    "BlogPosting": ["headline", "image", "datePublished", "author"],
    "NewsArticle": ["headline", "image", "datePublished", "author"],
    "Organization": ["name", "url", "logo"],
    "Offer": ["priceCurrency", "availability"],
}
LOCAL_SUBTYPES = {"Restaurant", "Dentist", "Store", "AutoRepair", "MedicalClinic",
                  "LegalService", "HomeAndConstructionBusiness", "ProfessionalService"}
URL_KEYS = {"url", "item", "logo", "image", "thumbnailUrl", "contentUrl", "embedUrl",
            "sameAs", "mainEntityOfPage", "@id"}
DATE_KEYS = {"datePublished", "dateModified", "uploadDate", "startDate", "endDate",
             "datePosted", "validThrough", "validFrom"}
ISO = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$")


class LDParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.blocks: list[str] = []
        self._buf: list[str] | None = None

    def handle_starttag(self, tag, attrs):
        if tag == "script" and dict(attrs).get("type", "").lower().strip() == "application/ld+json":
            self._buf = []

    def handle_data(self, data):
        if self._buf is not None:
            self._buf.append(data)

    def handle_endtag(self, tag):
        if tag == "script" and self._buf is not None:
            self.blocks.append("".join(self._buf))
            self._buf = None


def load(src: str) -> str:
    if src.startswith(("http://", "https://")):
        req = urllib.request.Request(src, headers={"User-Agent": "Mozilla/5.0 (jsonld-lint)"})
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.read().decode("utf-8", errors="replace")
    with open(src, encoding="utf-8", errors="replace") as f:
        return f.read()


def types_of(node: dict) -> list[str]:
    t = node.get("@type", [])
    return t if isinstance(t, list) else [t]


def walk(node, path, out):
    if isinstance(node, dict):
        if "@type" in node:
            out.append((path, node))
        for k, v in node.items():
            walk(v, f"{path}.{k}", out)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            walk(v, f"{path}[{i}]", out)


def check_values(node: dict, path: str, errors: list, warns: list):
    for k, v in node.items():
        vals = v if isinstance(v, list) else [v]
        for val in vals:
            if val in ("", None):
                errors.append(f"{path}.{k}: empty value (omit the property instead)")
            if isinstance(val, str):
                if k in URL_KEYS and k != "@id" and not val.startswith(("http://", "https://")):
                    errors.append(f"{path}.{k}: relative or invalid URL {val!r}")
                if k in DATE_KEYS and not ISO.match(val):
                    errors.append(f"{path}.{k}: not ISO 8601 {val!r}")
                if k in DATE_KEYS and "T" in val and not re.search(r"(Z|[+-]\d{2}:?\d{2})$", val):
                    warns.append(f"{path}.{k}: datetime without timezone {val!r}")
                if k == "availability" and not val.startswith("https://schema.org/"):
                    errors.append(f"{path}.availability: use full URL like https://schema.org/InStock")


def main(argv: list[str]) -> int:
    if not argv:
        print(__doc__)
        return 2
    try:
        html = load(argv[0])
    except Exception as exc:
        print(f"could not load {argv[0]}: {exc}")
        return 2
    p = LDParser()
    p.feed(html)
    if not p.blocks:
        print("No application/ld+json blocks found.")
        return 1
    errors: list[str] = []
    warns: list[str] = []
    seen_types: dict[str, int] = {}
    for bi, raw in enumerate(p.blocks):
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as exc:
            errors.append(f"block {bi}: invalid JSON ({exc})")
            continue
        if "--print" in argv:
            print(json.dumps(data, indent=2)[:4000])
        entities: list = []
        walk(data, f"block{bi}", entities)
        for path, node in entities:
            for t in types_of(node):
                seen_types[t] = seen_types.get(t, 0) + 1
                key = "LocalBusiness" if t in LOCAL_SUBTYPES else t
                for group in REQUIRED.get(key, []):
                    if not any(g in node for g in group):
                        if key == "ListItem" and group == ["name", "item"]:
                            continue
                        errors.append(f"{path} ({t}): missing required {' or '.join(group)}")
                for prop in RECOMMENDED.get(key, []):
                    if prop not in node:
                        warns.append(f"{path} ({t}): missing recommended {prop}")
                if t == "FAQPage":
                    warns.append(f"{path}: FAQPage rich results are limited to authoritative gov/health sites")
                if t == "HowTo":
                    warns.append(f"{path}: HowTo rich results were removed; markup gives no search feature")
            check_values(node, path, errors, warns)
    print(f"{len(p.blocks)} JSON-LD block(s); entity types: "
          + ", ".join(f"{k}x{v}" for k, v in sorted(seen_types.items())))
    for t in ("Product", "Organization", "BreadcrumbList"):
        if seen_types.get(t, 0) > 1 and t != "Organization":
            warns.append(f"{seen_types[t]} {t} entities on one page: check for duplicates (theme + custom code)")
    for w in warns:
        print(f"WARN  {w}")
    for e in errors:
        print(f"ERROR {e}")
    print(f"\n{len(errors)} error(s), {len(warns)} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc