Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DS-965: Linting Cache Config & ADR #370

Open
wants to merge 11 commits into
base: dev
Choose a base branch
from
Prev Previous commit
Next Next commit
DS-965 DS-919 DS-926: Next cache, config blok, ADRs
sherakama committed Nov 25, 2024
commit 9fe8d2960bba99da84042a653585e28ec8cb5071
4 changes: 2 additions & 2 deletions app/(editor)/editor/page.tsx
Original file line number Diff line number Diff line change
@@ -4,8 +4,8 @@ import { components as Components } from '@/components/StoryblokProvider';
import { resolveRelations } from '@/utilities/resolveRelations';
import { notFound } from 'next/navigation';
import { ComponentNotFound } from '@/components/Storyblok/ComponentNotFound';
import getStoryData from '@/utilities/data/getStoryData';
import getStoryList from '@/utilities/data/getStoryList';
import { getStoryData } from '@/utilities/data/getStoryData';
import { getStoryList } from '@/utilities/data/getStoryList';

// Control what happens when a dynamic segment is visited that was not generated with generateStaticParams.
export const dynamic = 'force-dynamic';
33 changes: 4 additions & 29 deletions app/(storyblok)/[[...slug]]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React from 'react';
import StoryblokProvider from '@/components/StoryblokProvider';
import {
ISbStoriesParams, getStoryblokApi, storyblokInit, apiPlugin, StoryblokStory, StoryblokClient,
storyblokInit, apiPlugin, StoryblokStory,
} from '@storyblok/react/rsc';
import { components as Components } from '@/components/StoryblokProvider';
import { resolveRelations } from '@/utilities/resolveRelations';
import { ComponentNotFound } from '@/components/Storyblok/ComponentNotFound';
import { isProduction } from '@/utilities/getActiveEnv';
import { getStoryDataCached } from '@/utilities/data/getStoryData';

