From c43bcb20439a6f63e26d567e3262ef2f72788d62 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Wed, 1 Apr 2026 01:02:50 +0300 Subject: [PATCH 01/54] feat(devtools): add SEO tab documentation with detailed features and functionality This commit introduces a new README.md file for the SEO tab in the devtools package. It outlines the purpose of the SEO tab, including its major features such as Social Previews, SERP Previews, JSON-LD Previews, and more. Each section provides an overview of functionality, data sources, and how the previews are rendered, enhancing the documentation for better user understanding. --- packages/devtools/src/tabs/seo-tab/README.md | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/devtools/src/tabs/seo-tab/README.md diff --git a/packages/devtools/src/tabs/seo-tab/README.md b/packages/devtools/src/tabs/seo-tab/README.md new file mode 100644 index 00000000..cf07db09 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/README.md @@ -0,0 +1,75 @@ +# SEO Tab Overview + +## Overview + +The seo tab contains major tabs that are complement to the inspect elements light house tab and not a replacement for them. It is a replacement for the extensions and simple tools you use to check and discover things by simply digging deeper in the html section, network or other pages in your site. + +SEO tabs: + +- Social Previews: shows open graph and twitter previews for you page when shared across social media apps. +- SERP Previews: shows you a similar preview of how your page will be displayed in search engine results page. +- JSON-LD Previews: shows you all the json ld detected in the page. +- Heading Structure Visualizer: preview your layout in heading tags. +- Links preview: check all page links and thier details like internal/external, text, ... +- Canonical & URL & if page is indexible and follow +- overview tab for SEO Score / Report: that contains a percentage of how everything is going in the other tabs and a small icon/link that will redirect them to the sepcific tab for more informations and details. + +## Social Previews + +Shows simulated share cards for major networks using metadata read from `document.head`. + +Implemented networks and tag checks: + +- Facebook, LinkedIn, Discord, Slack, Mastodon, Bluesky: + - `og:title`, `og:description`, `og:image`, `og:url` +- X/Twitter: + - `twitter:title`, `twitter:description`, `twitter:image`, `twitter:url` + +How it works: + +- Reads all `meta` tags from the current page head and maps matches into a per-network report. +- Renders one card per network with: + - network header color, + - preview image (or `No Image` placeholder), + - title (`No Title` fallback), + - description (`No Description` fallback), + - URL (falls back to `window.location.href` when missing). +- Lists missing tags under each network in a dedicated "Missing tags" block. +- Subscribes to head updates via `useHeadChanges` and refreshes reports reactively. + +## SERP Previews + +Shows Google-style result snippets based on the current page title, description, favicon, URL, and site name. + +Data sources: + +- `document.title` +- `` +- `` (fallback: hostname without `www.`) +- `` for favicon (resolved to absolute URL when possible) +- `window.location.href` + +Rendered previews: + +- Desktop preview +- Mobile preview + +Truncation and limits: + +- Title truncated to `~60` characters for display. +- Description truncated to `~158` characters for display. +- Mobile description overflow check uses `~120` characters (3-line approximation). + +Issue reporting: + +- Shared checks: + - missing favicon/icon, + - missing title, + - missing meta description, + - title likely too long (message references width > 600px). +- Desktop-specific check: + - meta description may be trimmed (desktop/mobile pixel-width guidance message). +- Mobile-specific check: + - description exceeds mobile 3-line limit. + +Like Social Previews, this section updates live through `useHeadChanges`. \ No newline at end of file From ffb1f35ad2e6032512e3c6f4a11b5dab29f4c3c4 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Wed, 1 Apr 2026 01:03:15 +0300 Subject: [PATCH 02/54] feat(devtools): add SEO analysis features including JSON-LD, heading structure, and links preview This commit introduces several new sections to the SEO tab in the devtools package, enhancing its functionality. The new features include: - **JSON-LD Preview**: Parses and validates JSON-LD scripts on the page, providing detailed feedback on required and recommended attributes. - **Heading Structure Preview**: Analyzes heading tags (`h1` to `h6`) for hierarchy and common issues, ensuring proper SEO practices. - **Links Preview**: Scans all links on the page, classifying them as internal, external, or invalid, and reports on accessibility and SEO-related issues. Additionally, the SEO tab navigation has been updated to include these new sections, improving user experience and accessibility of SEO insights. --- examples/react/basic/index.html | 9 + packages/devtools/src/tabs/seo-tab/README.md | 122 ++++- .../tabs/seo-tab/canonical-url-preview.tsx | 185 +++++++ .../seo-tab/heading-structure-preview.tsx | 153 ++++++ packages/devtools/src/tabs/seo-tab/index.tsx | 52 +- .../src/tabs/seo-tab/json-ld-preview.tsx | 509 ++++++++++++++++++ .../src/tabs/seo-tab/links-preview.tsx | 180 +++++++ 7 files changed, 1208 insertions(+), 2 deletions(-) create mode 100644 packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx create mode 100644 packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx create mode 100644 packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx create mode 100644 packages/devtools/src/tabs/seo-tab/links-preview.tsx diff --git a/examples/react/basic/index.html b/examples/react/basic/index.html index b63b73f6..fe1a2596 100644 --- a/examples/react/basic/index.html +++ b/examples/react/basic/index.html @@ -38,6 +38,15 @@ > +
diff --git a/packages/devtools/src/tabs/seo-tab/README.md b/packages/devtools/src/tabs/seo-tab/README.md index cf07db09..2735d3aa 100644 --- a/packages/devtools/src/tabs/seo-tab/README.md +++ b/packages/devtools/src/tabs/seo-tab/README.md @@ -72,4 +72,124 @@ Issue reporting: - Mobile-specific check: - description exceeds mobile 3-line limit. -Like Social Previews, this section updates live through `useHeadChanges`. \ No newline at end of file +Like Social Previews, this section updates live through `useHeadChanges`. + +## JSON-LD Previews + +Parses all `script[type="application/ld+json"]` blocks available on the current page and displays each block as formatted JSON with validation output. + +Current scan behavior: + +- Non-reactive by design. +- The section scans and validates JSON-LD when the tab is opened. +- If page JSON-LD changes later, reopen the tab to rescan. + +Supported schema types with dedicated manual validation rules: + +- `WebSite` +- `Organization` +- `Person` +- `Article` +- `Product` +- `BreadcrumbList` +- `FAQPage` +- `LocalBusiness` + +Validation model: + +- Shared checks for every entity: + - missing or invalid `@context` (expects Schema.org context), + - missing `@type`, + - invalid JSON syntax and invalid root shape. +- Type-specific checks: + - missing required attributes -> `error`, + - missing recommended attributes -> `warning`, + - missing optional attributes -> `info`, + - unknown/non-allowed attributes for that type -> `warning`. +- Unknown schema types still render parsed output and are reported as: + - `warning`: no dedicated validator yet. + +UI details: + +- One card per JSON-LD block with: + - detected type summary, + - formatted parsed JSON (or raw content for parse errors), + - copy action (`Copy parsed JSON-LD`), + - grouped severity messages (`error`, `warning`, `info`). + +JSON-LD health progress bar: + +- Displayed when at least one JSON-LD block is found. +- Starts at `100%`. +- Decreases by: + - `20` points per `error`, + - `10` points per `warning`. +- `info` issues (optional missing attributes) do not reduce score. +- Score is clamped between `0` and `100`. + +## Heading Structure Visualizer + +Scans all heading tags (`h1` to `h6`) on the page and renders the hierarchy in DOM order. + +Current behavior: + +- Non-reactive scan when the section is opened. +- Shows each heading with indentation based on heading level. +- Displays a structure issue list with severity. + +Checks included: + +- No headings found (`error`) +- Missing `h1` (`error`) +- Multiple `h1` (`warning`) +- First heading is not `h1` (`warning`) +- Skipped heading levels, e.g. `h2` to `h4` (`warning`) +- Empty heading text (`warning`) + +## Links Preview + +Collects links from the page and reports their SEO/security-related characteristics. + +Current behavior: + +- Non-reactive scan when the section is opened. +- Detects `a[href]` links and excludes devtools UI links. +- Classifies links as `internal`, `external`, `non-web`, or `invalid`. + +Checks included: + +- Missing visible/accessibility text (`error`) +- `javascript:` links (`error`) +- Invalid URL format (`error`) +- External `_blank` link without `noopener` (`warning`) +- Unexpected protocol (`warning`) +- External link without `nofollow` (`info`) +- Hash, mailto, tel, and other non-web links (`info`) + +## Canonical, URL, Indexability & Follow + +Evaluates canonical URL setup, robots directives, and basic URL hygiene. + +Current behavior: + +- Non-reactive scan when the section is opened. +- Reads canonical links from ``. +- Reads `robots` and `googlebot` meta directives. +- Derives indexability/follow from directives (`noindex`/`nofollow`). +- Includes a simple score (`100 - 25*errors - 10*warnings`). + +Checks included: + +- Missing canonical tag (`error`) +- Multiple canonical tags (`error`) +- Empty/invalid canonical href (`error`) +- Canonical with hash fragment (`warning`) +- Canonical cross-origin mismatch (`warning`) +- Page marked as `noindex` (`error`) +- Page marked as `nofollow` (`warning`) +- Missing robots directives (`info`) +- URL query parameters present (`info`) + +Note: + +- `X-Robots-Tag` response headers are not reliably available from this in-page view. \ No newline at end of file diff --git a/packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx b/packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx new file mode 100644 index 00000000..2d1441d3 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/canonical-url-preview.tsx @@ -0,0 +1,185 @@ +import { For } from 'solid-js' +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' + +type Severity = 'error' | 'warning' | 'info' + +type Issue = { + severity: Severity + message: string +} + +type CanonicalData = { + currentUrl: string + canonicalRaw: Array + canonicalResolved: Array + robots: Array + indexable: boolean + follow: boolean + issues: Array +} + +function severityColor(severity: Severity): string { + if (severity === 'error') return '#dc2626' + if (severity === 'warning') return '#d97706' + return '#2563eb' +} + +function getCanonicalData(): CanonicalData { + 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 indexable = !robots.includes('noindex') + const follow = !robots.includes('nofollow') + + 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, + } +} + +function getScore(issues: Array): number { + const errors = issues.filter((issue) => issue.severity === 'error').length + const warnings = issues.filter((issue) => issue.severity === 'warning').length + return Math.max(0, 100 - errors * 25 - warnings * 10) +} + +export function CanonicalUrlPreviewSection() { + const styles = useStyles() + const data = getCanonicalData() + const score = getScore(data.issues) + + return ( +
+ + Checks canonical URL, robots directives, indexability/follow signals, + and basic URL hygiene from the current page. + + +
+
SEO status
+
+ Score: {score}% + Indexable: {data.indexable ? 'Yes' : 'No'} + Follow: {data.follow ? 'Yes' : 'No'} + Canonical tags: {data.canonicalRaw.length} +
+
+ +
+
Signals
+
+ Current URL: {data.currentUrl} +
+
+ Canonical:{' '} + {data.canonicalResolved.join(', ') || data.canonicalRaw.join(', ') || 'None'} +
+
+ Robots directives: {data.robots.join(', ') || 'None'} +
+
+ X-Robots-Tag response headers are not available in this in-page view. +
+
+ +
+
Issues
+
    + + {(issue) => ( +
  • + [{issue.severity}] {issue.message} +
  • + )} +
    +
+
+
+ ) +} 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..bd0653e8 --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/heading-structure-preview.tsx @@ -0,0 +1,153 @@ +import { For, Show } from 'solid-js' +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' + +type Severity = 'error' | 'warning' | 'info' + +type HeadingItem = { + id: string + level: 1 | 2 | 3 | 4 | 5 | 6 + tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + text: string +} + +type HeadingIssue = { + severity: Severity + message: string +} + +function severityColor(severity: Severity): string { + if (severity === 'error') return '#dc2626' + if (severity === 'warning') return '#d97706' + return '#2563eb' +} + +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: 'warning', + message: `Multiple H1 headings detected (${h1Count}).`, + }) + } + + if (headings[0] && headings[0].level !== 1) { + issues.push({ + severity: 'warning', + 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: 'warning', + message: `${current.tag.toUpperCase()} is empty.`, + }) + } + if (index > 0) { + const previous = headings[index - 1]! + if (current.level - previous.level > 1) { + issues.push({ + severity: 'warning', + message: `Skipped heading level from ${previous.tag.toUpperCase()} to ${current.tag.toUpperCase()}.`, + }) + } + } + } + + if (issues.length === 0) { + issues.push({ + severity: 'info', + message: 'Heading hierarchy looks healthy.', + }) + } + + return issues +} + +export function HeadingStructurePreviewSection() { + const styles = useStyles() + const headings = extractHeadings() + const issues = validateHeadings(headings) + + return ( +
+ + Visualizes heading structure (`h1`-`h6`) in DOM order and highlights + common hierarchy issues. This section scans once when opened. + +
+
+ Total headings: {headings.length} +
+ 0} + fallback={ +
+ No headings found on this page. +
+ } + > +
    + + {(heading) => ( +
  • + {heading.tag.toUpperCase()} + {heading.text || '(empty heading)'} +
  • + )} +
    +
