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.
- name
schema-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
- Pages are not indexed at all — fix that first with technical-seo
- You want content that answers queries better — use seo-content-briefs
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.
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/AggregateRatingon your ownOrganizationorLocalBusinessis not eligible for review stars).
Decide what is worth marking up (2025–2026)
| Type | Rich result today | Recommendation |
|---|---|---|
Organization (home/about) | Knowledge panel logo, entity signals | Always, once, on home or about page |
WebSite | Site name in results | Home page only; name + alternateName + url |
BreadcrumbList | Breadcrumb trail in results | Every page below home |
Article / BlogPosting / NewsArticle | Better title, image, date display | Every editorial page |
Product + Offer | Price, availability, ratings; merchant listings | Every product page with a visible price |
LocalBusiness subtype | Local details, hours | One per physical location page |
SoftwareApplication | Rating and price | App pages with real user ratings |
Event, JobPosting, Recipe, VideoObject | Dedicated features | When the page genuinely is that thing |
ProfilePage, DiscussionForumPosting | Perspectives / forum features | Creator profiles, forum threads |
FAQPage | Only for well-known authoritative government and health sites since 2023 | Do not add expecting a rich result; acceptable only if the FAQ is visible and genuinely useful |
HowTo | Rich 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,0ratings, or placeholder images. - Absolute URLs everywhere; ISO 8601 dates with timezone;
priceas a number or numeric string with a.decimal and no currency symbol; currency as ISO 4217. - Use full schema.org enum URLs for
availabilityanditemCondition. - Emit
aggregateRatingonly whenreviewCount≥ 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
dateModifiedhonest: change it for meaningful edits, not on every build.
5. Validate
- Syntax and schema.org conformance:
scripts/extract_jsonld.pyand the Schema Markup Validator (validator.schema.org). - 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.
- Parity: open the page and confirm every marked-up value (price, rating, author, date, breadcrumb labels) is visible and identical.
- 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
Productnodes; Google may pick the wrong price. Reviewstars forOrganization/LocalBusinesson your own site will not show.- Product variants: use
ProductGroupwithhasVariantandvariesBy, or oneProductper variant URL; do not list one price for a range of variant prices. - Shipping and returns: supply
shippingDetailsandhasMerchantReturnPolicyon 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
noindexor 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
# 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
#!/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.