Skip to content

Commit

Permalink
Add React Query to cache Contentful client-side queries
Browse files Browse the repository at this point in the history
  • Loading branch information
adamjarling committed May 31, 2024
1 parent 020c4a8 commit 7c5b193
Show file tree
Hide file tree
Showing 13 changed files with 2,802 additions and 59 deletions.
1 change: 1 addition & 0 deletions lib/contentful/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
generated
158 changes: 158 additions & 0 deletions lib/contentful/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// tslint:disable:max-classes-per-file
// tslint:disable-next-line:interface-over-type-literal
export type JsonObject = { [k: string]: any }

export interface IEntry<TFields extends JsonObject> {
sys: ISys<'Entry'>,
fields: TFields
}

export class Entry<TFields extends JsonObject> implements IEntry<TFields> {
public readonly sys!: ISys<'Entry'>
public readonly fields!: TFields

protected constructor(entryOrId: IEntry<TFields> | string, contentType?: string, fields?: TFields) {
if (typeof entryOrId == 'string') {
if (!fields) {
throw new Error('No fields provided')
}
if (!contentType) {
throw new Error('No contentType provided')
}

this.sys = {
id: entryOrId,
type: 'Entry',
space: undefined,
contentType: {
sys: {
type: 'Link',
linkType: 'ContentType',
id: contentType,
},
},
}
this.fields = fields
} else {
if (typeof entryOrId.sys == 'undefined') {
throw new Error('Entry did not have a `sys`!')
}
if (typeof entryOrId.fields == 'undefined') {
throw new Error('Entry did not have a `fields`!')
}
Object.assign(this, entryOrId)
}
}
}

/**
* Checks whether the given object is a Contentful entry
* @param obj
*/
export function isEntry(obj: any): obj is IEntry<any> {
return obj && obj.sys && obj.sys.type === 'Entry'
}

interface IAssetFields {
title?: string,
description?: string,
file: {
url?: string,
details?: {
size?: number,
},
fileName?: string,
contentType?: string,
},
}

export interface IAsset {
sys: ISys<'Asset'>,
fields: IAssetFields
}

export class Asset implements IAsset {
public readonly sys!: ISys<'Asset'>
public readonly fields!: IAssetFields

constructor(asset: IAsset)
constructor(id: string, fields: IAssetFields)
constructor(entryOrId: IAsset | string, fields?: IAssetFields) {
if (typeof entryOrId == 'string') {
if (!fields) {
throw new Error('No fields provided')
}

this.sys = {
id: entryOrId,
type: 'Asset',
contentType: undefined,
}
this.fields = fields
} else {
if (typeof entryOrId.sys == 'undefined') {
throw new Error('Entry did not have a `sys`!')
}
if (typeof entryOrId.fields == 'undefined') {
throw new Error('Entry did not have a `fields`!')
}
Object.assign(this, entryOrId)
}
}
}

/**
* Checks whether the given object is a Contentful asset
* @param obj
*/
export function isAsset(obj: any): obj is IAsset {
return obj && obj.sys && obj.sys.type === 'Asset'
}

export interface ILink<Type extends string> {
sys: {
type: 'Link',
linkType: Type,
id: string,
},
}

export interface ISys<Type extends string> {
space?: ILink<'Space'>,
id: string,
type: Type,
createdAt?: string,
updatedAt?: string,
revision?: number,
environment?: ILink<'Environment'>,
contentType: Type extends 'Entry' ? ILink<'ContentType'> : undefined,
locale?: string,
}

/**
* Checks whether the given object is a Contentful link
* @param obj
*/
export function isLink(obj: any): obj is ILink<string> {
return obj && obj.sys && obj.sys.type === 'Link'
}

/**
* This complex type & associated helper allow us to mark an entry as
* not including any links. The compiler will understand that even though
* the entry type (i.e. 'IPage') contains links, a Resolved<IPage> will have
* resolved the links into the actual entries or assets.
*/
export type Resolved<TEntry> =
TEntry extends IEntry<infer TProps> ?
// TEntry is an entry and we know the type of it's props
IEntry<{
[P in keyof TProps]: ResolvedField<Exclude<TProps[P], ILink<any>>>
}>
: never

