sanity-plugin-seofields 1.1.1 → 1.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-seofields",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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",
@@ -197,6 +197,13 @@ const ColTitle = styled.div`
197
197
  min-width: 0;
198
198
  `
199
199
 
200
+ const TitleWrapper = styled.div`
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 4px;
204
+ flex-wrap: wrap;
205
+ `
206
+
200
207
  const ColType = styled.div`
201
208
  flex: 0.8;
202
209
  min-width: 80px;
@@ -248,6 +255,24 @@ const TypeBadge = styled.span`
248
255
  color: #5b21b6;
249
256
  `
250
257
 
258
+ const TypeText = styled.span`
259
+ font-size: 12px;
260
+ font-weight: 500;
261
+ color: #374151;
262
+ `
263
+
264
+ const CustomBadge = styled.span<{$bgColor?: string; $textColor?: string; $fontSize?: string}>`
265
+ display: inline-block;
266
+ padding: 2px 6px;
267
+ border-radius: 4px;
268
+ font-size: ${(p) => p.$fontSize || '10px'};
269
+ font-weight: 600;
270
+ margin-left: 6px;
271
+ background: ${(p) => p.$bgColor || '#e0e7ff'};
272
+ color: ${(p) => p.$textColor || '#3730a3'};
273
+ white-space: nowrap;
274
+ `
275
+
251
276
  const ScoreBadge = styled.span<{$score: number}>`
252
277
  display: inline-block;
253
278
  padding: 4px 10px;
@@ -440,6 +465,22 @@ const DocTitleAnchor: React.FC<{id: string; type: string; children: React.ReactN
440
465
  )
441
466
  }
442
467
 
468
+ // Sub-component to safely call docBadge outside a .map expression
469
+ const DocBadgeRenderer: React.FC<{
470
+ doc: DocumentWithSeoHealth & Record<string, unknown>
471
+ docBadge: (
472
+ doc: DocumentWithSeoHealth & Record<string, unknown>,
473
+ ) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
474
+ }> = ({doc, docBadge}) => {
475
+ const badge = docBadge(doc)
476
+ if (!badge) return null
477
+ return (
478
+ <CustomBadge $bgColor={badge.bgColor} $textColor={badge.textColor} $fontSize={badge.fontSize}>
479
+ {badge.label}
480
+ </CustomBadge>
481
+ )
482
+ }
483
+
443
484
  const spin = keyframes`
444
485
  to { transform: rotate(360deg); }
445
486
  `
@@ -608,6 +649,24 @@ const calculateHealthScore = (doc: any): SeoHealthMetrics => {
608
649
  return {score: totalScore, status, issues: allIssues}
609
650
  }
610
651
 
