sanity-plugin-seofields 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/dist/index.d.mts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +269 -26
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +269 -25
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/SeoHealthDashboard.tsx +322 -21
- package/src/components/SeoHealthPane.tsx +81 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-seofields",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "A Sanity Studio plugin to manage SEO fields like meta titles, descriptions, and Open Graph tags for structured, search-optimized content.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
|
2
2
|
import {useClient} from 'sanity'
|
|
3
3
|
import {useIntentLink} from 'sanity/router'
|
|
4
|
+
import {usePaneRouter} from 'sanity/structure'
|
|
4
5
|
import styled, {keyframes} from 'styled-components'
|
|
5
6
|
|
|
6
7
|
import {DocumentWithSeoHealth, SeoHealthMetrics} from '../types'
|
|
@@ -24,6 +25,22 @@ const PageTitle = styled.h1`
|
|
|
24
25
|
font-weight: 700;
|
|
25
26
|
color: #111827;
|
|
26
27
|
letter-spacing: -0.3px;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 10px;
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
const PreviewBadge = styled.span`
|
|
34
|
+
display: inline-block;
|
|
35
|
+
background: #fef3c7;
|
|
36
|
+
color: #92400e;
|
|
37
|
+
font-size: 11px;
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
padding: 4px 8px;
|
|
40
|
+
border-radius: 4px;
|
|
41
|
+
text-transform: uppercase;
|
|
42
|
+
letter-spacing: 0.5px;
|
|
43
|
+
margin-left: 8px;
|
|
27
44
|
`
|
|
28
45
|
|
|
29
46
|
const PageSubtitle = styled.p`
|
|
@@ -34,16 +51,9 @@ const PageSubtitle = styled.p`
|
|
|
34
51
|
|
|
35
52
|
const StatsGrid = styled.div`
|
|
36
53
|
display: grid;
|
|
37
|
-
grid-template-columns: repeat(
|
|
54
|
+
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
38
55
|
gap: 14px;
|
|
39
56
|
margin-bottom: 20px;
|
|
40
|
-
|
|
41
|
-
@media (max-width: 1100px) {
|
|
42
|
-
grid-template-columns: repeat(3, 1fr);
|
|
43
|
-
}
|
|
44
|
-
@media (max-width: 600px) {
|
|
45
|
-
grid-template-columns: repeat(2, 1fr);
|
|
46
|
-
}
|
|
47
57
|
`
|
|
48
58
|
|
|
49
59
|
const StatCard = styled.div<{$accent?: string}>`
|
|
@@ -202,6 +212,14 @@ const TitleWrapper = styled.div`
|
|
|
202
212
|
align-items: center;
|
|
203
213
|
gap: 4px;
|
|
204
214
|
flex-wrap: wrap;
|
|
215
|
+
min-width: 0;
|
|
216
|
+
`
|
|
217
|
+
|
|
218
|
+
/* Constrains the title + doc-id block so text-overflow works inside flex */
|
|
219
|
+
const TitleCell = styled.div`
|
|
220
|
+
min-width: 0;
|
|
221
|
+
overflow: hidden;
|
|
222
|
+
flex: 1;
|
|
205
223
|
`
|
|
206
224
|
|
|
207
225
|
const ColType = styled.div`
|
|
@@ -245,14 +263,14 @@ const DocId = styled.div`
|
|
|
245
263
|
text-overflow: ellipsis;
|
|
246
264
|
`
|
|
247
265
|
|
|
248
|
-
const TypeBadge = styled.span
|
|
266
|
+
const TypeBadge = styled.span<{$bgColor?: string; $textColor?: string}>`
|
|
249
267
|
display: inline-block;
|
|
250
268
|
padding: 3px 8px;
|
|
251
269
|
border-radius: 5px;
|
|
252
270
|
font-size: 11px;
|
|
253
271
|
font-weight: 500;
|
|
254
|
-
background: #ede9fe;
|
|
255
|
-
color: #5b21b6;
|
|
272
|
+
background: ${(p) => p.$bgColor || '#ede9fe'};
|
|
273
|
+
color: ${(p) => p.$textColor || '#5b21b6'};
|
|
256
274
|
`
|
|
257
275
|
|
|
258
276
|
const TypeText = styled.span`
|
|
@@ -465,6 +483,48 @@ const DocTitleAnchor: React.FC<{id: string; type: string; children: React.ReactN
|
|
|
465
483
|
)
|
|
466
484
|
}
|
|
467
485
|
|
|
486
|
+
// Wrapper that applies DocTitleLink styles to the ChildLink <a> rendered by Sanity's pane router
|
|
487
|
+
const PaneLinkWrapper = styled.span`
|
|
488
|
+
display: block;
|
|
489
|
+
min-width: 0;
|
|
490
|
+
overflow: hidden;
|
|
491
|
+
|
|
492
|
+
a {
|
|
493
|
+
font-size: 13px;
|
|
494
|
+
font-weight: 600;
|
|
495
|
+
color: #4f46e5;
|
|
496
|
+
white-space: nowrap;
|
|
497
|
+
overflow: hidden;
|
|
498
|
+
text-overflow: ellipsis;
|
|
499
|
+
text-decoration: none;
|
|
500
|
+
display: block;
|
|
501
|
+
transition: color 0.15s;
|
|
502
|
+
|
|
503
|
+
&:hover {
|
|
504
|
+
color: #4338ca;
|
|
505
|
+
text-decoration: underline;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
`
|
|
509
|
+
|
|
510
|
+
// Sub-component for desk-structure split-pane navigation.
|
|
511
|
+
// Uses ChildLink from usePaneRouter to open the document editor to the right
|
|
512
|
+
// while keeping the SEO Health pane visible on the left.
|
|
513
|
+
const DocTitleAnchorPane: React.FC<{id: string; type: string; children: React.ReactNode}> = ({
|
|
514
|
+
id,
|
|
515
|
+
type,
|
|
516
|
+
children,
|
|
517
|
+
}) => {
|
|
518
|
+
const {ChildLink} = usePaneRouter()
|
|
519
|
+
return (
|
|
520
|
+
<PaneLinkWrapper>
|
|
521
|
+
<ChildLink childId={id} childParameters={{type}}>
|
|
522
|
+
{children}
|
|
523
|
+
</ChildLink>
|
|
524
|
+
</PaneLinkWrapper>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
468
528
|
// Sub-component to safely call docBadge outside a .map expression
|
|
469
529
|
const DocBadgeRenderer: React.FC<{
|
|
470
530
|
doc: DocumentWithSeoHealth & Record<string, unknown>
|
|
@@ -509,6 +569,47 @@ const EmptyState = styled.div`
|
|
|
509
569
|
font-size: 13px;
|
|
510
570
|
`
|
|
511
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Color palette for dynamic document type badges
|
|
574
|
+
* Colors are randomly assigned based on type hash for visual variety
|
|
575
|
+
* while maintaining consistency across sessions
|
|
576
|
+
*/
|
|
577
|
+
const TYPE_COLOR_PALETTE: Array<{bg: string; text: string}> = [
|
|
578
|
+
{bg: '#dbeafe', text: '#0c4a6e'}, // Blue
|
|
579
|
+
{bg: '#dcfce7', text: '#14532d'}, // Green
|
|
580
|
+
{bg: '#fce7f3', text: '#500724'}, // Pink
|
|
581
|
+
{bg: '#fed7aa', text: '#7c2d12'}, // Orange
|
|
582
|
+
{bg: '#e9d5ff', text: '#581c87'}, // Purple
|
|
583
|
+
{bg: '#f3e8ff', text: '#3f0f5c'}, // Deep Purple
|
|
584
|
+
{bg: '#ccfbf1', text: '#134e4a'}, // Teal
|
|
585
|
+
{bg: '#ddd6fe', text: '#3730a3'}, // Indigo
|
|
586
|
+
{bg: '#fca5a5', text: '#7f1d1d'}, // Red
|
|
587
|
+
{bg: '#a7f3d0', text: '#065f46'}, // Emerald
|
|
588
|
+
{bg: '#fbbf24', text: '#78350f'}, // Amber
|
|
589
|
+
{bg: '#c4b5fd', text: '#3b0764'}, // Violet
|
|
590
|
+
{bg: '#f0fdf4', text: '#15803d'}, // Light Green
|
|
591
|
+
{bg: '#fef2f2', text: '#991b1b'}, // Light Red
|
|
592
|
+
{bg: '#f5f3ff', text: '#5b21b6'}, // Light Purple
|
|
593
|
+
{bg: '#fffbeb', text: '#92400e'}, // Light Amber
|
|
594
|
+
]
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Get dynamic color for a document type based on type name hash
|
|
598
|
+
* Same type always gets the same color, but assignment is not fixed
|
|
599
|
+
*/
|
|
600
|
+
const getTypeColor = (type: string): {bg: string; text: string} => {
|
|
601
|
+
// Generate consistent hash from type string using simple arithmetic
|
|
602
|
+
let hash = 0
|
|
603
|
+
for (let i = 0; i < type.length; i += 1) {
|
|
604
|
+
const char = type.charCodeAt(i)
|
|
605
|
+
hash = Math.abs(hash * 31 + char)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Use modulo to get index within palette range
|
|
609
|
+
const colorIndex = hash % TYPE_COLOR_PALETTE.length
|
|
610
|
+
return TYPE_COLOR_PALETTE[colorIndex]
|
|
611
|
+
}
|
|
612
|
+
|
|
512
613
|
const getStatusCategory = (score: number): SeoHealthMetrics['status'] => {
|
|
513
614
|
if (score >= 80) return 'excellent'
|
|
514
615
|
if (score >= 60) return 'good'
|
|
@@ -760,6 +861,167 @@ export interface SeoHealthDashboardProps {
|
|
|
760
861
|
* Defaults to `"No documents found"`.
|
|
761
862
|
*/
|
|
762
863
|
noDocuments?: React.ReactNode
|
|
864
|
+
/**
|
|
865
|
+
* Enable preview/demo mode to show dummy data.
|
|
866
|
+
* Useful for testing, documentation, or showcasing the dashboard.
|
|
867
|
+
* When enabled, displays realistic sample documents with various SEO scores.
|
|
868
|
+
* Defaults to `false`.
|
|
869
|
+
*/
|
|
870
|
+
previewMode?: boolean
|
|
871
|
+
/**
|
|
872
|
+
* When `true`, clicking a document title opens the document editor as a split
|
|
873
|
+
* pane to the right, keeping the SEO Health pane visible on the left.
|
|
874
|
+
* This uses Sanity's pane router and requires the component to be rendered
|
|
875
|
+
* inside a desk-structure pane context (i.e. via `createSeoHealthPane`).
|
|
876
|
+
*
|
|
877
|
+
* When `false` (default), clicking navigates to the document via the standard
|
|
878
|
+
* intent-link system (full navigation).
|
|
879
|
+
*
|
|
880
|
+
* This is set to `true` automatically by `createSeoHealthPane`.
|
|
881
|
+
*/
|
|
882
|
+
openInPane?: boolean
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Generate dummy data for preview mode showing various SEO health scenarios
|
|
887
|
+
*/
|
|
888
|
+
const generateDummyData = (): DocumentWithSeoHealth[] => {
|
|
889
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
890
|
+
const dummyDocs: any[] = [
|
|
891
|
+
{
|
|
892
|
+
_id: 'preview-post-1',
|
|
893
|
+
_type: 'post',
|
|
894
|
+
title: 'Getting Started with SEO Best Practices',
|
|
895
|
+
slug: {current: 'getting-started-seo'},
|
|
896
|
+
_updatedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
|
897
|
+
seo: {
|
|
898
|
+
title: 'Getting Started with SEO Best Practices | My Blog',
|
|
899
|
+
description:
|
|
900
|
+
'Learn the fundamentals of SEO optimization to improve your website visibility and search rankings.',
|
|
901
|
+
keywords: ['seo', 'best practices', 'optimization'],
|
|
902
|
+
metaImage: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}},
|
|
903
|
+
openGraph: {
|
|
904
|
+
title: 'SEO Best Practices Guide',
|
|
905
|
+
description: 'Master SEO optimization',
|
|
906
|
+
image: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}, alt: 'SEO Guide'},
|
|
907
|
+
type: 'article',
|
|
908
|
+
},
|
|
909
|
+
twitter: {
|
|
910
|
+
title: 'SEO Best Practices',
|
|
911
|
+
description: 'Learn SEO optimization',
|
|
912
|
+
image: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}, alt: 'Guide'},
|
|
913
|
+
card: 'summary_large_image',
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
_id: 'preview-post-2',
|
|
919
|
+
_type: 'post',
|
|
920
|
+
title: 'Advanced Analytics Strategy',
|
|
921
|
+
slug: {current: 'advanced-analytics'},
|
|
922
|
+
_updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
|
923
|
+
seo: {
|
|
924
|
+
title: 'Advanced Analytics',
|
|
925
|
+
description: 'Strategy tips',
|
|
926
|
+
keywords: ['analytics', 'data'],
|
|
927
|
+
openGraph: {
|
|
928
|
+
title: 'Analytics Guide',
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
_id: 'preview-page-1',
|
|
934
|
+
_type: 'page',
|
|
935
|
+
title: 'About Us',
|
|
936
|
+
slug: {current: 'about'},
|
|
937
|
+
_updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
|
938
|
+
seo: {
|
|
939
|
+
title: 'About',
|
|
940
|
+
keywords: ['company', 'team'],
|
|
941
|
+
metaImage: {_type: 'image', asset: {_ref: 'image-456', _type: 'reference'}},
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
_id: 'preview-post-3',
|
|
946
|
+
_type: 'post',
|
|
947
|
+
title: 'Content Marketing Trends for 2024',
|
|
948
|
+
slug: {current: 'content-marketing-trends'},
|
|
949
|
+
_updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
|
950
|
+
seo: {
|
|
951
|
+
title: 'Content Marketing Trends 2024',
|
|
952
|
+
description:
|
|
953
|
+
'Discover the latest content marketing trends and strategies to engage your audience effectively.',
|
|
954
|
+
keywords: ['content marketing', 'trends', 'strategy', 'engagement'],
|
|
955
|
+
metaImage: {_type: 'image', asset: {_ref: 'image-789', _type: 'reference'}},
|
|
956
|
+
openGraph: {
|
|
957
|
+
title: 'Content Marketing Trends 2024',
|
|
958
|
+
description: 'Latest trends in content marketing',
|
|
959
|
+
image: {_type: 'image', asset: {_ref: 'image-789', _type: 'reference'}, alt: 'Trends'},
|
|
960
|
+
type: 'article',
|
|
961
|
+
},
|
|
962
|
+
twitter: {
|
|
963
|
+
title: 'Content Marketing Trends',
|
|
964
|
+
description: 'Discover the latest trends',
|
|
965
|
+
card: 'summary',
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
_id: 'preview-post-4',
|
|
971
|
+
_type: 'product',
|
|
972
|
+
title: 'Pro Plan',
|
|
973
|
+
slug: {current: 'pro-plan'},
|
|
974
|
+
_updatedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(),
|
|
975
|
+
seo: {
|
|
976
|
+
title: 'Pro',
|
|
977
|
+
keywords: ['pricing'],
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
_id: 'preview-page-2',
|
|
982
|
+
_type: 'page',
|
|
983
|
+
title: 'Contact',
|
|
984
|
+
slug: {current: 'contact'},
|
|
985
|
+
_updatedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
|
|
986
|
+
seo: {
|
|
987
|
+
openGraph: {
|
|
988
|
+
title: 'Get in Touch',
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
_id: 'preview-post-5',
|
|
994
|
+
_type: 'post',
|
|
995
|
+
title: 'Mobile Optimization Guide',
|
|
996
|
+
slug: {current: 'mobile-optimization'},
|
|
997
|
+
_updatedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
|
998
|
+
seo: {
|
|
999
|
+
title: 'Mobile Optimization Guide: Best Practices for Responsive Design',
|
|
1000
|
+
description:
|
|
1001
|
+
'Complete guide to mobile optimization including responsive design, performance tips, and user experience best practices for modern web development.',
|
|
1002
|
+
keywords: ['mobile', 'optimization', 'responsive', 'performance'],
|
|
1003
|
+
metaImage: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}},
|
|
1004
|
+
openGraph: {
|
|
1005
|
+
title: 'Mobile Optimization Best Practices',
|
|
1006
|
+
description: 'Master mobile web optimization',
|
|
1007
|
+
image: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}, alt: 'Mobile'},
|
|
1008
|
+
type: 'article',
|
|
1009
|
+
},
|
|
1010
|
+
twitter: {
|
|
1011
|
+
title: 'Mobile Optimization Tips',
|
|
1012
|
+
description: 'Responsive design best practices',
|
|
1013
|
+
image: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}, alt: 'Mobile'},
|
|
1014
|
+
card: 'summary_large_image',
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
]
|
|
1019
|
+
|
|
1020
|
+
// Calculate health scores and return
|
|
1021
|
+
return dummyDocs.map((doc) => ({
|
|
1022
|
+
...doc,
|
|
1023
|
+
health: calculateHealthScore(doc),
|
|
1024
|
+
}))
|
|
763
1025
|
}
|
|
764
1026
|
|
|
765
1027
|
const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
@@ -780,6 +1042,8 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
|
780
1042
|
loadingLicense,
|
|
781
1043
|
loadingDocuments,
|
|
782
1044
|
noDocuments,
|
|
1045
|
+
previewMode = false,
|
|
1046
|
+
openInPane = false,
|
|
783
1047
|
}) => {
|
|
784
1048
|
const client = useClient({apiVersion})
|
|
785
1049
|
const [licenseStatus, setLicenseStatus] = useState<'loading' | 'valid' | 'invalid'>('loading')
|
|
@@ -800,6 +1064,12 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
|
800
1064
|
|
|
801
1065
|
const validateLicense = useCallback(
|
|
802
1066
|
async (forceRefresh = false) => {
|
|
1067
|
+
// Preview mode bypasses license validation
|
|
1068
|
+
if (previewMode) {
|
|
1069
|
+
setLicenseStatus('valid')
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
|
|
803
1073
|
// No key provided
|
|
804
1074
|
if (!licenseKey) {
|
|
805
1075
|
setLicenseStatus('invalid')
|
|
@@ -854,13 +1124,13 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
|
854
1124
|
}
|
|
855
1125
|
},
|
|
856
1126
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
857
|
-
[licenseKey],
|
|
1127
|
+
[licenseKey, previewMode],
|
|
858
1128
|
)
|
|
859
1129
|
|
|
860
1130
|
useEffect(() => {
|
|
861
1131
|
validateLicense()
|
|
862
1132
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
863
|
-
}, [licenseKey])
|
|
1133
|
+
}, [licenseKey, previewMode])
|
|
864
1134
|
|
|
865
1135
|
const handleMouseEnterIssues = (el: HTMLDivElement | null, issues: string[]) => {
|
|
866
1136
|
if (!el) return
|
|
@@ -881,6 +1151,12 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
|
881
1151
|
try {
|
|
882
1152
|
setLoading(true)
|
|
883
1153
|
|
|
1154
|
+
// Use dummy data in preview mode
|
|
1155
|
+
if (previewMode) {
|
|
1156
|
+
setDocuments(generateDummyData())
|
|
1157
|
+
return
|
|
1158
|
+
}
|
|
1159
|
+
|
|
884
1160
|
let groqQuery: string
|
|
885
1161
|
let params: Record<string, unknown> = {}
|
|
886
1162
|
|
|
@@ -932,7 +1208,16 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
|
932
1208
|
|
|
933
1209
|
fetchDocuments()
|
|
934
1210
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
935
|
-
}, [
|
|
1211
|
+
}, [
|
|
1212
|
+
client,
|
|
1213
|
+
customQuery,
|
|
1214
|
+
queryRequireSeo,
|
|
1215
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1216
|
+
JSON.stringify(queryTypes),
|
|
1217
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1218
|
+
JSON.stringify(titleField),
|
|
1219
|
+
previewMode,
|
|
1220
|
+
])
|
|
936
1221
|
|
|
937
1222
|
const uniqueDocumentTypes = useMemo(() => {
|
|
938
1223
|
const types = new Set(documents.map((doc) => doc._type))
|
|
@@ -1060,7 +1345,10 @@ export default defineConfig({
|
|
|
1060
1345
|
{/* Header */}
|
|
1061
1346
|
<PageHeader>
|
|
1062
1347
|
<PageTitle>
|
|
1063
|
-
|
|
1348
|
+
<span>
|
|
1349
|
+
{icon} {title}
|
|
1350
|
+
</span>
|
|
1351
|
+
{previewMode && <PreviewBadge>Preview Mode</PreviewBadge>}
|
|
1064
1352
|
</PageTitle>
|
|
1065
1353
|
<PageSubtitle>{description}</PageSubtitle>
|
|
1066
1354
|
</PageHeader>
|
|
@@ -1171,12 +1459,18 @@ export default defineConfig({
|
|
|
1171
1459
|
<TableRow key={doc._id}>
|
|
1172
1460
|
<ColTitle>
|
|
1173
1461
|
<TitleWrapper>
|
|
1174
|
-
<
|
|
1175
|
-
|
|
1176
|
-
{doc.
|
|
1177
|
-
|
|
1462
|
+
<TitleCell>
|
|
1463
|
+
{openInPane ? (
|
|
1464
|
+
<DocTitleAnchorPane id={doc._id} type={doc._type}>
|
|
1465
|
+
{doc.title || 'Untitled'}
|
|
1466
|
+
</DocTitleAnchorPane>
|
|
1467
|
+
) : (
|
|
1468
|
+
<DocTitleAnchor id={doc._id} type={doc._type}>
|
|
1469
|
+
{doc.title || 'Untitled'}
|
|
1470
|
+
</DocTitleAnchor>
|
|
1471
|
+
)}
|
|
1178
1472
|
{showDocumentId && <DocId>{doc._id}</DocId>}
|
|
1179
|
-
</
|
|
1473
|
+
</TitleCell>
|
|
1180
1474
|
{docBadge && (
|
|
1181
1475
|
<DocBadgeRenderer
|
|
1182
1476
|
doc={doc as DocumentWithSeoHealth & Record<string, unknown>}
|
|
@@ -1190,7 +1484,14 @@ export default defineConfig({
|
|
|
1190
1484
|
{typeColumnMode === 'text' ? (
|
|
1191
1485
|
<TypeText>{resolveTypeLabel(doc._type, typeLabels)}</TypeText>
|
|
1192
1486
|
) : (
|
|
1193
|
-
|
|
1487
|
+
(() => {
|
|
1488
|
+
const typeColor = getTypeColor(doc._type)
|
|
1489
|
+
return (
|
|
1490
|
+
<TypeBadge $bgColor={typeColor.bg} $textColor={typeColor.text}>
|
|
1491
|
+
{resolveTypeLabel(doc._type, typeLabels)}
|
|
1492
|
+
</TypeBadge>
|
|
1493
|
+
)
|
|
1494
|
+
})()
|
|
1194
1495
|
)}
|
|
1195
1496
|
</ColType>
|
|
1196
1497
|
)}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type {ComponentBuilder, StructureBuilder} from 'sanity/structure'
|
|
3
|
+
|
|
4
|
+
import SeoHealthDashboard, {SeoHealthDashboardProps} from './SeoHealthDashboard'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options accepted by `createSeoHealthPane`.
|
|
8
|
+
* All props from `SeoHealthDashboardProps` are supported.
|
|
9
|
+
*
|
|
10
|
+
* `licenseKey` is **required** — the dashboard will not render without it.
|
|
11
|
+
*/
|
|
12
|
+
export interface SeoHealthPaneOptions extends Omit<SeoHealthDashboardProps, 'customQuery'> {
|
|
13
|
+
/** Required license key (format: `SEOF-XXXX-XXXX-XXXX`). */
|
|
14
|
+
licenseKey: string
|
|
15
|
+
/**
|
|
16
|
+
* A fully custom GROQ query used to fetch documents for the dashboard.
|
|
17
|
+
* The query must return documents with at least: `_id`, `_type`, `title`, `seo`, `_updatedAt`.
|
|
18
|
+
*
|
|
19
|
+
* Takes precedence over `queryTypes` when both are provided.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* query: `*[_type in ["post","page"] && defined(seo)]{ _id, _type, title, slug, seo, _updatedAt }`
|
|
23
|
+
*/
|
|
24
|
+
query?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// function isStructureBuilder(arg: unknown): arg is StructureBuilder {
|
|
28
|
+
// return (
|
|
29
|
+
// arg !== null &&
|
|
30
|
+
// typeof arg === 'object' &&
|
|
31
|
+
// typeof (arg as StructureBuilder).component === 'function' &&
|
|
32
|
+
// typeof (arg as StructureBuilder).document === 'function'
|
|
33
|
+
// )
|
|
34
|
+
// }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a desk-structure pane for the SEO Health Dashboard.
|
|
38
|
+
*
|
|
39
|
+
* Returns a **`ComponentBuilder`** with a built-in `.child()` resolver so that
|
|
40
|
+
* clicking any document row opens the document editor as a split pane to the right.
|
|
41
|
+
*
|
|
42
|
+
* Use it **directly** as the `.child()` value — do **not** wrap it in `S.component()`.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* // sanity.config.ts
|
|
46
|
+
* structure: (S) =>
|
|
47
|
+
* S.list().items([
|
|
48
|
+
* S.listItem()
|
|
49
|
+
* .title('SEO Health')
|
|
50
|
+
* .child(
|
|
51
|
+
* createSeoHealthPane(S, {
|
|
52
|
+
* licenseKey: 'SEOF-XXXX-XXXX-XXXX',
|
|
53
|
+
* query: `*[_type == "post" && defined(seo)]{ _id, _type, title, slug, seo, _updatedAt }`,
|
|
54
|
+
* })
|
|
55
|
+
* ),
|
|
56
|
+
* ])
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function createSeoHealthPane(
|
|
60
|
+
optionsOrS: StructureBuilder,
|
|
61
|
+
optionsWhenS: SeoHealthPaneOptions,
|
|
62
|
+
): ComponentBuilder {
|
|
63
|
+
// ── Two-arg form: structure builder passed as first arg ──────────────────
|
|
64
|
+
const S = optionsOrS
|
|
65
|
+
const {query, openInPane = true, title: paneTitle, ...rest} = optionsWhenS ?? {}
|
|
66
|
+
|
|
67
|
+
const SeoHealthPane: React.FC = () => (
|
|
68
|
+
<SeoHealthDashboard customQuery={query} openInPane={openInPane} title={paneTitle} {...rest} />
|
|
69
|
+
)
|
|
70
|
+
SeoHealthPane.displayName = 'SeoHealthPane'
|
|
71
|
+
|
|
72
|
+
// Wire up the child resolver so ChildLink URLs resolve to the document editor
|
|
73
|
+
return (S.component(SeoHealthPane) as ComponentBuilder)
|
|
74
|
+
.title(paneTitle ?? 'SEO Health')
|
|
75
|
+
.child((docId: string, {params}: {params: Record<string, string | undefined>}) => {
|
|
76
|
+
const builder = S.document().documentId(docId)
|
|
77
|
+
return params?.type ? builder.schemaType(params.type) : builder
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default createSeoHealthPane
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export {default as twitterSchema} from './schemas/types/twitter'
|
|
|
19
19
|
// Export dashboard components and types
|
|
20
20
|
export {default as SeoHealthDashboard} from './components/SeoHealthDashboard'
|
|
21
21
|
export {default as SeoHealthTool} from './components/SeoHealthTool'
|
|
22
|
+
export {createSeoHealthPane} from './components/SeoHealthPane'
|
|
23
|
+
export type {SeoHealthPaneOptions} from './components/SeoHealthPane'
|
|
22
24
|
|
|
23
25
|
// Export types
|
|
24
26
|
export type {DocumentWithSeoHealth, SeoHealthMetrics, SeoHealthStatus} from './types'
|
package/src/plugin.ts
CHANGED
|
@@ -214,6 +214,16 @@ export interface SeoFieldsPluginConfig {
|
|
|
214
214
|
docBadge?: (
|
|
215
215
|
doc: DocumentWithSeoHealth & Record<string, unknown>,
|
|
216
216
|
) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
|
|
217
|
+
/**
|
|
218
|
+
* Enable preview/demo mode to show dummy data.
|
|
219
|
+
* Useful for testing, documentation, or showcasing the dashboard.
|
|
220
|
+
* When enabled, displays realistic sample documents with various SEO scores.
|
|
221
|
+
* Defaults to `false`.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* previewMode: true
|
|
225
|
+
*/
|
|
226
|
+
previewMode?: boolean
|
|
217
227
|
}
|
|
218
228
|
}
|
|
219
229
|
|
|
@@ -242,6 +252,7 @@ interface ResolvedDashboardConfig {
|
|
|
242
252
|
loadingLicense: string | undefined
|
|
243
253
|
loadingDocuments: string | undefined
|
|
244
254
|
noDocuments: string | undefined
|
|
255
|
+
previewMode: boolean | undefined
|
|
245
256
|
}
|
|
246
257
|
|
|
247
258
|
const resolveDashboardConfig = (
|
|
@@ -269,6 +280,7 @@ const resolveDashboardConfig = (
|
|
|
269
280
|
loadingLicense: cfg?.content?.loadingLicense,
|
|
270
281
|
loadingDocuments: cfg?.content?.loadingDocuments,
|
|
271
282
|
noDocuments: cfg?.content?.noDocuments,
|
|
283
|
+
previewMode: cfg?.previewMode,
|
|
272
284
|
}
|
|
273
285
|
}
|
|
274
286
|
|
|
@@ -295,6 +307,7 @@ const seofields = definePlugin<SeoFieldsPluginConfig | void>((config = {}) => {
|
|
|
295
307
|
loadingLicense: dash.loadingLicense,
|
|
296
308
|
loadingDocuments: dash.loadingDocuments,
|
|
297
309
|
noDocuments: dash.noDocuments,
|
|
310
|
+
previewMode: dash.previewMode,
|
|
298
311
|
})
|
|
299
312
|
|
|
300
313
|
return {
|