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.
Files changed (49) hide show
  1. package/dist/index.cjs +2604 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +422 -0
  4. package/dist/index.d.ts +339 -492
  5. package/dist/index.js +1284 -2013
  6. package/dist/index.js.map +1 -1
  7. package/dist/next.cjs +182 -0
  8. package/dist/next.cjs.map +1 -0
  9. package/dist/next.d.cts +241 -0
  10. package/dist/next.d.ts +202 -295
  11. package/dist/next.js +110 -70
  12. package/dist/next.js.map +1 -1
  13. package/dist/types-B91ena4g.d.cts +89 -0
  14. package/dist/types-B91ena4g.d.ts +89 -0
  15. package/package.json +37 -18
  16. package/dist/index.d.mts +0 -575
  17. package/dist/index.mjs +0 -3292
  18. package/dist/index.mjs.map +0 -1
  19. package/dist/next.d.mts +0 -334
  20. package/dist/next.mjs +0 -102
  21. package/dist/next.mjs.map +0 -1
  22. package/sanity.json +0 -8
  23. package/src/components/SeoHealthDashboard.tsx +0 -1568
  24. package/src/components/SeoHealthPane.tsx +0 -81
  25. package/src/components/SeoHealthTool.tsx +0 -11
  26. package/src/components/SeoPreview.tsx +0 -178
  27. package/src/components/meta/MetaDescription.tsx +0 -39
  28. package/src/components/meta/MetaTitle.tsx +0 -44
  29. package/src/components/openGraph/OgDescription.tsx +0 -46
  30. package/src/components/openGraph/OgTitle.tsx +0 -45
  31. package/src/components/twitter/twitterDescription.tsx +0 -45
  32. package/src/components/twitter/twitterTitle.tsx +0 -45
  33. package/src/helpers/SeoMetaTags.tsx +0 -154
  34. package/src/helpers/seoMeta.ts +0 -283
  35. package/src/index.ts +0 -26
  36. package/src/next.ts +0 -12
  37. package/src/plugin.ts +0 -344
  38. package/src/schemas/index.ts +0 -121
  39. package/src/schemas/types/index.ts +0 -20
  40. package/src/schemas/types/metaAttribute/index.ts +0 -60
  41. package/src/schemas/types/metaTag/index.ts +0 -17
  42. package/src/schemas/types/openGraph/index.ts +0 -114
  43. package/src/schemas/types/robots/index.ts +0 -26
  44. package/src/schemas/types/twitter/index.ts +0 -108
  45. package/src/types.ts +0 -108
  46. package/src/utils/fieldsUtils.ts +0 -160
  47. package/src/utils/seoUtils.ts +0 -423
  48. package/src/utils/utils.ts +0 -9
  49. 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