diff --git a/.changeset/puny-games-bow.md b/.changeset/puny-games-bow.md
new file mode 100644
index 00000000..4094f93b
--- /dev/null
+++ b/.changeset/puny-games-bow.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/devtools-seo': patch
+---
+
+Add the first SEO devtools plugin release with React support, live SERP and social previews, JSON-LD inspection, heading and link analysis, and an overview score. The plugin now ignores devtools-owned DOM, refreshes key sections on route changes, and uses a more balanced overall health weighting.
diff --git a/examples/react/basic/index.html b/examples/react/basic/index.html
index b63b73f6..e91aa16b 100644
--- a/examples/react/basic/index.html
+++ b/examples/react/basic/index.html
@@ -33,11 +33,23 @@
content="A basic example of using TanStack Devtools with React."
/>
+
+
+
A basic example of using TanStack Devtools with React.
+
You need to enable JavaScript to run this app.
diff --git a/examples/react/basic/src/setup.tsx b/examples/react/basic/src/setup.tsx
index a2839d99..d2d3b877 100644
--- a/examples/react/basic/src/setup.tsx
+++ b/examples/react/basic/src/setup.tsx
@@ -1,6 +1,4 @@
-import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
-import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
-
+import { TanStackDevtools } from '@tanstack/react-devtools'
import {
Link,
Outlet,
@@ -9,7 +7,8 @@ import {
createRoute,
createRouter,
} from '@tanstack/react-router'
-import { TanStackDevtools } from '@tanstack/react-devtools'
+import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
+import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
import { PackageJsonPanel } from './package-json-panel'
const rootRoute = createRootRoute({
@@ -28,6 +27,7 @@ const rootRoute = createRootRoute({
>
),
})
+
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
@@ -39,6 +39,7 @@ const indexRoute = createRoute({
)
},
})
+
function About() {
return (
@@ -46,15 +47,18 @@ function About() {
)
}
+
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: About,
})
+
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute])
const router = createRouter({
routeTree,
})
+
export default function DevtoolsExample() {
return (
<>
@@ -79,11 +83,6 @@ export default function DevtoolsExample() {
name: 'Package.json',
render: () => ,
},
-
- /* {
- name: "The actual app",
- render: ,
- } */
]}
/>
diff --git a/examples/react/seo/index.html b/examples/react/seo/index.html
new file mode 100644
index 00000000..ebe85132
--- /dev/null
+++ b/examples/react/seo/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+ SEO Devtools Example - TanStack Devtools
+
+
+
+
+
+
+
+
+
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/examples/react/seo/package.json b/examples/react/seo/package.json
new file mode 100644
index 00000000..96651397
--- /dev/null
+++ b/examples/react/seo/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@tanstack/react-devtools-seo-example",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port=3006",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test:types": "tsc"
+ },
+ "dependencies": {
+ "@tanstack/devtools-seo": "workspace:^",
+ "@tanstack/react-devtools": "^0.10.2",
+ "@tanstack/react-router": "^1.132.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@tanstack/devtools-vite": "0.6.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/examples/react/seo/public/emblem-light.svg b/examples/react/seo/public/emblem-light.svg
new file mode 100644
index 00000000..a58e69ad
--- /dev/null
+++ b/examples/react/seo/public/emblem-light.svg
@@ -0,0 +1,13 @@
+
+
+
+ emblem-light
+ Created with Sketch.
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/react/seo/src/App.tsx b/examples/react/seo/src/App.tsx
new file mode 100644
index 00000000..489488c3
--- /dev/null
+++ b/examples/react/seo/src/App.tsx
@@ -0,0 +1,56 @@
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '@tanstack/react-router'
+
+function AppShell() {
+ return (
+
+
+ Home
+ About
+
+
+
+ )
+}
+
+const rootRoute = createRootRoute({
+ component: AppShell,
+})
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => {
+ return Home
+ },
+})
+
+const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => {
+ return About
+ },
+})
+
+const routeTree = rootRoute.addChildren([indexRoute, aboutRoute])
+
+const router = createRouter({
+ routeTree,
+})
+
+export default function App() {
+ return
+}
diff --git a/examples/react/seo/src/index.tsx b/examples/react/seo/src/index.tsx
new file mode 100644
index 00000000..dbaab1ae
--- /dev/null
+++ b/examples/react/seo/src/index.tsx
@@ -0,0 +1,12 @@
+import { createRoot } from 'react-dom/client'
+import { seoDevtoolsPlugin } from '@tanstack/devtools-seo/react'
+import { TanStackDevtools } from '@tanstack/react-devtools'
+
+import App from './App'
+
+createRoot(document.getElementById('root')!).render(
+ <>
+
+
+ >,
+)
diff --git a/examples/react/seo/tsconfig.json b/examples/react/seo/tsconfig.json
new file mode 100644
index 00000000..a97ff8c1
--- /dev/null
+++ b/examples/react/seo/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/examples/react/seo/vite.config.ts b/examples/react/seo/vite.config.ts
new file mode 100644
index 00000000..337335d2
--- /dev/null
+++ b/examples/react/seo/vite.config.ts
@@ -0,0 +1,7 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+import { devtools } from '@tanstack/devtools-vite'
+
+export default defineConfig({
+ plugins: [devtools(), react()],
+})
diff --git a/package.json b/package.json
index b8089b81..883ea377 100644
--- a/package.json
+++ b/package.json
@@ -31,11 +31,11 @@
"test:eslint": "nx affected --target=test:eslint --exclude=examples/**",
"test:knip": "knip",
"test:lib": "nx affected --targets=test:lib --exclude=examples/**",
- "test:lib:dev": "pnpm test:lib && nx watch --all -- pnpm test:lib",
+ "test:lib:dev": "pnpm test:lib && node -e \"require('node:fs').rmSync('.nx/workspace-data/d/disabled',{force:true})\" && NX_DAEMON=true nx daemon --start && NX_DAEMON=true nx watch --all -- pnpm test:lib",
"test:pr": "nx affected --targets=test:eslint,test:sherif,test:knip,test:lib,test:types,test:build,build",
"test:sherif": "sherif",
"test:types": "nx affected --targets=test:types --exclude=examples/**",
- "watch": "pnpm run build:all && nx watch --all -- pnpm run build:all"
+ "watch": "pnpm run build:all && node -e \"require('node:fs').rmSync('.nx/workspace-data/d/disabled',{force:true})\" && NX_DAEMON=true nx daemon --start && NX_DAEMON=true nx watch --all -- pnpm run build:all"
},
"nx": {
"includedScripts": [
diff --git a/packages/devtools-seo/package.json b/packages/devtools-seo/package.json
new file mode 100644
index 00000000..54c332d5
--- /dev/null
+++ b/packages/devtools-seo/package.json
@@ -0,0 +1,78 @@
+{
+ "name": "@tanstack/devtools-seo",
+ "version": "0.1.0",
+ "description": "SEO overview panel for TanStack Devtools",
+ "author": "TanStack",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/devtools.git",
+ "directory": "packages/devtools-seo"
+ },
+ "homepage": "https://tanstack.com/devtools",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "keywords": [
+ "devtools",
+ "seo",
+ "solid-js"
+ ],
+ "type": "module",
+ "types": "dist/esm/index.d.ts",
+ "module": "dist/esm/index.js",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/index.js"
+ }
+ },
+ "./react": {
+ "import": {
+ "types": "./dist/esm/react/index.d.ts",
+ "default": "./dist/esm/react/index.js"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "engines": {
+ "node": ">=18"
+ },
+ "files": [
+ "dist/",
+ "src"
+ ],
+ "scripts": {
+ "clean": "premove ./build ./dist",
+ "lint:fix": "eslint ./src --fix",
+ "test:eslint": "eslint ./src",
+ "test:types": "tsc",
+ "test:build": "publint --strict",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@tanstack/devtools-ui": "workspace:*",
+ "@tanstack/devtools-utils": "workspace:*",
+ "goober": "^2.1.16",
+ "solid-js": "^1.9.9"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "solid-js": ">=1.9.7"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@tanstack/vite-config": "0.4.3",
+ "@types/react": "^19.2.0",
+ "react": "^19.2.0",
+ "vite": "^8.0.0",
+ "vite-plugin-solid": "^2.11.11"
+ }
+}
diff --git a/packages/devtools-seo/src/core.tsx b/packages/devtools-seo/src/core.tsx
new file mode 100644
index 00000000..340159c1
--- /dev/null
+++ b/packages/devtools-seo/src/core.tsx
@@ -0,0 +1,7 @@
+/** @jsxImportSource solid-js */
+
+import { constructCoreClass } from '@tanstack/devtools-utils/solid'
+
+const [SeoDevtoolsCore] = constructCoreClass(() => import('./solid-panel'))
+
+export { SeoDevtoolsCore }
diff --git a/packages/devtools/src/hooks/use-head-changes.ts b/packages/devtools-seo/src/hooks/use-head-changes.ts
similarity index 100%
rename from packages/devtools/src/hooks/use-head-changes.ts
rename to packages/devtools-seo/src/hooks/use-head-changes.ts
diff --git a/packages/devtools-seo/src/hooks/use-location-changes.ts b/packages/devtools-seo/src/hooks/use-location-changes.ts
new file mode 100644
index 00000000..f5fd6540
--- /dev/null
+++ b/packages/devtools-seo/src/hooks/use-location-changes.ts
@@ -0,0 +1,76 @@
+import { onCleanup, onMount } from 'solid-js'
+
+const LOCATION_CHANGE_EVENT = 'tanstack-devtools:locationchange'
+
+type LocationChangeListener = () => void
+
+const listeners = new Set()
+
+let lastHref = ''
+let teardownLocationObservation: (() => void) | undefined
+
+function emitLocationChangeIfNeeded() {
+ const nextHref = window.location.href
+ if (nextHref === lastHref) return
+ lastHref = nextHref
+ listeners.forEach((listener) => listener())
+}
+
+function dispatchLocationChangeEvent() {
+ window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT))
+}
+
+function observeLocationChanges() {
+ if (teardownLocationObservation) return
+
+ lastHref = window.location.href
+
+ const originalPushState = window.history.pushState
+ const originalReplaceState = window.history.replaceState
+
+ const handleLocationSignal = () => {
+ emitLocationChangeIfNeeded()
+ }
+
+ function patchedPushState(...args: Parameters) {
+ originalPushState.apply(window.history, args)
+ dispatchLocationChangeEvent()
+ }
+
+ function patchedReplaceState(...args: Parameters) {
+ originalReplaceState.apply(window.history, args)
+ dispatchLocationChangeEvent()
+ }
+
+ window.history.pushState = patchedPushState
+ window.history.replaceState = patchedReplaceState
+
+ window.addEventListener('popstate', handleLocationSignal)
+ window.addEventListener('hashchange', handleLocationSignal)
+ window.addEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)
+
+ teardownLocationObservation = () => {
+ if (window.history.pushState === patchedPushState) {
+ window.history.pushState = originalPushState
+ }
+ if (window.history.replaceState === patchedReplaceState) {
+ window.history.replaceState = originalReplaceState
+ }
+ window.removeEventListener('popstate', handleLocationSignal)
+ window.removeEventListener('hashchange', handleLocationSignal)
+ window.removeEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)
+ teardownLocationObservation = undefined
+ }
+}
+
+export function useLocationChanges(onChange: () => void) {
+ onMount(() => {
+ observeLocationChanges()
+ listeners.add(onChange)
+
+ onCleanup(() => {
+ listeners.delete(onChange)
+ if (listeners.size === 0) teardownLocationObservation?.()
+ })
+ })
+}
diff --git a/packages/devtools-seo/src/index.ts b/packages/devtools-seo/src/index.ts
new file mode 100644
index 00000000..c0f4286c
--- /dev/null
+++ b/packages/devtools-seo/src/index.ts
@@ -0,0 +1,6 @@
+export { SeoTab } from './tabs/seo-tab'
+export type {
+ SeoDetailView,
+ SeoIssue,
+ SeoSectionSummary,
+} from './utils/seo-section-summary'
diff --git a/packages/devtools-seo/src/react/SeoDevtools.tsx b/packages/devtools-seo/src/react/SeoDevtools.tsx
new file mode 100644
index 00000000..a34db45c
--- /dev/null
+++ b/packages/devtools-seo/src/react/SeoDevtools.tsx
@@ -0,0 +1,11 @@
+import { createReactPanel } from '@tanstack/devtools-utils/react'
+import { SeoDevtoolsCore } from '../core'
+
+import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react'
+
+export interface SeoDevtoolsReactInit extends DevtoolsPanelProps {}
+
+const [SeoDevtoolsPanel, SeoDevtoolsPanelNoOp] =
+ createReactPanel(SeoDevtoolsCore)
+
+export { SeoDevtoolsPanel, SeoDevtoolsPanelNoOp }
diff --git a/packages/devtools-seo/src/react/index.ts b/packages/devtools-seo/src/react/index.ts
new file mode 100644
index 00000000..2275c367
--- /dev/null
+++ b/packages/devtools-seo/src/react/index.ts
@@ -0,0 +1,9 @@
+'use client'
+
+export {
+ SeoDevtoolsPanel,
+ SeoDevtoolsPanelNoOp,
+ type SeoDevtoolsReactInit,
+} from './SeoDevtools'
+
+export { seoDevtoolsPlugin, seoDevtoolsNoOpPlugin } from './plugin'
diff --git a/packages/devtools-seo/src/react/plugin.ts b/packages/devtools-seo/src/react/plugin.ts
new file mode 100644
index 00000000..3b7985c4
--- /dev/null
+++ b/packages/devtools-seo/src/react/plugin.ts
@@ -0,0 +1,9 @@
+import { createReactPlugin } from '@tanstack/devtools-utils/react'
+import { SeoDevtoolsPanel } from './SeoDevtools'
+
+const [seoDevtoolsPlugin, seoDevtoolsNoOpPlugin] = createReactPlugin({
+ name: 'TanStack SEO',
+ Component: SeoDevtoolsPanel,
+})
+
+export { seoDevtoolsPlugin, seoDevtoolsNoOpPlugin }
diff --git a/packages/devtools-seo/src/solid-panel.tsx b/packages/devtools-seo/src/solid-panel.tsx
new file mode 100644
index 00000000..f037a73d
--- /dev/null
+++ b/packages/devtools-seo/src/solid-panel.tsx
@@ -0,0 +1,19 @@
+/** @jsxImportSource solid-js */
+
+import { ThemeContextProvider } from '@tanstack/devtools-ui'
+import { SeoTab } from './tabs/seo-tab'
+
+type SeoPluginPanelProps = {
+ theme: 'light' | 'dark'
+ devtoolsOpen: boolean
+}
+
+export default function SeoDevtoolsSolidPanel(props: SeoPluginPanelProps) {
+ void props.devtoolsOpen
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/devtools-seo/src/tabs/heading-structure-preview.tsx b/packages/devtools-seo/src/tabs/heading-structure-preview.tsx
new file mode 100644
index 00000000..7fed0894
--- /dev/null
+++ b/packages/devtools-seo/src/tabs/heading-structure-preview.tsx
@@ -0,0 +1,267 @@
+import {
+ For,
+ Show,
+ createMemo,
+ createSignal,
+ onCleanup,
+ onMount,
+} from 'solid-js'
+import { Section, SectionDescription } from '@tanstack/devtools-ui'
+import { useSeoStyles } from '../utils/use-seo-styles'
+import { pickSeverityClass } from '../utils/seo-severity'
+import { isInsideDevtools } from '../utils/devtools-dom-filter'
+import { useLocationChanges } from '../hooks/use-location-changes'
+import type { SeoSeverity } from '../utils/seo-severity'
+import type { SeoSectionSummary } from '../utils/seo-section-summary'
+
+type HeadingItem = {
+ id: string
+ level: 1 | 2 | 3 | 4 | 5 | 6
+ tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
+ text: string
+}
+
+type HeadingIssue = {
+ severity: SeoSeverity
+ message: string
+}
+
+function extractHeadings(): Array {
+ const nodes = Array.from(
+ document.body.querySelectorAll('h1,h2,h3,h4,h5,h6'),
+ ).filter((node) => !isInsideDevtools(node))
+
+ return nodes.map((node, index) => {
+ const tag = node.tagName.toLowerCase() as HeadingItem['tag']
+ const level = Number(tag[1]) as HeadingItem['level']
+
+ return {
+ id: node.id || `heading-${index}`,
+ level,
+ tag,
+ text: node.textContent.trim() || '',
+ }
+ })
+}
+
+function validateHeadings(headings: Array): Array {
+ if (headings.length === 0) {
+ return [
+ { severity: 'error', message: 'No heading tags found on this page.' },
+ ]
+ }
+
+ const issues: Array = []
+ const h1Count = headings.filter((h) => h.level === 1).length
+ if (h1Count === 0) {
+ issues.push({
+ severity: 'error',
+ message: 'No H1 heading found on this page.',
+ })
+ } else if (h1Count > 1) {
+ issues.push({
+ severity: 'error',
+ message: `Multiple H1 headings detected (${h1Count}).`,
+ })
+ }
+
+ if (headings[0] && headings[0].level !== 1) {
+ issues.push({
+ severity: 'error',
+ message: `First heading is ${headings[0].tag.toUpperCase()} instead of H1.`,
+ })
+ }
+
+ for (let index = 0; index < headings.length; index++) {
+ const current = headings[index]!
+ if (!current.text) {
+ issues.push({
+ severity: 'error',
+ message: `${current.tag.toUpperCase()} is empty.`,
+ })
+ }
+ if (index > 0) {
+ const previous = headings[index - 1]!
+ if (current.level - previous.level > 1) {
+ issues.push({
+ severity: 'error',
+ message: `Skipped heading level from ${previous.tag.toUpperCase()} to ${current.tag.toUpperCase()}.`,
+ })
+ }
+ }
+ }
+
+ return issues
+}
+
+/**
+ * Heading hierarchy issues and count for the SEO overview.
+ */
+export function getHeadingStructureSummary(): SeoSectionSummary {
+ const headings = extractHeadings()
+ const issues = validateHeadings(headings)
+ return {
+ issues,
+ hint: `${headings.length} heading(s)`,
+ }
+}
+
+function headingIndentClass(
+ s: ReturnType>,
+ level: HeadingItem['level'],
+): string {
+ switch (level) {
+ case 1:
+ return s.seoHeadingTreeIndent1
+ case 2:
+ return s.seoHeadingTreeIndent2
+ case 3:
+ return s.seoHeadingTreeIndent3
+ case 4:
+ return s.seoHeadingTreeIndent4
+ case 5:
+ return s.seoHeadingTreeIndent5
+ case 6:
+ return s.seoHeadingTreeIndent6
+ }
+}
+
+function headingTagClass(
+ s: ReturnType>,
+ level: HeadingItem['level'],
+): string {
+ const base = s.seoHeadingTag
+ switch (level) {
+ case 1:
+ return `${base} ${s.seoHeadingTagL1}`
+ case 2:
+ return `${base} ${s.seoHeadingTagL2}`
+ case 3:
+ return `${base} ${s.seoHeadingTagL3}`
+ case 4:
+ return `${base} ${s.seoHeadingTagL4}`
+ case 5:
+ return `${base} ${s.seoHeadingTagL5}`
+ case 6:
+ return `${base} ${s.seoHeadingTagL6}`
+ }
+}
+
+export function HeadingStructurePreviewSection() {
+ const styles = useSeoStyles()
+ const [tick, setTick] = createSignal(0)
+ const rescan = () => setTick((t) => t + 1)
+
+ useLocationChanges(rescan)
+
+ onMount(() => {
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ const target = mutation.target
+ if (target instanceof Element && isInsideDevtools(target)) continue
+ if (target.parentElement && isInsideDevtools(target.parentElement))
+ continue
+ rescan()
+ break
+ }
+ })
+
+ observer.observe(document.body, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ })
+
+ onCleanup(() => observer.disconnect())
+ })
+
+ const headings = createMemo(() => {
+ void tick()
+ return extractHeadings()
+ })
+
+ const issues = createMemo(() => validateHeadings(headings()))
+ const s = styles()
+
+ const issueBulletClass = (sev: SeoSeverity) =>
+ `${s.seoIssueBullet} ${pickSeverityClass(sev, {
+ error: s.seoIssueBulletError,
+ warning: s.seoIssueBulletWarning,
+ info: s.seoIssueBulletInfo,
+ })}`
+
+ const issueBadgeClass = (sev: SeoSeverity) =>
+ `${s.seoIssueSeverityBadge} ${pickSeverityClass(sev, {
+ error: s.seoIssueSeverityBadgeError,
+ warning: s.seoIssueSeverityBadgeWarning,
+ info: s.seoIssueSeverityBadgeInfo,
+ })}`
+
+ return (
+
+
+ Visualizes heading structure (`h1`-`h6`) in DOM order and highlights
+ common hierarchy issues. This section refreshes as the page changes.
+
+
+
+
+
0}
+ fallback={
+
+ No headings found on this page.
+
+ }
+ >
+
+
+ {(heading) => (
+
+
+ {heading.tag.toUpperCase()}
+
+
+ {heading.text || '(empty)'}
+
+
+ )}
+
+
+
+
+
+ 0}>
+
+
Structure issues
+
+
+ {(issue) => (
+
+ ●
+ {issue.message}
+
+ {issue.severity}
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/packages/devtools-seo/src/tabs/json-ld-preview.tsx b/packages/devtools-seo/src/tabs/json-ld-preview.tsx
new file mode 100644
index 00000000..62d8e5a9
--- /dev/null
+++ b/packages/devtools-seo/src/tabs/json-ld-preview.tsx
@@ -0,0 +1,765 @@
+import { For, Show, createMemo, createSignal } from 'solid-js'
+import { Section, SectionDescription } from '@tanstack/devtools-ui'
+import { useHeadChanges } from '../hooks/use-head-changes'
+import { useLocationChanges } from '../hooks/use-location-changes'
+import { isInsideDevtools } from '../utils/devtools-dom-filter'
+import { sectionHealthScore } from '../utils/seo-section-summary'
+import { useSeoStyles } from '../utils/use-seo-styles'
+import { pickSeverityClass, seoHealthTier } from '../utils/seo-severity'
+import type { SeoSeverity } from '../utils/seo-severity'
+import type { SeoSectionSummary } from '../utils/seo-section-summary'
+
+type JsonLdValue = Record
+
+type ValidationIssue = {
+ severity: SeoSeverity
+ message: string
+}
+
+type SchemaRule = {
+ required: Array
+ recommended: Array
+ optional: Array
+}
+
+type JsonLdEntry = {
+ id: string
+ raw: string
+ parsed: JsonLdValue | Array | null
+ types: Array
+ issues: Array
+}
+
+const SUPPORTED_RULES: Record = {
+ WebSite: {
+ required: ['@context', '@type', 'name', 'url'],
+ recommended: ['potentialAction'],
+ optional: ['description', 'inLanguage'],
+ },
+ Organization: {
+ required: ['@context', '@type', 'name', 'url'],
+ recommended: ['logo', 'sameAs'],
+ optional: ['description', 'email', 'telephone'],
+ },
+ Person: {
+ required: ['@context', '@type', 'name'],
+ recommended: ['url', 'sameAs'],
+ optional: ['image', 'jobTitle', 'description'],
+ },
+ Article: {
+ required: ['@context', '@type', 'headline', 'datePublished', 'author'],
+ recommended: ['dateModified', 'image', 'mainEntityOfPage'],
+ optional: ['description', 'publisher'],
+ },
+ Product: {
+ required: ['@context', '@type', 'name'],
+ recommended: ['image', 'description', 'offers'],
+ optional: ['brand', 'sku', 'aggregateRating', 'review'],
+ },
+ BreadcrumbList: {
+ required: ['@context', '@type', 'itemListElement'],
+ recommended: [],
+ optional: ['name'],
+ },
+ FAQPage: {
+ required: ['@context', '@type', 'mainEntity'],
+ recommended: [],
+ optional: [],
+ },
+ LocalBusiness: {
+ required: ['@context', '@type', 'name', 'address'],
+ recommended: ['telephone', 'openingHours'],
+ optional: ['geo', 'priceRange', 'url', 'sameAs', 'image'],
+ },
+}
+
+/** Types that get field previews, structured validation, and expandable raw JSON. */
+const JSON_LD_SUPPORTED_SCHEMA_TYPES: ReadonlyArray = Object.keys(
+ SUPPORTED_RULES,
+).sort((a, b) => a.localeCompare(b))
+
+function isSupportedSchemaType(typeName: string): boolean {
+ return Object.prototype.hasOwnProperty.call(SUPPORTED_RULES, typeName)
+}
+
+function entryUsesOnlySupportedTypes(entry: JsonLdEntry): boolean {
+ if (!entry.parsed || entry.types.length === 0) return false
+ return entry.types.every(isSupportedSchemaType)
+}
+
+function isRecord(value: unknown): value is JsonLdValue {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function getTypeList(entity: JsonLdValue): Array {
+ const typeField = entity['@type']
+ if (typeof typeField === 'string') return [typeField]
+ if (Array.isArray(typeField)) {
+ return typeField.filter((v): v is string => typeof v === 'string')
+ }
+ return []
+}
+
+function getEntities(payload: unknown): Array {
+ if (Array.isArray(payload)) {
+ return payload.filter(isRecord)
+ }
+ if (!isRecord(payload)) return []
+ const graph = payload['@graph']
+ if (Array.isArray(graph)) {
+ const graphEntities = graph.filter(isRecord)
+ if (graphEntities.length > 0) return graphEntities
+ }
+ return [payload]
+}
+
+function hasMissingKeys(
+ entity: JsonLdValue,
+ keys: Array,
+): Array {
+ return keys.filter((key) => {
+ const value = entity[key]
+ if (value === undefined || value === null) return true
+ if (typeof value === 'string' && !value.trim()) return true
+ if (Array.isArray(value) && value.length === 0) return true
+ return false
+ })
+}
+
+const VALID_SCHEMA_CONTEXTS = new Set([
+ 'https://schema.org',
+ 'http://schema.org',
+ 'https://schema.org/',
+ 'http://schema.org/',
+])
+
+function validateContext(entity: JsonLdValue): Array {
+ const context = entity['@context']
+ if (context === undefined) {
+ return [{ severity: 'error', message: 'Missing @context attribute.' }]
+ }
+ if (context === null || isRecord(context)) {
+ return []
+ }
+ if (Array.isArray(context)) {
+ const stringContexts = context.filter(
+ (value): value is string => typeof value === 'string',
+ )
+
+ if (
+ stringContexts.length > 0 &&
+ !stringContexts.some((value) => VALID_SCHEMA_CONTEXTS.has(value))
+ ) {
+ return [
+ {
+ severity: 'error',
+ message:
+ 'Array @context is missing a schema.org context URL in its string entries.',
+ },
+ ]
+ }
+
+ return []
+ }
+ if (typeof context === 'string') {
+ if (!VALID_SCHEMA_CONTEXTS.has(context)) {
+ return [
+ {
+ severity: 'error',
+ message: `Invalid @context value "${context}". Expected schema.org context.`,
+ },
+ ]
+ }
+ return []
+ }
+ return [
+ {
+ severity: 'error',
+ message:
+ 'Invalid @context type. Expected a schema.org URL, object, array, or null.',
+ },
+ ]
+}
+
+function validateTypes(entity: JsonLdValue): Array {
+ const types = getTypeList(entity)
+ if (types.length === 0) {
+ return [{ severity: 'error', message: 'Missing @type attribute.' }]
+ }
+ return []
+}
+
+function validateEntityByType(
+ entity: JsonLdValue,
+ typeName: string,
+): Array {
+ const rules = SUPPORTED_RULES[typeName]
+ if (!rules) {
+ return [
+ {
+ severity: 'warning',
+ message: `Type "${typeName}" has no dedicated validator yet.`,
+ },
+ ]
+ }
+
+ const issues: Array = []
+ const missingRequired = hasMissingKeys(entity, rules.required)
+ const missingRecommended = hasMissingKeys(entity, rules.recommended)
+ const missingOptional = hasMissingKeys(entity, rules.optional)
+
+ if (missingRequired.length > 0) {
+ issues.push({
+ severity: 'error',
+ message: `Missing required attributes: ${missingRequired.join(', ')}`,
+ })
+ }
+ if (missingRecommended.length > 0) {
+ issues.push({
+ severity: 'warning',
+ message: `Missing recommended attributes: ${missingRecommended.join(', ')}`,
+ })
+ }
+ if (missingOptional.length > 0) {
+ issues.push({
+ severity: 'info',
+ message: `Missing optional attributes: ${missingOptional.join(', ')}`,
+ })
+ }
+
+ return issues
+}
+
+function validateJsonLdValue(value: unknown): Array {
+ if (!isRecord(value) && !Array.isArray(value)) {
+ return [
+ {
+ severity: 'error',
+ message: 'JSON-LD root must be an object or an array of objects.',
+ },
+ ]
+ }
+
+ const entities = getEntities(value)
+ if (entities.length === 0) {
+ return [{ severity: 'error', message: 'No valid JSON-LD objects found.' }]
+ }
+
+ const issues: Array = []
+ for (const entity of entities) {
+ issues.push(...validateContext(entity))
+ issues.push(...validateTypes(entity))
+ const types = getTypeList(entity)
+ for (const typeName of types) {
+ issues.push(...validateEntityByType(entity, typeName))
+ }
+ }
+ return issues
+}
+
+function getTypeSummary(value: unknown): Array {
+ const entities = getEntities(value)
+ const typeSet = new Set()
+ for (const entity of entities) {
+ for (const type of getTypeList(entity)) {
+ typeSet.add(type)
+ }
+ }
+ return Array.from(typeSet)
+}
+
+function stringifyPreviewValue(value: unknown, maxLen = 200): string {
+ if (value === null || value === undefined) return '—'
+ if (typeof value === 'string') {
+ return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value
+ }
+ if (typeof value === 'number' || typeof value === 'boolean')
+ return String(value)
+ if (Array.isArray(value)) {
+ if (value.length === 0) return '(empty)'
+ if (value.length <= 3 && value.every((v) => typeof v === 'string')) {
+ return value.join(', ')
+ }
+ if (value.length === 1 && isRecord(value[0])) {
+ const o = value[0]
+ const t = typeof o['@type'] === 'string' ? String(o['@type']) : 'Item'
+ const label =
+ typeof o.name === 'string'
+ ? o.name
+ : typeof o.headline === 'string'
+ ? o.headline
+ : ''
+ return label ? `${t}: ${label}` : `${t} object`
+ }
+ return `${value.length} items`
+ }
+ if (isRecord(value)) {
+ if (typeof value['@type'] === 'string' && (value.name ?? value.headline)) {
+ const label =
+ typeof value.name === 'string' ? value.name : String(value.headline)
+ return `${value['@type']}: ${label}`
+ }
+ const json = JSON.stringify(value)
+ return json.length > maxLen ? `${json.slice(0, maxLen)}…` : json
+ }
+ return String(value)
+}
+
+function getEntityPreviewRows(
+ entity: JsonLdValue,
+): Array<{ label: string; value: string }> {
+ const types = getTypeList(entity)
+ const typeForKeys = types.find(isSupportedSchemaType)
+ if (!typeForKeys) return []
+ const rules = SUPPORTED_RULES[typeForKeys]
+ if (!rules) return []
+ const orderedKeys = [
+ ...rules.required,
+ ...rules.recommended,
+ ...rules.optional,
+ ].filter(
+ (k) => !k.startsWith('@') && entity[k] !== undefined && entity[k] !== null,
+ )
+ const seen = new Set()
+ const keys: Array = []
+ for (const k of orderedKeys) {
+ if (seen.has(k)) continue
+ seen.add(k)
+ keys.push(k)
+ if (keys.length >= 6) break
+ }
+ return keys.map((key) => ({
+ label: key,
+ value: stringifyPreviewValue(entity[key]),
+ }))
+}
+
+function analyzeJsonLdScripts(): Array {
+ const scripts = Array.from(
+ document.querySelectorAll(
+ 'script[type="application/ld+json"]',
+ ),
+ ).filter((script) => !isInsideDevtools(script))
+
+ return scripts.map((script, index) => {
+ const raw = script.text.trim()
+ if (raw.length === 0) {
+ return {
+ id: `jsonld-${index}`,
+ raw,
+ parsed: null,
+ types: [],
+ issues: [{ severity: 'error', message: 'Empty JSON-LD script block.' }],
+ }
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as JsonLdValue | Array
+ return {
+ id: `jsonld-${index}`,
+ raw,
+ parsed,
+ types: getTypeSummary(parsed),
+ issues: validateJsonLdValue(parsed),
+ }
+ } catch (error) {
+ const parseMessage =
+ error instanceof Error ? error.message : 'Unknown JSON parse error.'
+ return {
+ id: `jsonld-${index}`,
+ raw,
+ parsed: null,
+ types: [],
+ issues: [
+ {
+ severity: 'error',
+ message: `Invalid JSON syntax: ${parseMessage}`,
+ },
+ ],
+ }
+ }
+ })
+}
+
+/**
+ * Flattens validation issues from all JSON-LD blocks for the SEO overview.
+ */
+export function getJsonLdPreviewSummary(): SeoSectionSummary {
+ const entries = analyzeJsonLdScripts()
+ if (entries.length === 0) {
+ return {
+ issues: [
+ {
+ severity: 'info',
+ message: 'No JSON-LD scripts were detected on this page.',
+ },
+ ],
+ hint: 'No blocks',
+ }
+ }
+ const issues = entries.flatMap((entry) => entry.issues)
+ const gaps = sumMissingSchemaFieldCounts(entries)
+ const gapParts: Array = []
+ if (gaps.required > 0) gapParts.push(`${gaps.required} required`)
+ if (gaps.recommended > 0) gapParts.push(`${gaps.recommended} recommended`)
+ if (gaps.optional > 0) gapParts.push(`${gaps.optional} optional`)
+ const gapHint = gapParts.length > 0 ? ` · Gaps: ${gapParts.join(', ')}` : ''
+
+ return {
+ issues,
+ hint: `${entries.length} block(s)${gapHint}`,
+ }
+}
+
+/**
+ * Counts individual schema property names called out in missing-* validation messages.
+ */
+function sumMissingSchemaFieldCounts(entries: Array): {
+ required: number
+ recommended: number
+ optional: number
+} {
+ const out = { required: 0, recommended: 0, optional: 0 }
+ const rules: Array<{
+ severity: SeoSeverity
+ prefix: string
+ key: keyof typeof out
+ }> = [
+ {
+ severity: 'error',
+ prefix: 'Missing required attributes:',
+ key: 'required',
+ },
+ {
+ severity: 'warning',
+ prefix: 'Missing recommended attributes:',
+ key: 'recommended',
+ },
+ {
+ severity: 'info',
+ prefix: 'Missing optional attributes:',
+ key: 'optional',
+ },
+ ]
+
+ for (const entry of entries) {
+ for (const issue of entry.issues) {
+ for (const r of rules) {
+ if (issue.severity !== r.severity) continue
+ if (!issue.message.startsWith(r.prefix)) continue
+ const rest = issue.message.slice(r.prefix.length).trim()
+ const n = rest
+ ? rest
+ .split(',')
+ .map((x) => x.trim())
+ .filter(Boolean).length
+ : 0
+ out[r.key] += n
+ }
+ }
+ }
+ return out
+}
+
+/**
+ * JSON-LD health 0–100: errors and warnings dominate; each info issue applies a
+ * small penalty so optional-field gaps match how the SEO overview weights them.
+ */
+function getJsonLdScore(entries: Array): number {
+ return sectionHealthScore(entries.flatMap((entry) => entry.issues))
+}
+
+function JsonLdEntityPreviewCard(props: { entity: JsonLdValue }) {
+ const styles = useSeoStyles()
+ const s = styles()
+ const header = getTypeList(props.entity).join(' · ') || 'Entity'
+ const rows = getEntityPreviewRows(props.entity)
+
+ return (
+
+
+
0}
+ fallback={
+
+
+ (no fields to preview)
+
+
+ }
+ >
+
+
+ {(row) => (
+
+ {row.label}
+ {row.value}
+
+ )}
+
+
+
+
+ )
+}
+
+function JsonLdBlock(props: { entry: JsonLdEntry; index: number }) {
+ const styles = useSeoStyles()
+ const s = styles()
+
+ const copyParsed = async () => {
+ if (!props.entry.parsed) return
+ try {
+ await navigator.clipboard.writeText(
+ JSON.stringify(props.entry.parsed, null, 2),
+ )
+ } catch {
+ // ignore clipboard errors in restricted contexts
+ }
+ }
+
+ const bulletClass = (sev: SeoSeverity) =>
+ `${s.seoIssueBullet} ${pickSeverityClass(sev, {
+ error: s.seoIssueBulletError,
+ warning: s.seoIssueBulletWarning,
+ info: s.seoIssueBulletInfo,
+ })}`
+
+ const badgeClass = (sev: SeoSeverity) =>
+ `${s.seoIssueSeverityBadge} ${pickSeverityClass(sev, {
+ error: s.seoIssueSeverityBadgeError,
+ warning: s.seoIssueSeverityBadgeWarning,
+ info: s.seoIssueSeverityBadgeInfo,
+ })}`
+
+ const showPreview =
+ entryUsesOnlySupportedTypes(props.entry) && props.entry.parsed !== null
+
+ return (
+
+
+
+
+ {props.entry.parsed
+ ? JSON.stringify(props.entry.parsed, null, 2)
+ : props.entry.raw || 'No JSON-LD content found.'}
+
+ }
+ >
+
+
+ {(entity) => }
+
+
+
+ Raw JSON
+
+ {JSON.stringify(props.entry.parsed, null, 2)}
+
+
+
+
+
0}>
+
+
+ {(issue) => (
+
+ ●
+ {issue.message}
+ {issue.severity}
+
+ )}
+
+
+
+
+ ✓ No validation issues
+
+
+ )
+}
+
+export function JsonLdPreviewSection() {
+ const styles = useSeoStyles()
+ const [tick, setTick] = createSignal(0)
+
+ useHeadChanges(() => {
+ setTick((t) => t + 1)
+ })
+
+ useLocationChanges(() => {
+ setTick((t) => t + 1)
+ })
+
+ const entries = createMemo(() => {
+ void tick()
+ return analyzeJsonLdScripts()
+ })
+ const score = createMemo(() => getJsonLdScore(entries()))
+ const s = styles()
+ const fieldGaps = createMemo(() => sumMissingSchemaFieldCounts(entries()))
+ const healthScoreClass = () => {
+ const tier = seoHealthTier(score())
+ return tier === 'good'
+ ? s.seoHealthScoreGood
+ : tier === 'fair'
+ ? s.seoHealthScoreFair
+ : s.seoHealthScorePoor
+ }
+ const healthFillClass = () => {
+ const tier = seoHealthTier(score())
+ const tierFill =
+ tier === 'good'
+ ? s.seoHealthFillGood
+ : tier === 'fair'
+ ? s.seoHealthFillFair
+ : s.seoHealthFillPoor
+ return `${s.seoHealthFill} ${tierFill}`
+ }
+ const errorCount = () =>
+ entries().reduce(
+ (total, entry) =>
+ total +
+ entry.issues.filter((issue) => issue.severity === 'error').length,
+ 0,
+ )
+ const warningCount = () =>
+ entries().reduce(
+ (total, entry) =>
+ total +
+ entry.issues.filter((issue) => issue.severity === 'warning').length,
+ 0,
+ )
+ const infoCount = () =>
+ entries().reduce(
+ (total, entry) =>
+ total +
+ entry.issues.filter((issue) => issue.severity === 'info').length,
+ 0,
+ )
+ const progressAriaLabel = createMemo(() => {
+ const parts = [`JSON-LD health ${Math.round(score())} percent`]
+ const sev = [
+ errorCount() && `${errorCount()} error${errorCount() === 1 ? '' : 's'}`,
+ warningCount() &&
+ `${warningCount()} warning${warningCount() === 1 ? '' : 's'}`,
+ infoCount() && `${infoCount()} info`,
+ ].filter(Boolean)
+ if (sev.length) parts.push(sev.join(', '))
+ const gapBits: Array = []
+ if (fieldGaps().required > 0)
+ gapBits.push(
+ `${fieldGaps().required} required field${fieldGaps().required === 1 ? '' : 's'}`,
+ )
+ if (fieldGaps().recommended > 0)
+ gapBits.push(
+ `${fieldGaps().recommended} recommended field${fieldGaps().recommended === 1 ? '' : 's'}`,
+ )
+ if (fieldGaps().optional > 0)
+ gapBits.push(
+ `${fieldGaps().optional} optional field${fieldGaps().optional === 1 ? '' : 's'}`,
+ )
+ if (gapBits.length) parts.push(`Missing: ${gapBits.join(', ')}`)
+ return parts.join('. ')
+ })
+ const missingFieldsLine = createMemo(() => {
+ const bits: Array = []
+ if (fieldGaps().required > 0) bits.push(`${fieldGaps().required} required`)
+ if (fieldGaps().recommended > 0)
+ bits.push(`${fieldGaps().recommended} recommended`)
+ if (fieldGaps().optional > 0) bits.push(`${fieldGaps().optional} optional`)
+ if (bits.length === 0) return null
+ return `Missing schema fields: ${bits.join(' · ')}`
+ })
+
+ return (
+