sanity-plugin-seofields 1.2.4 → 1.2.6
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/dist/index.cjs +2604 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +422 -0
- package/dist/index.d.ts +339 -492
- package/dist/index.js +1284 -2013
- package/dist/index.js.map +1 -1
- package/dist/next.cjs +182 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +241 -0
- package/dist/next.d.ts +202 -295
- package/dist/next.js +110 -70
- package/dist/next.js.map +1 -1
- package/dist/types-B91ena4g.d.cts +89 -0
- package/dist/types-B91ena4g.d.ts +89 -0
- package/package.json +37 -18
- package/dist/index.d.mts +0 -575
- package/dist/index.mjs +0 -3292
- package/dist/index.mjs.map +0 -1
- package/dist/next.d.mts +0 -334
- package/dist/next.mjs +0 -102
- package/dist/next.mjs.map +0 -1
- package/sanity.json +0 -8
- package/src/components/SeoHealthDashboard.tsx +0 -1568
- package/src/components/SeoHealthPane.tsx +0 -81
- package/src/components/SeoHealthTool.tsx +0 -11
- package/src/components/SeoPreview.tsx +0 -178
- package/src/components/meta/MetaDescription.tsx +0 -39
- package/src/components/meta/MetaTitle.tsx +0 -44
- package/src/components/openGraph/OgDescription.tsx +0 -46
- package/src/components/openGraph/OgTitle.tsx +0 -45
- package/src/components/twitter/twitterDescription.tsx +0 -45
- package/src/components/twitter/twitterTitle.tsx +0 -45
- package/src/helpers/SeoMetaTags.tsx +0 -154
- package/src/helpers/seoMeta.ts +0 -283
- package/src/index.ts +0 -26
- package/src/next.ts +0 -12
- package/src/plugin.ts +0 -344
- package/src/schemas/index.ts +0 -121
- package/src/schemas/types/index.ts +0 -20
- package/src/schemas/types/metaAttribute/index.ts +0 -60
- package/src/schemas/types/metaTag/index.ts +0 -17
- package/src/schemas/types/openGraph/index.ts +0 -114
- package/src/schemas/types/robots/index.ts +0 -26
- package/src/schemas/types/twitter/index.ts +0 -108
- package/src/types.ts +0 -108
- package/src/utils/fieldsUtils.ts +0 -160
- package/src/utils/seoUtils.ts +0 -423
- package/src/utils/utils.ts +0 -9
- package/v2-incompatible.js +0 -11
|
@@ -1,81 +0,0 @@
|
|
|
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
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import SeoHealthDashboard, {SeoHealthDashboardProps} from './SeoHealthDashboard'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Sanity Tool component for the SEO Health Dashboard
|
|
5
|
-
* This component wraps the SeoHealthDashboard for use as a custom tool in Sanity Studio
|
|
6
|
-
*/
|
|
7
|
-
const SeoHealthTool = (props: SeoHealthDashboardProps) => {
|
|
8
|
-
return <SeoHealthDashboard {...props} />
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export default SeoHealthTool
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import {Box, Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
import styled from 'styled-components'
|
|
5
|
-
|
|
6
|
-
import {truncate} from '../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const PreviewContainer = styled.div`
|
|
9
|
-
max-width: 600px;
|
|
10
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
-
background: #ffffff;
|
|
12
|
-
border: 1px solid #dadce0;
|
|
13
|
-
border-radius: 8px;
|
|
14
|
-
overflow: hidden;
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
const PreviewHeader = styled.div`
|
|
18
|
-
background: #f8f9fa;
|
|
19
|
-
padding: 12px 16px;
|
|
20
|
-
border-bottom: 1px solid #dadce0;
|
|
21
|
-
display: flex;
|
|
22
|
-
align-items: center;
|
|
23
|
-
justify-content: space-between;
|
|
24
|
-
gap: 8px;
|
|
25
|
-
`
|
|
26
|
-
|
|
27
|
-
const PreviewBody = styled.div`
|
|
28
|
-
padding: 16px;
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
const SerpUrl = styled.p`
|
|
32
|
-
margin: 0 0 4px;
|
|
33
|
-
color: #006621;
|
|
34
|
-
font-size: 13px;
|
|
35
|
-
line-height: 1.4;
|
|
36
|
-
word-break: break-word;
|
|
37
|
-
`
|
|
38
|
-
|
|
39
|
-
const SerpTitle = styled.h3`
|
|
40
|
-
margin: 0 0 8px;
|
|
41
|
-
color: #1a0dab;
|
|
42
|
-
font-size: 18px;
|
|
43
|
-
font-weight: 500;
|
|
44
|
-
line-height: 1.4;
|
|
45
|
-
word-break: break-word;
|
|
46
|
-
|
|
47
|
-
&:hover {
|
|
48
|
-
text-decoration: underline;
|
|
49
|
-
}
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
const SerpDescription = styled.p`
|
|
53
|
-
margin: 0;
|
|
54
|
-
color: #545454;
|
|
55
|
-
font-size: 14px;
|
|
56
|
-
line-height: 1.6;
|
|
57
|
-
word-break: break-word;
|
|
58
|
-
display: -webkit-box;
|
|
59
|
-
-webkit-line-clamp: 2;
|
|
60
|
-
-webkit-box-orient: vertical;
|
|
61
|
-
overflow: hidden;
|
|
62
|
-
`
|
|
63
|
-
|
|
64
|
-
const LiveIndicator = styled.span`
|
|
65
|
-
display: inline-flex;
|
|
66
|
-
align-items: center;
|
|
67
|
-
gap: 4px;
|
|
68
|
-
font-size: 11px;
|
|
69
|
-
font-weight: 600;
|
|
70
|
-
text-transform: uppercase;
|
|
71
|
-
letter-spacing: 0.05em;
|
|
72
|
-
color: #4f46e5;
|
|
73
|
-
background: #f0f4ff;
|
|
74
|
-
padding: 4px 8px;
|
|
75
|
-
border-radius: 4px;
|
|
76
|
-
`
|
|
77
|
-
|
|
78
|
-
const SeoPreview = (props: StringInputProps): React.ReactElement => {
|
|
79
|
-
const {path, schemaType} = props
|
|
80
|
-
const {options} = schemaType as {
|
|
81
|
-
options?: {
|
|
82
|
-
baseUrl?: string
|
|
83
|
-
prefix?: ((doc: {_type?: string} & Record<string, unknown>) => string) | string
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
const baseUrl = options?.baseUrl || 'https://www.example.com'
|
|
87
|
-
const prefixFunction = options?.prefix as
|
|
88
|
-
| ((doc: {_type?: string} & Record<string, unknown>) => string)
|
|
89
|
-
| undefined
|
|
90
|
-
const parent = useFormValue([path[0]]) || {
|
|
91
|
-
title: '',
|
|
92
|
-
description: '',
|
|
93
|
-
canonicalUrl: '',
|
|
94
|
-
}
|
|
95
|
-
const rootDoc: {
|
|
96
|
-
slug?: {current: string}
|
|
97
|
-
} = useFormValue([]) || {
|
|
98
|
-
slug: {current: ''},
|
|
99
|
-
}
|
|
100
|
-
const slug: string = rootDoc?.slug?.current || ''
|
|
101
|
-
|
|
102
|
-
const {
|
|
103
|
-
title,
|
|
104
|
-
description,
|
|
105
|
-
canonicalUrl: url,
|
|
106
|
-
} = parent as {
|
|
107
|
-
title?: string
|
|
108
|
-
description?: string
|
|
109
|
-
canonicalUrl?: string
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Build full URL
|
|
113
|
-
const base = (url || baseUrl)?.replace(/\/+$/, '')
|
|
114
|
-
const slugStr = String(slug || '').replace(/^\/+/, '')
|
|
115
|
-
const pref = String(
|
|
116
|
-
prefixFunction ? prefixFunction(rootDoc as {slug?: {current: string}}) : '',
|
|
117
|
-
).replace(/^\/+|\/+$/g, '')
|
|
118
|
-
const urlPath = [pref, slugStr].filter(Boolean).join('/')
|
|
119
|
-
const finalUrl = urlPath ? `${base}/${urlPath}` : base
|
|
120
|
-
|
|
121
|
-
// Extract domain for display
|
|
122
|
-
const domain = (() => {
|
|
123
|
-
try {
|
|
124
|
-
const u = new URL(finalUrl || base)
|
|
125
|
-
return u.hostname
|
|
126
|
-
} catch {
|
|
127
|
-
return 'example.com'
|
|
128
|
-
}
|
|
129
|
-
})()
|
|
130
|
-
|
|
131
|
-
// Format URL display with › separator
|
|
132
|
-
const urlDisplay = `${domain}${urlPath ? ` › ${urlPath.split('/').slice(-1)[0]}` : ''}`
|
|
133
|
-
|
|
134
|
-
return (
|
|
135
|
-
<Box padding={3}>
|
|
136
|
-
<PreviewContainer>
|
|
137
|
-
<PreviewHeader>
|
|
138
|
-
<span
|
|
139
|
-
style={{
|
|
140
|
-
fontSize: '11px',
|
|
141
|
-
color: '#5f6368',
|
|
142
|
-
textTransform: 'uppercase',
|
|
143
|
-
letterSpacing: '0.05em',
|
|
144
|
-
}}
|
|
145
|
-
>
|
|
146
|
-
Search Preview
|
|
147
|
-
</span>
|
|
148
|
-
<LiveIndicator>
|
|
149
|
-
<span
|
|
150
|
-
style={{
|
|
151
|
-
width: '4px',
|
|
152
|
-
height: '4px',
|
|
153
|
-
borderRadius: '50%',
|
|
154
|
-
backgroundColor: '#4f46e5',
|
|
155
|
-
display: 'inline-block',
|
|
156
|
-
}}
|
|
157
|
-
/>
|
|
158
|
-
Live
|
|
159
|
-
</LiveIndicator>
|
|
160
|
-
</PreviewHeader>
|
|
161
|
-
|
|
162
|
-
<PreviewBody>
|
|
163
|
-
<SerpUrl>{finalUrl ? urlDisplay : 'example.com › page-url'}</SerpUrl>
|
|
164
|
-
<SerpTitle>
|
|
165
|
-
{title && title.length > 0 ? truncate(title, 60) : 'Your SEO Title will appear here'}
|
|
166
|
-
</SerpTitle>
|
|
167
|
-
<SerpDescription>
|
|
168
|
-
{description && description.length > 0
|
|
169
|
-
? truncate(description, 160)
|
|
170
|
-
: 'Your meta description will show up here. Make it compelling!'}
|
|
171
|
-
</SerpDescription>
|
|
172
|
-
</PreviewBody>
|
|
173
|
-
</PreviewContainer>
|
|
174
|
-
</Box>
|
|
175
|
-
)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export default SeoPreview
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import {Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React, {useMemo} from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
|
|
5
|
-
import {FeedbackType} from '../../types'
|
|
6
|
-
import {getMetaDescriptionValidationMessages} from '../../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const MetaDescription = (props: StringInputProps): React.ReactElement => {
|
|
9
|
-
const {value, renderDefault, path} = props
|
|
10
|
-
|
|
11
|
-
const parent = useFormValue([path[0]]) as {keywords?: string[]; _type?: string}
|
|
12
|
-
const isParentseoField = parent && parent?._type === 'seoFields'
|
|
13
|
-
const keywords = useMemo(() => parent?.keywords || [], [parent?.keywords])
|
|
14
|
-
|
|
15
|
-
const feedbackItems = useMemo(
|
|
16
|
-
() => getMetaDescriptionValidationMessages(value || '', keywords, isParentseoField),
|
|
17
|
-
[value, keywords, isParentseoField],
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<Stack space={3}>
|
|
22
|
-
{renderDefault(props)}
|
|
23
|
-
<Stack space={2}>
|
|
24
|
-
{feedbackItems.map((item: FeedbackType) => (
|
|
25
|
-
<div key={item.text} style={{display: 'flex', alignItems: 'center', gap: 7}}>
|
|
26
|
-
<div
|
|
27
|
-
style={{width: 10, height: 10, borderRadius: '50%', backgroundColor: item.color}}
|
|
28
|
-
/>
|
|
29
|
-
<Text weight="bold" muted size={14}>
|
|
30
|
-
{item.text}
|
|
31
|
-
</Text>
|
|
32
|
-
</div>
|
|
33
|
-
))}
|
|
34
|
-
</Stack>
|
|
35
|
-
</Stack>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export default MetaDescription
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import {Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React, {useMemo} from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
|
|
5
|
-
import {FeedbackType} from '../../types'
|
|
6
|
-
import {getMetaTitleValidationMessages} from '../../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const MetaTitle = (props: StringInputProps): React.ReactElement => {
|
|
9
|
-
const {value, renderDefault, path} = props
|
|
10
|
-
|
|
11
|
-
const parent = useFormValue([path[0]]) as {keywords?: string[]; _type?: string}
|
|
12
|
-
const isParentseoField = parent && parent?._type === 'seoFields'
|
|
13
|
-
const keywords = useMemo(() => parent?.keywords || [], [parent?.keywords])
|
|
14
|
-
|
|
15
|
-
const feedbackItems = useMemo(
|
|
16
|
-
() => getMetaTitleValidationMessages(value || '', keywords, isParentseoField),
|
|
17
|
-
[value, keywords, isParentseoField],
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<Stack space={3}>
|
|
22
|
-
{renderDefault(props)}
|
|
23
|
-
<Stack space={2}>
|
|
24
|
-
{feedbackItems.map((item: FeedbackType) => (
|
|
25
|
-
<div key={item.text} style={{display: 'flex', alignItems: 'center', gap: 7}}>
|
|
26
|
-
<div
|
|
27
|
-
style={{
|
|
28
|
-
minWidth: 10,
|
|
29
|
-
height: 10,
|
|
30
|
-
borderRadius: '50%',
|
|
31
|
-
backgroundColor: item.color,
|
|
32
|
-
}}
|
|
33
|
-
/>
|
|
34
|
-
<Text weight="bold" muted size={14}>
|
|
35
|
-
{item.text}
|
|
36
|
-
</Text>
|
|
37
|
-
</div>
|
|
38
|
-
))}
|
|
39
|
-
</Stack>
|
|
40
|
-
</Stack>
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export default MetaTitle
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import {Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React, {useMemo} from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
|
|
5
|
-
import {FeedbackType} from '../../types'
|
|
6
|
-
import {getOgDescriptionValidation} from '../../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const OgDescription = (props: StringInputProps): React.ReactElement => {
|
|
9
|
-
const {value, renderDefault, path} = props
|
|
10
|
-
|
|
11
|
-
// Access parent object to get keywords
|
|
12
|
-
const parent = useFormValue([path[0]]) as {keywords?: string[]; _type?: string}
|
|
13
|
-
const isParentseoField = parent && parent?._type === 'seoFields'
|
|
14
|
-
const keywords = useMemo(() => parent?.keywords || [], [parent?.keywords])
|
|
15
|
-
|
|
16
|
-
const feedbackItems = useMemo(
|
|
17
|
-
() => getOgDescriptionValidation(value || '', keywords, isParentseoField),
|
|
18
|
-
[value, keywords, isParentseoField],
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<Stack space={3}>
|
|
23
|
-
{renderDefault(props)}
|
|
24
|
-
{/* Validation */}
|
|
25
|
-
<Stack space={2}>
|
|
26
|
-
{feedbackItems.map((item: FeedbackType) => (
|
|
27
|
-
<div key={item.text} style={{display: 'flex', alignItems: 'center', gap: 7}}>
|
|
28
|
-
<div
|
|
29
|
-
style={{
|
|
30
|
-
minWidth: 10,
|
|
31
|
-
height: 10,
|
|
32
|
-
borderRadius: '50%',
|
|
33
|
-
backgroundColor: item.color,
|
|
34
|
-
}}
|
|
35
|
-
/>
|
|
36
|
-
<Text weight="bold" muted size={14}>
|
|
37
|
-
{item.text}
|
|
38
|
-
</Text>
|
|
39
|
-
</div>
|
|
40
|
-
))}
|
|
41
|
-
</Stack>
|
|
42
|
-
</Stack>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export default OgDescription
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import {Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React, {useMemo} from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
|
|
5
|
-
import {FeedbackType} from '../../types'
|
|
6
|
-
import {getOgTitleValidation} from '../../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const OgTitle: React.FC<StringInputProps> = (props) => {
|
|
9
|
-
const {value, renderDefault, path} = props
|
|
10
|
-
|
|
11
|
-
// Access parent object to get keywords
|
|
12
|
-
const parent = useFormValue([path[0]]) as {keywords?: string[]; _type?: string}
|
|
13
|
-
const isParentseoField = parent && parent?._type === 'seoFields'
|
|
14
|
-
const keywords = useMemo(() => parent?.keywords || [], [parent?.keywords])
|
|
15
|
-
|
|
16
|
-
const feedbackItems = useMemo(
|
|
17
|
-
() => getOgTitleValidation(value || '', keywords, isParentseoField),
|
|
18
|
-
[value, keywords, isParentseoField],
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<Stack space={3}>
|
|
23
|
-
{renderDefault(props)}
|
|
24
|
-
<Stack space={2}>
|
|
25
|
-
{feedbackItems.map((item: FeedbackType) => (
|
|
26
|
-
<div key={item.text} style={{display: 'flex', alignItems: 'center', gap: 7}}>
|
|
27
|
-
<div
|
|
28
|
-
style={{
|
|
29
|
-
minWidth: 10,
|
|
30
|
-
height: 10,
|
|
31
|
-
borderRadius: '50%',
|
|
32
|
-
backgroundColor: item.color,
|
|
33
|
-
}}
|
|
34
|
-
/>
|
|
35
|
-
<Text weight="bold" muted size={14}>
|
|
36
|
-
{item.text}
|
|
37
|
-
</Text>
|
|
38
|
-
</div>
|
|
39
|
-
))}
|
|
40
|
-
</Stack>
|
|
41
|
-
</Stack>
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export default OgTitle
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import {Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React, {useMemo} from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
|
|
5
|
-
import {FeedbackType} from '../../types'
|
|
6
|
-
import {getTwitterDescriptionValidation} from '../../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const TwitterDescription = (props: StringInputProps): React.ReactElement => {
|
|
9
|
-
const {value, renderDefault, path} = props
|
|
10
|
-
|
|
11
|
-
// Access parent object to get keywords
|
|
12
|
-
const parent = useFormValue([path[0]]) as {keywords?: string[]; _type?: string}
|
|
13
|
-
const isParentseoField = parent && parent?._type === 'seoFields'
|
|
14
|
-
const keywords = useMemo(() => parent?.keywords || [], [parent?.keywords])
|
|
15
|
-
|
|
16
|
-
const feedbackItems = useMemo(
|
|
17
|
-
() => getTwitterDescriptionValidation(value || '', keywords, isParentseoField),
|
|
18
|
-
[value, keywords, isParentseoField],
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<Stack space={3}>
|
|
23
|
-
{renderDefault(props)}
|
|
24
|
-
<Stack space={2}>
|
|
25
|
-
{feedbackItems.map((item: FeedbackType) => (
|
|
26
|
-
<div key={item.text} style={{display: 'flex', alignItems: 'center', gap: 7}}>
|
|
27
|
-
<div
|
|
28
|
-
style={{
|
|
29
|
-
minWidth: 10,
|
|
30
|
-
height: 10,
|
|
31
|
-
borderRadius: '50%',
|
|
32
|
-
backgroundColor: item.color,
|
|
33
|
-
}}
|
|
34
|
-
/>
|
|
35
|
-
<Text weight="bold" muted size={14}>
|
|
36
|
-
{item.text}
|
|
37
|
-
</Text>
|
|
38
|
-
</div>
|
|
39
|
-
))}
|
|
40
|
-
</Stack>
|
|
41
|
-
</Stack>
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export default TwitterDescription
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import {Stack, Text} from '@sanity/ui'
|
|
2
|
-
import React, {useMemo} from 'react'
|
|
3
|
-
import {StringInputProps, useFormValue} from 'sanity'
|
|
4
|
-
|
|
5
|
-
import {FeedbackType} from '../../types'
|
|
6
|
-
import {getTwitterTitleValidation} from '../../utils/seoUtils'
|
|
7
|
-
|
|
8
|
-
const TwitterTitle = (props: StringInputProps): React.ReactElement => {
|
|
9
|
-
const {value, renderDefault, path} = props
|
|
10
|
-
|
|
11
|
-
// Access parent object to get keywords
|
|
12
|
-
const parent = useFormValue([path[0]]) as {keywords?: string[]; _type?: string}
|
|
13
|
-
const isParentseoField = parent && parent?._type === 'seoFields'
|
|
14
|
-
const keywords = useMemo(() => parent?.keywords || [], [parent?.keywords])
|
|
15
|
-
|
|
16
|
-
const feedbackItems = useMemo(
|
|
17
|
-
() => getTwitterTitleValidation(value || '', keywords, isParentseoField),
|
|
18
|
-
[value, keywords, isParentseoField],
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<Stack space={3}>
|
|
23
|
-
{renderDefault(props)}
|
|
24
|
-
<Stack space={2}>
|
|
25
|
-
{feedbackItems.map((item: FeedbackType) => (
|
|
26
|
-
<div key={item.text} style={{display: 'flex', alignItems: 'center', gap: 7}}>
|
|
27
|
-
<div
|
|
28
|
-
style={{
|
|
29
|
-
minWidth: 10,
|
|
30
|
-
height: 10,
|
|
31
|
-
borderRadius: '50%',
|
|
32
|
-
backgroundColor: item.color,
|
|
33
|
-
}}
|
|
34
|
-
/>
|
|
35
|
-
<Text weight="bold" muted size={14}>
|
|
36
|
-
{item.text}
|
|
37
|
-
</Text>
|
|
38
|
-
</div>
|
|
39
|
-
))}
|
|
40
|
-
</Stack>
|
|
41
|
-
</Stack>
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export default TwitterTitle
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* <SeoMetaTags> — Framework-agnostic React SEO meta tag renderer.
|
|
3
|
-
*
|
|
4
|
-
* Renders all SEO meta tags as plain React elements.
|
|
5
|
-
* Place it inside your framework's <Head> component:
|
|
6
|
-
*
|
|
7
|
-
* @example Next.js Pages Router
|
|
8
|
-
* ```tsx
|
|
9
|
-
* import Head from 'next/head'
|
|
10
|
-
* import { SeoMetaTags } from 'sanity-plugin-seofields'
|
|
11
|
-
*
|
|
12
|
-
* export default function Page({ seo }) {
|
|
13
|
-
* return (
|
|
14
|
-
* <>
|
|
15
|
-
* <Head>
|
|
16
|
-
* <SeoMetaTags
|
|
17
|
-
* data={seo}
|
|
18
|
-
* baseUrl="https://example.com"
|
|
19
|
-
* path="/about"
|
|
20
|
-
* defaults={{ title: 'My Site', siteName: 'My Site' }}
|
|
21
|
-
* imageUrlResolver={(img) => urlFor(img).width(1200).url()}
|
|
22
|
-
* />
|
|
23
|
-
* </Head>
|
|
24
|
-
* <main>...</main>
|
|
25
|
-
* </>
|
|
26
|
-
* )
|
|
27
|
-
* }
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* @example Nuxt 3 / generic SSR (inside <Head> slot)
|
|
31
|
-
* ```tsx
|
|
32
|
-
* <Head>
|
|
33
|
-
* <SeoMetaTags data={seo} baseUrl="https://example.com" path="/" />
|
|
34
|
-
* </Head>
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
import React from 'react'
|
|
38
|
-
|
|
39
|
-
import type {SanityImage, SanityImageWithAlt, SeoFields} from '../types'
|
|
40
|
-
import {buildSeoMeta, type BuildSeoMetaOptions} from './seoMeta'
|
|
41
|
-
|
|
42
|
-
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
export interface SeoMetaTagsProps {
|
|
45
|
-
/**
|
|
46
|
-
* The raw SEO object from Sanity.
|
|
47
|
-
* Pass `null` / `undefined` to render only the defaults.
|
|
48
|
-
*/
|
|
49
|
-
data?: Partial<SeoFields> | null
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Base URL of your site, e.g. "https://example.com".
|
|
53
|
-
* Used for canonical link, og:url fallback.
|
|
54
|
-
*/
|
|
55
|
-
baseUrl?: string
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Current page path, e.g. "/about".
|
|
59
|
-
* Defaults to "".
|
|
60
|
-
*/
|
|
61
|
-
path?: string
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Default values used when SEO fields are missing.
|
|
65
|
-
*/
|
|
66
|
-
defaults?: BuildSeoMetaOptions['defaults']
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Resolve a Sanity image asset reference to a full URL string.
|
|
70
|
-
*
|
|
71
|
-
* @example
|
|
72
|
-
* imageUrlResolver={(img) => urlFor(img).width(1200).url()}
|
|
73
|
-
*/
|
|
74
|
-
imageUrlResolver?: (image: SanityImage | SanityImageWithAlt) => string | null | undefined
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ─── Component ────────────────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Renders all SEO meta tags for a page as plain React elements.
|
|
81
|
-
* Intended to be placed inside your framework's <Head> / <head> component.
|
|
82
|
-
*
|
|
83
|
-
* Renders:
|
|
84
|
-
* - `<title>`
|
|
85
|
-
* - `<meta name="description">`
|
|
86
|
-
* - `<meta name="keywords">`
|
|
87
|
-
* - `<meta name="robots">`
|
|
88
|
-
* - OpenGraph meta tags (`og:*`)
|
|
89
|
-
* - Twitter Card meta tags (`twitter:*`)
|
|
90
|
-
* - Any custom `seo.metaAttributes` as `<meta name="..." content="...">`
|
|
91
|
-
*/
|
|
92
|
-
export function SeoMetaTags({data, baseUrl, path, defaults, imageUrlResolver}: SeoMetaTagsProps) {
|
|
93
|
-
const meta = buildSeoMeta({seo: data, baseUrl, path, defaults, imageUrlResolver})
|
|
94
|
-
|
|
95
|
-
const robotsContent = [
|
|
96
|
-
meta.robots?.index === false ? 'noindex' : 'index',
|
|
97
|
-
meta.robots?.follow === false ? 'nofollow' : 'follow',
|
|
98
|
-
].join(', ')
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<>
|
|
102
|
-
{/* ── Title ── */}
|
|
103
|
-
{meta.title && <title>{meta.title}</title>}
|
|
104
|
-
|
|
105
|
-
{/* ── Basic meta ── */}
|
|
106
|
-
{meta.description && <meta name="description" content={meta.description} />}
|
|
107
|
-
{meta.keywords?.length ? <meta name="keywords" content={meta.keywords.join(', ')} /> : null}
|
|
108
|
-
<meta name="robots" content={robotsContent} />
|
|
109
|
-
<meta name="googlebot" content={robotsContent} />
|
|
110
|
-
|
|
111
|
-
{/* ── Open Graph ── */}
|
|
112
|
-
{meta.openGraph?.type && <meta property="og:type" content={meta.openGraph.type} />}
|
|
113
|
-
{meta.openGraph?.url && <meta property="og:url" content={meta.openGraph.url} />}
|
|
114
|
-
{meta.openGraph?.title && <meta property="og:title" content={meta.openGraph.title} />}
|
|
115
|
-
{meta.openGraph?.description && (
|
|
116
|
-
<meta property="og:description" content={meta.openGraph.description} />
|
|
117
|
-
)}
|
|
118
|
-
{meta.openGraph?.siteName && (
|
|
119
|
-
<meta property="og:site_name" content={meta.openGraph.siteName} />
|
|
120
|
-
)}
|
|
121
|
-
{meta.openGraph?.images?.map((img, i) => (
|
|
122
|
-
<React.Fragment key={`og-img-${i}`}>
|
|
123
|
-
<meta property="og:image" content={img.url} />
|
|
124
|
-
{img.width && <meta property="og:image:width" content={String(img.width)} />}
|
|
125
|
-
{img.height && <meta property="og:image:height" content={String(img.height)} />}
|
|
126
|
-
{img.alt && <meta property="og:image:alt" content={img.alt} />}
|
|
127
|
-
</React.Fragment>
|
|
128
|
-
))}
|
|
129
|
-
|
|
130
|
-
{/* ── Twitter Card ── */}
|
|
131
|
-
{meta.twitter?.card && <meta name="twitter:card" content={meta.twitter.card} />}
|
|
132
|
-
{meta.twitter?.site && <meta name="twitter:site" content={meta.twitter.site} />}
|
|
133
|
-
{meta.twitter?.creator && <meta name="twitter:creator" content={meta.twitter.creator} />}
|
|
134
|
-
{meta.twitter?.title && <meta name="twitter:title" content={meta.twitter.title} />}
|
|
135
|
-
{meta.twitter?.description && (
|
|
136
|
-
<meta name="twitter:description" content={meta.twitter.description} />
|
|
137
|
-
)}
|
|
138
|
-
{meta.twitter?.images?.map((url, i) => (
|
|
139
|
-
<meta key={`tw-img-${i}`} name="twitter:image" content={url} />
|
|
140
|
-
))}
|
|
141
|
-
|
|
142
|
-
{/* ── Custom meta attributes ── */}
|
|
143
|
-
{meta.other &&
|
|
144
|
-
Object.entries(meta.other).map(([name, content]) => (
|
|
145
|
-
<meta key={`custom-${name}`} name={name} content={content} />
|
|
146
|
-
))}
|
|
147
|
-
|
|
148
|
-
{/* ── Canonical URL ── */}
|
|
149
|
-
{meta.alternates?.canonical && <link rel="canonical" href={meta.alternates.canonical} />}
|
|
150
|
-
</>
|
|
151
|
-
)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export default SeoMetaTags
|