diff --git a/.changeset/vast-apes-attend.md b/.changeset/vast-apes-attend.md new file mode 100644 index 00000000..ad835ce8 --- /dev/null +++ b/.changeset/vast-apes-attend.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': patch +--- + +Implemented a new SERP (Search Engine Results Page) section in the SEO tab. This update introduces desktop and mobile preview of search results. It displays the current site's favicon, title and description while displaying errors and issues when they are not found or they exceed the character limit. diff --git a/examples/react/basic/index.html b/examples/react/basic/index.html index 1c96ec87..b63b73f6 100644 --- a/examples/react/basic/index.html +++ b/examples/react/basic/index.html @@ -28,6 +28,11 @@ Basic Example - TanStack Devtools + + A basic example of using TanStack Devtools with React. diff --git a/packages/devtools/src/styles/use-styles.ts b/packages/devtools/src/styles/use-styles.ts index 36feac1f..efaebad7 100644 --- a/packages/devtools/src/styles/use-styles.ts +++ b/packages/devtools/src/styles/use-styles.ts @@ -119,6 +119,32 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => { margin-bottom: 2rem; border-radius: 0.75rem; `, + seoSubNav: css` + display: flex; + flex-direction: row; + gap: 0; + margin-bottom: 1rem; + border-bottom: 1px solid ${t(colors.gray[200], colors.gray[800])}; + `, + seoSubNavLabel: css` + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: ${t(colors.gray[600], colors.gray[400])}; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + font-family: inherit; + &:hover { + color: ${t(colors.gray[800], colors.gray[200])}; + } + `, + seoSubNavLabelActive: css` + color: ${t(colors.gray[900], colors.gray[100])}; + border-bottom-color: ${t(colors.gray[900], colors.gray[100])}; + `, seoPreviewSection: css` display: flex; flex-direction: row; @@ -205,6 +231,139 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => { padding: 0 10px 8px 10px; font-size: 0.875rem; `, + serpPreviewBlock: css` + margin-bottom: 1.5rem; + border: 1px solid ${t(colors.gray[200], colors.gray[700])}; + border-radius: 10px; + padding: 1rem; + `, + serpPreviewLabel: css` + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: ${t(colors.gray[700], colors.gray[300])}; + `, + serpSnippet: css` + border: 1px solid ${t(colors.gray[100], colors.gray[800])}; + border-radius: 8px; + padding: 1rem 1.25rem; + background: ${t(colors.white, colors.darkGray[900])}; + max-width: 600px; + font-family: ${fontFamily.sans}; + box-shadow: 0 1px 2px ${t('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.08)')}; + `, + serpSnippetMobile: css` + border: 1px solid ${t(colors.gray[100], colors.gray[800])}; + border-radius: 8px; + padding: 1rem 1.25rem; + background: ${t(colors.white, colors.darkGray[900])}; + max-width: 380px; + font-family: ${fontFamily.sans}; + box-shadow: 0 1px 2px ${t('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.08)')}; + `, + serpSnippetDescMobile: css` + font-size: 0.875rem; + color: ${t(colors.gray[700], colors.gray[300])}; + margin: 0; + line-height: 1.5; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + `, + serpSnippetTopRow: css` + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + `, + serpSnippetFavicon: css` + width: 28px; + height: 28px; + border-radius: 50%; + flex-shrink: 0; + object-fit: contain; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + `, + serpSnippetDefaultFavicon: css` + width: 28px; + height: 28px; + background-color: ${t(colors.gray[200], colors.gray[800])}; + border-radius: 50%; + flex-shrink: 0; + object-fit: contain; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + `, + serpSnippetSiteColumn: css` + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + `, + serpSnippetSiteName: css` + font-size: 0.875rem; + color: ${t(colors.gray[900], colors.gray[100])}; + line-height: 1.4; + margin: 0; + `, + serpSnippetSiteUrl: css` + font-size: 0.75rem; + color: ${t(colors.gray[500], colors.gray[500])}; + line-height: 1.4; + margin: 0; + `, + serpSnippetTitle: css` + font-size: 1.25rem; + font-weight: 400; + color: ${t('#1a0dab', '#8ab4f8')}; + margin: 0 0 4px 0; + line-height: 1.3; + `, + serpSnippetDesc: css` + font-size: 0.875rem; + color: ${t(colors.gray[700], colors.gray[300])}; + margin: 0; + line-height: 1.5; + `, + serpMeasureHidden: css` + position: absolute; + left: -9999px; + top: 0; + visibility: hidden; + pointer-events: none; + box-sizing: border-box; + `, + serpMeasureHiddenMobile: css` + position: absolute; + left: -9999px; + top: 0; + width: 340px; + visibility: hidden; + pointer-events: none; + font-size: 0.875rem; + line-height: 1.5; + `, + serpReportSection: css` + margin-top: 1rem; + font-size: 0.875rem; + color: ${t(colors.gray[700], colors.gray[300])}; + `, + serpErrorList: css` + margin: 4px 0 0 0; + padding-left: 1.25rem; + list-style-type: disc; + `, + serpReportItem: css` + margin-top: 0.25rem; + color: ${t(colors.red[700], colors.red[400])}; + font-size: 0.875rem; + `, devtoolsPanelContainer: ( panelLocation: TanStackDevtoolsConfig['panelLocation'], isDetached: boolean, diff --git a/packages/devtools/src/tabs/seo-tab/index.tsx b/packages/devtools/src/tabs/seo-tab/index.tsx new file mode 100644 index 00000000..c00a97e9 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/index.tsx @@ -0,0 +1,41 @@ +import { Show, createSignal } from 'solid-js' +import { MainPanel } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' +import { SocialPreviewsSection } from './social-previews' +import { SerpPreviewSection } from './serp-preview' + +type SeoSubView = 'social-previews' | 'serp-preview' + +export const SeoTab = () => { + const [activeView, setActiveView] = + createSignal('social-previews') + const styles = useStyles() + + return ( + + + + + + + + + + + ) +} diff --git a/packages/devtools/src/tabs/seo-tab/serp-preview.tsx b/packages/devtools/src/tabs/seo-tab/serp-preview.tsx new file mode 100644 index 00000000..e787d6a9 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/serp-preview.tsx @@ -0,0 +1,256 @@ +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { For, createMemo, createSignal } from 'solid-js' +import { useHeadChanges } from '../../hooks/use-head-changes' +import { useStyles } from '../../styles/use-styles' + +/** Google typically truncates titles at ~60 characters. */ +const TITLE_MAX_CHARS = 60 +/** Meta description is often trimmed at ~158 characters on desktop. */ +const DESCRIPTION_MAX_CHARS = 158 +/** Approximate characters that fit in 3 lines at mobile width (~340px, ~14px font). */ +const DESCRIPTION_MOBILE_MAX_CHARS = 120 +const ELLIPSIS = '...' + +type SerpData = { + title: string + description: string + siteName: string + favicon: string | null + url: string +} + +type SerpOverflow = { + titleOverflow: boolean + descriptionOverflow: boolean + descriptionOverflowMobile: boolean +} + +type SerpCheck = { + message: string + hasIssue: (data: SerpData, overflow: SerpOverflow) => boolean +} + +type SerpPreview = { + label: string + isMobile: boolean + extraChecks: Array +} + +const COMMON_CHECKS: Array = [ + { + message: 'No favicon or icon set on the page.', + hasIssue: (data) => !data.favicon, + }, + { + message: 'No title tag set on the page.', + hasIssue: (data) => !data.title.trim(), + }, + { + message: 'No meta description set on the page.', + hasIssue: (data) => !data.description.trim(), + }, + { + message: + 'The title is wider than 600px and it may not be displayed in full length.', + hasIssue: (_, overflow) => overflow.titleOverflow, + }, +] + +const SERP_PREVIEWS: Array = [ + { + label: 'Desktop preview', + isMobile: false, + extraChecks: [ + { + message: + 'The meta description may get trimmed at ~960 pixels on desktop and at ~680px on mobile. Keep it below ~158 characters.', + hasIssue: (_, overflow) => overflow.descriptionOverflow, + }, + ], + }, + { + label: 'Mobile preview', + isMobile: true, + extraChecks: [ + { + message: + 'Description exceeds the 3-line limit for mobile view. Please shorten your text to fit within 3 lines.', + hasIssue: (_, overflow) => overflow.descriptionOverflowMobile, + }, + ], + }, +] + +function truncateToChars(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + if (maxChars <= ELLIPSIS.length) return ELLIPSIS + return text.slice(0, maxChars - ELLIPSIS.length) + ELLIPSIS +} + +function getSerpFromHead(): SerpData { + const title = document.title || '' + const url = typeof window !== 'undefined' ? window.location.href : '' + + const metaTags = Array.from(document.head.querySelectorAll('meta')) + const descriptionMeta = metaTags.find( + (m) => m.getAttribute('name')?.toLowerCase() === 'description', + ) + const description = descriptionMeta?.getAttribute('content')?.trim() || '' + + const siteNameMeta = metaTags.find( + (m) => m.getAttribute('property') === 'og:site_name', + ) + const siteName = + siteNameMeta?.getAttribute('content')?.trim() || + (typeof window !== 'undefined' + ? window.location.hostname.replace(/^www\./, '') + : '') + + const linkTags = Array.from(document.head.querySelectorAll('link')) + const iconLink = linkTags.find((l) => + l.getAttribute('rel')?.toLowerCase().split(/\s+/).includes('icon'), + ) + let favicon: string | null = iconLink?.getAttribute('href') || null + if (favicon && typeof window !== 'undefined') { + try { + favicon = new URL(favicon, url).href + } catch { + favicon = null + } + } + + return { title, description, siteName, favicon, url } +} + +function getSerpIssues( + data: SerpData, + overflow: SerpOverflow, + checks: Array, +): Array { + return checks.filter((c) => c.hasIssue(data, overflow)).map((c) => c.message) +} + +function SerpSnippetPreview(props: { + data: SerpData + displayTitle: string + displayDescription: string + isMobile: boolean + label: string + issues: Array +}) { + const styles = useStyles() + + return ( +
+
{props.label}
+
+
+ {props.data.favicon ? ( + favicon icon + ) : ( +
+ )} +
+ + {props.data.siteName || props.data.url} + + {props.data.url} +
+
+
+ {props.displayTitle || props.data.title || 'No title'} +
+ {!props.isMobile && ( +
+ {props.displayDescription || + props.data.description || + 'No meta description.'} +
+ )} + {props.isMobile && ( +
+ {props.displayDescription || + props.data.description || + 'No meta description.'} +
+ )} +
+ {props.issues.length > 0 ? ( +
+ Issues for {props.label}: +
    + + {(issue) =>
  • {issue}
  • } +
    +
+
+ ) : null} +
+ ) +} + +export function SerpPreviewSection() { + const [serp, setSerp] = createSignal(getSerpFromHead()) + + useHeadChanges(() => { + setSerp(getSerpFromHead()) + }) + + const serpPreviewState = createMemo(() => { + const data = serp() + const titleText = data.title || 'No title' + const descText = data.description || 'No meta description.' + + const displayTitle = truncateToChars(titleText, TITLE_MAX_CHARS) + const displayDescription = truncateToChars(descText, DESCRIPTION_MAX_CHARS) + + return { + displayTitle, + displayDescription, + overflow: { + titleOverflow: titleText.length > TITLE_MAX_CHARS, + descriptionOverflow: descText.length > DESCRIPTION_MAX_CHARS, + descriptionOverflowMobile: + descText.length > DESCRIPTION_MOBILE_MAX_CHARS, + }, + } + }) + + return ( +
+ + See how your title tag and meta description may look in Google search + results. Data is read from the current page. + + + {(preview) => { + const issues = createMemo(() => + getSerpIssues(serp(), serpPreviewState().overflow, [ + ...COMMON_CHECKS, + ...preview.extraChecks, + ]), + ) + + return ( + + ) + }} + +
+ ) +} diff --git a/packages/devtools/src/tabs/seo-tab.tsx b/packages/devtools/src/tabs/seo-tab/social-previews.tsx similarity index 69% rename from packages/devtools/src/tabs/seo-tab.tsx rename to packages/devtools/src/tabs/seo-tab/social-previews.tsx index 3aec384d..ebe3ed6a 100644 --- a/packages/devtools/src/tabs/seo-tab.tsx +++ b/packages/devtools/src/tabs/seo-tab/social-previews.tsx @@ -1,27 +1,7 @@ -import { For, createSignal } from 'solid-js' -import { - MainPanel, - Section, - SectionDescription, - SectionIcon, - SectionTitle, -} from '@tanstack/devtools-ui' -import { SocialBubble } from '@tanstack/devtools-ui/icons' -import { useStyles } from '../styles/use-styles' -import { useHeadChanges } from '../hooks/use-head-changes' - -type SocialMeta = { - title?: string - description?: string - image?: string - url?: string -} - -type SocialReport = { - network: string - found: Partial - missing: Array -} +import { createSignal, For } from 'solid-js' +import { useStyles } from '../../styles/use-styles' +import { useHeadChanges } from '../../hooks/use-head-changes' +import { Section, SectionDescription } from '@tanstack/devtools-ui' const SOCIALS = [ { @@ -96,6 +76,20 @@ const SOCIALS = [ }, // Add more networks as needed ] + +type SocialMeta = { + title?: string + description?: string + image?: string + url?: string +} + +type SocialReport = { + network: string + found: Partial + missing: Array +} + function SocialPreview(props: { meta: SocialMeta color: string @@ -145,7 +139,8 @@ function SocialPreview(props: {
) } -export const SeoTab = () => { + +export function SocialPreviewsSection() { const [reports, setReports] = createSignal>(analyzeHead()) const styles = useStyles() @@ -182,51 +177,43 @@ export const SeoTab = () => { }) return ( - -
- - - - - Social previews - - - See how your current page will look when shared on popular social - networks. The tool checks for essential meta tags and highlights any - that are missing. - -
- - {(report, i) => { - const social = SOCIALS[i()] - return ( -
- - {report.missing.length > 0 ? ( - <> -
- Missing tags for {social?.network}: +
+ + See how your current page will look when shared on popular social + networks. The tool checks for essential meta tags and highlights any + that are missing. + +
+ + {(report, i) => { + const social = SOCIALS[i()] + return ( +
+ + {report.missing.length > 0 ? ( + <> +
+ Missing tags for {social?.network}: -
    - - {(tag) => ( -
  • {tag}
  • - )} -
    -
-
- - ) : null} -
- ) - }} -
-
-
- +
    + + {(tag) => ( +
  • {tag}
  • + )} +
    +
+
+ + ) : null} +
+ ) + }} +
+
+
) }