Skip to content

Commit

Permalink
feat: create @session/sanity-cms package
Browse files Browse the repository at this point in the history
  • Loading branch information
Aerilym committed Sep 30, 2024
1 parent bd99144 commit bae61ba
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 2 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ This repository contains the following apps and packages:
- `@session/feture-flags`: Feature flags library for [Next.js](https://nextjs.org/) apps. Supporting client, server, and
remote flags. [Read more](packages/feature-flags/README.md).
- `@session/logger`: An opinionated logging wrapper. [Read more](packages/logger/README.md).
- `@session/sanity-cms`: A [Sanity](https://sanity.io/) CMS integration
library. [Read more](packages/sanity-cms/README.md).
- `@session/sent-staking-js`: Session Token Staking js library for interacting with the Session Token staking
backend. [Read more](packages/sent-staking-js/README.md).
- `@session/testing`: A testing utility library. [Read more](packages/testing/README.md).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @type {import("eslint").Linter.Config} */
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['@session/eslint-config/react-internal.js'],
extends: ['@session/eslint-config/next.js'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
Expand Down
8 changes: 8 additions & 0 deletions packages/sanity-cms/README.md
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.
62 changes: 62 additions & 0 deletions packages/sanity-cms/client.ts
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;
}
67 changes: 67 additions & 0 deletions packages/sanity-cms/fetch.ts
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.
5 changes: 5 additions & 0 deletions packages/sanity-cms/logger.ts
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;
35 changes: 35 additions & 0 deletions packages/sanity-cms/package.json
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"
}
}
150 changes: 150 additions & 0 deletions packages/sanity-cms/revalidate.ts
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 };
};
17 changes: 17 additions & 0 deletions packages/sanity-cms/tsconfig.json
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"
]
}
24 changes: 24 additions & 0 deletions packages/sanity-cms/util.ts
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;
};

0 comments on commit bae61ba

Please sign in to comment.