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 + * `