Skip to content

Commit 9cc6c8d

Browse files
committed
feat(seo): enhance SEO functionality with dynamic updates and improved validation
1 parent e6ba93f commit 9cc6c8d

5 files changed

Lines changed: 94 additions & 43 deletions

File tree

packages/devtools-seo/src/heading-structure-preview.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { For, Show, createMemo, createSignal } from 'solid-js'
1+
import {
2+
For,
3+
Show,
4+
createMemo,
5+
createSignal,
6+
onCleanup,
7+
onMount,
8+
} from 'solid-js'
29
import { Section, SectionDescription } from '@tanstack/devtools-ui'
310
import { useSeoStyles } from './use-seo-styles'
411
import { pickSeverityClass } from './seo-severity'
@@ -143,9 +150,29 @@ function headingTagClass(
143150
export function HeadingStructurePreviewSection() {
144151
const styles = useSeoStyles()
145152
const [tick, setTick] = createSignal(0)
153+
const rescan = () => setTick((t) => t + 1)
146154

147-
useLocationChanges(() => {
148-
setTick((t) => t + 1)
155+
useLocationChanges(rescan)
156+
157+
onMount(() => {
158+
const observer = new MutationObserver((mutations) => {
159+
for (const mutation of mutations) {
160+
const target = mutation.target
161+
if (target instanceof Element && isInsideDevtools(target)) continue
162+
if (target.parentElement && isInsideDevtools(target.parentElement))
163+
continue
164+
rescan()
165+
break
166+
}
167+
})
168+
169+
observer.observe(document.body, {
170+
childList: true,
171+
characterData: true,
172+
subtree: true,
173+
})
174+
175+
onCleanup(() => observer.disconnect())
149176
})
150177

151178
const headings = createMemo(() => {
@@ -174,7 +201,7 @@ export function HeadingStructurePreviewSection() {
174201
<Section>
175202
<SectionDescription>
176203
Visualizes heading structure (`h1`-`h6`) in DOM order and highlights
177-
common hierarchy issues. This section scans once when opened.
204+
common hierarchy issues. This section refreshes as the page changes.
178205
</SectionDescription>
179206

180207
<div class={s.serpPreviewBlock}>

packages/devtools-seo/src/hooks/use-location-changes.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,30 @@ function observeLocationChanges() {
3232
emitLocationChangeIfNeeded()
3333
}
3434

35-
window.history.pushState = function (...args) {
36-
originalPushState.apply(this, args)
35+
function patchedPushState(...args: Parameters<History['pushState']>) {
36+
originalPushState.apply(window.history, args)
3737
dispatchLocationChangeEvent()
3838
}
3939

40-
window.history.replaceState = function (...args) {
41-
originalReplaceState.apply(this, args)
40+
function patchedReplaceState(...args: Parameters<History['replaceState']>) {
41+
originalReplaceState.apply(window.history, args)
4242
dispatchLocationChangeEvent()
4343
}
4444

45+
window.history.pushState = patchedPushState
46+
window.history.replaceState = patchedReplaceState
47+
4548
window.addEventListener('popstate', handleLocationSignal)
4649
window.addEventListener('hashchange', handleLocationSignal)
4750
window.addEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)
4851

4952
teardownLocationObservation = () => {
50-
window.history.pushState = originalPushState
51-
window.history.replaceState = originalReplaceState
53+
if (window.history.pushState === patchedPushState) {
54+
window.history.pushState = originalPushState
55+
}
56+
if (window.history.replaceState === patchedReplaceState) {
57+
window.history.replaceState = originalReplaceState
58+
}
5259
window.removeEventListener('popstate', handleLocationSignal)
5360
window.removeEventListener('hashchange', handleLocationSignal)
5461
window.removeEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)

packages/devtools-seo/src/json-ld-preview.tsx

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isInsideDevtools } from './devtools-dom-filter'
44
import { useSeoStyles } from './use-seo-styles'
55
import { pickSeverityClass, seoHealthTier } from './seo-severity'
66
import type { SeoSeverity } from './seo-severity'
7+
import { sectionHealthScore } from './seo-section-summary'
78
import type { SeoSectionSummary } from './seo-section-summary'
89

910
type JsonLdValue = Record<string, unknown>
@@ -84,8 +85,6 @@ function entryUsesOnlySupportedTypes(entry: JsonLdEntry): boolean {
8485
return entry.types.every(isSupportedSchemaType)
8586
}
8687

87-
const RESERVED_KEYS = new Set(['@context', '@type', '@id', '@graph'])
88-
8988
function isRecord(value: unknown): value is JsonLdValue {
9089
return typeof value === 'object' && value !== null && !Array.isArray(value)
9190
}
@@ -134,9 +133,32 @@ const VALID_SCHEMA_CONTEXTS = new Set([
134133

135134
function validateContext(entity: JsonLdValue): Array<ValidationIssue> {
136135
const context = entity['@context']
137-
if (!context) {
136+
if (context === undefined) {
138137
return [{ severity: 'error', message: 'Missing @context attribute.' }]
139138
}
139+
if (context === null || isRecord(context)) {
140+
return []
141+
}
142+
if (Array.isArray(context)) {
143+
const stringContexts = context.filter(
144+
(value): value is string => typeof value === 'string',
145+
)
146+
147+
if (
148+
stringContexts.length > 0 &&
149+
!stringContexts.some((value) => VALID_SCHEMA_CONTEXTS.has(value))
150+
) {
151+
return [
152+
{
153+
severity: 'error',
154+
message:
155+
'Array @context is missing a schema.org context URL in its string entries.',
156+
},
157+
]
158+
}
159+
160+
return []
161+
}
140162
if (typeof context === 'string') {
141163
if (!VALID_SCHEMA_CONTEXTS.has(context)) {
142164
return [
@@ -151,7 +173,8 @@ function validateContext(entity: JsonLdValue): Array<ValidationIssue> {
151173
return [
152174
{
153175
severity: 'error',
154-
message: 'Invalid @context type. Expected a string schema.org URL.',
176+
message:
177+
'Invalid @context type. Expected a schema.org URL, object, array, or null.',
155178
},
156179
]
157180
}
@@ -202,20 +225,6 @@ function validateEntityByType(
202225
})
203226
}
204227

205-
const allowedSet = new Set([
206-
...rules.required,
207-
...rules.recommended,
208-
...rules.optional,
209-
...RESERVED_KEYS,
210-
])
211-
const unknownKeys = Object.keys(entity).filter((key) => !allowedSet.has(key))
212-
if (unknownKeys.length > 0) {
213-
issues.push({
214-
severity: 'warning',
215-
message: `Possible invalid attributes for ${typeName}: ${unknownKeys.join(', ')}`,
216-
})
217-
}
218-
219228
return issues
220229
}
221230

@@ -455,20 +464,7 @@ function sumMissingSchemaFieldCounts(entries: Array<JsonLdEntry>): {
455464
* small penalty so optional-field gaps match how the SEO overview weights them.
456465
*/
457466
function getJsonLdScore(entries: Array<JsonLdEntry>): number {
458-
let errors = 0
459-
let warnings = 0
460-
let infos = 0
461-
462-
for (const entry of entries) {
463-
for (const issue of entry.issues) {
464-
if (issue.severity === 'error') errors += 1
465-
else if (issue.severity === 'warning') warnings += 1
466-
else infos += 1
467-
}
468-
}
469-
470-
const penalty = Math.min(100, errors * 20 + warnings * 10 + infos * 2)
471-
return Math.max(0, 100 - penalty)
467+
return sectionHealthScore(entries.flatMap((entry) => entry.issues))
472468
}
473469

474470
function JsonLdEntityPreviewCard(props: { entity: JsonLdValue }) {

packages/devtools-seo/src/seo-tab.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,44 +17,60 @@ export const SeoTab = () => {
1717

1818
return (
1919
<MainPanel withPadding>
20-
<nav class={styles().seoSubNav} aria-label="SEO sections">
20+
<nav
21+
class={styles().seoSubNav}
22+
aria-label="SEO sections"
23+
role="tablist"
24+
>
2125
<button
2226
type="button"
27+
role="tab"
28+
aria-selected={activeView() === 'overview'}
2329
class={`${styles().seoSubNavLabel} ${activeView() === 'overview' ? styles().seoSubNavLabelActive : ''}`}
2430
onClick={() => setActiveView('overview')}
2531
>
2632
SEO Overview
2733
</button>
2834
<button
2935
type="button"
36+
role="tab"
37+
aria-selected={activeView() === 'heading-structure'}
3038
class={`${styles().seoSubNavLabel} ${activeView() === 'heading-structure' ? styles().seoSubNavLabelActive : ''}`}
3139
onClick={() => setActiveView('heading-structure')}
3240
>
3341
Heading Structure
3442
</button>
3543
<button
3644
type="button"
45+
role="tab"
46+
aria-selected={activeView() === 'links-preview'}
3747
class={`${styles().seoSubNavLabel} ${activeView() === 'links-preview' ? styles().seoSubNavLabelActive : ''}`}
3848
onClick={() => setActiveView('links-preview')}
3949
>
4050
Links Preview
4151
</button>
4252
<button
4353
type="button"
54+
role="tab"
55+
aria-selected={activeView() === 'social-previews'}
4456
class={`${styles().seoSubNavLabel} ${activeView() === 'social-previews' ? styles().seoSubNavLabelActive : ''}`}
4557
onClick={() => setActiveView('social-previews')}
4658
>
4759
Social Previews
4860
</button>
4961
<button
5062
type="button"
63+
role="tab"
64+
aria-selected={activeView() === 'serp-preview'}
5165
class={`${styles().seoSubNavLabel} ${activeView() === 'serp-preview' ? styles().seoSubNavLabelActive : ''}`}
5266
onClick={() => setActiveView('serp-preview')}
5367
>
5468
SERP Preview
5569
</button>
5670
<button
5771
type="button"
72+
role="tab"
73+
aria-selected={activeView() === 'json-ld-preview'}
5874
class={`${styles().seoSubNavLabel} ${activeView() === 'json-ld-preview' ? styles().seoSubNavLabelActive : ''}`}
5975
onClick={() => setActiveView('json-ld-preview')}
6076
>

packages/devtools-seo/src/serp-preview.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Section, SectionDescription } from '@tanstack/devtools-ui'
22
import { For, createMemo, createSignal } from 'solid-js'
33
import { useHeadChanges } from './hooks/use-head-changes'
4+
import { useLocationChanges } from './hooks/use-location-changes'
45
import { tokens } from './tokens'
56
import { useSeoStyles } from './use-seo-styles'
67
import type { SeoIssue, SeoSectionSummary } from './seo-section-summary'
@@ -529,6 +530,10 @@ export function SerpPreviewSection() {
529530
setSerp(getSerpFromHead())
530531
})
531532

533+
useLocationChanges(() => {
534+
setSerp(getSerpFromHead())
535+
})
536+
532537
const serpPreviewState = createMemo(() => {
533538
return getSerpPreviewState(serp())
534539
})

0 commit comments

Comments
 (0)