diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3ff4de..62310e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,10 @@ history stays consistent across GitHub and npm.
- Added `CMS-ROUTE-DUPLICATE` (error): flags two or more published documents
that resolve to the same route path. Drafts are ignored; the first document
in scan order is treated as the winner.
+- Added opt-in JSON-LD structured-data validation via
+ `checks.routes.structuredData`. On 2xx routes it parses
+ `application/ld+json` blocks and flags malformed JSON (`SEO-JSONLD-INVALID`,
+ warning) or a route with no structured data (`SEO-JSONLD-MISSING`, info).
- Added opt-in canonical validation via `checks.routes.canonical`. On 2xx
routes it parses `` and flags a missing canonical
(`SEO-CANONICAL-MISSING`, warning), a canonical on a different origin
diff --git a/apps/site/app/docs/configuration/page.tsx b/apps/site/app/docs/configuration/page.tsx
index 48effec..aeaf784 100644
--- a/apps/site/app/docs/configuration/page.tsx
+++ b/apps/site/app/docs/configuration/page.tsx
@@ -352,6 +352,14 @@ export default defineConfig({
disagrees with the route is a warning. It is off by default because it
requires reading response bodies.
+
+ Structured data is opt-in
+ {`checks: { routes: { structuredData: true } }`} parses
+ each 2xx route's application/ld+json blocks: a
+ malformed block is a warning (SEO-JSONLD-INVALID) and a
+ route with no structured data is info (SEO-JSONLD-MISSING).
+ Off by default because it reads response bodies.
+
Relationships
Relationship checks compare one field on a source document with one
diff --git a/apps/site/app/docs/diagnostics/page.tsx b/apps/site/app/docs/diagnostics/page.tsx
index 5ef409b..82b8cbd 100644
--- a/apps/site/app/docs/diagnostics/page.tsx
+++ b/apps/site/app/docs/diagnostics/page.tsx
@@ -140,6 +140,18 @@ export default function DiagnosticsPage() {
Canonical path disagrees with the probed path beyond trailing slash
and case.
+
+ A route has a malformed application/ld+json block.
+ Opt-in via checks.routes.structuredData.
+
+
+ A route renders no JSON-LD structured data. Informational; enabled
+ via checks.routes.structuredData.
+ new Response(body, { status: 200 }),
+ });
+}
+
+test("structured-data validation flags malformed JSON-LD", async () => {
+ const result = await scanForStructuredData(
+ '',
+ );
+ const codes = result.diagnostics.map((d) => d.code);
+ expect(codes).toContain("SEO-JSONLD-INVALID");
+ expect(codes).not.toContain("SEO-JSONLD-MISSING");
+});
+
+test("structured-data validation passes valid JSON-LD", async () => {
+ const result = await scanForStructuredData(
+ '',
+ );
+ expect(
+ result.diagnostics.filter((d) => d.code.startsWith("SEO-JSONLD")),
+ ).toEqual([]);
+});
+
+test("structured-data validation reports routes with no JSON-LD as info", async () => {
+ const result = await scanForStructuredData("");
+ const diag = result.diagnostics.find((d) => d.code === "SEO-JSONLD-MISSING");
+ expect(diag?.severity).toBe("info");
+});
diff --git a/packages/core/src/scan.ts b/packages/core/src/scan.ts
index 3d21855..a19f5ba 100644
--- a/packages/core/src/scan.ts
+++ b/packages/core/src/scan.ts
@@ -351,12 +351,18 @@ async function checkRouteReachability(
}
// Body-level checks on 2xx responses. Only run (and only read the body)
- // when explicitly enabled, since both require the response body:
+ // when explicitly enabled, since each requires the response body:
// - soft-404 detection via checks.routes.soft404
// - canonical validation via checks.routes.canonical
+ // - structured-data validation via checks.routes.structuredData
const soft404 = soft404Options(config);
const canonicalEnabled = canonicalCheckEnabled(config);
- if ((soft404 || canonicalEnabled) && status >= 200 && status < 300) {
+ const structuredDataEnabled = structuredDataCheckEnabled(config);
+ if (
+ (soft404 || canonicalEnabled || structuredDataEnabled) &&
+ status >= 200 &&
+ status < 300
+ ) {
try {
const body = await response.text();
@@ -377,6 +383,12 @@ async function checkRouteReachability(
...checkCanonical(config, candidate, diagnosticPath, url, body),
);
}
+
+ if (structuredDataEnabled) {
+ diagnostics.push(
+ ...checkStructuredData(config, candidate, diagnosticPath, body),
+ );
+ }
} catch {
// Body read errors are not actionable here; leave as silent.
}
@@ -535,6 +547,84 @@ function normalizeCanonicalPath(pathname: string): string {
return (pathname.replace(/\/+$/, "") || "/").toLowerCase();
}
+function structuredDataCheckEnabled(config: CmsLabConfig): boolean {
+ const routes = config.checks?.routes;
+ return (
+ typeof routes === "object" &&
+ routes !== null &&
+ routes.structuredData === true
+ );
+}
+
+/**
+ * Validate JSON-LD structured data on a 2xx route. Every
+ * `