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. +
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. + + +
+
+
Heading tree
+ + {headings.length} heading{headings.length === 1 ? '' : 's'} + +
+ 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(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 ( +
+
{header}
+ 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 ( +
+
+
+
Block #{props.index + 1}
+
+ {props.entry.types.length > 0 + ? props.entry.types.join(', ') + : 'Unknown type'} + {showPreview ? · preview : · raw JSON} +
+
+ + + +
+ + + {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 ( +
+ + Reads every {`