From f9969b2018fda3532deeb256234cff19a4f15291 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Mon, 14 Oct 2024 10:08:42 +1100 Subject: [PATCH] feat: create sanity cms metadata utility function --- apps/foundation/app/(Sanity)/layout.tsx | 5 ++ apps/foundation/app/(Site)/[slug]/page.tsx | 35 ++++++++ .../app/(Site)/blog/[slug]/page.tsx | 36 ++++++++ apps/foundation/app/(Site)/blog/layout.tsx | 15 ++++ apps/foundation/app/(Site)/layout.tsx | 20 ++++- apps/foundation/app/(Site)/page.tsx | 13 ++- .../sanity-cms/components/SanityImage.tsx | 11 ++- packages/sanity-cms/lib/metadata.ts | 83 +++++++++++++++++++ packages/sanity-cms/queries/getPost.ts | 1 + .../sanity-cms/schemas/fields/basic/seo.ts | 1 + packages/sanity-cms/schemas/site.ts | 18 +++- 11 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 packages/sanity-cms/lib/metadata.ts diff --git a/apps/foundation/app/(Sanity)/layout.tsx b/apps/foundation/app/(Sanity)/layout.tsx index 3a409094..d50d2354 100644 --- a/apps/foundation/app/(Sanity)/layout.tsx +++ b/apps/foundation/app/(Sanity)/layout.tsx @@ -1,5 +1,10 @@ import type { ReactNode } from 'react'; import '@session/ui/styles'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'STF | Sanity Studio', +}; export default function SanityLayout({ children }: { children: ReactNode }) { return ( diff --git a/apps/foundation/app/(Site)/[slug]/page.tsx b/apps/foundation/app/(Site)/[slug]/page.tsx index e20f758c..9a2532ba 100644 --- a/apps/foundation/app/(Site)/[slug]/page.tsx +++ b/apps/foundation/app/(Site)/[slug]/page.tsx @@ -6,6 +6,8 @@ import { getLandingPageSlug } from '@/lib/sanity/sanity-server'; import PortableText from '@/components/PortableText'; import logger from '@/lib/logger'; import { NEXTJS_EXPLICIT_IGNORED_ROUTES, NEXTJS_IGNORED_PATTERNS } from '@/lib/constants'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { generateSanityMetadata } from '@session/sanity-cms/lib/metadata'; /** * Force static rendering and cache the data of a layout or page by forcing `cookies()`, `headers()` @@ -19,6 +21,39 @@ export const dynamic = 'force-static'; */ export const dynamicParams = true; +export async function generateMetadata( + { params }: PageProps, + parent: ResolvingMetadata +): Promise { + const slug = params.slug; + if (!slug) { + logger.warn(`No slug provided for metadata generation`); + return {}; + } + + if ( + NEXTJS_EXPLICIT_IGNORED_ROUTES.includes(slug) || + NEXTJS_IGNORED_PATTERNS.some((pattern) => slug.includes(pattern)) + ) { + return {}; + } + + logger.info(`Generating metadata for slug ${slug}`); + + const page = await getPageBySlug({ client, slug }); + + if (!page) { + logger.warn(`No page found for slug ${slug}`); + return {}; + } + const parentMetadata = await parent; + return generateSanityMetadata(client, { + seo: page.seo, + parentMetadata, + type: 'website', + }); +} + export async function generateStaticParams() { const pages = await getPagesSlugs({ client }); const slugs = new Set(pages.map((page) => page.slug.current)); diff --git a/apps/foundation/app/(Site)/blog/[slug]/page.tsx b/apps/foundation/app/(Site)/blog/[slug]/page.tsx index 94ce96f0..99b0f667 100644 --- a/apps/foundation/app/(Site)/blog/[slug]/page.tsx +++ b/apps/foundation/app/(Site)/blog/[slug]/page.tsx @@ -4,6 +4,8 @@ import { getPostsSlugs } from '@session/sanity-cms/queries/getPosts'; import { getPostBySlug } from '@session/sanity-cms/queries/getPost'; import logger from '@/lib/logger'; import BlogPost from '@/app/(Site)/blog/[slug]/BlogPost'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { generateSanityMetadata } from '@session/sanity-cms/lib/metadata'; /** * Force static rendering and cache the data of a layout or page by forcing `cookies()`, `headers()` @@ -17,6 +19,40 @@ export const dynamic = 'force-static'; */ export const dynamicParams = true; +export async function generateMetadata( + { params }: PageProps, + parent: ResolvingMetadata +): Promise { + const slug = params.slug; + if (!slug) { + logger.warn(`No slug provided for metadata generation`); + return {}; + } + + logger.info(`Generating metadata for slug ${slug}`); + + const post = await getPostBySlug({ client, slug }); + + if (!post) { + logger.warn(`No post found for slug ${slug}`); + return {}; + } + const parentMetadata = await parent; + + const publishedTime = post.date ? new Date(post.date).toISOString() : undefined; + const authors = post.author?.name ? [post.author.name] : undefined; + const tags = post.tags?.length ? post.tags : undefined; + + return generateSanityMetadata(client, { + seo: post.seo, + parentMetadata, + type: 'article', + publishedTime, + authors, + tags, + }); +} + export async function generateStaticParams() { const posts = await getPostsSlugs({ client }); const slugs = new Set(posts.map((post) => post.slug.current)); diff --git a/apps/foundation/app/(Site)/blog/layout.tsx b/apps/foundation/app/(Site)/blog/layout.tsx index c9c5646e..af99c1b7 100644 --- a/apps/foundation/app/(Site)/blog/layout.tsx +++ b/apps/foundation/app/(Site)/blog/layout.tsx @@ -1,6 +1,21 @@ import type { ReactNode } from 'react'; import { Footer } from '@/components/Footer'; import { getInitialSiteDataForSSR } from '@/lib/sanity/sanity-server'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { generateSanityMetadata } from '@session/sanity-cms/lib/metadata'; +import { client } from '@/lib/sanity/sanity.client'; + +export async function generateMetadata(parent: ResolvingMetadata): Promise { + const { settings } = await getInitialSiteDataForSSR(); + + return settings.blogSeo + ? await generateSanityMetadata(client, { + seo: settings.blogSeo, + parentMetadata: await parent, + type: 'website', + }) + : {}; +} export default async function BlogLayout({ children }: { children: ReactNode }) { const { settings } = await getInitialSiteDataForSSR(); diff --git a/apps/foundation/app/(Site)/layout.tsx b/apps/foundation/app/(Site)/layout.tsx index 32cce167..07539d59 100644 --- a/apps/foundation/app/(Site)/layout.tsx +++ b/apps/foundation/app/(Site)/layout.tsx @@ -9,6 +9,25 @@ import DevSheetServerSide from '@/components/DevSheetServerSide'; import { getInitialSiteDataForSSR } from '@/lib/sanity/sanity-server'; import Head from 'next/head'; import { isProduction } from '@session/util-js/env'; +import { client } from '@/lib/sanity/sanity.client'; +import { generateSanityMetadata } from '@session/sanity-cms/lib/metadata'; +import type { Metadata } from 'next'; + +export async function generateMetadata(): Promise { + const { settings } = await getInitialSiteDataForSSR(); + + const generatedMetadata = settings.seo + ? await generateSanityMetadata(client, { + seo: settings.seo, + type: 'website', + }) + : {}; + + return { + ...generatedMetadata, + manifest: '/site.webmanifest', + }; +} export default async function RootLayout({ children }: { children: ReactNode }) { const { locale, direction, messages } = await getLocalizationData(); @@ -25,7 +44,6 @@ export default async function RootLayout({ children }: { children: ReactNode }) - diff --git a/apps/foundation/app/(Site)/page.tsx b/apps/foundation/app/(Site)/page.tsx index 652b8504..6767a5aa 100644 --- a/apps/foundation/app/(Site)/page.tsx +++ b/apps/foundation/app/(Site)/page.tsx @@ -1,6 +1,7 @@ import { getLandingPageSlug } from '@/lib/sanity/sanity-server'; -import UniversalPage from './[slug]/page'; +import UniversalPage, { generateMetadata as generateMetadataUniversalPage } from './[slug]/page'; import UniversalPageLayout from '@/app/(Site)/[slug]/layout'; +import type { ResolvingMetadata } from 'next'; /** * Force static rendering and cache the data of a layout or page by forcing `cookies()`, `headers()` @@ -9,6 +10,16 @@ import UniversalPageLayout from '@/app/(Site)/[slug]/layout'; */ export const dynamic = 'force-static'; +export async function generateMetadata(parent: ResolvingMetadata) { + const slug = await getLandingPageSlug(); + + if (!slug) { + throw new Error('No landing page set in settings to generate metadata'); + } + + return generateMetadataUniversalPage({ params: { slug } }, parent); +} + export default async function LandingPage() { const slug = await getLandingPageSlug(); diff --git a/packages/sanity-cms/components/SanityImage.tsx b/packages/sanity-cms/components/SanityImage.tsx index bbae30ab..544f5cd3 100644 --- a/packages/sanity-cms/components/SanityImage.tsx +++ b/packages/sanity-cms/components/SanityImage.tsx @@ -10,16 +10,25 @@ import type { import { cn } from '@session/ui/lib/utils'; import { safeTry } from '@session/util-js/try'; import { Fragment } from 'react'; +import type { SanityImageSource } from '@sanity/asset-utils'; +import type { CustomImageType } from '../schemas/fields/basic/seo'; export type SanityImageType = ImageFieldsSchemaType | ImageFieldsSchemaTypeWithoutAltText; +export const getSanityImageUrlBuilder = ( + client: SessionSanityClient, + value: SanityImageType | SanityImageSource | CustomImageType +) => { + return urlBuilder(client).image(value).fit('max').auto('format'); +}; + /** * Build image data from Sanity image schema and the image source. * @param client Sanity client * @param value Sanity image schema */ async function buildImage(client: SessionSanityClient, value: SanityImageType) { - const src = urlBuilder(client).image(value).fit('max').auto('format').url(); + const src = getSanityImageUrlBuilder(client, value).url(); const buffer = await fetch(src).then(async (res) => Buffer.from(await res.arrayBuffer())); const { diff --git a/packages/sanity-cms/lib/metadata.ts b/packages/sanity-cms/lib/metadata.ts new file mode 100644 index 00000000..1edacec7 --- /dev/null +++ b/packages/sanity-cms/lib/metadata.ts @@ -0,0 +1,83 @@ +import { getSanityImageUrlBuilder } from '../components/SanityImage'; +import type { SeoType } from '../schemas/fields/basic/seo'; +import type { Metadata, ResolvedMetadata } from 'next'; +import type { SessionSanityClient } from './client'; + +type GenericMetadataProps = { + seo?: SeoType; + parentMetadata?: ResolvedMetadata; +}; + +type MetadataProps = GenericMetadataProps & + ( + | { + type: 'article'; + publishedTime?: string; + authors?: null | string | URL | Array; + tags?: null | string | Array; + } + | { + type: 'website'; + } + ); + +export async function generateSanityMetadata( + client: SessionSanityClient, + props: MetadataProps +): Promise { + const { seo, parentMetadata, type } = props; + + /** Base SEO */ + const title = + seo?.metaTitle || + seo?.openGraph?.title || + parentMetadata?.title || + parentMetadata?.openGraph?.title; + + const description = + seo?.metaDescription || + seo?.openGraph?.description || + parentMetadata?.description || + parentMetadata?.openGraph?.description; + + const keywords = seo?.seoKeywords?.filter((keyword) => !!keyword); + + /** Open Graph */ + const ogTitle = seo?.openGraph?.title || parentMetadata?.openGraph?.title; + const ogDescription = seo?.openGraph?.description || parentMetadata?.openGraph?.description; + + const sanityOgImage = seo?.openGraph?.image + ? getSanityImageUrlBuilder(client, seo.openGraph.image) + .width(1200) + .height(630) + .fit('crop') + .url() + : null; + + const sanityMetaImage = seo?.metaImage + ? getSanityImageUrlBuilder(client, seo.metaImage).width(1200).height(630).fit('crop').url() + : null; + + const ogImage = sanityOgImage || sanityMetaImage || parentMetadata?.openGraph?.images?.[0]; + + return { + title: title, + description: description, + keywords: keywords?.length ? keywords : undefined, + openGraph: { + title: ogTitle, + description: ogDescription, + images: ogImage, + ...(type === 'article' + ? { + type: 'article', + publishedTime: props.publishedTime, + authors: props.authors, + tags: props.tags, + } + : { + type: 'website', + }), + }, + }; +} diff --git a/packages/sanity-cms/queries/getPost.ts b/packages/sanity-cms/queries/getPost.ts index 4920cfcc..14fb9ae4 100644 --- a/packages/sanity-cms/queries/getPost.ts +++ b/packages/sanity-cms/queries/getPost.ts @@ -8,6 +8,7 @@ const QUERY_GET_POSTS_WITH_SLUG = groq`*[_type == 'post' && slug.current == $slu export type FormattedPostType = Omit & { author: AuthorSchemaType | undefined; date: Date; + tags: Array; }; type QUERY_GET_POSTS_WITH_SLUG_RETURN_TYPE = Array; diff --git a/packages/sanity-cms/schemas/fields/basic/seo.ts b/packages/sanity-cms/schemas/fields/basic/seo.ts index 0fbe5066..4b663e5d 100644 --- a/packages/sanity-cms/schemas/fields/basic/seo.ts +++ b/packages/sanity-cms/schemas/fields/basic/seo.ts @@ -12,6 +12,7 @@ export type SeoType = { _type?: 'seo'; nofollowAttributes?: boolean; metaDescription?: string; + metaImage?: CustomImageType; additionalMetaTags?: MetaTagType[]; metaTitle?: string; seoKeywords?: string[]; diff --git a/packages/sanity-cms/schemas/site.ts b/packages/sanity-cms/schemas/site.ts index c717c1a1..49571aa1 100644 --- a/packages/sanity-cms/schemas/site.ts +++ b/packages/sanity-cms/schemas/site.ts @@ -71,7 +71,18 @@ const siteLinkFields = [ }), ]; -export const siteFields = [...siteLinkFields, ...legalFields, seoField]; +export const siteFields = [ + ...siteLinkFields, + ...legalFields, + seoField, + defineField({ + title: 'Blog SEO', + name: 'blogSeo', + description: 'SEO fields for Blog grid page and default SEO for blog posts', + type: 'seoMetaFields', + group: 'blogSeo', + }), +]; export const siteSchema = defineType({ type: 'document', @@ -85,6 +96,11 @@ export const siteSchema = defineType({ icon: RobotIcon, name: 'seo', }, + { + title: 'Blog SEO', + icon: RobotIcon, + name: 'blogSeo', + }, { title: 'Header', name: 'header',