652
+ const resolveTypeLabel = (type: string, typeLabels?: Record<string, string>): string =>
653
+ typeLabels?.[type] ?? type
654
+
655
+ /**
656
+ * Builds the GROQ projection snippet for the title field.
657
+ * - undefined / 'title' → `title`
658
+ * - 'name' → `"title": name`
659
+ * - { post: 'title', product: 'name' } → `"title": select(_type == "post" => title, _type == "product" => name, title)`
660
+ */
661
+ const buildTitleProjection = (titleField?: string | Record<string, string>): string => {
662
+ if (!titleField || titleField === 'title') return 'title'
663
+ if (typeof titleField === 'string') return `"title": ${titleField}`
664
+ const cases = Object.entries(titleField)
665
+ .map(([type, field]) => `_type == "${type}" => ${field}`)
666
+ .join(', ')
667
+ return `"title": select(${cases}, title)`
668
+ }
669
+
611
670
  export interface SeoHealthDashboardProps {
612
671
  icon?: string
613
672
  title?: string
@@ -641,6 +700,66 @@ export interface SeoHealthDashboardProps {
641
700
  * Obtain a key at https://sanity-plugin-seofields.thehardik.in
642
701
  */
643
702
  licenseKey?: string
703
+ /**
704
+ * Map raw `_type` values to human-readable display labels used in the
705
+ * Type column and the Type filter dropdown.
706
+ * Any type without an entry falls back to the raw `_type` string.
707
+ *
708
+ * @example
709
+ * typeLabels={{ productDrug: 'Products', singleCondition: 'Condition' }}
710
+ */
711
+ typeLabels?: Record<string, string>
712
+ /**
713
+ * Controls how the type is rendered in the Type column.
714
+ * - `'badge'` (default) — coloured pill, consistent with score badges
715
+ * - `'text'` — plain text, useful for dense layouts
716
+ */
717
+ typeColumnMode?: 'badge' | 'text'
718
+ /**
719
+ * The document field to use as the display title.
720
+ *
721
+ * - `string` — use this field for every document type (e.g. `'name'`)
722
+ * - `Record<string, string>` — per-type mapping; unmapped types fall back to `title`
723
+ *
724
+ * @example
725
+ * // Same field for all types
726
+ * titleField: 'name'
727
+ *
728
+ * @example
729
+ * // Different field per type
730
+ * titleField: { post: 'title', product: 'name', category: 'label' }
731
+ */
732
+ titleField?: string | Record<string, string>
733
+ /**
734
+ * Callback function to render a custom badge next to the document title.
735
+ * Receives the full document and should return badge data or undefined.
736
+ *
737
+ * @example
738
+ * docBadge: (doc) => {
739
+ * if (doc.services === 'NHS')
740
+ * return { label: 'NHS', bgColor: '#e0f2fe', textColor: '#0369a1' }
741
+ * if (doc.services === 'Private')
742
+ * return { label: 'Private', bgColor: '#fef3c7', textColor: '#92400e' }
743
+ * }
744
+ */
745
+ docBadge?: (
746
+ doc: DocumentWithSeoHealth & Record<string, unknown>,
747
+ ) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
748
+ /**
749
+ * Custom text shown while the license key is being verified.
750
+ * Defaults to `"Verifying license…"`.
751
+ */
752
+ loadingLicense?: React.ReactNode
753
+ /**
754
+ * Custom text shown while documents are being fetched.
755
+ * Defaults to `"Loading documents…"`.
756
+ */
757
+ loadingDocuments?: React.ReactNode
758
+ /**
759
+ * Custom text shown when the query returns zero results.
760
+ * Defaults to `"No documents found"`.
761
+ */
762
+ noDocuments?: React.ReactNode
644
763
  }
645
764
 
646
765
  const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
@@ -654,6 +773,13 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
654
773
  customQuery,
655
774
  apiVersion = '2023-01-01',
656
775
  licenseKey,
776
+ typeLabels,
777
+ typeColumnMode = 'badge',
778
+ titleField,
779
+ docBadge,
780
+ loadingLicense,
781
+ loadingDocuments,
782
+ noDocuments,
657
783
  }) => {
658
784
  const client = useClient({apiVersion})
659
785
  const [licenseStatus, setLicenseStatus] = useState<'loading' | 'valid' | 'invalid'>('loading')
@@ -764,10 +890,11 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
764
890
  } else if (queryTypes && queryTypes.length > 0) {
765
891
  // Mode 2: filter by specific document types (excluding drafts)
766
892
  const seoFilter = queryRequireSeo ? ' && seo != null' : ''
893
+ const titleProj = buildTitleProjection(titleField)
767
894
  groqQuery = `*[_type in $types${seoFilter} && !(_id in path("drafts.**"))]{
768
895
  _id,
769
896
  _type,
770
- title,
897
+ ${titleProj},
771
898
  slug,
772
899
  seo,
773
900
  _updatedAt
@@ -775,10 +902,11 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
775
902
  params = {types: queryTypes}
776
903
  } else {
777
904
  // Mode 1: default — all documents with an seo field (excluding drafts)
905
+ const titleProj = buildTitleProjection(titleField)
778
906
  groqQuery = `*[seo != null && !(_id in path("drafts.**"))]{
779
907
  _id,
780
908
  _type,
781
- title,
909
+ ${titleProj},
782
910
  slug,
783
911
  seo,
784
912
  _updatedAt
@@ -804,7 +932,7 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
804
932
 
805
933
  fetchDocuments()
806
934
  // eslint-disable-next-line react-hooks/exhaustive-deps
807
- }, [client, customQuery, queryRequireSeo, JSON.stringify(queryTypes)])
935
+ }, [client, customQuery, queryRequireSeo, JSON.stringify(queryTypes), JSON.stringify(titleField)])
808
936
 
