sanity-plugin-seofields 1.2.3 → 1.2.5

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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Headless CMS integration helpers for sanity-plugin-seofields
3
+ *
4
+ * Provides framework-agnostic SEO metadata utilities for use with:
5
+ * - Next.js App Router → buildSeoMeta() inside generateMetadata()
6
+ * - Next.js Pages Router → <SeoMetaTags> inside Next.js <Head>
7
+ * - Nuxt / Remix / any SSR → <SeoMetaTags> inside your <head> slot
8
+ */
9
+
10
+ import type {SanityImage, SanityImageWithAlt, SeoFields} from '../types'
11
+
12
+ // ─── Types ────────────────────────────────────────────────────────────────────
13
+
14
+ /** Structured metadata returned by buildSeoMeta(). Compatible with Next.js Metadata (App Router). */
15
+ export interface SeoMetadata {
16
+ title?: string | null
17
+ description?: string | null
18
+ keywords?: string[]
19
+ robots?: {
20
+ index?: boolean
21
+ follow?: boolean
22
+ googleBot?: {
23
+ index?: boolean
24
+ follow?: boolean
25
+ }
26
+ }
27
+ openGraph?: {
28
+ type?: string
29
+ url?: string
30
+ title?: string
31
+ description?: string
32
+ siteName?: string
33
+ images?: Array<{url: string; width?: number; height?: number; alt?: string}>
34
+ }
35
+ twitter?: {
36
+ card?: string
37
+ site?: string
38
+ creator?: string
39
+ title?: string
40
+ description?: string
41
+ images?: string[]
42
+ }
43
+ alternates?: {
44
+ canonical?: string
45
+ }
46
+ /** Any custom meta attributes from seo.metaAttributes */
47
+ other?: Record<string, string>
48
+ }
49
+
50
+ /** Default values used when SEO fields are missing. */
51
+ export interface SeoMetaDefaults {
52
+ title?: string
53
+ description?: string
54
+ siteName?: string
55
+ twitterSite?: string
56
+ twitterCreator?: string
57
+ /** Fallback image URL when no OG / Twitter image is set. */
58
+ ogImage?: string
59
+ }
60
+
61
+ /**
62
+ * Permissive image shape accepted by buildSeoMeta — compatible with both the
63
+ * plugin's SanityImage and Sanity's code-generated image type (where `asset`
64
+ * and `alt` are optional).
65
+ */
66
+ interface SeoImageInput {
67
+ _type?: string
68
+ asset?: {_ref: string; _type: string; _weak?: boolean; [key: string]: unknown}
69
+ hotspot?: unknown
70
+ crop?: unknown
71
+ alt?: string
72
+ }
73
+
74
+ /**
75
+ * Input-compatible variant of SeoFields. Structurally matches Sanity's
76
+ * code-generated types (where `asset`, `alt`, `key`, and `type` are all
77
+ * optional), so you can pass `data.seo` from a sanityFetch result directly
78
+ * without any `as any` or manual casting.
79
+ */
80
+ export interface SeoFieldsInput {
81
+ _type?: string
82
+ robots?: {noIndex?: boolean | null; noFollow?: boolean | null} | null
83
+ title?: string | null
84
+ description?: string | null
85
+ metaImage?: SeoImageInput | null
86
+ metaAttributes?: Array<{_key?: string; key?: string; value?: string; type?: string}> | null
87
+ keywords?: string[] | null
88
+ canonicalUrl?: string | null
89
+ openGraph?: {
90
+ _type?: string
91
+ url?: string | null
92
+ title?: string | null
93
+ description?: string | null
94
+ siteName?: string | null
95
+ type?: string | null
96
+ imageType?: string | null
97
+ image?: SeoImageInput | null
98
+ imageUrl?: string | null
99
+ } | null
100
+ twitter?: {
101
+ _type?: string
102
+ card?: string | null
103
+ site?: string | null
104
+ creator?: string | null
105
+ title?: string | null
106
+ description?: string | null
107
+ imageType?: string | null
108
+ image?: SeoImageInput | null
109
+ imageUrl?: string | null
110
+ } | null
111
+ }
112
+
113
+ /** Options accepted by buildSeoMeta(). */
114
+ export interface BuildSeoMetaOptions {
115
+ /**
116
+ * The raw SEO object from Sanity (_type excluded or included — both work).
117
+ * Pass `null` or `undefined` to fall back entirely to `defaults`.
118
+ *
119
+ * Accepts both the strict plugin `SeoFields` type and Sanity's code-generated
120
+ * type (which has all nested fields optional) without any `as any` cast.
121
+ */
122
+ seo?: SeoFieldsInput | null
123
+
124
+ /**
125
+ * The base URL of your site, e.g. "https://example.com".
126
+ * Used for canonical URL and OpenGraph URL construction.
127
+ */
128
+ baseUrl?: string
129
+
130
+ /**
131
+ * The path for the current page, e.g. "/about".
132
+ * Combined with baseUrl to produce the canonical + OG url.
133
+ * Defaults to "".
134
+ */
135
+ path?: string
136
+
137
+ /**
138
+ * Default values used when the Sanity SEO fields are empty / missing.
139
+ */
140
+ defaults?: SeoMetaDefaults
141
+
142
+ /**
143
+ * Resolve a Sanity image asset to a plain URL string.
144
+ *
145
+ * @example (using @sanity/image-url)
146
+ * imageUrlResolver: (img) => urlFor(img).width(1200).url()
147
+ */
148
+ imageUrlResolver?: (image: SanityImage | SanityImageWithAlt) => string | null | undefined
149
+ }
150
+
151
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
152
+
153
+ const VALID_OG_TYPES = [
154
+ 'website',
155
+ 'article',
156
+ 'profile',
157
+ 'book',
158
+ 'music',
159
+ 'video',
160
+ 'product',
161
+ ] as const
162
+ type OGType = (typeof VALID_OG_TYPES)[number]
163
+
164
+ /**
165
+ * Coerce an arbitrary string to a valid OpenGraph type.
166
+ * Falls back to "website" when the value is invalid.
167
+ */
168
+ export function sanitizeOGType(value?: string): OGType {
169
+ if (value && (VALID_OG_TYPES as readonly string[]).includes(value)) {
170
+ return value as OGType
171
+ }
172
+ return 'website'
173
+ }
174
+
175
+ const VALID_TWITTER_CARDS = ['summary', 'summary_large_image', 'app', 'player'] as const
176
+ type TwitterCard = (typeof VALID_TWITTER_CARDS)[number]
177
+
178
+ /**
179
+ * Coerce an arbitrary string to a valid Twitter card type.
180
+ * Falls back to "summary_large_image" when the value is invalid.
181
+ */
182
+ export function sanitizeTwitterCard(value?: string): TwitterCard {
183
+ if (value && (VALID_TWITTER_CARDS as readonly string[]).includes(value)) {
184
+ return value as TwitterCard
185
+ }
186
+ return 'summary_large_image'
187
+ }
188
+
189
+ // ─── Core builder ─────────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Convert a Sanity SEO object into a structured metadata object.
193
+ *
194
+ * The return value is structurally compatible with Next.js App Router's
195
+ * `Metadata` type, so you can return it directly from `generateMetadata()`.
196
+ *
197
+ * @example Next.js App Router
198
+ * ```ts
199
+ * import { buildSeoMeta } from 'sanity-plugin-seofields'
200
+ * import { urlFor } from '@/sanity/lib/image'
201
+ *
202
+ * export async function generateMetadata(): Promise<Metadata> {
203
+ * const { seo } = await sanityFetch({ query: PAGE_SEO_QUERY })
204
+ * return buildSeoMeta({
205
+ * seo,
206
+ * baseUrl: process.env.NEXT_PUBLIC_SITE_URL,
207
+ * path: '/about',
208
+ * defaults: { title: 'My Site', siteName: 'My Site' },
209
+ * imageUrlResolver: (img) => urlFor(img).width(1200).url(),
210
+ * })
211
+ * }
212
+ * ```
213
+ */
214
+ export function buildSeoMeta(options: BuildSeoMetaOptions): SeoMetadata {
215
+ const {seo, baseUrl = '', path = '', defaults = {}, imageUrlResolver} = options
216
+
217
+ const normalizedBase = baseUrl.replace(/\/+$/, '') // remove trailing /
218
+ const normalizedPath = path.replace(/^\/+/, '') // remove leading /
219
+
220
+ const fullUrl = [normalizedBase, normalizedPath].filter(Boolean).join('/')
221
+
222
+ // ── OG image resolution ──
223
+ let ogImageURL: string = defaults.ogImage || ''
224
+ if (seo?.openGraph?.imageType === 'url' && seo.openGraph.imageUrl) {
225
+ ogImageURL = seo.openGraph.imageUrl
226
+ } else if (seo?.openGraph?.image && imageUrlResolver) {
227
+ ogImageURL = imageUrlResolver(seo.openGraph.image as SanityImage) || ogImageURL
228
+ }
229
+
230
+ // ── Twitter image resolution ──
231
+ let twitterImageURL: string = ogImageURL // reuse OG image as fallback
232
+ if (seo?.twitter?.imageType === 'url' && seo.twitter.imageUrl) {
233
+ twitterImageURL = seo.twitter.imageUrl
234
+ } else if (seo?.twitter?.image && imageUrlResolver) {
235
+ twitterImageURL = imageUrlResolver(seo.twitter.image as SanityImage) || twitterImageURL
236
+ }
237
+
238
+ // ── Custom meta attributes → `other` map ──
239
+ const other: Record<string, string> = {}
240
+ if (Array.isArray(seo?.metaAttributes)) {
241
+ for (const attr of seo!.metaAttributes!) {
242
+ if (attr.key && attr.value) {
243
+ other[attr.key] = attr.value
244
+ }
245
+ }
246
+ }
247
+
248
+ const ogUrl = seo?.openGraph?.url || fullUrl
249
+
250
+ return {
251
+ title: seo?.title ?? defaults.title ?? null,
252
+ description: seo?.description ?? defaults.description ?? null,
253
+ keywords: seo?.keywords?.length ? (seo.keywords as string[]) : undefined,
254
+ robots: {
255
+ index: !seo?.robots?.noIndex,
256
+ follow: !seo?.robots?.noFollow,
257
+ googleBot: {
258
+ index: !seo?.robots?.noIndex,
259
+ follow: !seo?.robots?.noFollow,
260
+ },
261
+ },
262
+ openGraph: {
263
+ type: sanitizeOGType(seo?.openGraph?.type ?? undefined),
264
+ url: ogUrl || undefined,
265
+ title: seo?.openGraph?.title ?? defaults.title,
266
+ description: seo?.openGraph?.description ?? defaults.description,
267
+ siteName: seo?.openGraph?.siteName ?? defaults.siteName,
268
+ images: ogImageURL ? [{url: ogImageURL}] : [],
269
+ },
270
+ twitter: {
271
+ card: sanitizeTwitterCard(seo?.twitter?.card ?? undefined),
272
+ site: seo?.twitter?.site ?? defaults.twitterSite,
273
+ creator: seo?.twitter?.creator ?? defaults.twitterCreator,
274
+ title: seo?.twitter?.title ?? defaults.title,
275
+ description: seo?.twitter?.description ?? defaults.description,
276
+ images: twitterImageURL ? [twitterImageURL] : [],
277
+ },
278
+ alternates: {
279
+ canonical: fullUrl || undefined,
280
+ },
281
+ ...(Object.keys(other).length > 0 ? {other} : {}),
282
+ }
283
+ }
package/src/next.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Next.js App Router helpers — safe to import in Server Components.
3
+ *
4
+ * @example
5
+ * import { buildSeoMeta, SeoMetaTags } from 'sanity-plugin-seofields/next'
6
+ */
7
+ export {buildSeoMeta, sanitizeOGType, sanitizeTwitterCard} from './helpers/seoMeta'
8
+
9
+ export type {BuildSeoMetaOptions, SeoMetaDefaults, SeoMetadata} from './helpers/seoMeta'
10
+
11
+ export {SeoMetaTags} from './helpers/SeoMetaTags'
12
+ export type {SeoMetaTagsProps} from './helpers/SeoMetaTags'
package/src/types.ts CHANGED
@@ -35,8 +35,8 @@ export interface RobotsSettings {
35
35
  // Meta Attribute
36
36
  export interface MetaAttribute {
37
37
  _type: 'metaAttribute'
38
- key: string
39
- type: 'string' | 'image'
38
+ key?: string
39
+ type?: 'string' | 'image'
40
40
  value?: string
41
41
  image?: SanityImage
42
42
  }
@@ -44,6 +44,8 @@ export interface MetaAttribute {
44
44
  // Open Graph settings
45
45
  export interface OpenGraphSettings {
46
46
  _type: 'openGraph'
47
+ /** The canonical URL for OpenGraph (og:url). Maps to the `url` field in Sanity. */
48
+ url?: string
47
49
  title?: string
48
50
  description?: string
49
51
  siteName?: string
@@ -58,6 +60,7 @@ export interface TwitterCardSettings {
58
60
  _type: 'twitter'
59
61
  card?: 'summary' | 'summary_large_image' | 'app' | 'player'
60
62
  site?: string
63
+ creator?: string
61
64
  title?: string
62
65
  description?: string
63
66
  imageType?: 'upload' | 'url'
@@ -73,6 +76,7 @@ export interface SeoFields {
73
76
  title?: string
74
77
  description?: string
75
78
  metaImage?: SanityImage
79
+ metaAttributes?: MetaAttribute[]
76
80
  keywords?: string[]
77
81
  canonicalUrl?: string
78
82
  openGraph?: OpenGraphSettings