+
+
+ +
+
Structure issues
+
    + + {(issue) => ( +
  • + [{issue.severity}] {issue.message} +
  • + )} +
    +
+
+
+ ) +} diff --git a/packages/devtools/src/tabs/seo-tab/index.tsx b/packages/devtools/src/tabs/seo-tab/index.tsx index c00a97e9..b8667de9 100644 --- a/packages/devtools/src/tabs/seo-tab/index.tsx +++ b/packages/devtools/src/tabs/seo-tab/index.tsx @@ -3,8 +3,18 @@ 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 { CanonicalUrlPreviewSection } from './canonical-url-preview' -type SeoSubView = 'social-previews' | 'serp-preview' +type SeoSubView = + | 'social-previews' + | 'serp-preview' + | 'json-ld-preview' + | 'heading-structure' + | 'links-preview' + | 'canonical-url' export const SeoTab = () => { const [activeView, setActiveView] = @@ -28,6 +38,34 @@ export const SeoTab = () => { > SERP Preview + + + + @@ -36,6 +74,18 @@ export const SeoTab = () => { + + + + + + + + + + + + ) } 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..9e54714d --- /dev/null +++ b/packages/devtools/src/tabs/seo-tab/json-ld-preview.tsx @@ -0,0 +1,509 @@ +import { For, Show } from 'solid-js' +import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useStyles } from '../../styles/use-styles' + +type JsonLdValue = Record + +type IssueSeverity = 'error' | 'warning' | 'info' + +type ValidationIssue = { + severity: IssueSeverity + message: string +} + +type SchemaRule = { + required: Array + recommended: Array + optional: Array + allowed: 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'], + allowed: ['name', 'url', 'description', 'inLanguage', 'potentialAction'], + }, + Organization: { + required: ['@context', '@type', 'name', 'url'], + recommended: ['logo', 'sameAs'], + optional: ['description', 'email', 'telephone'], + allowed: [ + 'name', + 'url', + 'logo', + 'sameAs', + 'description', + 'email', + 'telephone', + ], + }, + Person: { + required: ['@context', '@type', 'name'], + recommended: ['url', 'sameAs'], + optional: ['image', 'jobTitle'], + allowed: ['name', 'url', 'sameAs', 'image', 'jobTitle', 'description'], + }, + Article: { + required: ['@context', '@type', 'headline', 'datePublished', 'author'], + recommended: ['dateModified', 'image', 'mainEntityOfPage'], + optional: ['description', 'publisher'], + allowed: [ + 'headline', + 'datePublished', + 'author', + 'dateModified', + 'image', + 'mainEntityOfPage', + 'description', + 'publisher', + ], + }, + Product: { + required: ['@context', '@type', 'name'], + recommended: ['image', 'description', 'offers'], + optional: ['brand', 'sku', 'aggregateRating', 'review'], + allowed: [ + 'name', + 'image', + 'description', + 'offers', + 'brand', + 'sku', + 'aggregateRating', + 'review', + ], + }, + BreadcrumbList: { + required: ['@context', '@type', 'itemListElement'], + recommended: [], + optional: ['name'], + allowed: ['itemListElement', 'name'], + }, + FAQPage: { + required: ['@context', '@type', 'mainEntity'], + recommended: [], + optional: [], + allowed: ['mainEntity'], + }, + LocalBusiness: { + required: ['@context', '@type', 'name', 'address'], + recommended: ['telephone', 'openingHours'], + optional: ['geo', 'priceRange', 'url', 'sameAs'], + allowed: [ + 'name', + 'address', + 'telephone', + 'openingHours', + 'geo', + 'priceRange', + 'url', + 'sameAs', + 'image', + ], + }, +} + +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 + }) +} + +function validateContext(entity: JsonLdValue): Array { + const context = entity['@context'] + if (!context) { + return [{ severity: 'error', message: 'Missing @context attribute.' }] + } + if (typeof context === 'string') { + if ( + !context.includes('schema.org') && + context !== 'https://schema.org' && + context !== 'http://schema.org' + ) { + 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.allowed, ...Array.from(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 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) { + 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}`, + }, + ], + } + } + }) +} + +function severityColor(severity: IssueSeverity): string { + if (severity === 'error') return '#dc2626' + if (severity === 'warning') return '#d97706' + return '#2563eb' +} + +function getJsonLdScore(entries: Array): number { + let errors = 0 + let warnings = 0 + + for (const entry of entries) { + for (const issue of entry.issues) { + if (issue.severity === 'error') errors += 1 + if (issue.severity === 'warning') warnings += 1 + } + } + + // Optional/info issues do not reduce score. + const penalty = errors * 20 + warnings * 10 + return Math.max(0, 100 - penalty) +} + +function scoreColor(score: number): string { + if (score >= 80) return '#16a34a' + if (score >= 50) return '#d97706' + return '#dc2626' +} + +function JsonLdBlock(props: { entry: JsonLdEntry; index: number }) { + const styles = useStyles() + + 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 + } + } + + return ( +
+
JSON-LD Block #{props.index + 1}
+
+ Detected types:{' '} + {props.entry.types.length > 0 ? props.entry.types.join(', ') : 'Unknown'} +
+ + + +
+        {props.entry.parsed
+          ? JSON.stringify(props.entry.parsed, null, 2)
+          : props.entry.raw || 'No JSON-LD content found.'}
+      
+ 0}> +
+ Validation issues: +
    + + {(issue) => ( +
  • + [{issue.severity}] {issue.message} +
  • + )} +
    +