809
937
  const uniqueDocumentTypes = useMemo(() => {
810
938
  const types = new Set(documents.map((doc) => doc._type))
@@ -863,7 +991,7 @@ const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
863
991
  {licenseStatus === 'loading' && (
864
992
  <LoadingState style={{padding: '80px 24px'}}>
865
993
  <Spinner />
866
- Verifying license…
994
+ {loadingLicense ?? 'Verifying license…'}
867
995
  </LoadingState>
868
996
  )}
869
997
  {licenseStatus === 'invalid' && (
@@ -1005,7 +1133,7 @@ export default defineConfig({
1005
1133
  <option value="all">All Types</option>
1006
1134
  {uniqueDocumentTypes.map((type) => (
1007
1135
  <option key={type} value={type}>
1008
- {type}
1136
+ {resolveTypeLabel(type, typeLabels)}
1009
1137
  </option>
1010
1138
  ))}
1011
1139
  </StyledSelect>
@@ -1024,12 +1152,12 @@ export default defineConfig({
1024
1152
  {loading && (
1025
1153
  <LoadingState>
1026
1154
  <Spinner />
1027
- Loading documents…
1155
+ {loadingDocuments ?? 'Loading documents…'}
1028
1156
  </LoadingState>
1029
1157
  )}
1030
1158
  {!loading &&
1031
1159
  (filteredAndSortedDocs.length === 0 ? (
1032
- <EmptyState>No documents found</EmptyState>
1160
+ <EmptyState>{noDocuments ?? 'No documents found'}</EmptyState>
1033
1161
  ) : (
1034
1162
  <>
1035
1163
  <TableHeader>
@@ -1042,14 +1170,28 @@ export default defineConfig({
1042
1170
  return (
1043
1171
  <TableRow key={doc._id}>
1044
1172
  <ColTitle>
1045
- <DocTitleAnchor id={doc._id} type={doc._type}>
1046
- {doc.title || 'Untitled'}
1047
- </DocTitleAnchor>
1048
- {showDocumentId && <DocId>{doc._id}</DocId>}
1173
+ <TitleWrapper>
1174
+ <div>
1175
+ <DocTitleAnchor id={doc._id} type={doc._type}>
1176
+ {doc.title || 'Untitled'}
1177
+ </DocTitleAnchor>
1178
+ {showDocumentId && <DocId>{doc._id}</DocId>}
1179
+ </div>
1180
+ {docBadge && (
1181
+ <DocBadgeRenderer
1182
+ doc={doc as DocumentWithSeoHealth & Record<string, unknown>}
1183
+ docBadge={docBadge}
1184
+ />
1185
+ )}
1186
+ </TitleWrapper>
1049
1187
  </ColTitle>
1050
1188
  {showTypeColumn && (
1051
1189
  <ColType>
1052
- <TypeBadge>{doc._type}</TypeBadge>
1190
+ {typeColumnMode === 'text' ? (
1191
+ <TypeText>{resolveTypeLabel(doc._type, typeLabels)}</TypeText>
1192
+ ) : (
1193
+ <TypeBadge>{resolveTypeLabel(doc._type, typeLabels)}</TypeBadge>
1194
+ )}
1053
1195
  </ColType>
1054
1196
  )}
1055
1197
  <ColScore>
package/src/plugin.ts CHANGED
@@ -4,6 +4,7 @@ import {definePlugin} from 'sanity'
4
4
 
5
5
  import SeoHealthTool from './components/SeoHealthTool'
6
6
  import types from './schemas/types'
7
+ import type {DocumentWithSeoHealth} from './types'
7
8
 
8
9
  export interface SeoFieldConfig {
9
10
  title?: string
@@ -131,6 +132,12 @@ export interface SeoFieldsPluginConfig {
131
132
  icon?: string
132
133
  title?: string
133
134
  description?: string
135
+ /** Text shown while the license key is being verified. Defaults to "Verifying license…" */
136
+ loadingLicense?: string
137
+ /** Text shown while documents are being fetched. Defaults to "Loading documents…" */
138
+ loadingDocuments?: string
139
+ /** Text shown when the query returns zero results. Defaults to "No documents found" */
140
+ noDocuments?: string
134
141
  }
135
142
  display?: {
136
143
  typeColumn?: boolean
@@ -164,73 +171,142 @@ export interface SeoFieldsPluginConfig {
164
171
  * Obtain a license at https://sanity-plugin-seofields.thehardik.in
165
172
  */
166
173
  licenseKey?: string
174
+ /**
175
+ * Map raw `_type` values to human-readable display labels.
176
+ * Used in both the Type column and the Type filter dropdown.
177
+ * Any type without an entry falls back to the raw `_type` string.
178
+ *
179
+ * @example
180
+ * typeLabels: { productDrug: 'Products', singleCondition: 'Condition' }
181
+ */
182
+ typeLabels?: Record<string, string>
183
+ /**
184
+ * Controls how the document type is rendered in the Type column.
185
+ * - `'badge'` (default) — coloured pill
186
+ * - `'text'` — plain text, useful for dense layouts
187
+ */
188
+ typeColumnMode?: 'badge' | 'text'
189
+ /**
190
+ * The document field to use as the display title in the dashboard.
191
+ *
192
+ * - `string` — use this field for every document type (e.g. `'name'`)
193
+ * - `Record<string, string>` — per-type mapping; unmapped types fall back to `title`
194
+ *
195
+ * @example
196
+ * titleField: 'name'
197
+ *
198
+ * @example
199
+ * titleField: { post: 'title', product: 'name', category: 'label' }
200
+ */
201
+ titleField?: string | Record<string, string>
202
+ /**
203
+ * Callback function to render a custom badge next to the document title.
204
+ * Receives the full document and should return badge data or undefined.
205
+ *
206
+ * @example
207
+ * docBadge: (doc) => {
208
+ * if (doc.services === 'NHS')
209
+ * return { label: 'NHS', bgColor: '#e0f2fe', textColor: '#0369a1' }
210
+ * if (doc.services === 'Private')
211
+ * return { label: 'Private', bgColor: '#fef3c7', textColor: '#92400e' }
212
+ * }
213
+ */
214
+ docBadge?: (
215
+ doc: DocumentWithSeoHealth & Record<string, unknown>,
216
+ ) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
167
217
  }
168
218
  }
169
219
 
220
+ interface ResolvedDashboardConfig {
221
+ enabled: boolean
222
+ toolTitle: string
223
+ toolName: string
224
+ icon: string | undefined
225
+ title: string | undefined
226
+ description: string | undefined
227
+ showTypeColumn: boolean | undefined
228
+ showDocumentId: boolean | undefined
229
+ queryTypes: string[] | undefined
230
+ queryRequireSeo: boolean | undefined
231
+ queryGroq: string | undefined
232
+ apiVersion: string | undefined
233
+ licenseKey: string | undefined
234
+ typeLabels: Record<string, string> | undefined
235
+ typeColumnMode: 'badge' | 'text' | undefined
236
+ titleField: string | Record<string, string> | undefined
237
+ docBadge:
238
+ | ((
239
+ doc: DocumentWithSeoHealth & Record<string, unknown>,
240
+ ) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined)
241
+ | undefined
242
+ loadingLicense: string | undefined
243
+ loadingDocuments: string | undefined
244
+ noDocuments: string | undefined
245
+ }
246
+
247
+ const resolveDashboardConfig = (
248
+ healthDashboard: SeoFieldsPluginConfig['healthDashboard'],
249
+ ): ResolvedDashboardConfig => {
250
+ const cfg = typeof healthDashboard === 'object' ? healthDashboard : undefined
251
+ return {
252
+ enabled: healthDashboard !== false,
253
+ toolTitle: cfg?.tool?.title ?? 'SEO Health',
254
+ toolName: cfg?.tool?.name ?? 'seo-health-dashboard',
255
+ icon: cfg?.content?.icon,
256
+ title: cfg?.content?.title,
257
+ description: cfg?.content?.description,
258
+ showTypeColumn: cfg?.display?.typeColumn,
259
+ showDocumentId: cfg?.display?.documentId,
260
+ queryTypes: cfg?.query?.types,
261
+ queryRequireSeo: cfg?.query?.requireSeo,
262
+ queryGroq: cfg?.query?.groq,
263
+ apiVersion: cfg?.apiVersion,
264
+ licenseKey: cfg?.licenseKey,
265
+ typeLabels: cfg?.typeLabels,
266
+ typeColumnMode: cfg?.typeColumnMode,
267
+ titleField: cfg?.titleField,
268
+ docBadge: cfg?.docBadge,
269
+ loadingLicense: cfg?.content?.loadingLicense,
270
+ loadingDocuments: cfg?.content?.loadingDocuments,
271
+ noDocuments: cfg?.content?.noDocuments,
272
+ }
273
+ }
274
+
170
275
  const seofields = definePlugin<SeoFieldsPluginConfig | void>((config = {}) => {
171
276
  const {healthDashboard = true} = config as SeoFieldsPluginConfig
172
- const dashboardEnabled = healthDashboard !== false
173
- const dashboardToolTitle =
174
- typeof healthDashboard === 'object'
175
- ? (healthDashboard.tool?.title ?? 'SEO Health')
176
- : 'SEO Health'
177
- const dashboardName =
178
- typeof healthDashboard === 'object'
179
- ? (healthDashboard.tool?.name ?? 'seo-health-dashboard')
180
- : 'seo-health-dashboard'
181
- const dashboardPageIcon =
182
- typeof healthDashboard === 'object' ? (healthDashboard.content?.icon ?? undefined) : undefined
183
- const dashboardPageTitle =
184
- typeof healthDashboard === 'object' ? (healthDashboard.content?.title ?? undefined) : undefined
185
- const dashboardDescription =
186
- typeof healthDashboard === 'object'
187
- ? (healthDashboard.content?.description ?? undefined)
188
- : undefined
189
- const dashboardShowTypeColumn =
190
- typeof healthDashboard === 'object'
191
- ? (healthDashboard.display?.typeColumn ?? undefined)
192
- : undefined
193
- const dashboardShowDocumentId =
194
- typeof healthDashboard === 'object'
195
- ? (healthDashboard.display?.documentId ?? undefined)
196
- : undefined
197
- const dashboardQueryTypes =
198
- typeof healthDashboard === 'object' ? (healthDashboard.query?.types ?? undefined) : undefined
199
- const dashboardQueryRequireSeo =
200
- typeof healthDashboard === 'object'
201
- ? (healthDashboard.query?.requireSeo ?? undefined)
202
- : undefined
203
- const dashboardQueryGroq =
204
- typeof healthDashboard === 'object' ? (healthDashboard.query?.groq ?? undefined) : undefined
205
- const dashboardApiVersion =
206
- typeof healthDashboard === 'object' ? (healthDashboard.apiVersion ?? undefined) : undefined
207
- const dashboardLicenseKey =
208
- typeof healthDashboard === 'object' ? (healthDashboard.licenseKey ?? undefined) : undefined
277
+ const dash = resolveDashboardConfig(healthDashboard)
209
278
 
210
279
  const BoundSeoHealthTool = () =>
211
280
  React.createElement(SeoHealthTool, {
212
- icon: dashboardPageIcon,
213
- title: dashboardPageTitle,
214
- description: dashboardDescription,
215
- showTypeColumn: dashboardShowTypeColumn,
216
- showDocumentId: dashboardShowDocumentId,
217
- queryTypes: dashboardQueryTypes,
218
- queryRequireSeo: dashboardQueryRequireSeo,
219
- customQuery: dashboardQueryGroq,
220
- apiVersion: dashboardApiVersion,
221
- licenseKey: dashboardLicenseKey,
281
+ icon: dash.icon,
282
+ title: dash.title,
283
+ description: dash.description,
284
+ showTypeColumn: dash.showTypeColumn,
285
+ showDocumentId: dash.showDocumentId,
286
+ queryTypes: dash.queryTypes,
287
+ queryRequireSeo: dash.queryRequireSeo,
288
+ customQuery: dash.queryGroq,
289
+ apiVersion: dash.apiVersion,
290
+ licenseKey: dash.licenseKey,
291
+ typeLabels: dash.typeLabels,
292
+ typeColumnMode: dash.typeColumnMode,
293
+ titleField: dash.titleField,
294
+ docBadge: dash.docBadge,
295
+ loadingLicense: dash.loadingLicense,
296
+ loadingDocuments: dash.loadingDocuments,
297
+ noDocuments: dash.noDocuments,
222
298
  })
223
299
 
224
300
  return {
225
301
  name: 'sanity-plugin-seofields',
226
302
  schema: {
227
- types: types(config as SeoFieldsPluginConfig), // pass config down to schemas
303
+ types: types(config as SeoFieldsPluginConfig),
228
304
  },
229
- ...(dashboardEnabled && {
305
+ ...(dash.enabled && {
230
306
  tools: [
231
307
  {
232
- name: dashboardName,
233
- title: dashboardToolTitle,
308
+ name: dash.toolName,
309
+ title: dash.toolTitle,
234
310
  component: BoundSeoHealthTool,
235
311
  icon: () => '📊',
236
312
  },
@@ -111,7 +111,7 @@ export const getMetaDescriptionValidationMessages = (
111
111
  ): FeedbackType[] => {
112
112
  const feedback: FeedbackType[] = []
113
113
 
114
- const minChar = 150
114
+ const minChar = 120
115
115
  const maxChar = 160
116
116
  const charCount = description?.length || 0
117
117