diff --git a/.changeset/puny-games-bow.md b/.changeset/puny-games-bow.md
new file mode 100644
index 00000000..e9d75df2
--- /dev/null
+++ b/.changeset/puny-games-bow.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/devtools': patch
+---
+
+Introduce a new SEO tab in devtools: live head-driven social and SERP previews, structured data (JSON-LD), heading and link analysis, plus an overview that scores and links into each section.
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/package.json b/package.json
index b8089b81..ad4bb620 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,7 @@
"size-limit": [
{
"path": "packages/devtools/dist/index.js",
- "limit": "60 KB"
+ "limit": "69 KB"
},
{
"path": "packages/event-bus-client/dist/esm/plugin.js",
diff --git a/packages/devtools/src/hooks/use-location-changes.ts b/packages/devtools/src/hooks/use-location-changes.ts
new file mode 100644
index 00000000..a00301fb
--- /dev/null
+++ b/packages/devtools/src/hooks/use-location-changes.ts
@@ -0,0 +1,69 @@
+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()
+ }
+
+ window.history.pushState = function (...args) {
+ originalPushState.apply(this, args)
+ dispatchLocationChangeEvent()
+ }
+
+ window.history.replaceState = function (...args) {
+ originalReplaceState.apply(this, args)
+ dispatchLocationChangeEvent()
+ }
+
+ window.addEventListener('popstate', handleLocationSignal)
+ window.addEventListener('hashchange', handleLocationSignal)
+ window.addEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)
+
+ teardownLocationObservation = () => {
+ window.history.pushState = originalPushState
+ 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/src/styles/use-styles.ts b/packages/devtools/src/styles/use-styles.ts
index 5f67546c..5beb984b 100644
--- a/packages/devtools/src/styles/use-styles.ts
+++ b/packages/devtools/src/styles/use-styles.ts
@@ -122,11 +122,43 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
seoSubNav: css`
display: flex;
flex-direction: row;
+ flex-wrap: nowrap;
gap: 0;
margin-bottom: 1rem;
border-bottom: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ min-width: 0;
+
+ @media (max-width: 1024px) {
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior-x: contain;
+ scrollbar-width: thin;
+
+ &::after {
+ content: '';
+ flex-shrink: 0;
+ width: max(20px, env(safe-area-inset-right, 0px));
+ align-self: stretch;
+ }
+
+ &::-webkit-scrollbar {
+ height: 5px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: ${t(colors.gray[100], colors.gray[800])};
+ border-radius: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: ${t(colors.gray[300], colors.gray[600])};
+ border-radius: 4px;
+ }
+ }
`,
seoSubNavLabel: css`
+ flex-shrink: 0;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
@@ -137,6 +169,7 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
margin-bottom: -1px;
cursor: pointer;
font-family: inherit;
+ white-space: nowrap;
&:hover {
color: ${t(colors.gray[800], colors.gray[200])};
}
@@ -248,7 +281,7 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
border-radius: 8px;
padding: 1rem 1.25rem;
background: ${t(colors.white, colors.darkGray[900])};
- max-width: 600px;
+ max-width: 620px;
font-family: ${fontFamily.sans};
box-shadow: 0 1px 2px ${t('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.08)')};
`,
@@ -257,7 +290,7 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
border-radius: 8px;
padding: 1rem 1.25rem;
background: ${t(colors.white, colors.darkGray[900])};
- max-width: 380px;
+ max-width: 328px;
font-family: ${fontFamily.sans};
box-shadow: 0 1px 2px ${t('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.08)')};
`,
@@ -364,6 +397,811 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
color: ${t(colors.red[700], colors.red[400])};
font-size: 0.875rem;
`,
+ seoIssueList: css`
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ `,
+ seoIssueListNested: css`
+ margin: 6px 0 0 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ `,
+ seoIssueRow: css`
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ font-size: 0.875rem;
+ line-height: 1.45;
+ `,
+ seoIssueRowCompact: css`
+ display: flex;
+ gap: 6px;
+ align-items: flex-start;
+ font-size: 0.6875rem;
+ line-height: 1.45;
+ `,
+ seoIssueBullet: css`
+ flex-shrink: 0;
+ padding-top: 1px;
+ `,
+ /** Default foreground for SEO issue copy (no layout). */
+ seoIssueText: css`
+ color: ${t(colors.gray[900], colors.gray[100])};
+ line-height: 1.45;
+ `,
+ seoIssueMessage: css`
+ flex: 1;
+ min-width: 0;
+ color: ${t(colors.gray[900], colors.gray[100])};
+ line-height: 1.45;
+ `,
+ seoIssueSeverityBadge: css`
+ flex-shrink: 0;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+ padding-top: 2px;
+ `,
+ seoMetaRow: css`
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ font-size: 0.75rem;
+ padding: 5px 0;
+ border-bottom: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ `,
+ seoMetaRowLabel: css`
+ min-width: 130px;
+ flex-shrink: 0;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoMetaRowValue: css`
+ word-break: break-all;
+ color: ${t(colors.gray[900], colors.gray[100])};
+ line-height: 1.45;
+ `,
+ seoIssueBulletError: css`
+ color: #dc2626;
+ `,
+ seoIssueBulletWarning: css`
+ color: #d97706;
+ `,
+ seoIssueBulletInfo: css`
+ color: #2563eb;
+ `,
+ seoIssueSeverityBadgeError: css`
+ color: #dc2626;
+ `,
+ seoIssueSeverityBadgeWarning: css`
+ color: #d97706;
+ `,
+ seoIssueSeverityBadgeInfo: css`
+ color: #2563eb;
+ `,
+ seoChipRow: css`
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ `,
+ seoPill: css`
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ `,
+ seoPillNeutral: css`
+ background: ${t(colors.gray[100], colors.gray[800] + '40')};
+ color: ${t(colors.gray[600], colors.gray[400])};
+ `,
+ seoPillMuted: css`
+ background: ${t(colors.gray[200], '#6b728018')};
+ color: ${t(colors.gray[600], '#9ca3af')};
+ `,
+ seoPillInternal: css`
+ background: ${t(colors.gray[200], '#6b728018')};
+ color: ${t(colors.gray[700], '#6b7280')};
+ `,
+ seoPillBlue: css`
+ background: ${t(colors.blue[50], '#3b82f618')};
+ color: ${t(colors.blue[700], '#3b82f6')};
+ `,
+ seoPillAmber: css`
+ background: ${t(colors.yellow[50], '#d9770618')};
+ color: ${t(colors.yellow[700], '#d97706')};
+ `,
+ seoPillRed: css`
+ background: ${t(colors.red[50], '#dc262618')};
+ color: ${t(colors.red[700], '#dc2626')};
+ `,
+ seoLinksReportItem: css`
+ padding: 8px 0;
+ border-bottom: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ &:last-child {
+ border-bottom: none;
+ }
+ `,
+ seoLinksReportTopRow: css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 2px;
+ `,
+ seoLinkKindBadge: css`
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ flex-shrink: 0;
+ `,
+ seoLinkKindInternal: css`
+ background: ${t(colors.gray[200], '#6b728018')};
+ color: ${t(colors.gray[700], '#6b7280')};
+ `,
+ seoLinkKindExternal: css`
+ background: ${t(colors.blue[50], '#3b82f618')};
+ color: ${t(colors.blue[700], '#3b82f6')};
+ `,
+ seoLinkKindNonWeb: css`
+ background: ${t(colors.yellow[50], '#d9770618')};
+ color: ${t(colors.yellow[700], '#d97706')};
+ `,
+ seoLinkKindInvalid: css`
+ background: ${t(colors.red[50], '#dc262618')};
+ color: ${t(colors.red[700], '#dc2626')};
+ `,
+ seoLinksAnchorText: css`
+ font-size: 12px;
+ font-weight: 500;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: ${t(colors.gray[900], colors.gray[100])};
+ line-height: 1.45;
+ `,
+ seoLinksHrefLine: css`
+ font-size: 11px;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ padding-left: 2px;
+ `,
+ seoLinksAccordion: css`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ `,
+ seoLinksAccordionSection: css`
+ border: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ border-radius: 8px;
+ overflow: hidden;
+ background: ${t(colors.white, colors.darkGray[900])};
+ `,
+ seoLinksAccordionTrigger: css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 10px;
+ padding: 8px 10px;
+ border: none;
+ background: ${t(colors.gray[50], colors.darkGray[800])};
+ cursor: pointer;
+ font-family: inherit;
+ text-align: left;
+ color: ${t(colors.gray[900], colors.gray[100])};
+ font-size: 12px;
+ font-weight: 600;
+ &:hover {
+ background: ${t(colors.gray[100], colors.darkGray[700])};
+ }
+ `,
+ seoLinksAccordionTriggerLeft: css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ `,
+ seoLinksAccordionChevron: css`
+ flex-shrink: 0;
+ font-size: 10px;
+ line-height: 1;
+ color: ${t(colors.gray[500], colors.gray[500])};
+ transition: transform 0.15s ease;
+ `,
+ seoLinksAccordionChevronOpen: css`
+ transform: rotate(90deg);
+ `,
+ seoLinksAccordionPanel: css`
+ border-top: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ padding: 2px 10px 6px;
+ `,
+ seoLinksAccordionInnerList: css`
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ `,
+ seoHealthHeaderRow: css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+ `,
+ seoHealthLabelMuted: css`
+ font-size: 12px;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoHealthScoreGood: css`
+ font-size: 13px;
+ font-weight: 600;
+ color: #16a34a;
+ `,
+ seoHealthScoreFair: css`
+ font-size: 13px;
+ font-weight: 600;
+ color: #d97706;
+ `,
+ seoHealthScorePoor: css`
+ font-size: 13px;
+ font-weight: 600;
+ color: #dc2626;
+ `,
+ seoHealthTrack: css`
+ width: 100%;
+ height: 10px;
+ border-radius: 999px;
+ background: ${t(colors.gray[100], colors.gray[800])};
+ border: 1px solid ${t(colors.gray[200], colors.gray[700])};
+ overflow: hidden;
+ box-shadow: inset 0 1px 2px
+ ${t('rgba(15, 23, 42, 0.06)', 'rgba(0, 0, 0, 0.35)')};
+ `,
+ seoHealthFill: css`
+ height: 100%;
+ min-width: 0;
+ max-width: 100%;
+ border-radius: 999px;
+ transition: width 0.45s cubic-bezier(0.33, 1, 0.68, 1);
+ box-shadow: 0 1px 2px
+ ${t('rgba(15, 23, 42, 0.12)', 'rgba(0, 0, 0, 0.25)')};
+ `,
+ seoHealthFillGood: css`
+ background: linear-gradient(
+ 90deg,
+ ${t(colors.green[700], '#15803d')} 0%,
+ ${t(colors.green[500], '#22c55e')} 100%
+ );
+ `,
+ seoHealthFillFair: css`
+ background: linear-gradient(
+ 90deg,
+ ${t(colors.yellow[700], '#b45309')} 0%,
+ ${t(colors.yellow[500], '#eab308')} 100%
+ );
+ `,
+ seoHealthFillPoor: css`
+ background: linear-gradient(
+ 90deg,
+ ${t(colors.red[700], '#b91c1c')} 0%,
+ ${t(colors.red[500], '#ef4444')} 100%
+ );
+ `,
+ seoHealthCountsRow: css`
+ display: flex;
+ gap: 12px;
+ margin-top: 8px;
+ font-size: 11px;
+ `,
+ seoHealthCountError: css`
+ color: #dc2626;
+ `,
+ seoHealthCountWarning: css`
+ color: #d97706;
+ `,
+ seoHealthCountInfo: css`
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoJsonLdHealthMissingLine: css`
+ margin-top: 6px;
+ font-size: 11px;
+ line-height: 1.4;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoOverviewPillsRow: css`
+ display: flex;
+ gap: 6px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+ `,
+ seoPillStatusOk: css`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ background: ${t(colors.green[50], '#16a34a18')};
+ color: ${t(colors.green[700], '#16a34a')};
+ `,
+ seoPillStatusWarn: css`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ background: ${t(colors.yellow[50], '#d9770618')};
+ color: ${t(colors.yellow[700], '#d97706')};
+ `,
+ seoPillStatusBad: css`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ background: ${t(colors.red[50], '#dc262618')};
+ color: ${t(colors.red[700], '#dc2626')};
+ `,
+ seoPillMetaCount: css`
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ background: ${t(colors.gray[100], colors.gray[800] + '40')};
+ color: ${t(colors.gray[600], '#9ca3af')};
+ `,
+ seoOverviewCheckListCaption: css`
+ margin: 0 0 8px 0;
+ font-size: 11px;
+ line-height: 1.4;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoOverviewScoreRingWrap: css`
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ `,
+ seoOverviewScoreRingSvg: css`
+ display: block;
+ `,
+ seoOverviewScoreRingTrack: css`
+ fill: none;
+ stroke: ${t(colors.gray[200], colors.gray[700])};
+ stroke-width: 3;
+ `,
+ seoOverviewScoreRingLabel: css`
+ font-size: 10px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ font-family: ${fontFamily.sans};
+ fill: ${t(colors.gray[800], colors.gray[100])};
+ `,
+ seoOverviewCheckList: css`
+ display: flex;
+ flex-direction: column;
+ border: 1px solid ${t(colors.gray[200], colors.gray[700])};
+ border-radius: 8px;
+ overflow: hidden;
+ `,
+ seoOverviewCheckRow: css`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ margin: 0;
+ padding: 9px 10px;
+ text-align: left;
+ border: none;
+ border-bottom: 1px solid ${t(colors.gray[100], colors.gray[800])};
+ background: ${t(colors.white, colors.darkGray[900])};
+ color: ${t(colors.gray[900], colors.gray[100])};
+ font-size: 13px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.1s ease;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ background: ${t(colors.gray[50], colors.gray[800] + '55')};
+ }
+
+ &:focus {
+ outline: none;
+ }
+
+ &:focus-visible {
+ position: relative;
+ z-index: 1;
+ box-shadow: inset 0 0 0 2px ${t(colors.blue[500], colors.blue[400])};
+ }
+ `,
+ seoOverviewCheckBody: css`
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ `,
+ seoOverviewCheckTitle: css`
+ font-weight: 500;
+ font-size: 13px;
+ line-height: 1.3;
+ `,
+ seoOverviewCheckMeta: css`
+ font-size: 11px;
+ line-height: 1.35;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ `,
+ seoOverviewCheckCounts: css`
+ flex-shrink: 0;
+ font-family: ${fontFamily.mono};
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.3;
+ letter-spacing: -0.02em;
+ `,
+ seoOverviewCheckNError: css`
+ color: #dc2626;
+ font-weight: 500;
+ `,
+ seoOverviewCheckNWarn: css`
+ color: #d97706;
+ font-weight: 500;
+ `,
+ seoOverviewCheckNInfo: css`
+ color: ${t(colors.blue[600], colors.blue[400])};
+ font-weight: 500;
+ `,
+ seoOverviewCheckNZero: css`
+ color: ${t(colors.gray[400], colors.gray[600])};
+ font-weight: 400;
+ `,
+ seoOverviewCheckNSep: css`
+ color: ${t(colors.gray[300], colors.gray[600])};
+ margin: 0 1px;
+ font-weight: 400;
+ `,
+ seoOverviewCheckChevron: css`
+ flex-shrink: 0;
+ color: ${t(colors.gray[400], colors.gray[500])};
+ font-size: 15px;
+ line-height: 1.2;
+ `,
+ seoHeadingTreeHeaderRow: css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ `,
+ serpPreviewLabelFlat: css`
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 0;
+ color: ${t(colors.gray[700], colors.gray[300])};
+ `,
+ seoHeadingTreeCount: css`
+ font-size: 11px;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoHeadingTreeList: css`
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ `,
+ seoHeadingTreeItem: css`
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+ `,
+ seoHeadingTreeIndent1: css`
+ padding-left: 0;
+ `,
+ seoHeadingTreeIndent2: css`
+ padding-left: 14px;
+ `,
+ seoHeadingTreeIndent3: css`
+ padding-left: 28px;
+ `,
+ seoHeadingTreeIndent4: css`
+ padding-left: 42px;
+ `,
+ seoHeadingTreeIndent5: css`
+ padding-left: 56px;
+ `,
+ seoHeadingTreeIndent6: css`
+ padding-left: 70px;
+ `,
+ seoHeadingTag: css`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 26px;
+ height: 16px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ flex-shrink: 0;
+ font-family: monospace, ui-monospace, monospace;
+ `,
+ seoHeadingTagL1: css`
+ background: #60a5fa18;
+ color: #60a5fa;
+ `,
+ seoHeadingTagL2: css`
+ background: #34d39918;
+ color: #34d399;
+ `,
+ seoHeadingTagL3: css`
+ background: #a78bfa18;
+ color: #a78bfa;
+ `,
+ seoHeadingTagL4: css`
+ background: #f59e0b18;
+ color: #f59e0b;
+ `,
+ seoHeadingTagL5: css`
+ background: #f8717118;
+ color: #f87171;
+ `,
+ seoHeadingTagL6: css`
+ background: #94a3b818;
+ color: #94a3b8;
+ `,
+ seoHeadingLineText: css`
+ font-size: 12px;
+ font-style: normal;
+ color: ${t(colors.gray[900], colors.gray[100])};
+ line-height: 1.45;
+ `,
+ seoHeadingLineTextEmpty: css`
+ font-size: 12px;
+ font-style: italic;
+ opacity: 0.65;
+ color: ${t(colors.gray[900], colors.gray[100])};
+ line-height: 1.45;
+ `,
+ seoSerpIssueListItem: css`
+ margin-top: 0.25rem;
+ `,
+ seoJsonLdBlockHeaderRow: css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ `,
+ serpPreviewLabelSub: css`
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 2px;
+ color: ${t(colors.gray[700], colors.gray[300])};
+ `,
+ seoJsonLdBlockTypes: css`
+ font-size: 11px;
+ color: ${t(colors.gray[500], colors.gray[400])};
+ `,
+ seoJsonLdCopyButton: css`
+ border: 1px solid ${t(colors.gray[300], colors.gray[700])};
+ border-radius: 5px;
+ padding: 3px 10px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 11px;
+ color: ${t(colors.gray[600], colors.gray[400])};
+ font-family: inherit;
+ `,
+ seoJsonLdPre: css`
+ margin: 0;
+ max-height: 260px;
+ overflow: auto;
+ padding: 10px;
+ font-size: 11px;
+ line-height: 1.5;
+ border-radius: 6px;
+ border: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ background: ${t(colors.gray[50], '#0d1117')};
+ color: ${t(colors.gray[800], colors.gray[300])};
+ white-space: pre-wrap;
+ word-break: break-word;
+ `,
+ seoIssueListTopSpaced: css`
+ margin-top: 10px;
+ `,
+ seoJsonLdOkLine: css`
+ margin-top: 8px;
+ color: ${t(colors.green[700], '#16a34a')};
+ font-size: 12px;
+ `,
+ seoJsonLdHealthCard: css`
+ margin-bottom: 12px;
+ border: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ border-radius: 8px;
+ padding: 12px;
+ background: ${t(colors.gray[50], colors.darkGray[900])};
+ `,
+ seoJsonLdHealthTitle: css`
+ font-size: 12px;
+ font-weight: 600;
+ color: ${t(colors.gray[800], colors.gray[300])};
+ `,
+ seoJsonLdSupportedIntro: css`
+ margin: 0 0 12px 0;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ background: ${t(colors.gray[50], colors.darkGray[900])};
+ `,
+ seoJsonLdSupportedIntroLabel: css`
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ color: ${t(colors.gray[600], colors.gray[400])};
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 8px;
+ `,
+ seoJsonLdSupportedChips: css`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ `,
+ seoJsonLdSupportedChip: css`
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ font-family: ${fontFamily.mono};
+ border: 1px solid ${t(colors.gray[200], colors.gray[700])};
+ background: ${t(colors.white, colors.darkGray[800])};
+ color: ${t(colors.gray[800], colors.gray[200])};
+ `,
+ seoJsonLdCardGrid: css`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 10px;
+ `,
+ seoJsonLdEntityCard: css`
+ border: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ border-radius: 8px;
+ padding: 8px 10px;
+ background: ${t(colors.white, colors.darkGray[800])};
+ `,
+ seoJsonLdEntityCardHeader: css`
+ font-size: 11px;
+ font-weight: 700;
+ color: ${t(colors.blue[700], colors.blue[400])};
+ margin-bottom: 6px;
+ font-family: ${fontFamily.mono};
+ `,
+ seoJsonLdEntityCardRows: css`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 11px;
+ line-height: 1.4;
+ `,
+ seoJsonLdEntityCardRow: css`
+ display: flex;
+ gap: 6px;
+ align-items: baseline;
+ min-width: 0;
+ `,
+ seoJsonLdEntityCardKey: css`
+ flex-shrink: 0;
+ color: ${t(colors.gray[500], colors.gray[500])};
+ font-weight: 500;
+ `,
+ seoJsonLdEntityCardValue: css`
+ color: ${t(colors.gray[900], colors.gray[100])};
+ word-break: break-word;
+ min-width: 0;
+ `,
+ seoJsonLdRawDetails: css`
+ margin-top: 4px;
+ border-radius: 6px;
+ border: 1px solid ${t(colors.gray[200], colors.gray[800])};
+ overflow: hidden;
+ `,
+ seoJsonLdRawSummary: css`
+ cursor: pointer;
+ padding: 6px 10px;
+ font-size: 11px;
+ font-weight: 500;
+ color: ${t(colors.gray[600], colors.gray[400])};
+ background: ${t(colors.gray[100], colors.darkGray[800])};
+ list-style: none;
+ user-select: none;
+ &::-webkit-details-marker {
+ display: none;
+ }
+ `,
+ seoSocialAccentFacebook: css`
+ border-color: #4267b2;
+ `,
+ seoSocialHeaderFacebook: css`
+ color: #4267b2;
+ `,
+ seoSocialAccentTwitter: css`
+ border-color: #1da1f2;
+ `,
+ seoSocialHeaderTwitter: css`
+ color: #1da1f2;
+ `,
+ seoSocialAccentLinkedin: css`
+ border-color: #0077b5;
+ `,
+ seoSocialHeaderLinkedin: css`
+ color: #0077b5;
+ `,
+ seoSocialAccentDiscord: css`
+ border-color: #5865f2;
+ `,
+ seoSocialHeaderDiscord: css`
+ color: #5865f2;
+ `,
+ seoSocialAccentSlack: css`
+ border-color: #4a154b;
+ `,
+ seoSocialHeaderSlack: css`
+ color: #4a154b;
+ `,
+ seoSocialAccentMastodon: css`
+ border-color: #6364ff;
+ `,
+ seoSocialHeaderMastodon: css`
+ color: #6364ff;
+ `,
+ seoSocialAccentBluesky: css`
+ border-color: #1185fe;
+ `,
+ seoSocialHeaderBluesky: css`
+ color: #1185fe;
+ `,
+ seoPreviewImagePlaceholder: css`
+ background: ${t(colors.gray[200], '#222')};
+ color: ${t(colors.gray[500], '#888')};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 80px;
+ width: 100%;
+ `,
devtoolsPanelContainer: (
panelLocation: TanStackDevtoolsConfig['panelLocation'],
isDetached: boolean,
diff --git a/packages/devtools/src/tabs/seo-tab/canonical-url-data.ts b/packages/devtools/src/tabs/seo-tab/canonical-url-data.ts
new file mode 100644
index 00000000..e30c2e9a
--- /dev/null
+++ b/packages/devtools/src/tabs/seo-tab/canonical-url-data.ts
@@ -0,0 +1,129 @@
+import type { SeoSeverity } from './seo-severity'
+
+type CanonicalPageIssue = {
+ severity: SeoSeverity
+ message: string
+}
+
+/**
+ * Canonical URL, robots, and basic URL hygiene derived from the current
+ * document head and `window.location`.
+ */
+type CanonicalPageData = {
+ currentUrl: string
+ canonicalRaw: Array
+ canonicalResolved: Array
+ robots: Array
+ indexable: boolean
+ follow: boolean
+ issues: Array
+}
+
+export function getCanonicalPageData(): CanonicalPageData {
+ const currentUrl = window.location.href
+ const current = new URL(currentUrl)
+
+ const canonicalLinks = Array.from(
+ document.head.querySelectorAll('link[rel]'),
+ ).filter((link) => link.rel.toLowerCase().split(/\s+/).includes('canonical'))
+
+ const canonicalRaw = canonicalLinks.map(
+ (link) => link.getAttribute('href') || '',
+ )
+ const canonicalResolved: Array = []
+ const issues: Array = []
+
+ if (canonicalLinks.length === 0) {
+ issues.push({ severity: 'error', message: 'No canonical link found.' })
+ }
+ if (canonicalLinks.length > 1) {
+ issues.push({
+ severity: 'error',
+ message: 'Multiple canonical links found.',
+ })
+ }
+
+ for (const raw of canonicalRaw) {
+ if (!raw.trim()) {
+ issues.push({ severity: 'error', message: 'Canonical href is empty.' })
+ continue
+ }
+ try {
+ const resolved = new URL(raw, currentUrl)
+ canonicalResolved.push(resolved.href)
+
+ if (resolved.hash) {
+ issues.push({
+ severity: 'warning',
+ message: 'Canonical URL contains a hash fragment.',
+ })
+ }
+ if (resolved.origin !== current.origin) {
+ issues.push({
+ severity: 'warning',
+ message: 'Canonical URL points to a different origin.',
+ })
+ }
+ } catch {
+ issues.push({
+ severity: 'error',
+ message: `Canonical URL is invalid: ${raw}`,
+ })
+ }
+ }
+
+ const robotsMetas = Array.from(
+ document.head.querySelectorAll('meta[name]'),
+ ).filter((meta) => {
+ const name = meta.getAttribute('name')?.toLowerCase()
+ return name === 'robots' || name === 'googlebot'
+ })
+
+ const robots = robotsMetas
+ .map((meta) => meta.getAttribute('content') || '')
+ .flatMap((content) =>
+ content
+ .split(',')
+ .map((token) => token.trim().toLowerCase())
+ .filter(Boolean),
+ )
+
+ const hasNoindex = robots.includes('noindex') || robots.includes('none')
+ const hasNofollow = robots.includes('nofollow') || robots.includes('none')
+ const indexable = !hasNoindex
+ const follow = !hasNofollow
+
+ if (!indexable) {
+ issues.push({ severity: 'error', message: 'Page is marked as noindex.' })
+ }
+ if (!follow) {
+ issues.push({ severity: 'warning', message: 'Page is marked as nofollow.' })
+ }
+ if (robots.length === 0) {
+ issues.push({
+ severity: 'info',
+ message:
+ 'No robots meta found. Default behavior is usually index/follow.',
+ })
+ }
+
+ if (current.pathname !== '/' && /[A-Z]/.test(current.pathname)) {
+ issues.push({
+ severity: 'warning',
+ message: 'URL path contains uppercase characters.',
+ })
+ }
+ if (current.search) {
+ issues.push({ severity: 'info', message: 'URL contains query parameters.' })
+ }
+
+ return {
+ currentUrl,
+ canonicalRaw,
+ canonicalResolved,
+ robots,
+ indexable,
+ follow,
+ issues,
+ }
+}
diff --git a/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx b/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx
new file mode 100644
index 00000000..d2c6325d
--- /dev/null
+++ b/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx
@@ -0,0 +1,228 @@
+import { For, Show } from 'solid-js'
+import { Section, SectionDescription } from '@tanstack/devtools-ui'
+import { useStyles } from '../../styles/use-styles'
+import { pickSeverityClass } from './seo-severity'
+import type { SeoSeverity } from './seo-severity'
+import type { SeoSectionSummary } from './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'),
+ )
+
+ 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 = useStyles()
+ const headings = extractHeadings()
+ const issues = 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 scans once when opened.
+
+
+
+
+
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/src/tabs/seo-tab/index.tsx b/packages/devtools/src/tabs/seo-tab/index.tsx
index c00a97e9..6c753191 100644
--- a/packages/devtools/src/tabs/seo-tab/index.tsx
+++ b/packages/devtools/src/tabs/seo-tab/index.tsx
@@ -3,23 +3,48 @@ import { MainPanel } from '@tanstack/devtools-ui'
import { useStyles } from '../../styles/use-styles'
import { SocialPreviewsSection } from './social-previews'
import { SerpPreviewSection } from './serp-preview'
+import { JsonLdPreviewSection } from './json-ld-preview'
+import { HeadingStructurePreviewSection } from './heading-structure-preview'
+import { LinksPreviewSection } from './links-preview'
+import { SeoOverviewSection } from './seo-overview'
+import type { SeoDetailView } from './seo-section-summary'
-type SeoSubView = 'social-previews' | 'serp-preview'
+type SeoSubView = 'overview' | SeoDetailView
export const SeoTab = () => {
- const [activeView, setActiveView] =
- createSignal('social-previews')
+ const [activeView, setActiveView] = createSignal('overview')
const styles = useStyles()
return (
+ setActiveView('overview')}
+ >
+ SEO Overview
+
+ setActiveView('heading-structure')}
+ >
+ Heading Structure
+
+ setActiveView('links-preview')}
+ >
+ Links Preview
+
setActiveView('social-previews')}
>
- Social previews
+ Social Previews
{
>
SERP Preview
+ setActiveView('json-ld-preview')}
+ >
+ JSON-LD Preview
+
+
+ setActiveView(view)} />
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx b/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx
new file mode 100644
index 00000000..80d0c67b
--- /dev/null
+++ b/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx
@@ -0,0 +1,745 @@
+import { For, Show } from 'solid-js'
+import { Section, SectionDescription } from '@tanstack/devtools-ui'
+import { useStyles } from '../../styles/use-styles'
+import { pickSeverityClass, seoHealthTier } from './seo-severity'
+import type { SeoSeverity } from './seo-severity'
+import type { SeoSectionSummary } from './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)
+}
+
+const RESERVED_KEYS = new Set(['@context', '@type', '@id', '@graph'])
+
+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) {
+ return [{ severity: 'error', message: 'Missing @context attribute.' }]
+ }
+ 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 string schema.org URL.',
+ },
+ ]
+}
+
+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(', ')}`,
+ })
+ }
+
+ const allowedSet = new Set([
+ ...rules.required,
+ ...rules.recommended,
+ ...rules.optional,
+ ...RESERVED_KEYS,
+ ])
+ const unknownKeys = Object.keys(entity).filter((key) => !allowedSet.has(key))
+ if (unknownKeys.length > 0) {
+ issues.push({
+ severity: 'warning',
+ message: `Possible invalid attributes for ${typeName}: ${unknownKeys.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"]',
+ ),
+ )
+
+ return scripts.map((script, index) => {
+ const raw = script.textContent.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 {
+ let errors = 0
+ let warnings = 0
+ let infos = 0
+
+ for (const entry of entries) {
+ for (const issue of entry.issues) {
+ if (issue.severity === 'error') errors += 1
+ else if (issue.severity === 'warning') warnings += 1
+ else infos += 1
+ }
+ }
+
+ const penalty = Math.min(100, errors * 20 + warnings * 10 + infos * 2)
+ return Math.max(0, 100 - penalty)
+}
+
+function JsonLdEntityPreviewCard(props: { entity: JsonLdValue }) {
+ const styles = useStyles()
+ 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 = useStyles()
+ 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 entries = analyzeJsonLdScripts()
+ const styles = useStyles()
+ const score = getJsonLdScore(entries)
+ const s = styles()
+ const fieldGaps = 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 = (() => {
+ 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 = (() => {
+ 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 (
+