+
+
+ +
+ No validation issues found for this block. +
+
+
+ ) +} + +export function JsonLdPreviewSection() { + const entries = analyzeJsonLdScripts() + const styles = useStyles() + const score = getJsonLdScore(entries) + const barColor = scoreColor(score) + 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, + ) + + return ( +
+ + Parses all {` + +
+ + + diff --git a/examples/react/seo/package.json b/examples/react/seo/package.json new file mode 100644 index 00000000..77fdb779 --- /dev/null +++ b/examples/react/seo/package.json @@ -0,0 +1,37 @@ +{ + "name": "@tanstack/react-devtools-seo-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3006", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/devtools-seo": "workspace:^", + "@tanstack/react-devtools": "^0.10.1", + "@tanstack/react-router": "^1.132.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tanstack/devtools-vite": "0.6.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/seo/src/App.tsx b/examples/react/seo/src/App.tsx new file mode 100644 index 00000000..489488c3 --- /dev/null +++ b/examples/react/seo/src/App.tsx @@ -0,0 +1,56 @@ +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' + +function AppShell() { + return ( +
+ + +
+ ) +} + +const rootRoute = createRootRoute({ + component: AppShell, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return

Home

+ }, +}) + +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () => { + return

About

+ }, +}) + +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + +const router = createRouter({ + routeTree, +}) + +export default function App() { + return +} diff --git a/examples/react/seo/src/index.tsx b/examples/react/seo/src/index.tsx new file mode 100644 index 00000000..419e4291 --- /dev/null +++ b/examples/react/seo/src/index.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { seoDevtoolsPlugin } from '@tanstack/devtools-seo/react' +import { TanStackDevtools } from '@tanstack/react-devtools' + +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + + , +) diff --git a/examples/react/seo/tsconfig.json b/examples/react/seo/tsconfig.json new file mode 100644 index 00000000..a97ff8c1 --- /dev/null +++ b/examples/react/seo/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/react/seo/vite.config.ts b/examples/react/seo/vite.config.ts new file mode 100644 index 00000000..337335d2 --- /dev/null +++ b/examples/react/seo/vite.config.ts @@ -0,0 +1,7 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import { devtools } from '@tanstack/devtools-vite' + +export default defineConfig({ + plugins: [devtools(), react()], +}) diff --git a/package.json b/package.json index bc80453b..883ea377 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "size-limit": [ { "path": "packages/devtools/dist/index.js", - "limit": "69 KB" + "limit": "60 KB" }, { "path": "packages/event-bus-client/dist/esm/plugin.js", diff --git a/packages/devtools-seo/package.json b/packages/devtools-seo/package.json index 54c332d5..d5406419 100644 --- a/packages/devtools-seo/package.json +++ b/packages/devtools-seo/package.json @@ -54,6 +54,7 @@ "build": "vite build" }, "dependencies": { + "@tanstack/devtools": "workspace:*", "@tanstack/devtools-ui": "workspace:*", "@tanstack/devtools-utils": "workspace:*", "goober": "^2.1.16", diff --git a/packages/devtools-seo/src/core.tsx b/packages/devtools-seo/src/core.tsx new file mode 100644 index 00000000..0016326b --- /dev/null +++ b/packages/devtools-seo/src/core.tsx @@ -0,0 +1,9 @@ +/** @jsxImportSource solid-js */ + +import { constructCoreClass } from '@tanstack/devtools-utils/solid' + +const [SeoDevtoolsCore, SeoDevtoolsCoreNoOp] = constructCoreClass( + () => import('./solid-panel'), +) + +export { SeoDevtoolsCore, SeoDevtoolsCoreNoOp } diff --git a/packages/devtools-seo/src/heading-structure-preview.tsx b/packages/devtools-seo/src/heading-structure-preview.tsx index e7c6818e..a14ba7b8 100644 --- a/packages/devtools-seo/src/heading-structure-preview.tsx +++ b/packages/devtools-seo/src/heading-structure-preview.tsx @@ -1,7 +1,8 @@ -import { For, Show } from 'solid-js' +import { For, Show, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { useSeoStyles } from './use-seo-styles' import { pickSeverityClass } from './seo-severity' +import { useLocationChanges } from './hooks/use-location-changes' import type { SeoSeverity } from './seo-severity' import type { SeoSectionSummary } from './seo-section-summary' @@ -140,8 +141,18 @@ function headingTagClass( export function HeadingStructurePreviewSection() { const styles = useSeoStyles() - const headings = extractHeadings() - const issues = validateHeadings(headings) + const [tick, setTick] = createSignal(0) + + useLocationChanges(() => { + setTick((t) => t + 1) + }) + + const headings = createMemo(() => { + void tick() + return extractHeadings() + }) + + const issues = createMemo(() => validateHeadings(headings())) const s = styles() const issueBulletClass = (sev: SeoSeverity) => @@ -169,11 +180,11 @@ export function HeadingStructurePreviewSection() {
Heading tree
- {headings.length} heading{headings.length === 1 ? '' : 's'} + {headings().length} heading{headings().length === 1 ? '' : 's'}
0} + when={headings().length > 0} fallback={
No headings found on this page. @@ -181,7 +192,7 @@ export function HeadingStructurePreviewSection() { } >
    - + {(heading) => (
- 0}> + 0}>
Structure issues
    - + {(issue) => (
  • โ— diff --git a/packages/devtools-seo/src/links-preview.tsx b/packages/devtools-seo/src/links-preview.tsx index 73f676f6..54de2a8f 100644 --- a/packages/devtools-seo/src/links-preview.tsx +++ b/packages/devtools-seo/src/links-preview.tsx @@ -1,8 +1,9 @@ -import { For, Show, createSignal } from 'solid-js' +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { useSeoStyles } from './use-seo-styles' import { countBySeverity } from './seo-section-summary' import { pickSeverityClass } from './seo-severity' +import { useLocationChanges } from './hooks/use-location-changes' import type { SeoSectionSummary } from './seo-section-summary' import type { SeoSeverity } from './seo-severity' @@ -193,23 +194,49 @@ function linkKindBadgeClass( export function LinksPreviewSection() { const styles = useSeoStyles() - const links = analyzeLinks() - const linksForReport = sortLinksForDisplay(links) - const groups = groupLinksByKindOrdered(linksForReport) + const [tick, setTick] = createSignal(0) + + useLocationChanges(() => { + setTick((t) => t + 1) + }) + + const links = createMemo(() => { + void tick() + return analyzeLinks() + }) + + const linksForReport = createMemo(() => sortLinksForDisplay(links())) + const groups = createMemo(() => groupLinksByKindOrdered(linksForReport())) const [openKinds, setOpenKinds] = createSignal>( - new Set(groups.map((g) => g.kind)), + new Set(groups().map((g) => g.kind)), + ) + + createEffect(() => { + const kinds = groups().map((g) => g.kind) + if (openKinds().size === 0 && kinds.length > 0) { + setOpenKinds(new Set(kinds)) + } + }) + + useLocationChanges(() => { + setOpenKinds(new Set(groups().map((g) => g.kind))) + }) + + const issueCount = createMemo(() => + links().reduce((count, row) => count + row.issues.length, 0), ) - const issueCount = links.reduce((count, row) => count + row.issues.length, 0) - - const counts = links.reduce( - (acc, row) => { - acc[row.kind] += 1 - return acc - }, - { internal: 0, external: 0, 'non-web': 0, invalid: 0 } as Record< - LinkKind, - number - >, + + const counts = createMemo(() => + links().reduce( + (acc, row) => { + acc[row.kind] += 1 + return acc + }, + { internal: 0, external: 0, 'non-web': 0, invalid: 0 } as Record< + LinkKind, + number + >, + ), ) const s = styles() @@ -240,34 +267,34 @@ export function LinksPreviewSection() {
    Links summary
    - {links.length} total + {links().length} total - {counts.internal} internal + {counts().internal} internal - {counts.external} external + {counts().external} external - 0}> + 0}> - {counts['non-web']} non-web + {counts()['non-web']} non-web - 0}> + 0}> - {counts.invalid} invalid + {counts().invalid} invalid - 0}> + 0}> - {issueCount} issue{issueCount === 1 ? '' : 's'} + {issueCount()} issue{issueCount() === 1 ? '' : 's'}
0} + when={links().length > 0} fallback={
No links found on this page. @@ -277,7 +304,7 @@ export function LinksPreviewSection() {
Links report
    - + {(group) => { const expanded = () => openKinds().has(group.kind) return ( diff --git a/packages/devtools-seo/src/react/SeoDevtools.tsx b/packages/devtools-seo/src/react/SeoDevtools.tsx index 2dda9e7e..a34db45c 100644 --- a/packages/devtools-seo/src/react/SeoDevtools.tsx +++ b/packages/devtools-seo/src/react/SeoDevtools.tsx @@ -1,44 +1,11 @@ -'use client' - -import { Fragment, createElement, useEffect, useRef } from 'react' -import { render } from 'solid-js/web' -import { ThemeContextProvider } from '@tanstack/devtools-ui' -import { SeoTab } from '../seo-tab' +import { createReactPanel } from '@tanstack/devtools-utils/react' +import { SeoDevtoolsCore } from '../core' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react' export interface SeoDevtoolsReactInit extends DevtoolsPanelProps {} -function SeoDevtoolsPanel(props: SeoDevtoolsReactInit) { - const rootRef = useRef(null) - - useEffect(() => { - if (!rootRef.current) { - return - } - - const dispose = render( - () => ( - - - - ), - rootRef.current, - ) - - return () => { - dispose() - } - }, [props]) - - return createElement('div', { - ref: rootRef, - style: { height: '100%' }, - }) -} - -function SeoDevtoolsPanelNoOp() { - return createElement(Fragment) -} +const [SeoDevtoolsPanel, SeoDevtoolsPanelNoOp] = + createReactPanel(SeoDevtoolsCore) export { SeoDevtoolsPanel, SeoDevtoolsPanelNoOp } diff --git a/packages/devtools-seo/src/solid-panel.tsx b/packages/devtools-seo/src/solid-panel.tsx new file mode 100644 index 00000000..595a66f6 --- /dev/null +++ b/packages/devtools-seo/src/solid-panel.tsx @@ -0,0 +1,19 @@ +/** @jsxImportSource solid-js */ + +import { ThemeContextProvider } from '@tanstack/devtools-ui' +import { SeoTab } from './seo-tab' + +type SeoPluginPanelProps = { + theme: 'light' | 'dark' + devtoolsOpen: boolean +} + +export default function SeoDevtoolsSolidPanel(props: SeoPluginPanelProps) { + void props.devtoolsOpen + + return ( + + + + ) +} From 3614539dcaebeca6144d2dffda5cab150a9af0bd Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Thu, 9 Apr 2026 23:00:23 +0300 Subject: [PATCH 35/54] feat(devtools): add devtools DOM filter and integrate it into SEO components --- examples/react/seo/index.html | 2 +- examples/react/seo/src/index.tsx | 5 ++--- packages/devtools-seo/src/devtools-dom-filter.ts | 15 +++++++++++++++ .../src/heading-structure-preview.tsx | 3 ++- packages/devtools-seo/src/json-ld-preview.tsx | 3 ++- packages/devtools-seo/src/links-preview.tsx | 9 ++------- 6 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 packages/devtools-seo/src/devtools-dom-filter.ts diff --git a/examples/react/seo/index.html b/examples/react/seo/index.html index 04a811a2..ebe85132 100644 --- a/examples/react/seo/index.html +++ b/examples/react/seo/index.html @@ -21,7 +21,7 @@ diff --git a/examples/react/seo/src/index.tsx b/examples/react/seo/src/index.tsx index 419e4291..dbaab1ae 100644 --- a/examples/react/seo/src/index.tsx +++ b/examples/react/seo/src/index.tsx @@ -1,4 +1,3 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { seoDevtoolsPlugin } from '@tanstack/devtools-seo/react' import { TanStackDevtools } from '@tanstack/react-devtools' @@ -6,8 +5,8 @@ import { TanStackDevtools } from '@tanstack/react-devtools' import App from './App' createRoot(document.getElementById('root')!).render( - + <> - , + , ) diff --git a/packages/devtools-seo/src/devtools-dom-filter.ts b/packages/devtools-seo/src/devtools-dom-filter.ts new file mode 100644 index 00000000..54b07f45 --- /dev/null +++ b/packages/devtools-seo/src/devtools-dom-filter.ts @@ -0,0 +1,15 @@ +const DEVTOOLS_ROOT_SELECTORS = [ + '#tanstack_devtools', + '[data-testid="tanstack_devtools"]', + '[data-devtools-root]', + '[id^="plugin-container-"]', + '[id^="plugin-title-container-"]', +] as const + +export function isInsideDevtools(node: Element | null): boolean { + if (!node) { + return false + } + + return DEVTOOLS_ROOT_SELECTORS.some((selector) => !!node.closest(selector)) +} diff --git a/packages/devtools-seo/src/heading-structure-preview.tsx b/packages/devtools-seo/src/heading-structure-preview.tsx index a14ba7b8..625ad0e0 100644 --- a/packages/devtools-seo/src/heading-structure-preview.tsx +++ b/packages/devtools-seo/src/heading-structure-preview.tsx @@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { useSeoStyles } from './use-seo-styles' import { pickSeverityClass } from './seo-severity' +import { isInsideDevtools } from './devtools-dom-filter' import { useLocationChanges } from './hooks/use-location-changes' import type { SeoSeverity } from './seo-severity' import type { SeoSectionSummary } from './seo-section-summary' @@ -21,7 +22,7 @@ type HeadingIssue = { function extractHeadings(): Array { const nodes = Array.from( document.body.querySelectorAll('h1,h2,h3,h4,h5,h6'), - ) + ).filter((node) => !isInsideDevtools(node)) return nodes.map((node, index) => { const tag = node.tagName.toLowerCase() as HeadingItem['tag'] diff --git a/packages/devtools-seo/src/json-ld-preview.tsx b/packages/devtools-seo/src/json-ld-preview.tsx index b906027f..8a32a314 100644 --- a/packages/devtools-seo/src/json-ld-preview.tsx +++ b/packages/devtools-seo/src/json-ld-preview.tsx @@ -1,5 +1,6 @@ import { For, Show } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { isInsideDevtools } from './devtools-dom-filter' import { useSeoStyles } from './use-seo-styles' import { pickSeverityClass, seoHealthTier } from './seo-severity' import type { SeoSeverity } from './seo-severity' @@ -327,7 +328,7 @@ function analyzeJsonLdScripts(): Array { document.querySelectorAll( 'script[type="application/ld+json"]', ), - ) + ).filter((script) => !isInsideDevtools(script)) return scripts.map((script, index) => { const raw = script.textContent.trim() || '' diff --git a/packages/devtools-seo/src/links-preview.tsx b/packages/devtools-seo/src/links-preview.tsx index 54de2a8f..98332527 100644 --- a/packages/devtools-seo/src/links-preview.tsx +++ b/packages/devtools-seo/src/links-preview.tsx @@ -1,5 +1,6 @@ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { isInsideDevtools } from './devtools-dom-filter' import { useSeoStyles } from './use-seo-styles' import { countBySeverity } from './seo-section-summary' import { pickSeverityClass } from './seo-severity' @@ -106,13 +107,7 @@ function analyzeLinks(): Array { const anchors = Array.from( document.body.querySelectorAll('a[href]'), ) - return anchors - .filter( - (anchor) => - !anchor.closest('[data-testid="tanstack_devtools"]') && - !anchor.closest('[data-devtools-root]'), - ) - .map(classifyLink) + return anchors.filter((anchor) => !isInsideDevtools(anchor)).map(classifyLink) } /** Display order in the links report: internal, external, non-web, then invalid. */ From f9f82d10a1dfc2f06c59115f348f8a1c9e70d283 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Thu, 9 Apr 2026 23:06:53 +0300 Subject: [PATCH 36/54] feat(icons): add emblem-light SVG for SEO components --- examples/react/seo/public/emblem-light.svg | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 examples/react/seo/public/emblem-light.svg diff --git a/examples/react/seo/public/emblem-light.svg b/examples/react/seo/public/emblem-light.svg new file mode 100644 index 00000000..a58e69ad --- /dev/null +++ b/examples/react/seo/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file From 11c25b81a00ca2c75ca422afad0981e1eac72ae1 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Thu, 9 Apr 2026 23:12:49 +0300 Subject: [PATCH 37/54] feat(seo): add weight property to SeoSectionSummary and update score calculation --- packages/devtools-seo/src/links-preview.tsx | 37 +++++++++++++++---- packages/devtools-seo/src/seo-overview.tsx | 1 + .../devtools-seo/src/seo-section-summary.ts | 23 +++++++++--- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/devtools-seo/src/links-preview.tsx b/packages/devtools-seo/src/links-preview.tsx index 98332527..af7c700d 100644 --- a/packages/devtools-seo/src/links-preview.tsx +++ b/packages/devtools-seo/src/links-preview.tsx @@ -202,19 +202,40 @@ export function LinksPreviewSection() { const linksForReport = createMemo(() => sortLinksForDisplay(links())) const groups = createMemo(() => groupLinksByKindOrdered(linksForReport())) - const [openKinds, setOpenKinds] = createSignal>( - new Set(groups().map((g) => g.kind)), - ) + const [openKinds, setOpenKinds] = createSignal>(new Set()) + const [knownGroupKey, setKnownGroupKey] = createSignal('') createEffect(() => { const kinds = groups().map((g) => g.kind) - if (openKinds().size === 0 && kinds.length > 0) { - setOpenKinds(new Set(kinds)) + const nextKey = kinds.join('|') + + if (nextKey === knownGroupKey()) { + return } - }) - useLocationChanges(() => { - setOpenKinds(new Set(groups().map((g) => g.kind))) + setOpenKinds((prev) => { + if (knownGroupKey() === '') { + return new Set(kinds) + } + + const next = new Set() + + for (const kind of kinds) { + if (prev.has(kind)) { + next.add(kind) + } + } + + for (const kind of kinds) { + if (!prev.has(kind)) { + next.add(kind) + } + } + + return next + }) + + setKnownGroupKey(nextKey) }) const issueCount = createMemo(() => diff --git a/packages/devtools-seo/src/seo-overview.tsx b/packages/devtools-seo/src/seo-overview.tsx index a2026307..79304805 100644 --- a/packages/devtools-seo/src/seo-overview.tsx +++ b/packages/devtools-seo/src/seo-overview.tsx @@ -157,6 +157,7 @@ export function SeoOverviewSection(props: { const canonicalSummary: SeoSectionSummary = { issues: canonical.issues, hint: canonical.indexable ? 'Indexable' : 'Noindex', + weight: 0.55, } const health = aggregateSeoHealth([ diff --git a/packages/devtools-seo/src/seo-section-summary.ts b/packages/devtools-seo/src/seo-section-summary.ts index b26a4d54..44d9b24e 100644 --- a/packages/devtools-seo/src/seo-section-summary.ts +++ b/packages/devtools-seo/src/seo-section-summary.ts @@ -19,6 +19,8 @@ export type SeoIssueCounts = { export type SeoSectionSummary = { issues: Array hint?: string + /** Relative influence on the overall overview score. Default: 1 */ + weight?: number /** When `issues` is capped, total issues before capping. */ issueCount?: number /** Per-severity totals before any display cap is applied. */ @@ -100,12 +102,21 @@ export function aggregateSeoHealth(summaries: Array): { }, { error: 0, warning: 0, info: 0 }, ) - const penalty = Math.min( - 100, - counts.error * 22 + counts.warning * 9 + counts.info * 2, - ) - const score = Math.max(0, 100 - penalty) + const weightedScore = + summaries.reduce((total, summary) => { + const weight = Math.max(0, summary.weight ?? 1) + return total + sectionHealthScore(summary) * weight + }, 0) / + Math.max( + 1, + summaries.reduce( + (total, summary) => total + Math.max(0, summary.weight ?? 1), + 0, + ), + ) + + const score = Math.max(0, Math.min(100, Math.round(weightedScore))) const label: 'Good' | 'Fair' | 'Poor' = - counts.error > 0 ? 'Poor' : counts.warning > 0 ? 'Fair' : 'Good' + score >= 80 ? 'Good' : score >= 50 ? 'Fair' : 'Poor' return { score, label, counts } } From 4e6a3a0d58bf324d564d3ec02f7ba5678552f83e Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Thu, 9 Apr 2026 23:13:36 +0300 Subject: [PATCH 38/54] feat(seo): release first SEO devtools plugin with React support and enhanced features --- .changeset/puny-games-bow.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/puny-games-bow.md b/.changeset/puny-games-bow.md index e9d75df2..4094f93b 100644 --- a/.changeset/puny-games-bow.md +++ b/.changeset/puny-games-bow.md @@ -1,5 +1,5 @@ --- -'@tanstack/devtools': patch +'@tanstack/devtools-seo': 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. +Add the first SEO devtools plugin release with React support, live SERP and social previews, JSON-LD inspection, heading and link analysis, and an overview score. The plugin now ignores devtools-owned DOM, refreshes key sections on route changes, and uses a more balanced overall health weighting. From cf234dddc14e3a2207efcc67831d5b37d18d0c28 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban Date: Thu, 9 Apr 2026 23:24:26 +0300 Subject: [PATCH 39/54] refactor(devtools-seo): clean up SeoDevtoolsCore export and remove unused functions --- packages/devtools-seo/package.json | 1 - packages/devtools-seo/src/core.tsx | 6 +- packages/devtools-seo/src/use-seo-styles.ts | 2 +- .../devtools/src/hooks/use-head-changes.ts | 110 ------------------ .../src/hooks/use-location-changes.ts | 69 ----------- 5 files changed, 3 insertions(+), 185 deletions(-) delete mode 100644 packages/devtools/src/hooks/use-head-changes.ts delete mode 100644 packages/devtools/src/hooks/use-location-changes.ts diff --git a/packages/devtools-seo/package.json b/packages/devtools-seo/package.json index d5406419..54c332d5 100644 --- a/packages/devtools-seo/package.json +++ b/packages/devtools-seo/package.json @@ -54,7 +54,6 @@ "build": "vite build" }, "dependencies": { - "@tanstack/devtools": "workspace:*", "@tanstack/devtools-ui": "workspace:*", "@tanstack/devtools-utils": "workspace:*", "goober": "^2.1.16", diff --git a/packages/devtools-seo/src/core.tsx b/packages/devtools-seo/src/core.tsx index 0016326b..340159c1 100644 --- a/packages/devtools-seo/src/core.tsx +++ b/packages/devtools-seo/src/core.tsx @@ -2,8 +2,6 @@ import { constructCoreClass } from '@tanstack/devtools-utils/solid' -const [SeoDevtoolsCore, SeoDevtoolsCoreNoOp] = constructCoreClass( - () => import('./solid-panel'), -) +const [SeoDevtoolsCore] = constructCoreClass(() => import('./solid-panel')) -export { SeoDevtoolsCore, SeoDevtoolsCoreNoOp } +export { SeoDevtoolsCore } diff --git a/packages/devtools-seo/src/use-seo-styles.ts b/packages/devtools-seo/src/use-seo-styles.ts index b692ccd1..064d5896 100644 --- a/packages/devtools-seo/src/use-seo-styles.ts +++ b/packages/devtools-seo/src/use-seo-styles.ts @@ -5,7 +5,7 @@ import { tokens } from './tokens' import type { TanStackDevtoolsTheme } from '@tanstack/devtools-ui' -export function createSeoStyles(theme: TanStackDevtoolsTheme) { +function createSeoStyles(theme: TanStackDevtoolsTheme) { const { colors, font } = tokens const { fontFamily } = font const css = goober.css diff --git a/packages/devtools/src/hooks/use-head-changes.ts b/packages/devtools/src/hooks/use-head-changes.ts deleted file mode 100644 index 777460f5..00000000 --- a/packages/devtools/src/hooks/use-head-changes.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { onCleanup, onMount } from 'solid-js' - -type HeadChange = - | { kind: 'added'; node: Node } - | { kind: 'removed'; node: Node } - | { - kind: 'attr' - target: Element - name: string | null - oldValue: string | null - } - | { kind: 'title'; title: string } - -type UseHeadChangesOptions = { - /** - * Observe attribute changes on elements inside - * Default: true - */ - attributes?: boolean - /** - * Observe added/removed nodes in - * Default: true - */ - childList?: boolean - /** - * Observe descendants of - * Default: true - */ - subtree?: boolean - /** - * Also observe changes explicitly - * Default: true - */ - observeTitle?: boolean -} - -export function useHeadChanges( - onChange: (change: HeadChange, raw?: MutationRecord) => void, - opts: UseHeadChangesOptions = {}, -) { - const { - attributes = true, - childList = true, - subtree = true, - observeTitle = true, - } = opts - - onMount(() => { - const headObserver = new MutationObserver((mutations) => { - for (const m of mutations) { - if (m.type === 'childList') { - m.addedNodes.forEach((node) => onChange({ kind: 'added', node }, m)) - m.removedNodes.forEach((node) => - onChange({ kind: 'removed', node }, m), - ) - } else if (m.type === 'attributes') { - const el = m.target as Element - onChange( - { - kind: 'attr', - target: el, - name: m.attributeName, - oldValue: m.oldValue ?? null, - }, - m, - ) - } else { - // If someone mutates a Text node inside <title>, surface it as a title change. - const isInTitle = - m.target.parentNode && - (m.target.parentNode as Element).tagName.toLowerCase() === 'title' - if (isInTitle) onChange({ kind: 'title', title: document.title }, m) - } - } - }) - - headObserver.observe(document.head, { - childList, - attributes, - subtree, - attributeOldValue: attributes, - characterData: true, // helps catch <title> text node edits - characterDataOldValue: false, - }) - - // Extra explicit observer for <title>, since `document.title = "..."` - // may not always bubble as a head mutation in all setups. - let titleObserver: MutationObserver | undefined - if (observeTitle) { - const titleEl = - document.head.querySelector('title') || - // create a <title> if missing so future changes are observable - document.head.appendChild(document.createElement('title')) - - titleObserver = new MutationObserver(() => { - onChange({ kind: 'title', title: document.title }) - }) - titleObserver.observe(titleEl, { - childList: true, - characterData: true, - subtree: true, - }) - } - - onCleanup(() => { - headObserver.disconnect() - titleObserver?.disconnect() - }) - }) -} diff --git a/packages/devtools/src/hooks/use-location-changes.ts b/packages/devtools/src/hooks/use-location-changes.ts deleted file mode 100644 index a00301fb..00000000 --- a/packages/devtools/src/hooks/use-location-changes.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { onCleanup, onMount } from 'solid-js' - -const LOCATION_CHANGE_EVENT = 'tanstack-devtools:locationchange' - -type LocationChangeListener = () => void - -const listeners = new Set<LocationChangeListener>() - -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?.() - }) - }) -} From 800eb0fe75589b8f40e3594bd608b4b4fd150483 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:27:51 +0000 Subject: [PATCH 40/54] ci: apply automated fixes --- packages/devtools-seo/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/devtools-seo/src/index.ts b/packages/devtools-seo/src/index.ts index 886647cf..67e62992 100644 --- a/packages/devtools-seo/src/index.ts +++ b/packages/devtools-seo/src/index.ts @@ -1,2 +1,6 @@ export { SeoTab } from './seo-tab' -export type { SeoDetailView, SeoIssue, SeoSectionSummary } from './seo-section-summary' +export type { + SeoDetailView, + SeoIssue, + SeoSectionSummary, +} from './seo-section-summary' From 0a3ac720144b92d3fb4f69fee57990cc011db765 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Thu, 9 Apr 2026 23:36:41 +0300 Subject: [PATCH 41/54] chore(deps): update @tanstack/react-devtools to version ^0.10.2 --- examples/react/seo/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/react/seo/package.json b/examples/react/seo/package.json index 77fdb779..96651397 100644 --- a/examples/react/seo/package.json +++ b/examples/react/seo/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/devtools-seo": "workspace:^", - "@tanstack/react-devtools": "^0.10.1", + "@tanstack/react-devtools": "^0.10.2", "@tanstack/react-router": "^1.132.0", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18a23b6..d5aad3e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,7 +529,7 @@ importers: specifier: workspace:^ version: link:../../../packages/devtools-seo '@tanstack/react-devtools': - specifier: ^0.10.1 + specifier: ^0.10.2 version: link:../../../packages/react-devtools '@tanstack/react-router': specifier: ^1.132.0 From 9cc6c8dbf27a36c2cd1c7d0c02ccc1a9c8fb5c47 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Thu, 9 Apr 2026 23:57:16 +0300 Subject: [PATCH 42/54] feat(seo): enhance SEO functionality with dynamic updates and improved validation --- .../src/heading-structure-preview.tsx | 35 +++++++++-- .../src/hooks/use-location-changes.ts | 19 ++++-- packages/devtools-seo/src/json-ld-preview.tsx | 60 +++++++++---------- packages/devtools-seo/src/seo-tab.tsx | 18 +++++- packages/devtools-seo/src/serp-preview.tsx | 5 ++ 5 files changed, 94 insertions(+), 43 deletions(-) diff --git a/packages/devtools-seo/src/heading-structure-preview.tsx b/packages/devtools-seo/src/heading-structure-preview.tsx index 625ad0e0..a56631e6 100644 --- a/packages/devtools-seo/src/heading-structure-preview.tsx +++ b/packages/devtools-seo/src/heading-structure-preview.tsx @@ -1,4 +1,11 @@ -import { For, Show, createMemo, createSignal } from 'solid-js' +import { + For, + Show, + createMemo, + createSignal, + onCleanup, + onMount, +} from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { useSeoStyles } from './use-seo-styles' import { pickSeverityClass } from './seo-severity' @@ -143,9 +150,29 @@ function headingTagClass( export function HeadingStructurePreviewSection() { const styles = useSeoStyles() const [tick, setTick] = createSignal(0) + const rescan = () => setTick((t) => t + 1) - useLocationChanges(() => { - setTick((t) => t + 1) + useLocationChanges(rescan) + + onMount(() => { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + const target = mutation.target + if (target instanceof Element && isInsideDevtools(target)) continue + if (target.parentElement && isInsideDevtools(target.parentElement)) + continue + rescan() + break + } + }) + + observer.observe(document.body, { + childList: true, + characterData: true, + subtree: true, + }) + + onCleanup(() => observer.disconnect()) }) const headings = createMemo(() => { @@ -174,7 +201,7 @@ export function HeadingStructurePreviewSection() { <Section> <SectionDescription> Visualizes heading structure (`h1`-`h6`) in DOM order and highlights - common hierarchy issues. This section scans once when opened. + common hierarchy issues. This section refreshes as the page changes. </SectionDescription> <div class={s.serpPreviewBlock}> diff --git a/packages/devtools-seo/src/hooks/use-location-changes.ts b/packages/devtools-seo/src/hooks/use-location-changes.ts index a00301fb..f5fd6540 100644 --- a/packages/devtools-seo/src/hooks/use-location-changes.ts +++ b/packages/devtools-seo/src/hooks/use-location-changes.ts @@ -32,23 +32,30 @@ function observeLocationChanges() { emitLocationChangeIfNeeded() } - window.history.pushState = function (...args) { - originalPushState.apply(this, args) + function patchedPushState(...args: Parameters<History['pushState']>) { + originalPushState.apply(window.history, args) dispatchLocationChangeEvent() } - window.history.replaceState = function (...args) { - originalReplaceState.apply(this, args) + function patchedReplaceState(...args: Parameters<History['replaceState']>) { + originalReplaceState.apply(window.history, args) dispatchLocationChangeEvent() } + window.history.pushState = patchedPushState + window.history.replaceState = patchedReplaceState + window.addEventListener('popstate', handleLocationSignal) window.addEventListener('hashchange', handleLocationSignal) window.addEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal) teardownLocationObservation = () => { - window.history.pushState = originalPushState - window.history.replaceState = originalReplaceState + if (window.history.pushState === patchedPushState) { + window.history.pushState = originalPushState + } + if (window.history.replaceState === patchedReplaceState) { + window.history.replaceState = originalReplaceState + } window.removeEventListener('popstate', handleLocationSignal) window.removeEventListener('hashchange', handleLocationSignal) window.removeEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal) diff --git a/packages/devtools-seo/src/json-ld-preview.tsx b/packages/devtools-seo/src/json-ld-preview.tsx index 8a32a314..cf4ad8f8 100644 --- a/packages/devtools-seo/src/json-ld-preview.tsx +++ b/packages/devtools-seo/src/json-ld-preview.tsx @@ -4,6 +4,7 @@ import { isInsideDevtools } from './devtools-dom-filter' import { useSeoStyles } from './use-seo-styles' import { pickSeverityClass, seoHealthTier } from './seo-severity' import type { SeoSeverity } from './seo-severity' +import { sectionHealthScore } from './seo-section-summary' import type { SeoSectionSummary } from './seo-section-summary' type JsonLdValue = Record<string, unknown> @@ -84,8 +85,6 @@ function entryUsesOnlySupportedTypes(entry: JsonLdEntry): boolean { 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) } @@ -134,9 +133,32 @@ const VALID_SCHEMA_CONTEXTS = new Set([ function validateContext(entity: JsonLdValue): Array<ValidationIssue> { const context = entity['@context'] - if (!context) { + if (context === undefined) { return [{ severity: 'error', message: 'Missing @context attribute.' }] } + if (context === null || isRecord(context)) { + return [] + } + if (Array.isArray(context)) { + const stringContexts = context.filter( + (value): value is string => typeof value === 'string', + ) + + if ( + stringContexts.length > 0 && + !stringContexts.some((value) => VALID_SCHEMA_CONTEXTS.has(value)) + ) { + return [ + { + severity: 'error', + message: + 'Array @context is missing a schema.org context URL in its string entries.', + }, + ] + } + + return [] + } if (typeof context === 'string') { if (!VALID_SCHEMA_CONTEXTS.has(context)) { return [ @@ -151,7 +173,8 @@ function validateContext(entity: JsonLdValue): Array<ValidationIssue> { return [ { severity: 'error', - message: 'Invalid @context type. Expected a string schema.org URL.', + message: + 'Invalid @context type. Expected a schema.org URL, object, array, or null.', }, ] } @@ -202,20 +225,6 @@ function validateEntityByType( }) } - 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 } @@ -455,20 +464,7 @@ function sumMissingSchemaFieldCounts(entries: Array<JsonLdEntry>): { * small penalty so optional-field gaps match how the SEO overview weights them. */ function getJsonLdScore(entries: Array<JsonLdEntry>): 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) + return sectionHealthScore(entries.flatMap((entry) => entry.issues)) } function JsonLdEntityPreviewCard(props: { entity: JsonLdValue }) { diff --git a/packages/devtools-seo/src/seo-tab.tsx b/packages/devtools-seo/src/seo-tab.tsx index bba8f295..745be028 100644 --- a/packages/devtools-seo/src/seo-tab.tsx +++ b/packages/devtools-seo/src/seo-tab.tsx @@ -17,9 +17,15 @@ export const SeoTab = () => { return ( <MainPanel withPadding> - <nav class={styles().seoSubNav} aria-label="SEO sections"> + <nav + class={styles().seoSubNav} + aria-label="SEO sections" + role="tablist" + > <button type="button" + role="tab" + aria-selected={activeView() === 'overview'} class={`${styles().seoSubNavLabel} ${activeView() === 'overview' ? styles().seoSubNavLabelActive : ''}`} onClick={() => setActiveView('overview')} > @@ -27,6 +33,8 @@ export const SeoTab = () => { </button> <button type="button" + role="tab" + aria-selected={activeView() === 'heading-structure'} class={`${styles().seoSubNavLabel} ${activeView() === 'heading-structure' ? styles().seoSubNavLabelActive : ''}`} onClick={() => setActiveView('heading-structure')} > @@ -34,6 +42,8 @@ export const SeoTab = () => { </button> <button type="button" + role="tab" + aria-selected={activeView() === 'links-preview'} class={`${styles().seoSubNavLabel} ${activeView() === 'links-preview' ? styles().seoSubNavLabelActive : ''}`} onClick={() => setActiveView('links-preview')} > @@ -41,6 +51,8 @@ export const SeoTab = () => { </button> <button type="button" + role="tab" + aria-selected={activeView() === 'social-previews'} class={`${styles().seoSubNavLabel} ${activeView() === 'social-previews' ? styles().seoSubNavLabelActive : ''}`} onClick={() => setActiveView('social-previews')} > @@ -48,6 +60,8 @@ export const SeoTab = () => { </button> <button type="button" + role="tab" + aria-selected={activeView() === 'serp-preview'} class={`${styles().seoSubNavLabel} ${activeView() === 'serp-preview' ? styles().seoSubNavLabelActive : ''}`} onClick={() => setActiveView('serp-preview')} > @@ -55,6 +69,8 @@ export const SeoTab = () => { </button> <button type="button" + role="tab" + aria-selected={activeView() === 'json-ld-preview'} class={`${styles().seoSubNavLabel} ${activeView() === 'json-ld-preview' ? styles().seoSubNavLabelActive : ''}`} onClick={() => setActiveView('json-ld-preview')} > diff --git a/packages/devtools-seo/src/serp-preview.tsx b/packages/devtools-seo/src/serp-preview.tsx index 2d4633e7..2010e542 100644 --- a/packages/devtools-seo/src/serp-preview.tsx +++ b/packages/devtools-seo/src/serp-preview.tsx @@ -1,6 +1,7 @@ import { Section, SectionDescription } from '@tanstack/devtools-ui' import { For, createMemo, createSignal } from 'solid-js' import { useHeadChanges } from './hooks/use-head-changes' +import { useLocationChanges } from './hooks/use-location-changes' import { tokens } from './tokens' import { useSeoStyles } from './use-seo-styles' import type { SeoIssue, SeoSectionSummary } from './seo-section-summary' @@ -529,6 +530,10 @@ export function SerpPreviewSection() { setSerp(getSerpFromHead()) }) + useLocationChanges(() => { + setSerp(getSerpFromHead()) + }) + const serpPreviewState = createMemo(() => { return getSerpPreviewState(serp()) }) From 7e2d9ae37fa972057dfc4d8931eabc48c15aa016 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 11:17:47 +0300 Subject: [PATCH 43/54] feat(tests): implement TestClientEventBus for event handling in tests --- packages/devtools-seo/src/json-ld-preview.tsx | 2 +- packages/event-bus-client/package.json | 3 -- packages/event-bus-client/tests/index.test.ts | 31 +++++++++++++++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/devtools-seo/src/json-ld-preview.tsx b/packages/devtools-seo/src/json-ld-preview.tsx index cf4ad8f8..e99b7312 100644 --- a/packages/devtools-seo/src/json-ld-preview.tsx +++ b/packages/devtools-seo/src/json-ld-preview.tsx @@ -1,10 +1,10 @@ import { For, Show } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { isInsideDevtools } from './devtools-dom-filter' +import { sectionHealthScore } from './seo-section-summary' import { useSeoStyles } from './use-seo-styles' import { pickSeverityClass, seoHealthTier } from './seo-severity' import type { SeoSeverity } from './seo-severity' -import { sectionHealthScore } from './seo-section-summary' import type { SeoSectionSummary } from './seo-section-summary' type JsonLdValue = Record<string, unknown> diff --git a/packages/event-bus-client/package.json b/packages/event-bus-client/package.json index 409d0cc9..73d55a5a 100644 --- a/packages/event-bus-client/package.json +++ b/packages/event-bus-client/package.json @@ -56,8 +56,5 @@ "test:types": "tsc", "test:build": "publint --strict", "build": "vite build" - }, - "devDependencies": { - "@tanstack/devtools-event-bus": "workspace:*" } } diff --git a/packages/event-bus-client/tests/index.test.ts b/packages/event-bus-client/tests/index.test.ts index 1a043d28..aea8e50a 100644 --- a/packages/event-bus-client/tests/index.test.ts +++ b/packages/event-bus-client/tests/index.test.ts @@ -1,16 +1,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ClientEventBus } from '@tanstack/devtools-event-bus/client' import { EventClient } from '../src' +class TestClientEventBus { + #dispatcher = (e: Event) => { + const event = (e as CustomEvent).detail + window.dispatchEvent(new CustomEvent(event.type, { detail: event })) + window.dispatchEvent( + new CustomEvent('tanstack-devtools-global', { + detail: event, + }), + ) + } + + #connectFunction = () => { + window.dispatchEvent(new CustomEvent('tanstack-connect-success')) + } + + start() { + window.addEventListener('tanstack-dispatch-event', this.#dispatcher) + window.addEventListener('tanstack-connect', this.#connectFunction) + } + + stop() { + window.removeEventListener('tanstack-dispatch-event', this.#dispatcher) + window.removeEventListener('tanstack-connect', this.#connectFunction) + } +} + // client bus uses window to dispatch events const clientBusEmitTarget = window describe('EventClient', () => { - let bus: ClientEventBus + let bus: TestClientEventBus beforeEach(() => { // Create a fresh bus for each test to ensure isolation - bus = new ClientEventBus() + bus = new TestClientEventBus() bus.start() }) From d8cb1f74f911c79f4f722fe79a1ded99b10c4251 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 11:20:03 +0300 Subject: [PATCH 44/54] chore: clean up event-bus-client entry in pnpm-lock.yaml --- pnpm-lock.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5aad3e2..c0c39612 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1038,11 +1038,7 @@ importers: specifier: ^8.18.1 version: 8.18.1 - packages/event-bus-client: - devDependencies: - '@tanstack/devtools-event-bus': - specifier: workspace:* - version: link:../event-bus + packages/event-bus-client: {} packages/preact-devtools: dependencies: From 2086fc15a7fac111aeec0cf486b0235fa3c9afcc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:26:24 +0000 Subject: [PATCH 45/54] ci: apply automated fixes --- packages/devtools-seo/src/seo-tab.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/devtools-seo/src/seo-tab.tsx b/packages/devtools-seo/src/seo-tab.tsx index 745be028..66346139 100644 --- a/packages/devtools-seo/src/seo-tab.tsx +++ b/packages/devtools-seo/src/seo-tab.tsx @@ -17,11 +17,7 @@ export const SeoTab = () => { return ( <MainPanel withPadding> - <nav - class={styles().seoSubNav} - aria-label="SEO sections" - role="tablist" - > + <nav class={styles().seoSubNav} aria-label="SEO sections" role="tablist"> <button type="button" role="tab" From 8c53b0338c97b260a42c63e7f51fa3c6ed6c2ec4 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 11:33:46 +0300 Subject: [PATCH 46/54] feat: add SEO overview, SERP preview, and social previews sections - Implemented SeoOverviewSection to provide an overview of SEO health and issues. - Created SerpPreviewSection to display how the title and meta description appear in search results. - Developed SocialPreviewsSection to analyze and display social media meta tags for various platforms. - Added navigation for switching between SEO sections in SeoTab. - Introduced utility functions for measuring text width and truncating text for SERP previews. --- packages/devtools-seo/src/index.ts | 2 +- packages/devtools-seo/src/solid-panel.tsx | 2 +- .../src/{ => tabs}/heading-structure-preview.tsx | 12 ++++++------ .../src/{ => tabs}/json-ld-preview.tsx | 12 ++++++------ .../src/{ => tabs}/links-preview.tsx | 14 +++++++------- .../devtools-seo/src/{ => tabs}/seo-overview.tsx | 16 ++++++++-------- packages/devtools-seo/src/{ => tabs}/seo-tab.tsx | 4 ++-- .../devtools-seo/src/{ => tabs}/serp-preview.tsx | 10 +++++----- .../src/{ => tabs}/social-previews.tsx | 8 ++++---- 9 files changed, 40 insertions(+), 40 deletions(-) rename packages/devtools-seo/src/{ => tabs}/heading-structure-preview.tsx (95%) rename packages/devtools-seo/src/{ => tabs}/json-ld-preview.tsx (98%) rename packages/devtools-seo/src/{ => tabs}/links-preview.tsx (96%) rename packages/devtools-seo/src/{ => tabs}/seo-overview.tsx (96%) rename packages/devtools-seo/src/{ => tabs}/seo-tab.tsx (97%) rename packages/devtools-seo/src/{ => tabs}/serp-preview.tsx (98%) rename packages/devtools-seo/src/{ => tabs}/social-previews.tsx (97%) diff --git a/packages/devtools-seo/src/index.ts b/packages/devtools-seo/src/index.ts index 67e62992..906c1313 100644 --- a/packages/devtools-seo/src/index.ts +++ b/packages/devtools-seo/src/index.ts @@ -1,4 +1,4 @@ -export { SeoTab } from './seo-tab' +export { SeoTab } from './tabs/seo-tab' export type { SeoDetailView, SeoIssue, diff --git a/packages/devtools-seo/src/solid-panel.tsx b/packages/devtools-seo/src/solid-panel.tsx index 595a66f6..f037a73d 100644 --- a/packages/devtools-seo/src/solid-panel.tsx +++ b/packages/devtools-seo/src/solid-panel.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource solid-js */ import { ThemeContextProvider } from '@tanstack/devtools-ui' -import { SeoTab } from './seo-tab' +import { SeoTab } from './tabs/seo-tab' type SeoPluginPanelProps = { theme: 'light' | 'dark' diff --git a/packages/devtools-seo/src/heading-structure-preview.tsx b/packages/devtools-seo/src/tabs/heading-structure-preview.tsx similarity index 95% rename from packages/devtools-seo/src/heading-structure-preview.tsx rename to packages/devtools-seo/src/tabs/heading-structure-preview.tsx index a56631e6..66e19cb6 100644 --- a/packages/devtools-seo/src/heading-structure-preview.tsx +++ b/packages/devtools-seo/src/tabs/heading-structure-preview.tsx @@ -7,12 +7,12 @@ import { onMount, } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { useSeoStyles } from './use-seo-styles' -import { pickSeverityClass } from './seo-severity' -import { isInsideDevtools } from './devtools-dom-filter' -import { useLocationChanges } from './hooks/use-location-changes' -import type { SeoSeverity } from './seo-severity' -import type { SeoSectionSummary } from './seo-section-summary' +import { useSeoStyles } from '../use-seo-styles' +import { pickSeverityClass } from '../seo-severity' +import { isInsideDevtools } from '../devtools-dom-filter' +import { useLocationChanges } from '../hooks/use-location-changes' +import type { SeoSeverity } from '../seo-severity' +import type { SeoSectionSummary } from '../seo-section-summary' type HeadingItem = { id: string diff --git a/packages/devtools-seo/src/json-ld-preview.tsx b/packages/devtools-seo/src/tabs/json-ld-preview.tsx similarity index 98% rename from packages/devtools-seo/src/json-ld-preview.tsx rename to packages/devtools-seo/src/tabs/json-ld-preview.tsx index e99b7312..72686797 100644 --- a/packages/devtools-seo/src/json-ld-preview.tsx +++ b/packages/devtools-seo/src/tabs/json-ld-preview.tsx @@ -1,11 +1,11 @@ import { For, Show } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { isInsideDevtools } from './devtools-dom-filter' -import { sectionHealthScore } from './seo-section-summary' -import { useSeoStyles } from './use-seo-styles' -import { pickSeverityClass, seoHealthTier } from './seo-severity' -import type { SeoSeverity } from './seo-severity' -import type { SeoSectionSummary } from './seo-section-summary' +import { isInsideDevtools } from '../devtools-dom-filter' +import { sectionHealthScore } from '../seo-section-summary' +import { useSeoStyles } from '../use-seo-styles' +import { pickSeverityClass, seoHealthTier } from '../seo-severity' +import type { SeoSeverity } from '../seo-severity' +import type { SeoSectionSummary } from '../seo-section-summary' type JsonLdValue = Record<string, unknown> diff --git a/packages/devtools-seo/src/links-preview.tsx b/packages/devtools-seo/src/tabs/links-preview.tsx similarity index 96% rename from packages/devtools-seo/src/links-preview.tsx rename to packages/devtools-seo/src/tabs/links-preview.tsx index af7c700d..328bd9c9 100644 --- a/packages/devtools-seo/src/links-preview.tsx +++ b/packages/devtools-seo/src/tabs/links-preview.tsx @@ -1,12 +1,12 @@ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { isInsideDevtools } from './devtools-dom-filter' -import { useSeoStyles } from './use-seo-styles' -import { countBySeverity } from './seo-section-summary' -import { pickSeverityClass } from './seo-severity' -import { useLocationChanges } from './hooks/use-location-changes' -import type { SeoSectionSummary } from './seo-section-summary' -import type { SeoSeverity } from './seo-severity' +import { isInsideDevtools } from '../devtools-dom-filter' +import { useSeoStyles } from '../use-seo-styles' +import { countBySeverity } from '../seo-section-summary' +import { pickSeverityClass } from '../seo-severity' +import { useLocationChanges } from '../hooks/use-location-changes' +import type { SeoSectionSummary } from '../seo-section-summary' +import type { SeoSeverity } from '../seo-severity' type LinkKind = 'internal' | 'external' | 'non-web' | 'invalid' diff --git a/packages/devtools-seo/src/seo-overview.tsx b/packages/devtools-seo/src/tabs/seo-overview.tsx similarity index 96% rename from packages/devtools-seo/src/seo-overview.tsx rename to packages/devtools-seo/src/tabs/seo-overview.tsx index 79304805..2487359a 100644 --- a/packages/devtools-seo/src/seo-overview.tsx +++ b/packages/devtools-seo/src/tabs/seo-overview.tsx @@ -1,24 +1,24 @@ import { For, Show, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { useHeadChanges } from './hooks/use-head-changes' -import { useLocationChanges } from './hooks/use-location-changes' -import { useSeoStyles } from './use-seo-styles' -import { getCanonicalPageData } from './canonical-url-data' +import { useHeadChanges } from '../hooks/use-head-changes' +import { useLocationChanges } from '../hooks/use-location-changes' +import { useSeoStyles } from '../use-seo-styles' +import { getCanonicalPageData } from '../canonical-url-data' import { getSocialPreviewsSummary } from './social-previews' import { getSerpPreviewSummary } from './serp-preview' import { getJsonLdPreviewSummary } from './json-ld-preview' import { getHeadingStructureSummary } from './heading-structure-preview' import { getLinksPreviewSummary } from './links-preview' -import { pickSeverityClass, seoHealthTier } from './seo-severity' +import { pickSeverityClass, seoHealthTier } from '../seo-severity' import { aggregateSeoHealth, countBySeverity, sectionHealthScore, totalIssueCount, worstSeverity, -} from './seo-section-summary' -import type { SeoSeverity } from './seo-severity' -import type { SeoDetailView, SeoSectionSummary } from './seo-section-summary' +} from '../seo-section-summary' +import type { SeoSeverity } from '../seo-severity' +import type { SeoDetailView, SeoSectionSummary } from '../seo-section-summary' type OverviewRow = { id: SeoDetailView diff --git a/packages/devtools-seo/src/seo-tab.tsx b/packages/devtools-seo/src/tabs/seo-tab.tsx similarity index 97% rename from packages/devtools-seo/src/seo-tab.tsx rename to packages/devtools-seo/src/tabs/seo-tab.tsx index 745be028..0eabfe5f 100644 --- a/packages/devtools-seo/src/seo-tab.tsx +++ b/packages/devtools-seo/src/tabs/seo-tab.tsx @@ -1,13 +1,13 @@ import { Show, createSignal } from 'solid-js' import { MainPanel } from '@tanstack/devtools-ui' -import { useSeoStyles } from './use-seo-styles' +import { useSeoStyles } from '../use-seo-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' +import type { SeoDetailView } from '../seo-section-summary' type SeoSubView = 'overview' | SeoDetailView diff --git a/packages/devtools-seo/src/serp-preview.tsx b/packages/devtools-seo/src/tabs/serp-preview.tsx similarity index 98% rename from packages/devtools-seo/src/serp-preview.tsx rename to packages/devtools-seo/src/tabs/serp-preview.tsx index 2010e542..7a530503 100644 --- a/packages/devtools-seo/src/serp-preview.tsx +++ b/packages/devtools-seo/src/tabs/serp-preview.tsx @@ -1,10 +1,10 @@ import { Section, SectionDescription } from '@tanstack/devtools-ui' import { For, createMemo, createSignal } from 'solid-js' -import { useHeadChanges } from './hooks/use-head-changes' -import { useLocationChanges } from './hooks/use-location-changes' -import { tokens } from './tokens' -import { useSeoStyles } from './use-seo-styles' -import type { SeoIssue, SeoSectionSummary } from './seo-section-summary' +import { useHeadChanges } from '../hooks/use-head-changes' +import { useLocationChanges } from '../hooks/use-location-changes' +import { tokens } from '../tokens' +import { useSeoStyles } from '../use-seo-styles' +import type { SeoIssue, SeoSectionSummary } from '../seo-section-summary' const ELLIPSIS = '...' const DESKTOP_TITLE_MAX_WIDTH_PX = 620 diff --git a/packages/devtools-seo/src/social-previews.tsx b/packages/devtools-seo/src/tabs/social-previews.tsx similarity index 97% rename from packages/devtools-seo/src/social-previews.tsx rename to packages/devtools-seo/src/tabs/social-previews.tsx index e77d65dd..95a66c3d 100644 --- a/packages/devtools-seo/src/social-previews.tsx +++ b/packages/devtools-seo/src/tabs/social-previews.tsx @@ -1,9 +1,9 @@ import { For, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { useSeoStyles } from './use-seo-styles' -import { useHeadChanges } from './hooks/use-head-changes' -import type { SeoSectionSummary } from './seo-section-summary' -import type { SeoSeverity } from './seo-severity' +import { useSeoStyles } from '../use-seo-styles' +import { useHeadChanges } from '../hooks/use-head-changes' +import type { SeoSectionSummary } from '../seo-section-summary' +import type { SeoSeverity } from '../seo-severity' type SocialAccent = | 'facebook' From 42a5eade3c20d086db1c5e196163970672061b02 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 11:43:32 +0300 Subject: [PATCH 47/54] feat: add SEO styles utility using goober for dynamic theming --- packages/devtools-seo/src/index.ts | 2 +- .../src/tabs/heading-structure-preview.tsx | 10 +++++----- packages/devtools-seo/src/tabs/json-ld-preview.tsx | 12 ++++++------ packages/devtools-seo/src/tabs/links-preview.tsx | 12 ++++++------ packages/devtools-seo/src/tabs/seo-overview.tsx | 12 ++++++------ packages/devtools-seo/src/tabs/seo-tab.tsx | 4 ++-- packages/devtools-seo/src/tabs/serp-preview.tsx | 6 +++--- packages/devtools-seo/src/tabs/social-previews.tsx | 6 +++--- .../src/{ => utils}/canonical-url-data.ts | 0 .../src/{ => utils}/devtools-dom-filter.ts | 0 .../src/{ => utils}/seo-section-summary.ts | 0 .../devtools-seo/src/{ => utils}/seo-severity.ts | 0 packages/devtools-seo/src/{ => utils}/tokens.ts | 0 .../devtools-seo/src/{ => utils}/use-seo-styles.ts | 0 14 files changed, 32 insertions(+), 32 deletions(-) rename packages/devtools-seo/src/{ => utils}/canonical-url-data.ts (100%) rename packages/devtools-seo/src/{ => utils}/devtools-dom-filter.ts (100%) rename packages/devtools-seo/src/{ => utils}/seo-section-summary.ts (100%) rename packages/devtools-seo/src/{ => utils}/seo-severity.ts (100%) rename packages/devtools-seo/src/{ => utils}/tokens.ts (100%) rename packages/devtools-seo/src/{ => utils}/use-seo-styles.ts (100%) diff --git a/packages/devtools-seo/src/index.ts b/packages/devtools-seo/src/index.ts index 906c1313..c0f4286c 100644 --- a/packages/devtools-seo/src/index.ts +++ b/packages/devtools-seo/src/index.ts @@ -3,4 +3,4 @@ export type { SeoDetailView, SeoIssue, SeoSectionSummary, -} from './seo-section-summary' +} from './utils/seo-section-summary' diff --git a/packages/devtools-seo/src/tabs/heading-structure-preview.tsx b/packages/devtools-seo/src/tabs/heading-structure-preview.tsx index 66e19cb6..7fed0894 100644 --- a/packages/devtools-seo/src/tabs/heading-structure-preview.tsx +++ b/packages/devtools-seo/src/tabs/heading-structure-preview.tsx @@ -7,12 +7,12 @@ import { onMount, } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { useSeoStyles } from '../use-seo-styles' -import { pickSeverityClass } from '../seo-severity' -import { isInsideDevtools } from '../devtools-dom-filter' +import { useSeoStyles } from '../utils/use-seo-styles' +import { pickSeverityClass } from '../utils/seo-severity' +import { isInsideDevtools } from '../utils/devtools-dom-filter' import { useLocationChanges } from '../hooks/use-location-changes' -import type { SeoSeverity } from '../seo-severity' -import type { SeoSectionSummary } from '../seo-section-summary' +import type { SeoSeverity } from '../utils/seo-severity' +import type { SeoSectionSummary } from '../utils/seo-section-summary' type HeadingItem = { id: string diff --git a/packages/devtools-seo/src/tabs/json-ld-preview.tsx b/packages/devtools-seo/src/tabs/json-ld-preview.tsx index 72686797..6131d64e 100644 --- a/packages/devtools-seo/src/tabs/json-ld-preview.tsx +++ b/packages/devtools-seo/src/tabs/json-ld-preview.tsx @@ -1,11 +1,11 @@ import { For, Show } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { isInsideDevtools } from '../devtools-dom-filter' -import { sectionHealthScore } from '../seo-section-summary' -import { useSeoStyles } from '../use-seo-styles' -import { pickSeverityClass, seoHealthTier } from '../seo-severity' -import type { SeoSeverity } from '../seo-severity' -import type { SeoSectionSummary } from '../seo-section-summary' +import { isInsideDevtools } from '../utils/devtools-dom-filter' +import { sectionHealthScore } from '../utils/seo-section-summary' +import { useSeoStyles } from '../utils/use-seo-styles' +import { pickSeverityClass, seoHealthTier } from '../utils/seo-severity' +import type { SeoSeverity } from '../utils/seo-severity' +import type { SeoSectionSummary } from '../utils/seo-section-summary' type JsonLdValue = Record<string, unknown> diff --git a/packages/devtools-seo/src/tabs/links-preview.tsx b/packages/devtools-seo/src/tabs/links-preview.tsx index 328bd9c9..a7956ca3 100644 --- a/packages/devtools-seo/src/tabs/links-preview.tsx +++ b/packages/devtools-seo/src/tabs/links-preview.tsx @@ -1,12 +1,12 @@ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { isInsideDevtools } from '../devtools-dom-filter' -import { useSeoStyles } from '../use-seo-styles' -import { countBySeverity } from '../seo-section-summary' -import { pickSeverityClass } from '../seo-severity' +import { isInsideDevtools } from '../utils/devtools-dom-filter' +import { useSeoStyles } from '../utils/use-seo-styles' +import { countBySeverity } from '../utils/seo-section-summary' +import { pickSeverityClass } from '../utils/seo-severity' import { useLocationChanges } from '../hooks/use-location-changes' -import type { SeoSectionSummary } from '../seo-section-summary' -import type { SeoSeverity } from '../seo-severity' +import type { SeoSectionSummary } from '../utils/seo-section-summary' +import type { SeoSeverity } from '../utils/seo-severity' type LinkKind = 'internal' | 'external' | 'non-web' | 'invalid' diff --git a/packages/devtools-seo/src/tabs/seo-overview.tsx b/packages/devtools-seo/src/tabs/seo-overview.tsx index 2487359a..211753ba 100644 --- a/packages/devtools-seo/src/tabs/seo-overview.tsx +++ b/packages/devtools-seo/src/tabs/seo-overview.tsx @@ -2,23 +2,23 @@ import { For, Show, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { useHeadChanges } from '../hooks/use-head-changes' import { useLocationChanges } from '../hooks/use-location-changes' -import { useSeoStyles } from '../use-seo-styles' -import { getCanonicalPageData } from '../canonical-url-data' +import { useSeoStyles } from '../utils/use-seo-styles' +import { getCanonicalPageData } from '../utils/canonical-url-data' import { getSocialPreviewsSummary } from './social-previews' import { getSerpPreviewSummary } from './serp-preview' import { getJsonLdPreviewSummary } from './json-ld-preview' import { getHeadingStructureSummary } from './heading-structure-preview' import { getLinksPreviewSummary } from './links-preview' -import { pickSeverityClass, seoHealthTier } from '../seo-severity' +import { pickSeverityClass, seoHealthTier } from '../utils/seo-severity' import { aggregateSeoHealth, countBySeverity, sectionHealthScore, totalIssueCount, worstSeverity, -} from '../seo-section-summary' -import type { SeoSeverity } from '../seo-severity' -import type { SeoDetailView, SeoSectionSummary } from '../seo-section-summary' +} from '../utils/seo-section-summary' +import type { SeoSeverity } from '../utils/seo-severity' +import type { SeoDetailView, SeoSectionSummary } from '../utils/seo-section-summary' type OverviewRow = { id: SeoDetailView diff --git a/packages/devtools-seo/src/tabs/seo-tab.tsx b/packages/devtools-seo/src/tabs/seo-tab.tsx index 0eabfe5f..f9378e11 100644 --- a/packages/devtools-seo/src/tabs/seo-tab.tsx +++ b/packages/devtools-seo/src/tabs/seo-tab.tsx @@ -1,13 +1,13 @@ import { Show, createSignal } from 'solid-js' import { MainPanel } from '@tanstack/devtools-ui' -import { useSeoStyles } from '../use-seo-styles' +import { useSeoStyles } from '../utils/use-seo-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' +import type { SeoDetailView } from '../utils/seo-section-summary' type SeoSubView = 'overview' | SeoDetailView diff --git a/packages/devtools-seo/src/tabs/serp-preview.tsx b/packages/devtools-seo/src/tabs/serp-preview.tsx index 7a530503..08851ea0 100644 --- a/packages/devtools-seo/src/tabs/serp-preview.tsx +++ b/packages/devtools-seo/src/tabs/serp-preview.tsx @@ -2,9 +2,9 @@ import { Section, SectionDescription } from '@tanstack/devtools-ui' import { For, createMemo, createSignal } from 'solid-js' import { useHeadChanges } from '../hooks/use-head-changes' import { useLocationChanges } from '../hooks/use-location-changes' -import { tokens } from '../tokens' -import { useSeoStyles } from '../use-seo-styles' -import type { SeoIssue, SeoSectionSummary } from '../seo-section-summary' +import { tokens } from '../utils/tokens' +import { useSeoStyles } from '../utils/use-seo-styles' +import type { SeoIssue, SeoSectionSummary } from '../utils/seo-section-summary' const ELLIPSIS = '...' const DESKTOP_TITLE_MAX_WIDTH_PX = 620 diff --git a/packages/devtools-seo/src/tabs/social-previews.tsx b/packages/devtools-seo/src/tabs/social-previews.tsx index 95a66c3d..9f089115 100644 --- a/packages/devtools-seo/src/tabs/social-previews.tsx +++ b/packages/devtools-seo/src/tabs/social-previews.tsx @@ -1,9 +1,9 @@ import { For, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' -import { useSeoStyles } from '../use-seo-styles' +import { useSeoStyles } from '../utils/use-seo-styles' import { useHeadChanges } from '../hooks/use-head-changes' -import type { SeoSectionSummary } from '../seo-section-summary' -import type { SeoSeverity } from '../seo-severity' +import type { SeoSectionSummary } from '../utils/seo-section-summary' +import type { SeoSeverity } from '../utils/seo-severity' type SocialAccent = | 'facebook' diff --git a/packages/devtools-seo/src/canonical-url-data.ts b/packages/devtools-seo/src/utils/canonical-url-data.ts similarity index 100% rename from packages/devtools-seo/src/canonical-url-data.ts rename to packages/devtools-seo/src/utils/canonical-url-data.ts diff --git a/packages/devtools-seo/src/devtools-dom-filter.ts b/packages/devtools-seo/src/utils/devtools-dom-filter.ts similarity index 100% rename from packages/devtools-seo/src/devtools-dom-filter.ts rename to packages/devtools-seo/src/utils/devtools-dom-filter.ts diff --git a/packages/devtools-seo/src/seo-section-summary.ts b/packages/devtools-seo/src/utils/seo-section-summary.ts similarity index 100% rename from packages/devtools-seo/src/seo-section-summary.ts rename to packages/devtools-seo/src/utils/seo-section-summary.ts diff --git a/packages/devtools-seo/src/seo-severity.ts b/packages/devtools-seo/src/utils/seo-severity.ts similarity index 100% rename from packages/devtools-seo/src/seo-severity.ts rename to packages/devtools-seo/src/utils/seo-severity.ts diff --git a/packages/devtools-seo/src/tokens.ts b/packages/devtools-seo/src/utils/tokens.ts similarity index 100% rename from packages/devtools-seo/src/tokens.ts rename to packages/devtools-seo/src/utils/tokens.ts diff --git a/packages/devtools-seo/src/use-seo-styles.ts b/packages/devtools-seo/src/utils/use-seo-styles.ts similarity index 100% rename from packages/devtools-seo/src/use-seo-styles.ts rename to packages/devtools-seo/src/utils/use-seo-styles.ts From 7b833cffa95179f24c368d441e4ae3843785869e Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 11:58:44 +0300 Subject: [PATCH 48/54] refactor: reorganize imports in SeoOverviewSection for better clarity --- packages/devtools-seo/src/tabs/seo-overview.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/devtools-seo/src/tabs/seo-overview.tsx b/packages/devtools-seo/src/tabs/seo-overview.tsx index 211753ba..a6778411 100644 --- a/packages/devtools-seo/src/tabs/seo-overview.tsx +++ b/packages/devtools-seo/src/tabs/seo-overview.tsx @@ -2,13 +2,6 @@ import { For, Show, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' import { useHeadChanges } from '../hooks/use-head-changes' import { useLocationChanges } from '../hooks/use-location-changes' -import { useSeoStyles } from '../utils/use-seo-styles' -import { getCanonicalPageData } from '../utils/canonical-url-data' -import { getSocialPreviewsSummary } from './social-previews' -import { getSerpPreviewSummary } from './serp-preview' -import { getJsonLdPreviewSummary } from './json-ld-preview' -import { getHeadingStructureSummary } from './heading-structure-preview' -import { getLinksPreviewSummary } from './links-preview' import { pickSeverityClass, seoHealthTier } from '../utils/seo-severity' import { aggregateSeoHealth, @@ -17,6 +10,13 @@ import { totalIssueCount, worstSeverity, } from '../utils/seo-section-summary' +import { useSeoStyles } from '../utils/use-seo-styles' +import { getCanonicalPageData } from '../utils/canonical-url-data' +import { getSocialPreviewsSummary } from './social-previews' +import { getSerpPreviewSummary } from './serp-preview' +import { getJsonLdPreviewSummary } from './json-ld-preview' +import { getHeadingStructureSummary } from './heading-structure-preview' +import { getLinksPreviewSummary } from './links-preview' import type { SeoSeverity } from '../utils/seo-severity' import type { SeoDetailView, SeoSectionSummary } from '../utils/seo-section-summary' From 4bc654e88e828cf4722dd8b2b09fc2cc152b8c05 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:59:32 +0000 Subject: [PATCH 49/54] ci: apply automated fixes --- packages/devtools-seo/src/tabs/seo-overview.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/devtools-seo/src/tabs/seo-overview.tsx b/packages/devtools-seo/src/tabs/seo-overview.tsx index a6778411..a5f213ee 100644 --- a/packages/devtools-seo/src/tabs/seo-overview.tsx +++ b/packages/devtools-seo/src/tabs/seo-overview.tsx @@ -18,7 +18,10 @@ import { getJsonLdPreviewSummary } from './json-ld-preview' import { getHeadingStructureSummary } from './heading-structure-preview' import { getLinksPreviewSummary } from './links-preview' import type { SeoSeverity } from '../utils/seo-severity' -import type { SeoDetailView, SeoSectionSummary } from '../utils/seo-section-summary' +import type { + SeoDetailView, + SeoSectionSummary, +} from '../utils/seo-section-summary' type OverviewRow = { id: SeoDetailView From fb12efbf9b84f4a1b7c2479a2d730977e9234781 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 12:05:53 +0300 Subject: [PATCH 50/54] feat: enhance SERP title overflow checks for desktop and mobile views --- .../devtools-seo/src/tabs/serp-preview.tsx | 25 ++++++++++++++----- packages/devtools-seo/src/utils/tokens.ts | 4 +-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/devtools-seo/src/tabs/serp-preview.tsx b/packages/devtools-seo/src/tabs/serp-preview.tsx index 08851ea0..6eb75198 100644 --- a/packages/devtools-seo/src/tabs/serp-preview.tsx +++ b/packages/devtools-seo/src/tabs/serp-preview.tsx @@ -25,7 +25,8 @@ type SerpData = { } type SerpOverflow = { - titleOverflow: boolean + titleOverflowDesktop: boolean + titleOverflowMobile: boolean descriptionOverflow: boolean descriptionOverflowMobile: boolean } @@ -64,8 +65,8 @@ const COMMON_CHECKS: Array<SerpCheck> = [ }, { message: - 'The title is wider than 600px and it may not be displayed in full length.', - hasIssue: (_, overflow) => overflow.titleOverflow, + `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, + hasIssue: (_, overflow) => overflow.titleOverflowDesktop, }, ] @@ -85,6 +86,10 @@ const SERP_PREVIEWS: Array<SerpPreview> = [ label: 'Mobile preview', isMobile: true, extraChecks: [ + { + message: `The title is wider than ${MOBILE_TITLE_MAX_WIDTH_PX}px and may be trimmed in the mobile preview.`, + hasIssue: (_, overflow) => overflow.titleOverflowMobile, + }, { message: 'Description exceeds the 3-line limit for mobile view. Please shorten your text to fit within 3 lines.', @@ -326,8 +331,10 @@ function getSerpPreviewState(data: SerpData): SerpPreviewState { DESCRIPTION_FONT, ), overflow: { - titleOverflow: + titleOverflowDesktop: measureTextWidth(titleText, TITLE_FONT) > DESKTOP_TITLE_MAX_WIDTH_PX, + titleOverflowMobile: + measureTextWidth(titleText, TITLE_FONT) > MOBILE_TITLE_MAX_WIDTH_PX, descriptionOverflow: desktopDescriptionLines.length > DESKTOP_DESCRIPTION_MAX_LINES || desktopDescriptionLines.reduce( @@ -404,11 +411,17 @@ export function getSerpPreviewSummary(): SeoSectionSummary { message: 'No meta description set on the page.', }) } - if (overflow.titleOverflow) { + if (overflow.titleOverflowDesktop) { issues.push({ severity: 'warning', message: - 'The title is wider than 600px and it may not be displayed in full length.', + `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, + }) + } + if (overflow.titleOverflowMobile) { + issues.push({ + severity: 'warning', + message: `The title is wider than ${MOBILE_TITLE_MAX_WIDTH_PX}px and may be trimmed in the mobile preview.`, }) } if (overflow.descriptionOverflow) { diff --git a/packages/devtools-seo/src/utils/tokens.ts b/packages/devtools-seo/src/utils/tokens.ts index 9d247cf1..55c21822 100644 --- a/packages/devtools-seo/src/utils/tokens.ts +++ b/packages/devtools-seo/src/utils/tokens.ts @@ -271,8 +271,8 @@ export const tokens = { 96: 'calc(var(--tsrd-font-size) * 24)', }, shadow: { - xs: (_: string = 'rgb(0 0 0 / 0.1)') => - `0 1px 2px 0 rgb(0 0 0 / 0.05)` as const, + xs: (color: string = 'rgb(0 0 0 / 0.05)') => + `0 1px 2px 0 ${color}` as const, sm: (color: string = 'rgb(0 0 0 / 0.1)') => `0 1px 3px 0 ${color}, 0 1px 2px -1px ${color}` as const, md: (color: string = 'rgb(0 0 0 / 0.1)') => From 351231342ba468b41787b28721e664bfc85f2feb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:06:41 +0000 Subject: [PATCH 51/54] ci: apply automated fixes --- packages/devtools-seo/src/tabs/serp-preview.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/devtools-seo/src/tabs/serp-preview.tsx b/packages/devtools-seo/src/tabs/serp-preview.tsx index 6eb75198..8d7a6134 100644 --- a/packages/devtools-seo/src/tabs/serp-preview.tsx +++ b/packages/devtools-seo/src/tabs/serp-preview.tsx @@ -64,8 +64,7 @@ const COMMON_CHECKS: Array<SerpCheck> = [ hasIssue: (data) => !data.description.trim(), }, { - message: - `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, + message: `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, hasIssue: (_, overflow) => overflow.titleOverflowDesktop, }, ] @@ -414,8 +413,7 @@ export function getSerpPreviewSummary(): SeoSectionSummary { if (overflow.titleOverflowDesktop) { issues.push({ severity: 'warning', - message: - `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, + message: `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, }) } if (overflow.titleOverflowMobile) { From 5a9644ab2126b32f7543d235c347491112190eb3 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 12:11:18 +0300 Subject: [PATCH 52/54] feat: optimize JSON-LD analysis with reactive signals and memoization --- .../devtools-seo/src/tabs/json-ld-preview.tsx | 96 +++++++++++-------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/packages/devtools-seo/src/tabs/json-ld-preview.tsx b/packages/devtools-seo/src/tabs/json-ld-preview.tsx index 6131d64e..14eb4f05 100644 --- a/packages/devtools-seo/src/tabs/json-ld-preview.tsx +++ b/packages/devtools-seo/src/tabs/json-ld-preview.tsx @@ -1,5 +1,7 @@ -import { For, Show } from 'solid-js' +import { For, Show, createMemo, createSignal } from 'solid-js' import { Section, SectionDescription } from '@tanstack/devtools-ui' +import { useHeadChanges } from '../hooks/use-head-changes' +import { useLocationChanges } from '../hooks/use-location-changes' import { isInsideDevtools } from '../utils/devtools-dom-filter' import { sectionHealthScore } from '../utils/seo-section-summary' import { useSeoStyles } from '../utils/use-seo-styles' @@ -340,7 +342,7 @@ function analyzeJsonLdScripts(): Array<JsonLdEntry> { ).filter((script) => !isInsideDevtools(script)) return scripts.map((script, index) => { - const raw = script.textContent.trim() || '' + const raw = script.text.trim() if (raw.length === 0) { return { id: `jsonld-${index}`, @@ -600,13 +602,26 @@ function JsonLdBlock(props: { entry: JsonLdEntry; index: number }) { } export function JsonLdPreviewSection() { - const entries = analyzeJsonLdScripts() const styles = useSeoStyles() - const score = getJsonLdScore(entries) + const [tick, setTick] = createSignal(0) + + useHeadChanges(() => { + setTick((t) => t + 1) + }) + + useLocationChanges(() => { + setTick((t) => t + 1) + }) + + const entries = createMemo(() => { + void tick() + return analyzeJsonLdScripts() + }) + const score = createMemo(() => getJsonLdScore(entries())) const s = styles() - const fieldGaps = sumMissingSchemaFieldCounts(entries) + const fieldGaps = createMemo(() => sumMissingSchemaFieldCounts(entries())) const healthScoreClass = () => { - const tier = seoHealthTier(score) + const tier = seoHealthTier(score()) return tier === 'good' ? s.seoHealthScoreGood : tier === 'fair' @@ -614,7 +629,7 @@ export function JsonLdPreviewSection() { : s.seoHealthScorePoor } const healthFillClass = () => { - const tier = seoHealthTier(score) + const tier = seoHealthTier(score()) const tierFill = tier === 'good' ? s.seoHealthFillGood @@ -623,55 +638,56 @@ export function JsonLdPreviewSection() { : s.seoHealthFillPoor return `${s.seoHealthFill} ${tierFill}` } - const errorCount = entries.reduce( + const errorCount = () => entries().reduce( (total, entry) => total + entry.issues.filter((issue) => issue.severity === 'error').length, 0, ) - const warningCount = entries.reduce( + const warningCount = () => entries().reduce( (total, entry) => total + entry.issues.filter((issue) => issue.severity === 'warning').length, 0, ) - const infoCount = entries.reduce( + 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 progressAriaLabel = createMemo(() => { + const parts = [`JSON-LD health ${Math.round(score())} percent`] const sev = [ - errorCount && `${errorCount} error${errorCount === 1 ? '' : 's'}`, - warningCount && `${warningCount} warning${warningCount === 1 ? '' : 's'}`, - infoCount && `${infoCount} info`, + 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<string> = [] - if (fieldGaps.required > 0) + if (fieldGaps().required > 0) gapBits.push( - `${fieldGaps.required} required field${fieldGaps.required === 1 ? '' : 's'}`, + `${fieldGaps().required} required field${fieldGaps().required === 1 ? '' : 's'}`, ) - if (fieldGaps.recommended > 0) + if (fieldGaps().recommended > 0) gapBits.push( - `${fieldGaps.recommended} recommended field${fieldGaps.recommended === 1 ? '' : 's'}`, + `${fieldGaps().recommended} recommended field${fieldGaps().recommended === 1 ? '' : 's'}`, ) - if (fieldGaps.optional > 0) + if (fieldGaps().optional > 0) gapBits.push( - `${fieldGaps.optional} optional field${fieldGaps.optional === 1 ? '' : 's'}`, + `${fieldGaps().optional} optional field${fieldGaps().optional === 1 ? '' : 's'}`, ) if (gapBits.length) parts.push(`Missing: ${gapBits.join(', ')}`) return parts.join('. ') - })() - const missingFieldsLine = (() => { + }) + const missingFieldsLine = createMemo(() => { const bits: Array<string> = [] - 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 (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 ( <Section> @@ -693,7 +709,7 @@ export function JsonLdPreviewSection() { </div> </div> <Show - when={entries.length > 0} + when={entries().length > 0} fallback={ <div class={styles().seoMissingTagsSection}> No JSON-LD scripts were detected on this page. @@ -703,37 +719,39 @@ export function JsonLdPreviewSection() { <div class={s.seoJsonLdHealthCard}> <div class={s.seoHealthHeaderRow}> <span class={s.seoJsonLdHealthTitle}>JSON-LD Health</span> - <span class={healthScoreClass()}>{score}%</span> + <span class={healthScoreClass()}>{score()}%</span> </div> <div class={s.seoHealthTrack} role="progressbar" aria-valuemin={0} aria-valuemax={100} - aria-valuenow={Math.round(score)} - aria-label={progressAriaLabel} + aria-valuenow={Math.round(score())} + aria-label={progressAriaLabel()} > <div class={healthFillClass()} - style={{ width: `${Math.min(100, Math.max(0, score))}%` }} + style={{ width: `${Math.min(100, Math.max(0, score()))}%` }} /> </div> <div class={s.seoHealthCountsRow}> <span class={s.seoHealthCountError}> - {errorCount} error{errorCount === 1 ? '' : 's'} + {errorCount()} error{errorCount() === 1 ? '' : 's'} </span> <span class={s.seoHealthCountWarning}> - {warningCount} warning{warningCount === 1 ? '' : 's'} + {warningCount()} warning{warningCount() === 1 ? '' : 's'} </span> <span class={s.seoHealthCountInfo}> - {infoCount} info{infoCount === 1 ? '' : 's'} (2 pts each) + {infoCount()} info{infoCount() === 1 ? '' : 's'} (2 pts each) </span> </div> - <Show when={missingFieldsLine}> - <div class={s.seoJsonLdHealthMissingLine}>{missingFieldsLine}</div> + <Show when={missingFieldsLine()}> + <div class={s.seoJsonLdHealthMissingLine}> + {missingFieldsLine()} + </div> </Show> </div> - <For each={entries}> + <For each={entries()}> {(entry, index) => <JsonLdBlock entry={entry} index={index()} />} </For> </Show> From 88a9a25cfc2879008407861bbf238442f2ef32f8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:28:15 +0000 Subject: [PATCH 53/54] ci: apply automated fixes --- .../devtools-seo/src/tabs/json-ld-preview.tsx | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/devtools-seo/src/tabs/json-ld-preview.tsx b/packages/devtools-seo/src/tabs/json-ld-preview.tsx index 14eb4f05..62d8e5a9 100644 --- a/packages/devtools-seo/src/tabs/json-ld-preview.tsx +++ b/packages/devtools-seo/src/tabs/json-ld-preview.tsx @@ -638,22 +638,27 @@ export function JsonLdPreviewSection() { : 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 errorCount = () => + entries().reduce( + (total, entry) => + total + + entry.issues.filter((issue) => issue.severity === 'error').length, + 0, + ) + const warningCount = () => + entries().reduce( + (total, entry) => + total + + entry.issues.filter((issue) => issue.severity === 'warning').length, + 0, + ) + const infoCount = () => + entries().reduce( + (total, entry) => + total + + entry.issues.filter((issue) => issue.severity === 'info').length, + 0, + ) const progressAriaLabel = createMemo(() => { const parts = [`JSON-LD health ${Math.round(score())} percent`] const sev = [ From 0da8f2d1c3b939dd3ab6aad9a82ed06f8aa86c66 Mon Sep 17 00:00:00 2001 From: Abed Al Ghani Shaaban <abedshaaban600@gmail.com> Date: Fri, 10 Apr 2026 15:38:01 +0300 Subject: [PATCH 54/54] feat: remove mobile title overflow checks and unify title width handling --- packages/devtools-seo/src/tabs/serp-preview.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/devtools-seo/src/tabs/serp-preview.tsx b/packages/devtools-seo/src/tabs/serp-preview.tsx index 8d7a6134..7a6cc1ee 100644 --- a/packages/devtools-seo/src/tabs/serp-preview.tsx +++ b/packages/devtools-seo/src/tabs/serp-preview.tsx @@ -8,7 +8,6 @@ import type { SeoIssue, SeoSectionSummary } from '../utils/seo-section-summary' const ELLIPSIS = '...' const DESKTOP_TITLE_MAX_WIDTH_PX = 620 -const MOBILE_TITLE_MAX_WIDTH_PX = 328 const DESKTOP_DESCRIPTION_TOTAL_WIDTH_PX = 960 const DESKTOP_DESCRIPTION_MAX_LINES = 2 const MOBILE_DESCRIPTION_WIDTH_PX = 320 @@ -26,7 +25,6 @@ type SerpData = { type SerpOverflow = { titleOverflowDesktop: boolean - titleOverflowMobile: boolean descriptionOverflow: boolean descriptionOverflowMobile: boolean } @@ -85,10 +83,6 @@ const SERP_PREVIEWS: Array<SerpPreview> = [ label: 'Mobile preview', isMobile: true, extraChecks: [ - { - message: `The title is wider than ${MOBILE_TITLE_MAX_WIDTH_PX}px and may be trimmed in the mobile preview.`, - hasIssue: (_, overflow) => overflow.titleOverflowMobile, - }, { message: 'Description exceeds the 3-line limit for mobile view. Please shorten your text to fit within 3 lines.', @@ -314,7 +308,7 @@ function getSerpPreviewState(data: SerpData): SerpPreviewState { ), displayTitleMobile: truncateToWidth( titleText, - MOBILE_TITLE_MAX_WIDTH_PX, + DESKTOP_TITLE_MAX_WIDTH_PX, TITLE_FONT, ), displayDescriptionDesktop: truncateToTotalWrappedWidth( @@ -332,8 +326,6 @@ function getSerpPreviewState(data: SerpData): SerpPreviewState { overflow: { titleOverflowDesktop: measureTextWidth(titleText, TITLE_FONT) > DESKTOP_TITLE_MAX_WIDTH_PX, - titleOverflowMobile: - measureTextWidth(titleText, TITLE_FONT) > MOBILE_TITLE_MAX_WIDTH_PX, descriptionOverflow: desktopDescriptionLines.length > DESKTOP_DESCRIPTION_MAX_LINES || desktopDescriptionLines.reduce( @@ -416,12 +408,6 @@ export function getSerpPreviewSummary(): SeoSectionSummary { message: `The title is wider than ${DESKTOP_TITLE_MAX_WIDTH_PX}px and it may not be displayed in full length.`, }) } - if (overflow.titleOverflowMobile) { - issues.push({ - severity: 'warning', - message: `The title is wider than ${MOBILE_TITLE_MAX_WIDTH_PX}px and may be trimmed in the mobile preview.`, - }) - } if (overflow.descriptionOverflow) { issues.push({ severity: 'warning',