type ResolvedField<TField> =
TField extends Array<infer TItem> ?
// Array of entries - dive into the item type to remove links
Array<Exclude<TItem, ILink<any>>> :
TField
46 changes: 46 additions & 0 deletions lib/contentful/ext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ContentfulClientApi, Entry as ContentfulEntry } from 'contentful'
import { Entry, IAsset, IEntry, JsonObject, Resolved } from './base'

// Comment this out if you do not have the `contentful` NPM module installed.
declare module 'contentful' {
// tslint:disable:interface-name
export interface Entry<T> {
toPlainObject(): IEntry<T>
}

export interface Asset {
toPlainObject(): IAsset
}

export interface ContentfulClientApi {
/**
* Get an entry, casting to its content type.
*
* Since the Contentful API by default resolves to one layer, the resulting
* type is a Resolved entry.
*/
getEntry<T extends IEntry<any>>(id: string, query?: any): Promise<Resolved<T>>
getEntry<T>(id: string, query?: any): Promise<Resolved<Entry<T>>>
}
}

declare module './base' {
export interface Entry<TFields extends JsonObject> {
/**
* Resolves this entry to the specified depth (less than 10), and returns the
* raw object.
* @param n The depth to resolve to.
* @param client The client to use.
*/
resolve(n: number, client: ContentfulClientApi, query?: any): Promise<Resolved<IEntry<TFields>>>
}
}

Entry.prototype.resolve = async function(n, client, query?: any) {
const id = this.sys.id
const entry = await client.getEntry(id, Object.assign({}, query, { include: n }))
const pojo = (entry as ContentfulEntry<any>).toPlainObject()

Object.assign(this, pojo)
return pojo
}
11 changes: 11 additions & 0 deletions lib/contentful/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IEntry, ILink } from './base'
import { TypeDirectory } from './generated'

export * from './base'
export {wrap} from './generated'

export type KnownContentType = keyof TypeDirectory

require('./ext')
// include this to extend the generated objects
// require('./ext/menu')
54 changes: 54 additions & 0 deletions lib/contentful/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { IEntry, isEntry, isLink, JsonObject, Resolved } from '.'

/**
* Returns a boolean indicating whether the given entry is resolved to a certain
* depth. Typescript can understand the result of this within an if or switch
* statement.
*
* @param entry The entry whose fields should be checked for links
* @param depth how far down the tree to expect that the entry was resolved.
* @returns a boolean indicating that the entry is a Resolved entry.
*/
export function isResolved<TProps extends JsonObject>(
entry: IEntry<TProps>,
depth: number = 1,
): entry is Resolved<IEntry<TProps>> {
if (depth < 1) { throw new Error(`Depth cannot be less than 1 (was ${depth})`) }

return Object.keys(entry.fields).every((field) => {
const val = entry.fields[field]

if (Array.isArray(val)) {
return val.every(check)
} else {
return check(val)
}
})

function check(val: any): boolean {
if (isLink(val)) {
return false
}
if (depth > 1 && isEntry(val)) {
return isResolved(val, depth - 1)
}
return true
}
}

/**
* Expects that an entry has been resolved to at least a depth of 1,
* throwing an error if not.
*
* @param entry The entry whose fields should be checked for links
* @returns the same entry object, declaring it as resolved.
*/
export function expectResolved<TProps extends JsonObject>(
entry: IEntry<TProps>,
depth: number = 1,
): Resolved<IEntry<TProps>> {
if (isResolved(entry, depth)) {
return entry
}
throw new Error(`${entry.sys.contentType.sys.id} ${entry.sys.id} was not fully resolved`)
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@heroicons/react": "^2.1.3",
"@next/third-parties": "^14.2.3",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-query": "^5.40.0",
"contentful": "^10.11.7",
"contentful-management": "^11.26.2",
"install": "^0.13.0",
Expand All @@ -31,9 +32,11 @@
"react-loading-skeleton": "^3.4.0"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.35.6",
"@types/node": "^20.12.13",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"contentful-cli": "^3.3.1",
"eslint": "^9.3.0",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
Expand Down
Loading

0 comments on commit 7c5b193

Please sign in to comment.