-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: create @session/sanity-cms package
- Loading branch information
Showing
11 changed files
with
372 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 2 additions & 2 deletions
4
packages/util/.eslintrc.js → packages/sanity-cms/.eslintrc.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
# @session/sanity-cms | ||
|
||
This package is a [Sanity](https://sanity.io/) CMS integration library for websites in the monorepo. It provides proper | ||
typings for Sanity CMS data and a custom Sanity client and fetch handler. | ||
|
||
## Getting Started | ||
|
||
You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { createClient, SanityClient } from 'next-sanity'; | ||
import { type SanityFetch, sanityFetchGeneric, type SanityFetchOptions } from './fetch'; | ||
import logger from './logger'; | ||
|
||
export type CreateSanityClientOptions = { | ||
projectId: string; | ||
dataset: string; | ||
/** @see https://www.sanity.io/docs/api-versioning */ | ||
apiVersion: string; | ||
draftToken?: string; | ||
}; | ||
|
||
export type SessionSanityClient = Omit<SanityClient, 'fetch'> & { | ||
fetch: SanityFetch; | ||
}; | ||
|
||
/** | ||
* Create a new Sanity client with all the required options. | ||
* @param projectId - The Sanity project ID. | ||
* @param dataset - The dataset name of the Sanity project. | ||
* @param apiVersion - The API version used. | ||
* @param draftToken - The draft token of the Sanity project. | ||
*/ | ||
export function createSanityClient({ | ||
projectId, | ||
dataset, | ||
apiVersion, | ||
draftToken, | ||
}: CreateSanityClientOptions): SessionSanityClient { | ||
const client = createClient({ | ||
projectId, | ||
dataset, | ||
apiVersion, | ||
useCdn: false, | ||
}); | ||
|
||
if (!draftToken) { | ||
logger.warn('No draft token provided, draft mode will be disabled'); | ||
} | ||
|
||
/** This is required to make TS happy about replacing the `fetch` method on the client */ | ||
const sessionSanityClient = client as unknown as SessionSanityClient; | ||
|
||
sessionSanityClient.fetch = async <const QueryString extends string>({ | ||
query, | ||
params = {}, | ||
revalidate, | ||
tags, | ||
isClient = false, | ||
}: SanityFetchOptions<QueryString>) => | ||
sanityFetchGeneric<QueryString>({ | ||
client, | ||
token: draftToken, | ||
query, | ||
params, | ||
revalidate, | ||
tags, | ||
isClient, | ||
}); | ||
|
||
return sessionSanityClient; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import type { FilteredResponseQueryOptions, QueryParams, SanityClient } from 'next-sanity'; | ||
import { isDraftModeEnabled } from './util'; | ||
import { safeTry } from '@session/util-js/try'; | ||
|
||
export type SanityFetchOptions<QueryString extends string> = { | ||
query: QueryString; | ||
params?: QueryParams; | ||
revalidate?: number; | ||
tags?: Array<string>; | ||
isClient?: boolean; | ||
}; | ||
|
||
export type SanityFetch = <QueryString extends string>( | ||
options: SanityFetchOptions<QueryString> | ||
) => ReturnType<typeof sanityFetchGeneric<QueryString>>; | ||
|
||
/** | ||
* This type fixes the broken `next-sanity` types. Somebody got a little too excited using and | ||
* relying on global type declarations and this caused a global type conflicts, forcing the `next` | ||
* and `cache` properties to be ONLY `undefined` as TS couldn't find the `next property in all | ||
* @see {RequestInit} declarations (it's only in the next.js declaration, not node, web lib, or | ||
* workers versions). | ||
*/ | ||
type FixedSanityResponseQueryOptions = Omit<FilteredResponseQueryOptions, 'next' | 'cache'> & { | ||
cache?: RequestCache; | ||
next?: { | ||
revalidate?: number | false; | ||
tags?: string[]; | ||
}; | ||
}; | ||
|
||
export type SanityFetchGenericOptions<QueryString extends string> = | ||
SanityFetchOptions<QueryString> & { | ||
client: SanityClient; | ||
token?: string; | ||
}; | ||
|
||
export const sanityFetchGeneric = async <const QueryString extends string>({ | ||
client, | ||
token, | ||
query, | ||
params = {}, | ||
revalidate, | ||
tags, | ||
isClient = false, | ||
}: SanityFetchGenericOptions<QueryString>) => | ||
safeTry( | ||
(async () => { | ||
const isDraftMode = token && isDraftModeEnabled(isClient); | ||
|
||
const options = { | ||
token, | ||
perspective: isDraftMode ? 'previewDrafts' : 'published', | ||
next: { | ||
revalidate: tags?.length ? false : revalidate, | ||
tags, | ||
}, | ||
} satisfies FixedSanityResponseQueryOptions; | ||
|
||
return client.fetch<QueryString>( | ||
query, | ||
params, | ||
/** @see {FixedSanityResponseQueryOptions} */ | ||
options as unknown as FilteredResponseQueryOptions | ||
); | ||
})() | ||
); |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { initLogger } from '@session/util-logger'; | ||
|
||
const logger = initLogger(); | ||
|
||
export default logger; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
{ | ||
"name": "@session/sanity-cms", | ||
"version": "0.0.0", | ||
"private": true, | ||
"exports": { | ||
"./*": "./*.ts" | ||
}, | ||
"scripts": { | ||
"check-types": "tsc --noEmit", | ||
"lint": "eslint .", | ||
"test": "jest --passWithNoTests" | ||
}, | ||
"devDependencies": { | ||
"@session/eslint-config": "workspace:*", | ||
"@session/testing": "workspace:*", | ||
"@session/typescript-config": "workspace:*", | ||
"@types/react": "^18.3.1", | ||
"@types/react-dom": "^18.3.0", | ||
"next": "14.2.12", | ||
"react": "18.3.1" | ||
}, | ||
"engines": { | ||
"node": ">=22", | ||
"pnpm": ">=9", | ||
"yarn": "use pnpm", | ||
"npm": "use pnpm" | ||
}, | ||
"dependencies": { | ||
"@sanity/image-url": "^1.0.2", | ||
"@session/logger": "workspace:*", | ||
"@session/util-js": "workspace:*", | ||
"@session/util-logger": "workspace:*", | ||
"next-sanity": "^9.5.0" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
import { revalidateTag } from 'next/cache'; | ||
import { type NextRequest, NextResponse } from 'next/server'; | ||
import { parseBody } from 'next-sanity/webhook'; | ||
import logger from './logger'; | ||
import { safeTry } from '@session/util-js/try'; | ||
|
||
type WebhookPayload = { | ||
/** The CMS content type that was published or updated. */ | ||
_type: string; | ||
}; | ||
|
||
type RssGeneratorConfig = { | ||
/** The CMS content type that the generator should be run for.*/ | ||
_type: string; | ||
/** | ||
* The function that generates the RSS feed. This function should return a | ||
* Promise that resolves when the feed has been generated. | ||
*/ | ||
generateRssFeed: () => Promise<void>; | ||
}; | ||
|
||
type CreateRevalidateHandlerOptions = { | ||
/** The secret used to verify the webhook request. */ | ||
revalidateSecret: string; | ||
/** An array of RSS generator configurations. {@link RssGeneratorConfig} */ | ||
rssGenerators?: Array<RssGeneratorConfig>; | ||
}; | ||
|
||
/** | ||
* Creates a revalidate handler for Sanity CMS content. | ||
* | ||
* @param revalidateSecret - The secret used to verify the webhook request. | ||
* @param rssGenerators - An array of RSS generator configurations. {@link RssGeneratorConfig} | ||
* | ||
* @throws {TypeError} If the `revalidateSecret` is not provided. | ||
* @throws {TypeError} If the `rssGenerators` is not an array. | ||
* @throws {TypeError} If any of the `rssGenerators` have a `_type` that is not a non-empty string. | ||
* @throws {TypeError} If any of the `rssGenerators` have a `generateRssFeed` that is not a function. | ||
* @returns The revalidate handler. | ||
* | ||
* @example | ||
* export const { POST } = createRevalidateHandler({ | ||
* revalidateSecret: REVALIDATE_SECRET, | ||
* rssGenerators: [ | ||
* { | ||
* _type: 'post', | ||
* generateRssFeed: async () => { | ||
* // Generate the RSS feed | ||
* }, | ||
* }, | ||
* ], | ||
* }); | ||
*/ | ||
export const createRevalidateHandler = ({ | ||
revalidateSecret, | ||
rssGenerators, | ||
}: CreateRevalidateHandlerOptions) => { | ||
if (!revalidateSecret) { | ||
throw new TypeError('Revalidate secret is required to create a revalidate handler'); | ||
} | ||
|
||
const rssGeneratorsMap: Map<string, () => Promise<void>> = new Map(); | ||
if (rssGenerators) { | ||
if (!Array.isArray(rssGenerators)) { | ||
throw new TypeError('rssGenerators must be an array'); | ||
} | ||
|
||
if (rssGenerators.length > 0) { | ||
rssGenerators.forEach(({ _type, generateRssFeed }) => { | ||
if (typeof _type !== 'string' || _type.length === 0) { | ||
throw new TypeError('_type must be a non-empty string'); | ||
} | ||
|
||
if (typeof generateRssFeed !== 'function') { | ||
throw new TypeError('generateRssFeed must be a function'); | ||
} | ||
|
||
rssGeneratorsMap.set(_type, generateRssFeed); | ||
}); | ||
logger.info(`Creating revalidate handler with rss generators: ${rssGeneratorsMap.keys()}`); | ||
} | ||
} | ||
|
||
/** | ||
* Revalidate the cache for a specific CMS resource | ||
* | ||
* This endpoint is used to revalidate the cache for a specific CMS resource. | ||
* It is called by the CMS when a resource is published or updated. The CMS | ||
* sends a POST request to this endpoint with a JSON body containing the type | ||
* of the resource and the ID of the resource. The endpoint then revalidates | ||
* the cache for the resource and returns a JSON response with the status of the revalidation. | ||
* | ||
* @param req - The incoming request with a JSON body containing the type and ID of the resource | ||
* and a signature to verify the request | ||
* @returns The result of the revalidation as a JSON response | ||
*/ | ||
const revalidateHandler = async (req: NextRequest) => { | ||
try { | ||
if (!revalidateSecret) { | ||
return new NextResponse('Missing revalidate secret', { | ||
status: 500, | ||
}); | ||
} | ||
|
||
const { isValidSignature, body } = await parseBody<WebhookPayload>(req, revalidateSecret); | ||
|
||
if (!isValidSignature) { | ||
return new NextResponse( | ||
JSON.stringify({ message: 'Invalid signature', isValidSignature, body }), | ||
{ status: 401 } | ||
); | ||
} else if (!body?._type) { | ||
return new NextResponse(JSON.stringify({ message: 'Bad Request', body }), { status: 400 }); | ||
} | ||
|
||
// If the `_type` is `post`, then all `client.fetch` calls with | ||
// `{next: {tags: ['post']}}` will be revalidated | ||
revalidateTag(body._type); | ||
|
||
/** | ||
* If there are any RSS generators configured, then we revalidate them | ||
* here. This is useful for when you want to generate RSS feeds for | ||
* specific content types. | ||
*/ | ||
if (rssGeneratorsMap.size > 0 && rssGeneratorsMap.has(body._type)) { | ||
const generator = rssGeneratorsMap.get(body._type); | ||
if (generator) { | ||
const [err] = await safeTry(generator()); | ||
if (err) console.error(err); | ||
} else { | ||
console.error(`No generator found for type ${body._type}`); | ||
} | ||
} | ||
|
||
return NextResponse.json({ | ||
status: 200, | ||
revalidated: true, | ||
now: Date.now(), | ||
body, | ||
}); | ||
} catch (err) { | ||
logger.error(err); | ||
return new NextResponse(err instanceof Error ? err.message : 'Internal Server Error', { | ||
status: 500, | ||
}); | ||
} | ||
}; | ||
|
||
return { POST: revalidateHandler }; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
{ | ||
"extends": "@session/typescript-config/nextjs.json", | ||
"compilerOptions": { | ||
"baseUrl": ".", | ||
"outDir": "dist" | ||
}, | ||
"include": [ | ||
"./**.ts", | ||
"tests/**.ts", | ||
"jest.config.js" | ||
], | ||
"exclude": [ | ||
"node_modules", | ||
"turbo", | ||
"dist" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { safeTrySync } from '@session/util-js/try'; | ||
import { draftMode } from 'next/headers'; | ||
import logger from './logger'; | ||
|
||
/** | ||
* Checks if draft mode is enabled. | ||
* | ||
* @link https://nextjs.org/docs/app/api-reference/functions/draft-mode#checking-if-draft-mode-is-enabled | ||
* | ||
* @param isClient - If the function is being called from the client | ||
* @returns If draft mode is enabled | ||
*/ | ||
export const isDraftModeEnabled = (isClient = false) => { | ||
if (isClient) return false; | ||
|
||
const [error, result] = safeTrySync(draftMode); | ||
|
||
if (error) { | ||
logger.error(`Error getting draft mode`, error); | ||
return false; | ||
} | ||
|
||
return result.isEnabled; | ||
}; |