Skip to content

Commit ffb1f35

Browse files
committed
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.
1 parent c43bcb2 commit ffb1f35

File tree

7 files changed

+1208
-2
lines changed

7 files changed

+1208
-2
lines changed

examples/react/basic/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@
3838
>
3939
</head>
4040
<body>
41+
<script type="application/ld+json">
42+
{
43+
"@context": "https://schema.org",
44+
"@type": "Organization",
45+
"name": "TanStack Devtools",
46+
"url": "https://tanstack.com/devtools",
47+
"logo": "https://tanstack.com/devtools/logo.png"
48+
}
49+
</script>
4150
<noscript>You need to enable JavaScript to run this app.</noscript>
4251
<div id="root"></div>
4352
<script type="module" src="/src/index.tsx"></script>

packages/devtools/src/tabs/seo-tab/README.md

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,124 @@ Issue reporting:
7272
- Mobile-specific check:
7373
- description exceeds mobile 3-line limit.
7474

75-
Like Social Previews, this section updates live through `useHeadChanges`.
75+
Like Social Previews, this section updates live through `useHeadChanges`.
76+
77+
## JSON-LD Previews
78+
79+
Parses all `script[type="application/ld+json"]` blocks available on the current page and displays each block as formatted JSON with validation output.
80+
81+
Current scan behavior:
82+
83+
- Non-reactive by design.
84+
- The section scans and validates JSON-LD when the tab is opened.
85+
- If page JSON-LD changes later, reopen the tab to rescan.
86+
87+
Supported schema types with dedicated manual validation rules:
88+
89+
- `WebSite`
90+
- `Organization`
91+
- `Person`
92+
- `Article`
93+
- `Product`
94+
- `BreadcrumbList`
95+
- `FAQPage`
96+
- `LocalBusiness`
97+
98+
Validation model:
99+
100+
- Shared checks for every entity:
101+
- missing or invalid `@context` (expects Schema.org context),
102+
- missing `@type`,
103+
- invalid JSON syntax and invalid root shape.
104+
- Type-specific checks:
105+
- missing required attributes -> `error`,
106+
- missing recommended attributes -> `warning`,
107+
- missing optional attributes -> `info`,
108+
- unknown/non-allowed attributes for that type -> `warning`.
109+
- Unknown schema types still render parsed output and are reported as:
110+
- `warning`: no dedicated validator yet.
111+
112+
UI details:
113+
114+
- One card per JSON-LD block with:
115+
- detected type summary,
116+
- formatted parsed JSON (or raw content for parse errors),
117+
- copy action (`Copy parsed JSON-LD`),
118+
- grouped severity messages (`error`, `warning`, `info`).
119+
120+
JSON-LD health progress bar:
121+
122+
- Displayed when at least one JSON-LD block is found.
123+
- Starts at `100%`.
124+
- Decreases by:
125+
- `20` points per `error`,
126+
- `10` points per `warning`.
127+
- `info` issues (optional missing attributes) do not reduce score.
128+
- Score is clamped between `0` and `100`.
129+
130+
## Heading Structure Visualizer
131+
132+
Scans all heading tags (`h1` to `h6`) on the page and renders the hierarchy in DOM order.
133+
134+
Current behavior:
135+
136+
- Non-reactive scan when the section is opened.
137+
- Shows each heading with indentation based on heading level.
138+
- Displays a structure issue list with severity.
139+
140+
Checks included:
141+
142+
- No headings found (`error`)
143+
- Missing `h1` (`error`)
144+
- Multiple `h1` (`warning`)
145+
- First heading is not `h1` (`warning`)
146+
- Skipped heading levels, e.g. `h2` to `h4` (`warning`)
147+
- Empty heading text (`warning`)
148+
149+
## Links Preview
150+
151+
Collects links from the page and reports their SEO/security-related characteristics.
152+
153+
Current behavior:
154+
155+
- Non-reactive scan when the section is opened.
156+
- Detects `a[href]` links and excludes devtools UI links.
157+
- Classifies links as `internal`, `external`, `non-web`, or `invalid`.
158+
159+
Checks included:
160+
161+
- Missing visible/accessibility text (`error`)
162+
- `javascript:` links (`error`)
163+
- Invalid URL format (`error`)
164+
- External `_blank` link without `noopener` (`warning`)
165+
- Unexpected protocol (`warning`)
166+
- External link without `nofollow` (`info`)
167+
- Hash, mailto, tel, and other non-web links (`info`)
168+
169+
## Canonical, URL, Indexability & Follow
170+
171+
Evaluates canonical URL setup, robots directives, and basic URL hygiene.
172+
173+
Current behavior:
174+
175+
- Non-reactive scan when the section is opened.
176+
- Reads canonical links from `<head>`.
177+
- Reads `robots` and `googlebot` meta directives.
178+
- Derives indexability/follow from directives (`noindex`/`nofollow`).
179+
- Includes a simple score (`100 - 25*errors - 10*warnings`).
180+
181+
Checks included:
182+
183+
- Missing canonical tag (`error`)
184+
- Multiple canonical tags (`error`)
185+
- Empty/invalid canonical href (`error`)
186+
- Canonical with hash fragment (`warning`)
187+
- Canonical cross-origin mismatch (`warning`)
188+
- Page marked as `noindex` (`error`)
189+
- Page marked as `nofollow` (`warning`)
190+
- Missing robots directives (`info`)
191+
- URL query parameters present (`info`)
192+
193+
Note:
194+
195+
- `X-Robots-Tag` response headers are not reliably available from this in-page view.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { For } from 'solid-js'
2+
import { Section, SectionDescription } from '@tanstack/devtools-ui'
3+
import { useStyles } from '../../styles/use-styles'
4+
5+
type Severity = 'error' | 'warning' | 'info'
6+
7+
type Issue = {
8+
severity: Severity
9+
message: string
10+
}
11+
12+
type CanonicalData = {
13+
currentUrl: string
14+
canonicalRaw: Array<string>
15+
canonicalResolved: Array<string>
16+
robots: Array<string>
17+
indexable: boolean
18+
follow: boolean
19+
issues: Array<Issue>
20+
}
21+
22+
function severityColor(severity: Severity): string {
23+
if (severity === 'error') return '#dc2626'
24+
if (severity === 'warning') return '#d97706'
25+
return '#2563eb'
26+
}
27+
28+
function getCanonicalData(): CanonicalData {
29+
const currentUrl = window.location.href
30+
const current = new URL(currentUrl)
31+
32+
const canonicalLinks = Array.from(
33+
document.head.querySelectorAll<HTMLLinkElement>('link[rel]'),
34+
).filter((link) => link.rel.toLowerCase().split(/\s+/).includes('canonical'))
35+
36+
const canonicalRaw = canonicalLinks.map((link) => link.getAttribute('href') || '')
37+
const canonicalResolved: Array<string> = []
38+
const issues: Array<Issue> = []
39+
40+
if (canonicalLinks.length === 0) {
41+
issues.push({ severity: 'error', message: 'No canonical link found.' })
42+
}
43+
if (canonicalLinks.length > 1) {
44+
issues.push({ severity: 'error', message: 'Multiple canonical links found.' })
45+
}
46+
47+
for (const raw of canonicalRaw) {
48+
if (!raw.trim()) {
49+
issues.push({ severity: 'error', message: 'Canonical href is empty.' })
50+
continue
51+
}
52+
try {
53+
const resolved = new URL(raw, currentUrl)
54+
canonicalResolved.push(resolved.href)
55+
56+
if (resolved.hash) {
57+
issues.push({
58+
severity: 'warning',
59+
message: 'Canonical URL contains a hash fragment.',
60+
})
61+
}
62+
if (resolved.origin !== current.origin) {
63+
issues.push({
64+
severity: 'warning',
65+
message: 'Canonical URL points to a different origin.',
66+
})
67+
}
68+
} catch {
69+
issues.push({ severity: 'error', message: `Canonical URL is invalid: ${raw}` })
70+
}
71+
}
72+
73+
const robotsMetas = Array.from(
74+
document.head.querySelectorAll<HTMLMetaElement>('meta[name]'),
75+
).filter((meta) => {
76+
const name = meta.getAttribute('name')?.toLowerCase()
77+
return name === 'robots' || name === 'googlebot'
78+
})
79+
80+
const robots = robotsMetas
81+
.map((meta) => meta.getAttribute('content') || '')
82+
.flatMap((content) =>
83+
content
84+
.split(',')
85+
.map((token) => token.trim().toLowerCase())
86+
.filter(Boolean),
87+
)
88+
89+
const indexable = !robots.includes('noindex')
90+
const follow = !robots.includes('nofollow')
91+
92+
if (!indexable) {
93+
issues.push({ severity: 'error', message: 'Page is marked as noindex.' })
94+
}
95+
if (!follow) {
96+
issues.push({ severity: 'warning', message: 'Page is marked as nofollow.' })
97+
}
98+
if (robots.length === 0) {
99+
issues.push({
100+
severity: 'info',
101+
message: 'No robots meta found. Default behavior is usually index/follow.',
102+
})
103+
}
104+
105+
if (current.pathname !== '/' && /[A-Z]/.test(current.pathname)) {
106+
issues.push({
107+
severity: 'warning',
108+
message: 'URL path contains uppercase characters.',
109+
})
110+
}
111+
if (current.search) {
112+
issues.push({ severity: 'info', message: 'URL contains query parameters.' })
113+
}
114+
115+
return {
116+
currentUrl,
117+
canonicalRaw,
118+
canonicalResolved,
119+
robots,
120+
indexable,
121+
follow,
122+
issues,
123+
}
124+
}
125+
126+
function getScore(issues: Array<Issue>): number {
127+
const errors = issues.filter((issue) => issue.severity === 'error').length
128+
const warnings = issues.filter((issue) => issue.severity === 'warning').length
129+
return Math.max(0, 100 - errors * 25 - warnings * 10)
130+
}
131+
132+
export function CanonicalUrlPreviewSection() {
133+
const styles = useStyles()
134+
const data = getCanonicalData()
135+
const score = getScore(data.issues)
136+
137+
return (
138+
<Section>
139+
<SectionDescription>
140+
Checks canonical URL, robots directives, indexability/follow signals,
141+
and basic URL hygiene from the current page.
142+
</SectionDescription>
143+
144+
<div class={styles().serpPreviewBlock}>
145+
<div class={styles().serpPreviewLabel}>SEO status</div>
146+
<div style={{ display: 'flex', gap: '12px', 'flex-wrap': 'wrap' }}>
147+
<span>Score: {score}%</span>
148+
<span>Indexable: {data.indexable ? 'Yes' : 'No'}</span>
149+
<span>Follow: {data.follow ? 'Yes' : 'No'}</span>
150+
<span>Canonical tags: {data.canonicalRaw.length}</span>
151+
</div>
152+
</div>
153+
154+
<div class={styles().serpPreviewBlock}>
155+
<div class={styles().serpPreviewLabel}>Signals</div>
156+
<div>
157+
<strong>Current URL:</strong> {data.currentUrl}
158+
</div>
159+
<div>
160+
<strong>Canonical:</strong>{' '}
161+
{data.canonicalResolved.join(', ') || data.canonicalRaw.join(', ') || 'None'}
162+
</div>
163+
<div>
164+
<strong>Robots directives:</strong> {data.robots.join(', ') || 'None'}
165+
</div>
166+
<div style={{ 'margin-top': '6px', 'font-size': '12px', color: '#9ca3af' }}>
167+
X-Robots-Tag response headers are not available in this in-page view.
168+
</div>
169+
</div>
170+
171+
<div class={styles().serpPreviewBlock}>
172+
<div class={styles().serpPreviewLabel}>Issues</div>
173+
<ul class={styles().serpErrorList}>
174+
<For each={data.issues}>
175+
{(issue) => (
176+
<li style={{ color: severityColor(issue.severity), 'margin-top': '4px' }}>
177+
[{issue.severity}] {issue.message}
178+
</li>
179+
)}
180+
</For>
181+
</ul>
182+
</div>
183+
</Section>
184+
)
185+
}

0 commit comments

Comments
 (0)