// Storyblok bridge options.
const bridgeOptions = {
@@ -31,35 +31,10 @@ storyblokInit({
});

/**
* Get the data out of the Storyblok API for the page.
*
* Make sure to not export the below functions otherwise there will be a typescript error
* https://github.com/vercel/next.js/discussions/48724
* Get the story data from the Storyblok API through the cache.
*/
async function getStoryData(slug = 'momentum/page-not-found') {
const isProd = isProduction();
const storyblokApi: StoryblokClient = getStoryblokApi();
const sbParams: ISbStoriesParams = {
version: isProd ? 'published' : 'draft',
resolve_relations: resolveRelations,
};

try {
const story = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
return story;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {

if (error && error.status && error.status === 404) {
return { data: 404 };
}

throw error;
}
};

export default async function PageNotFound() {
const { data } = await getStoryData();
const { data } = await getStoryDataCached({ path: 'momentum/page-not-found'});

if (data === 404) {
return (
66 changes: 21 additions & 45 deletions app/(storyblok)/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Metadata } from 'next';
import {
ISbStoriesParams, getStoryblokApi, storyblokInit, apiPlugin, StoryblokStory, StoryblokClient,
storyblokInit, apiPlugin, StoryblokStory,
} from '@storyblok/react/rsc';
import { components as Components } from '@/components/StoryblokProvider';
import { resolveRelations } from '@/utilities/resolveRelations';
import { getPageMetadata } from '@/utilities/getPageMetadata';
import { ComponentNotFound } from '@/components/Storyblok/ComponentNotFound';
import { notFound } from 'next/navigation';
import getStoryData from '@/utilities/data/getStoryData';
import getStoryList from '@/utilities/data/getStoryList';
import { getStoryDataCached, getConfigBlokCached, getAllStoriesCached } from '@/utilities/data/';
import { getStoryListCached } from '@/utilities/data/getStoryList';
import { isProduction } from '@/utilities/getActiveEnv';
import { getSlugPrefix } from '@/utilities/getSlugPrefix';

@@ -57,25 +57,15 @@ storyblokInit({
*/
export async function generateStaticParams() {
const isProd = isProduction();
// Fetch new content from storyblok.
const storyblokApi: StoryblokClient = getStoryblokApi();
const sbParams: ISbStoriesParams = {
version: isProd ? 'published' : 'draft',
resolve_links: '0',
resolve_assets: 0,
per_page: 100,
starts_with: getSlugPrefix() + '/',
};

// Use the `cdn/links` endpoint to get a list of all stories without all the extra data.
const response = await storyblokApi.getAll('cdn/links', sbParams);

// Filters
let stories = response;

// Get all the stories.
let stories = await getAllStoriesCached();
// Filter out folders.
stories = response.filter((link) => link.is_folder === false);
stories = stories.filter((link) => link.is_folder === false);
// Filter out test content by filtering out the `test` folder.
stories = stories.filter((link) => !link.slug.startsWith(getSlugPrefix() + '/test'));
if (isProd) {
stories = stories.filter((link) => !link.slug.startsWith(getSlugPrefix() + '/test'));
}
// Filter out globals by filtering out the `global-components` folder.
stories = stories.filter((link) => !link.slug.startsWith(getSlugPrefix() + '/global-components'));

@@ -101,9 +91,6 @@ export async function generateStaticParams() {

});

// Add the home page.
paths.push({ slug: [''] });

return paths;
};

@@ -112,28 +99,17 @@ export async function generateStaticParams() {
*/
export async function generateMetadata({ params }: ParamsType): Promise<Metadata> {
const { slug } = params;
try {

// Convert the slug to a path.
const slugPath = slug ? slug.join('/') : '';

// Construct the slug for Storyblok.
const prefixedSlug = getSlugPrefix() + '/' + slugPath;
const slugPrefix = getSlugPrefix();
const slugPath = slug ? slug.join('/') : '';
const prefixedSlug = slugPrefix + '/' + slugPath;
const config = await getConfigBlokCached();

// Get the story data.
const { data } = await getStoryData({ path: prefixedSlug });
if (!data.story || !data.story.content) {
notFound();
}
const blok = data.story.content;
const meta = getPageMetadata({ blok, slug: slugPath });
return meta;
}
catch (error) {
console.log('Metadata error:', error, slug);
}
// Get the story data.
const { data: { story } } = await getStoryDataCached({ path: prefixedSlug });

notFound();
// Generate the metadata.
const meta = getPageMetadata({ story, sbConfig: config, slug: slugPath });
return meta;
}

/**
@@ -149,15 +125,15 @@ export default async function Page({ params }: ParamsType) {
const prefixedSlug = getSlugPrefix() + '/' + slugPath;

// Get data out of the API.
const { data } = await getStoryData({ path: prefixedSlug });
const { data } = await getStoryDataCached({ path: prefixedSlug });

// Define an additional data container to pass through server data fetch to client components.
// as everything below the `StoryblokStory` is a client side component.
let extra = {};

// Get additional data for those stories that need it.
if (data?.story?.content?.component === 'sbStoryFilterPage') {
extra = await getStoryList({ path: prefixedSlug });
extra = await getStoryListCached({ path: prefixedSlug });
}

// Failed to fetch from API because story slug was not found.
17 changes: 17 additions & 0 deletions docs/decisions/0005-storyblok-config-blok.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 5. Storyblok Config Blok

Date: 2024-10-22

## Context

Global and site wide and specific configuration that is editable by content authors

## Decision

Creating a central and content author editable configuration blok in Storyblok was chosen to create a centralized place for site configuration. For example, default SEO information like the site title and description should have a single place to change that is available to content authors. This centralized place for site configuration can lead to other global configuration like 'block from search engines' (nobots) or maintenance mode in the future.

The possibility for creating other site configuration bloks is open if separation of configuration is needed but the general idea is that we should use and build upon the existing one.

## Consequences

There is now a need to pull a configuration block from Storyblok when building page metadata.
29 changes: 29 additions & 0 deletions docs/decisions/0006-next-cache-and-storyblok-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 6. Next cache and Storyblok API

Date: 2024-10-23

## Context

Save on API calls and build time by caching the Storyblok API responses

## Decision

Provide a Nextjs cached and uncached option for developers to fetch from the Storyblok API through utility functions. The utility functions will have sensible defaults and allow developer to opt in to them. Developers are encouraged to use the utility functions and to create their own cached versions as well.

We are still using the Storyblok API cache through the storyblok js client as it handles cache invalidation 'automagically'.

Storyblok API Cache
* https://www.storyblok.com/docs/api/content-delivery/v2/getting-started/cache-invalidation
* https://www.storyblok.com/tp/optimize-your-caching-strategy-with-storyblok

Storyblok JS Client Cache
* https://github.com/storyblok/storyblok-js-client/#activating-request-cache

Nextjs Unstable Cache
* https://nextjs.org/docs/app/api-reference/functions/unstable_cache

## Consequences

* Caching in Nextjs should help mitigate against multiple API calls for the same thing on a single build
* Developers will need to opt-in to using the cache as it is not a default
* The utility functions are caching 'indefinitely' as they should be cleared on every build.
37 changes: 37 additions & 0 deletions utilities/data/getAllStories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
ISbStoriesParams, getStoryblokApi, StoryblokClient,
} from '@storyblok/react/rsc';
import { isProduction } from '@/utilities/getActiveEnv';
import { getSlugPrefix } from '@/utilities/getSlugPrefix';
import { unstable_cache } from 'next/cache';

/**
* Fetches all stories from Storyblok.
*/
export const getAllStories = async () => {
const isProd = isProduction();
// Fetch new content from storyblok.
const storyblokApi: StoryblokClient = getStoryblokApi();
const sbParams: ISbStoriesParams = {
version: isProd ? 'published' : 'draft',
resolve_links: '0',
resolve_assets: 0,
starts_with: getSlugPrefix() + '/',
};

// Use the `cdn/links` endpoint to get a list of all stories without all the extra data.
const response = await storyblokApi.getAll('cdn/links', sbParams);

return response;
};

/**
* Get all stories from Storyblok through the cache.
*/
export const getAllStoriesCached = unstable_cache(
getAllStories,
[],
{
tags: ['story', 'all'],
},
);
33 changes: 33 additions & 0 deletions utilities/data/getConfigBlok.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getStoryblokApi, StoryblokClient } from '@storyblok/react/rsc';
import { isProduction } from '@/utilities/getActiveEnv';
import { unstable_cache } from 'next/cache';

/**
* Get the global configuration from Storyblok.
*/
export const getConfigBlok = async () => {
const storyblokApi: StoryblokClient = getStoryblokApi();
const isProd = isProduction();

// Get the global configuration.
const { data: { story: config } } = await storyblokApi.get(
'cdn/stories/tour/global-components/configuration/site-configuration',
{
version: isProd ? 'published' : 'draft',
token: process.env.STORYBLOK_ACCESS_TOKEN,
},
);

return config;
};

/**
* Get the global configuration from Storyblok through the cache.
*/
export const getConfigBlokCached = unstable_cache(
getConfigBlok,
['site-configuration'],
{
tags: ['global', 'config'],
},
);
54 changes: 32 additions & 22 deletions utilities/data/getStoryData.ts
Original file line number Diff line number Diff line change
@@ -3,34 +3,44 @@ import type { ISbStoriesParams, ISbResult } from '@storyblok/react';
import { resolveRelations } from '@/utilities/resolveRelations';
import { getStoryblokApi, StoryblokClient } from '@storyblok/react/rsc';
import { isProduction } from '../getActiveEnv';
import { unstable_cache } from 'next/cache';

/**
* Get the data out of the Storyblok API for the page.
*/
async function getStoryData({ path, isEditor = false }: getStoryDataProps): Promise<ISbResult | { data: 404 }> {
const storyblokApi: StoryblokClient = getStoryblokApi();
const isProd = isProduction();
export const getStoryData =
async ({ path, isEditor = false }: getStoryDataProps): Promise<ISbResult | { data: 404 }> => {
const storyblokApi: StoryblokClient = getStoryblokApi();
const isProd = isProduction();

const sbParams: ISbStoriesParams = {
version: isProd && !isEditor ? 'published' : 'draft',
cv: isEditor ? Date.now() : undefined,
resolve_relations: resolveRelations,
token: isEditor ? process.env.STORYBLOK_PREVIEW_EDITOR_TOKEN : process.env.STORYBLOK_ACCESS_TOKEN,
};
const sbParams: ISbStoriesParams = {
version: isProd && !isEditor ? 'published' : 'draft',
cv: isEditor ? Date.now() : undefined,
resolve_relations: resolveRelations,
token: isEditor ? process.env.STORYBLOK_PREVIEW_EDITOR_TOKEN : process.env.STORYBLOK_ACCESS_TOKEN,
};

const slug = path.replace(/\/$/, ''); // Remove trailing slash.
const slug = path.replace(/\/$/, ''); // Remove trailing slash.

try {
const story: ISbResult = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
return story;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error && error.status && error.status === 404) {
return { data: 404 };
try {
const story: ISbResult = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
return story;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error && error.status && error.status === 404) {
return { data: 404 };
}
throw error;
}
throw error;
}

};
};

export default getStoryData;
/**
* Get the data out of the Storyblok API for the page through the cache.
*/
export const getStoryDataCached = unstable_cache(
getStoryData,
[],
{
tags: ['story', 'page'],
},
);
14 changes: 12 additions & 2 deletions utilities/data/getStoryList.ts
Original file line number Diff line number Diff line change
@@ -2,11 +2,12 @@ import type { getStoryDataProps, FilterQuery } from '@/utilities/data/types';
import { ISbStoriesParams, getStoryblokApi, StoryblokClient } from '@storyblok/react/rsc';
import { isProduction } from '../getActiveEnv';
import { getSlugPrefix } from '../getSlugPrefix';
import { unstable_cache } from 'next/cache';

/**
* Get a list of stories that are of component sbStoryMvp in reverse chronological order.
*/
async function getStoryList({ path }: getStoryDataProps) {
export async function getStoryList({ path }: getStoryDataProps) {
const isProd = isProduction();
const storyblokApi: StoryblokClient = getStoryblokApi();
const fullslug = path.replace(/\/$/, '');
@@ -61,4 +62,13 @@ async function getStoryList({ path }: getStoryDataProps) {
}
}

export default getStoryList;
/**
* Get the data out of the Storyblok API for the page through the cache.
*/
export const getStoryListCached = unstable_cache(
getStoryList,
[],
{
tags: ['story', 'page', 'list'],
},
);
4 changes: 4 additions & 0 deletions utilities/data/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './getAllStories';
export * from './getConfigBlok';
export * from './getStoryData';
export * from './getStoryList';
53 changes: 53 additions & 0 deletions utilities/getFirstImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type SbImageType } from '@/components/Storyblok/Storyblok.types';

/**
* A utility function to get the first image from the story content.
*
* @param priorityFields - The priority fields to check for an image.
* @param content - The content object to check for an image field
*
* @returns SbImageType | false - The first image found in the content object.
*/

export const getFirstImage = (priorityFields: string[], content: { [key:string]: unknown }): SbImageType | false => {
if (!content || typeof content !== 'object') {
throw new Error('Content object is required.');
}

// Check the priority fields for an image.
for (const field of priorityFields) {
if (content[field]) {
return content[field];
}
}

// Dig through the content object to find an image field.
const image = findFirstAssetField(content);
return image;
};

// Loop through the object and the nested arrays and objects to find an image field.
// An image field has the key 'fieldtype` and the value `asset`.
// It should also have an id that is an integer.
const findFirstAssetField = (obj: { [key:string]: unknown }): SbImageType | false => {
// Check if the current object has the required fieldtype as 'asset'
// and the id is an integer as the field could be empty.
if (obj && obj.fieldtype === 'asset' && obj.id && typeof obj.id === 'number') {
return obj;
}

// Iterate through the object keys to recursively search in nested objects or arrays
if (obj && typeof obj === 'object') {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const result = findFirstAssetField(obj[key] as { [key: string]: unknown });
if (result) {
return result;
}
}
}
}

// Return false if no asset field is found
return false;
};
167 changes: 95 additions & 72 deletions utilities/getPageMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { type ISbStoryData } from '@storyblok/react/rsc';
import { getProcessedImage } from '@/utilities/getProcessedImage';
import { type SbImageType, type SbLinkType } from '@/components/Storyblok/Storyblok.types';
import { type SbLinkType } from '@/components/Storyblok/Storyblok.types';
import { config } from './config';
import { sbStripSlugURL } from './sbStripSlugUrl';
import { getFirstImage } from './getFirstImage';

export type SbSEOType = {
title?: string;
@@ -16,91 +17,113 @@ export type SbSEOType = {
};

type PageMetadataProps = {
blok: {
component: string;
title: string;
dek?: string;
heroImage?: SbImageType;
heroPicker?: ISbStoryData[];
bgImage?: SbImageType;
noindex?: boolean;
canonicalUrl?: SbLinkType;
seo?: SbSEOType;
},
story: ISbStoryData & {
content: ISbStoryData['content'] & {
noindex?: boolean;
seo?: SbSEOType;
title?: string;
canonicalUrl?: SbLinkType;
};
};
sbConfig: ISbStoryData;
slug: string;
};

export const getPageMetadata = ({
blok: {
component,
title: pageTitle,
dek,
heroImage: { filename = '', focus = '' } = {},
heroPicker,
bgImage: { filename: bgFilename = '', focus: bgFocus = '' } = {},
noindex = false,
canonicalUrl,
seo: {
title: seoTitle,
description: seoDescription,
og_title,
og_description,
og_image,
twitter_title,
twitter_description,
twitter_image,
} = {},
},
slug,
}: PageMetadataProps) => {
// We only care about canonical URL for production so ok to use the prod URL here
const { siteTitle, siteDescription, siteUrlProd: siteUrl } = config;
const heroPickerImage = heroPicker?.[0]?.content?.heroImage?.filename;
const heroPickerFocus = heroPicker?.[0]?.content?.heroImage?.focus;
/**
* Get the page metadata for the story.
* Merge the story data with the global configuration to generate the metadata.
*
* @param story - The story data.
* @param sbConfig - The global configuration.
* @param slug - The slug of the story.
*
* @returns Metadata - The metadata for the page.
*/
export const getPageMetadata = ({ story, sbConfig, slug }: PageMetadataProps) => {
// Story explicit content.
const {
name,
content: {
noindex,
seo,
title,
canonicalUrl,
},
} = story;

const title = seoTitle || pageTitle;
const searchTitle = slug === 'home' ? 'Home' : title;
const description = seoDescription || dek || siteDescription;
const ogTitle = og_title || title;
const ogDescription = og_description || description;
const heroImageCropped = getProcessedImage(filename, '1200x630', focus) || getProcessedImage(bgFilename, '1200x630', bgFocus) || getProcessedImage(heroPickerImage, '1200x630', heroPickerFocus);
// Config component.
const {
content: {
seoOgType,
seoOgImage: {
filename,
},
seoSiteTitle,
seoSiteDescription,
},
} = sbConfig;

/**
* The og_image and twitter_image fields provided by the Storyblok SEO plugin has no image focus support
*/
const ogCropped = getProcessedImage(og_image, '1200x630');
// Twitter card image has an aspect ratio of 2:1
const twitterCropped = getProcessedImage(twitter_image, '1200x600');
const ogImage = ogCropped || heroImageCropped;
// Default hardcoded values.
const {
siteUrlProd,
siteTitle,
siteDescription,
} = config;

const ogType = component === 'sbStoryMvp' ? 'article' : 'website';
// Canonical URL.
// Canonical priority: Story Canonical URL > Config Site URL + Slug
let canonical = `${siteUrlProd}${sbStripSlugURL(slug)}`;
if (canonicalUrl) {
switch (canonicalUrl.linktype) {
case 'story': {
if (canonicalUrl.cached_url && canonicalUrl.cached_url.length) {
canonical = `${siteUrlProd}${sbStripSlugURL(canonicalUrl.cached_url)}`;
}
}
break;
case 'url': {
if (canonicalUrl.url && canonicalUrl.url.length) {
canonical = canonicalUrl.url;
}
}
break;
}
}

// Self reference URL is the page's URL without any query params
const selfReferencingUrl = slug !== 'home' ? `${siteUrl}/${slug}` : siteUrl;
// If the canonical URL is entered in Storyblok, find the full URL for it
const canonicalNotSelf = canonicalUrl?.linktype === 'story' && canonicalUrl.cached_url
? `${siteUrl}${sbStripSlugURL(canonicalUrl.cached_url)}`
: canonicalUrl?.url;
const canonical = canonicalNotSelf || selfReferencingUrl;
// Process the images.
// Use the explicitly set image from the SEO component if available,
// then use a known field if the CT has it,
// otherwise use the first image in the content.
const knownImageFields = ['heroImage']; // order of priority
const firstImage = getFirstImage(knownImageFields, story.content);
const firstImageProcessed = firstImage ? getProcessedImage(firstImage.filename, '1200x630', firstImage.focus) : undefined;
// Process the images. Use the explicitly set image if available, otherwise use the first image in the content.
const ogImage = seo?.og_image ? getProcessedImage(seo.og_image, '1200x630') : firstImageProcessed;
const twitterImage = seo?.twitter_image ? getProcessedImage(seo.twitter_image, '1200x600') : firstImageProcessed;
const defaultImage = filename ? getProcessedImage(filename, '1200x630') : firstImageProcessed;

// SEO metadata.
// Image priority: Story SEO > First Image > Config Blok Default Image
// Title priority: Story SEO > Story Title > Config Blok Site Title
// Description priority: Story SEO > Config Blok Site Description > Hardcoded Site Description
return {
title: `${searchTitle} | ${siteTitle}`,
description: description,
metadataBase: new URL(config.siteUrlProd),
openGraph: {
title: ogTitle,
description: ogDescription,
images: ogImage,
type: ogType,
title: `${seo.title || title || name} | ${seoSiteTitle || siteTitle}`,
description: seo?.description || seoSiteDescription || siteDescription,
metadataBase: new URL(siteUrlProd),
openGraph:{
title: seo?.og_title || title || name,
description: seo?.og_description || seo?.description || seoSiteDescription || siteDescription,
images: ogImage || defaultImage,
type: seoOgType || 'website',
},
twitter: {
card: 'summary_large_image',
title: twitter_title,
description: twitter_description,
images: twitterCropped,
title: seo?.twitter_title || title || name,
description: seo?.twitter_description || seo?.description || seoSiteDescription || siteDescription,
images: twitterImage || defaultImage,
},
alternates: {
canonical: !noindex && canonical,
canonical,
},
robots: noindex && 'noindex',
};