diff --git a/.env.example b/.env.example index 2d69e00..5f6f257 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ STAGING_SECRET= +# Webhook revalidation: set WEBHOOK_SECRET to enable on-demand ISR via Contentful webhooks. +# Configure the Contentful webhook to POST to: https:///api/revalidate?secret= +# Setting either WEBHOOK_SECRET or FORCE_STATIC disables all time-based ISR (fully static build). +WEBHOOK_SECRET= +# Set to any non-empty value to disable all ISR with no webhook revalidation (frozen static site). +FORCE_STATIC= CONTENTFUL_SPACE_ID= CONTENTFUL_ENVIRONMENT_ID= CONTENTFUL_ACCESS_TOKEN= diff --git a/README.md b/README.md index ce612c9..3f0541c 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,92 @@ System environment variables and page metadata will also be updated to show it's Any changes made on Contentful will be reflected on the staging server **every 30 seconds**. +## Contentful Webhook Setup + +This guide explains how to configure a Contentful webhook to trigger on-demand ISR revalidation for the session website. + +### Prerequisites + +Set `WEBHOOK_SECRET` in your environment. This activates webhook mode, which also disables time-based ISR — the site becomes fully static and only revalidates when the webhook fires. + +```env +WEBHOOK_SECRET=your-secret-value-here +``` + +### Contentful Configuration + +1. In Contentful, go to **Settings → Webhooks → Add Webhook**. + +2. **Name**: `Session Website Revalidation` (or any descriptive name) + +3. **URL**: `https:///api/revalidate?secret=` + + Replace `` with your production domain and `` with the value set in your environment. + +4. **Triggers**: Select the following events under **Entries**: + - `Publish` + - `Unpublish` + - `Delete` + + Deselect all others (Save, Auto save, Create, Archive, Unarchive). The handler ignores other events, but limiting triggers avoids unnecessary webhook calls. + +5. **Content type filter** *(optional but recommended)*: Restrict to the content types the site uses: + - `post` + - `page` + - `faq_item` + +6. **Headers**: No custom headers are required. The secret is passed as a query parameter. If you prefer header-based auth, you can add a custom header (e.g. `X-Webhook-Secret: `) and update the handler to verify it instead. + +7. **Content type** (request body): Leave as the default — `application/vnd.contentful.management.v1+json`. + +8. Click **Save**. + +### What Gets Revalidated + +| Content type | Event | What is revalidated | +|-------------|--------------|---------------------------------------------------------------| +| `post` | publish | `/${slug}`, `/blog` (all locales), tag pages for all post tags | +| `post` | unpublish / delete | `/blog` (all locales) — slug not available in tombstone payload | +| `page` | publish | `/${slug}` (all locales) | +| `page` | unpublish / delete | Data cache busted only — slug not available in tombstone payload | +| `faq_item` | any | `/faq` (all locales) | + +### Notes on unpublish / delete + +Contentful sends a tombstone payload for unpublish and delete events — the `fields` object is absent. The handler can still identify the content type from `sys.contentType.sys.id` and will: + +- Bust the relevant Next.js data cache tag so subsequent requests fetch fresh content. +- Revalidate listing pages (blog index, faq) so removed content disappears from lists. +- For posts: the post's own page at `/${slug}` will naturally return 404 on the next visit because it's no longer in Contentful. ISR handles this via `notFound: true` in `getStaticProps`. +- For pages: without a slug, the specific page path cannot be targeted. The stale page will persist until the next visitor triggers a background regeneration (which will then 404 it). + +### Verifying the Webhook + +After saving, use the **Send test** button in Contentful to send a test request. The handler will return one of: + +- `200 { revalidated: true, ... }` — success +- `200 { revalidated: false, reason: "Ignored topic" }` — test event uses a non-revalidation topic, expected +- `401` — secret mismatch, check the URL query parameter +- `503` — `WEBHOOK_SECRET` is not set in the environment + +You can also test locally with: + +```bash +curl -X POST \ + "http://localhost:3000/api/revalidate?secret=your-secret-value-here" \ + -H "Content-Type: application/vnd.contentful.management.v1+json" \ + -H "X-Contentful-Topic: ContentManagement.Entry.publish" \ + -d '{ + "sys": { + "contentType": { "sys": { "id": "post" } } + }, + "fields": { + "slug": { "en-US": "your-post-slug" } + }, + "metadata": { "tags": [] } + }' +``` + ## License Distributed under the GNU GPLv3 License. See [LICENSE](LICENSE) for more information. diff --git a/constants/cms.ts b/constants/cms.ts index ef82e09..f4e94ca 100644 --- a/constants/cms.ts +++ b/constants/cms.ts @@ -1,13 +1,28 @@ import isLive from '@/utils/environment'; +/** + * When either WEBHOOK_SECRET or FORCE_STATIC is set the site operates in fully-static + * mode: getStaticProps returns revalidate: false on every page and Next.js will never + * re-fetch content on its own schedule. + * + * WEBHOOK_SECRET additionally enables the /api/revalidate endpoint so Contentful can + * trigger on-demand revalidation when content is published. + * + * Configure the Contentful webhook to POST to: + * https:///api/revalidate?secret= + */ +export const IS_STATIC_MODE = + typeof process !== 'undefined' && + !!(process.env.FORCE_STATIC || process.env.WEBHOOK_SECRET); + const CMS = { BLOG_RESULTS_PER_PAGE: 13, BLOG_RESULTS_PER_PAGE_TAGGED: 12, // Next.js will try and re-build the page when a request comes in - // every 1 hour for production and every 30 seconds for staging - CONTENT_REVALIDATE_RATE: isLive() ? 3600 : 30, - // For older blog posts (>30 days), revalidate once per day - CONTENT_REVALIDATE_RATE_OLD: isLive() ? 86400 : 30, + // every 6 hours for production and every 30 seconds for staging + CONTENT_REVALIDATE_RATE: isLive() ? 21600 : 30, + // For older blog posts (>30 days), revalidate once per week + CONTENT_REVALIDATE_RATE_OLD: isLive() ? 604800 : 30, // Age threshold (in days) to consider a post "old" OLD_POST_AGE_DAYS: 30, // So we dont get rate limited by the GitHub API @@ -16,13 +31,13 @@ const CMS = { /** * Calculate the appropriate revalidation time for a blog post based on its age. - * + * * Strategy: - * - Posts newer than 30 days: revalidate every 1 hour (more frequent updates expected) - * - Posts older than 30 days: revalidate once per day (content is stable) - * + * - Posts newer than 30 days: revalidate every 6 hours (recently published content) + * - Posts older than 30 days: revalidate once per week (stable content) + * * This reduces API calls for older content that rarely changes. - * + * * @param publishedDateISO - ISO date string of when the post was published * @returns Revalidation time in seconds */ diff --git a/constants/index.ts b/constants/index.ts index cf2bcdf..2d4a88b 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -1,5 +1,5 @@ import BANNER from './banner'; -import CMS, { getRevalidationTime } from './cms'; +import CMS, { getRevalidationTime, IS_STATIC_MODE } from './cms'; import LINKS from './links'; import METADATA from './metadata'; import NAVIGATION from './navigation'; @@ -7,4 +7,4 @@ import SIGNUPS from './signups'; import TOS from './tos'; import UI from './ui'; -export { BANNER, CMS, getRevalidationTime, LINKS, METADATA, NAVIGATION, SIGNUPS, TOS, UI }; +export { BANNER, CMS, getRevalidationTime, IS_STATIC_MODE, LINKS, METADATA, NAVIGATION, SIGNUPS, TOS, UI }; diff --git a/lib/app_localization b/lib/app_localization new file mode 160000 index 0000000..8ab418c --- /dev/null +++ b/lib/app_localization @@ -0,0 +1 @@ +Subproject commit 8ab418ca14a512f30ceb84bb69ac48403289c1ed diff --git a/next.config.js b/next.config.js index 804416e..f223853 100644 --- a/next.config.js +++ b/next.config.js @@ -100,6 +100,8 @@ const nextConfig = { MAILERLITE_API_KEY: process.env.MAILERLITE_API_KEY, MAILERLITE_GROUP_ID: process.env.MAILERLITE_GROUP_ID, NEXT_PUBLIC_TRANSLATION_MODE: process.env.NEXT_PUBLIC_TRANSLATION_MODE, + WEBHOOK_SECRET: process.env.WEBHOOK_SECRET, + FORCE_STATIC: process.env.FORCE_STATIC, }, async headers() { @@ -311,10 +313,6 @@ const nextConfig = { source: '/windows', destination: '/api/download/windows', }, - { - source: '/blog/:slug', - destination: '/:slug', - }, ]; }, diff --git a/pages/[slug].tsx b/pages/[slug].tsx index 54d921e..6534d28 100644 --- a/pages/[slug].tsx +++ b/pages/[slug].tsx @@ -1,34 +1,24 @@ import type { GetStaticPaths, GetStaticPropsContext } from 'next'; import type { ReactElement } from 'react'; -import BlogPost from '@/components/BlogPost'; import RichPage from '@/components/RichPage'; -import { CMS, getRevalidationTime } from '@/constants'; -import { fetchBlogEntries, fetchEntryBySlug, generateLinkMeta } from '@/services/cms'; +import { CMS, IS_STATIC_MODE } from '@/constants'; +import { fetchEntryBySlug, generateLinkMeta } from '@/services/cms'; import { hasRedirection } from '@/services/redirect'; -import { type IPage, type IPost, isPost } from '@/types/cms'; +import { type IPage, isPost } from '@/types/cms'; interface Props { - content: IPage | IPost; - otherPosts?: IPost[]; + content: IPage; messages: any; } export default function Page(props: Props): ReactElement { - const { content } = props; - if (isPost(content)) { - return ; - } else { - return ; - } + return ; } export async function getStaticProps(context: GetStaticPropsContext) { const locale = context.locale || 'en'; - console.log( - `Building: Page%c${context.params?.slug ? ` /${context.params?.slug}` : ''}`, - 'color: purple;' - ); + console.log(`[Build] Page: /${context.params?.slug ?? ''}`); const slug = String(context.params?.slug); const messages = (await import(`../locales/${locale}.json`)).default; @@ -38,46 +28,28 @@ export async function getStaticProps(context: GetStaticPropsContext) { return { props: { messages }, redirect: redirect, - revalidate: CMS.CONTENT_REVALIDATE_RATE, + revalidate: IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE, }; } try { - const content: IPage | IPost = await fetchEntryBySlug(slug); - - // embedded links in content body need metadata for preview - content.body = await generateLinkMeta(content.body); - - const props: Props = { content, messages }; + const content = await fetchEntryBySlug(slug); + // Posts have moved to /blog/[slug] — redirect permanently so existing links are preserved if (isPost(content)) { - // we want 6 posts excluding the current one if it's found - const { entries: posts } = await fetchBlogEntries(7); - props.otherPosts = posts - .filter((post) => { - return content.slug !== post.slug; - }) - .slice(0, 6); + return { + redirect: { destination: `/blog/${slug}`, permanent: true }, + }; } - // Calculate revalidation time based on content age - const revalidate = isPost(content) - ? getRevalidationTime(content.publishedDateISO) - : CMS.CONTENT_REVALIDATE_RATE; + // embedded links in content body need metadata for preview + content.body = await generateLinkMeta(content.body); - // Log revalidation time in dev builds - if (process.env.NODE_ENV === 'development') { - const contentType = isPost(content) ? 'Post' : 'Page'; - const ageInfo = isPost(content) - ? ` (published: ${content.publishedDate})` - : ''; - console.log( - `[Revalidate] ${contentType} "/${slug}"${ageInfo} - ${revalidate}s (${Math.round(revalidate / 60)}min)` - ); - } + const revalidate = IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE; + console.log(`[Build] Done: /${slug} (page, revalidate=${IS_STATIC_MODE ? 'static/webhook' : `${revalidate}s`})`); return { - props, + props: { content, messages }, revalidate, }; } catch (err) { @@ -85,47 +57,32 @@ export async function getStaticProps(context: GetStaticPropsContext) { if (process.env.NODE_ENV === 'development') { console.warn(`[404] Page not found: "/${slug}"`); } - + // For non-dev, only log actual errors (not 404s from regular navigation) if (err instanceof Error && !err.message.includes('Failed to fetch entry')) { console.error(err); } - + return { props: { messages }, notFound: true, - // Use longer revalidation for 404 pages to reduce unnecessary rebuilds - revalidate: CMS.CONTENT_REVALIDATE_RATE_OLD, + revalidate: IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE_OLD, }; } } export const getStaticPaths: GetStaticPaths = async ({ locales }) => { - const { fetchPages, fetchAllBlogEntries } = await import('@/services/cms'); + const { fetchPages } = await import('@/services/cms'); const { entries: pages } = await fetchPages(); - const posts = await fetchAllBlogEntries(); - // Generate paths for pages (all locales) and posts (en only) - const pagePaths = pages.flatMap((page) => + // Only pre-build CMS pages. Post slugs are handled by pages/blog/[slug].tsx. + // Any /{post-slug} hit falls through to getStaticProps which issues a permanent redirect. + const paths = pages.flatMap((page) => (locales || ['en']).map((locale) => ({ - params: { - slug: page.slug, - }, + params: { slug: page.slug }, locale, })) ); - const postPaths = posts.map((post) => ({ - params: { - slug: post.slug, - }, - locale: 'en', - })); - - const paths = [...pagePaths, ...postPaths]; - - return { - paths, - fallback: 'blocking', - }; + return { paths, fallback: 'blocking' }; }; diff --git a/pages/api/revalidate.ts b/pages/api/revalidate.ts new file mode 100644 index 0000000..78a3624 --- /dev/null +++ b/pages/api/revalidate.ts @@ -0,0 +1,162 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import nextConfig from 'next.config'; +import { fetchTagList } from '@/services/cms'; + +// Disable Next.js body parser — Contentful sends Content-Type: application/vnd.contentful.management.v1+json +// which Next.js won't auto-parse. We read and parse the raw body manually. +export const config = { api: { bodyParser: false } }; + +const locales: string[] = nextConfig.i18n.locales; +const defaultLocale: string = nextConfig.i18n.defaultLocale; + +/** + * Contentful topic values this handler acts on. + * All other topics (save, auto_save, create, archive, unarchive) are acknowledged but ignored. + */ +const REVALIDATE_TOPICS = new Set([ + 'ContentManagement.Entry.publish', + 'ContentManagement.Entry.unpublish', + 'ContentManagement.Entry.delete', +]); + +/** + * Contentful content is not localized — all locale variants of a page serve the + * same Contentful data with statically-bundled UI translations. However, Next.js + * ISR caches each locale variant independently, so all of them must be revalidated + * when Contentful content changes. + * + * Exception: posts are built for locale 'en' only in getStaticPaths, so they have + * no locale-prefixed variants and only need a single path revalidated. + */ +function localizedPaths(slug: string): string[] { + return locales.map((locale) => + locale === defaultLocale ? `/${slug}` : `/${locale}/${slug}` + ); +} + +function readRawBody(req: NextApiRequest): Promise { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + req.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +/** + * Contentful webhook handler — triggers on-demand ISR revalidation via res.revalidate(). + * + * revalidateTag() is intentionally not used here: it requires App Router context and + * throws in Pages Router API routes. Instead, unstable_cache uses a short TTL in static + * mode (see services/cms.ts) so data is always fresh when ISR regeneration runs. + * + * Configure Contentful to POST to: + * https:///api/revalidate?secret= + * + * Supported content types: + * - post → revalidates the post page, /blog (all locales, also regenerates RSS), + * and tag pages (all locales) + * - page → revalidates the page slug (all locales) + * - faq_item → revalidates /faq (all locales) + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const secret = process.env.WEBHOOK_SECRET; + if (!secret) { + return res.status(503).json({ message: 'Webhook revalidation is not configured' }); + } + + if (req.query.secret !== secret) { + return res.status(401).json({ message: 'Invalid token' }); + } + + // Only act on publish / unpublish / delete — ignore save, auto_save, create, archive, etc. + const topic = req.headers['x-contentful-topic'] as string | undefined; + if (!topic || !REVALIDATE_TOPICS.has(topic)) { + return res.status(200).json({ revalidated: false, reason: 'Ignored topic', topic }); + } + + try { + const payload = (await readRawBody(req)) as Record; + + // sys.contentType is present on all events, including delete (sys.type === 'DeletedEntry') + const contentType: string | undefined = payload?.sys?.contentType?.sys?.id; + + // fields is only present on publish events; unpublish/delete send tombstone payloads with no fields + const slugField = payload?.fields?.slug; + const slug: string | null = slugField?.['en-US'] ?? slugField?.['en'] ?? null; + + // metadata.tags (tag references) is only present on publish events + const tagRefs: Array<{ sys: { id: string } }> = payload?.metadata?.tags ?? []; + + // Build the list of paths to revalidate + const paths: string[] = []; + + if (contentType === 'post') { + // Always revalidate the blog index (lists all posts and regenerates the RSS feed) + paths.push(...localizedPaths('blog')); + + if (slug) { + // Posts live at /blog/{slug} in pages/blog/[slug].tsx. + // Posts are built for locale 'en' only so there are no locale-prefixed variants. + paths.push(`/blog/${slug}`); + + // Tag pages for each tag on this post (only available on publish events) + if (tagRefs.length > 0) { + const taglist = await fetchTagList(); + for (const ref of tagRefs) { + const tagName = taglist[ref.sys.id]; + if (tagName) { + paths.push(...localizedPaths(`tag/${encodeURIComponent(tagName)}`)); + } + } + } + } + } else if (contentType === 'page') { + if (slug) { + paths.push(...localizedPaths(slug)); + } + } else if (contentType === 'faq_item') { + paths.push(...localizedPaths('faq')); + } else { + return res.status(200).json({ revalidated: false, reason: 'Unhandled content type', contentType }); + } + + console.log(`[Revalidate] topic=${topic} contentType=${contentType} slug=${slug ?? 'none'} paths=${paths.join(', ') || 'none'}`); + + // res.revalidate() is the correct Pages Router mechanism for on-demand ISR. + // It marks the path as stale so the next request triggers background regeneration. + const results = await Promise.allSettled(paths.map((path) => res.revalidate(path))); + + const failed = results + .map((result, i) => (result.status === 'rejected' ? paths[i] : null)) + .filter((p): p is string => p !== null); + + if (failed.length > 0) { + console.error('[Revalidate] Failed paths:', failed); + } + + return res.status(200).json({ + revalidated: true, + topic, + contentType, + slug, + paths, + ...(failed.length > 0 && { failed }), + }); + } catch (err) { + console.error('[Revalidate] Unexpected error:', err); + return res.status(500).json({ message: 'Revalidation failed', error: String(err) }); + } +} diff --git a/pages/blog/[slug].tsx b/pages/blog/[slug].tsx new file mode 100644 index 0000000..b88c347 --- /dev/null +++ b/pages/blog/[slug].tsx @@ -0,0 +1,64 @@ +import type { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; +import type { ReactElement } from 'react'; +import BlogPost from '@/components/BlogPost'; +import { CMS, getRevalidationTime, IS_STATIC_MODE } from '@/constants'; +import { fetchAllBlogEntries, fetchBlogEntries, fetchEntryBySlug, generateLinkMeta } from '@/services/cms'; +import { type IPost, isPost } from '@/types/cms'; + +interface Props { + post: IPost; + otherPosts: IPost[]; + messages: any; +} + +export default function BlogPostPage(props: Props): ReactElement { + return ; +} + +export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext) => { + const locale = context.locale || 'en'; + const slug = String(context.params?.slug); + + console.log(`[Build] Page: /blog/${slug}`); + + const messages = (await import(`../../locales/${locale}.json`)).default; + + try { + const content = await fetchEntryBySlug(slug); + + if (!isPost(content)) { + return { notFound: true }; + } + + content.body = await generateLinkMeta(content.body); + + const { entries: posts } = await fetchBlogEntries(7); + const otherPosts = posts.filter((p) => p.slug !== slug).slice(0, 6); + + const revalidate = IS_STATIC_MODE ? false : getRevalidationTime(content.publishedDateISO); + const revalidateInfo = IS_STATIC_MODE ? 'static/webhook' : `${revalidate}s`; + console.log(`[Build] Done: /blog/${slug} (revalidate=${revalidateInfo})`); + + return { + props: { post: content, otherPosts, messages }, + revalidate, + }; + } catch (err) { + if (err instanceof Error && !err.message.includes('Failed to fetch entry')) { + console.error(err); + } + return { + notFound: true, + revalidate: IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE_OLD, + }; + } +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const posts = await fetchAllBlogEntries(); + const paths = posts.map((post) => ({ + params: { slug: post.slug }, + locale: 'en', + })); + return { paths, fallback: 'blocking' }; +}; diff --git a/pages/blog/index.tsx b/pages/blog/index.tsx index 3c9a42b..0f7931a 100644 --- a/pages/blog/index.tsx +++ b/pages/blog/index.tsx @@ -5,28 +5,28 @@ import Container from '@/components/Container'; import PostCard from '@/components/cards/PostCard'; import PostList from '@/components/posts/PostList'; import Layout from '@/components/ui/Layout'; -import { CMS } from '@/constants'; +import { CMS, IS_STATIC_MODE } from '@/constants'; import METADATA from '@/constants/metadata'; import { generateRoute } from '@/services/cms'; import type { IPost } from '@/types/cms'; +import generateRSSFeed from '@/utils/rss'; interface Props { posts: IPost[]; } export const getStaticProps: GetStaticProps = async (_context: GetStaticPropsContext) => { + console.log('[Build] Page: /blog'); const { fetchAllBlogEntries } = await import('@/services/cms'); const posts = await fetchAllBlogEntries(); + console.log(`[Build] Done: /blog (${posts.length} posts)`); - const revalidate = CMS.CONTENT_REVALIDATE_RATE; - - // Log revalidation time in dev builds - if (process.env.NODE_ENV === 'development') { - console.log( - `[Revalidate] Blog Index - ${revalidate}s (${Math.round(revalidate / 60)}min)` - ); + if (process.env.NEXT_PUBLIC_SITE_ENV !== 'development') { + generateRSSFeed(posts); } + const revalidate = IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE; + return { props: { posts, messages: (await import(`../../locales/${_context.locale}.json`)).default }, revalidate, diff --git a/pages/faq.tsx b/pages/faq.tsx index dc63b13..3f55bfe 100644 --- a/pages/faq.tsx +++ b/pages/faq.tsx @@ -6,7 +6,7 @@ import Container from '@/components/Container'; import Accordion from '@/components/ui/Accordion'; import Headline from '@/components/ui/Headline'; import Layout from '@/components/ui/Layout'; -import { CMS } from '@/constants'; +import { CMS, IS_STATIC_MODE } from '@/constants'; import METADATA from '@/constants/metadata'; import { fetchFAQItems, generateLinkMeta } from '@/services/cms'; import type { IFAQItem, IFAQList } from '@/types/cms'; @@ -139,6 +139,6 @@ export const getStaticProps: GetStaticProps = async (_context: GetStaticPropsCon total, messages: (await import(`../locales/${_context.locale}.json`)).default, }, - revalidate: CMS.CONTENT_REVALIDATE_RATE, + revalidate: IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE, }; }; diff --git a/pages/index.tsx b/pages/index.tsx index e058795..69fb817 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -4,8 +4,6 @@ import Benefits from '@/components/sections/Benefits'; import Features from '@/components/sections/Features'; import Hero from '@/components/sections/Hero'; import Layout from '@/components/ui/Layout'; -import { CMS } from '@/constants'; -import generateRSSFeed from '@/utils/rss'; export default function Home() { return ( @@ -19,23 +17,7 @@ export default function Home() { } export const getStaticProps: GetStaticProps = async (_context: GetStaticPropsContext) => { - if (process.env.NEXT_PUBLIC_SITE_ENV !== 'development') { - const { fetchAllBlogEntries } = await import('@/services/cms'); - const posts = await fetchAllBlogEntries(); - generateRSSFeed(posts); - } - - const revalidate = CMS.CONTENT_REVALIDATE_RATE; - - // Log revalidation time in dev builds - if (process.env.NODE_ENV === 'development') { - console.log( - `[Revalidate] Home Page - ${revalidate}s (${Math.round(revalidate / 60)}min)` - ); - } - return { props: { messages: (await import(`../locales/${_context.locale}.json`)).default }, - revalidate, }; }; diff --git a/pages/tag/[tag].tsx b/pages/tag/[tag].tsx index fa0fb5c..2a94de2 100644 --- a/pages/tag/[tag].tsx +++ b/pages/tag/[tag].tsx @@ -4,7 +4,7 @@ import type { ReactElement } from 'react'; import Container from '@/components/Container'; import PostList from '@/components/posts/PostList'; import Layout from '@/components/ui/Layout'; -import { CMS, METADATA } from '@/constants'; +import { CMS, IS_STATIC_MODE, METADATA } from '@/constants'; import { fetchBlogEntriesByTag, fetchTagList } from '@/services/cms'; import type { IPost, ITagList } from '@/types/cms'; @@ -42,21 +42,14 @@ export default function Tag(props: Props): ReactElement { } export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext) => { - console.log(`Building: Results for tag "%c${context.params?.tag}"`, 'color: purple;'); + console.log(`[Build] Page: /tag/${context.params?.tag}`); const tag = String(context.params?.tag); - const revalidate = CMS.CONTENT_REVALIDATE_RATE; + const revalidate = IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE; try { const { entries: posts } = await fetchBlogEntriesByTag(tag); - // Log revalidation time in dev builds - if (process.env.NODE_ENV === 'development') { - console.log( - `[Revalidate] Tag Page "${tag}" - ${revalidate}s (${Math.round(revalidate / 60)}min)` - ); - } - return { props: { tag, diff --git a/services/cache.ts b/services/cache.ts deleted file mode 100644 index a180d27..0000000 --- a/services/cache.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Blog Cache Service - * - * Provides in-memory caching for Contentful blog data to reduce API calls. - * - * Features: - * - Configurable TTL (Time To Live) for cache entries - * - Automatic expiration checking - * - Periodic cleanup of expired entries - * - Type-safe cache operations - * - * Cache Strategy: - * - Default TTL: 1 hour (3600000ms) aligned with CONTENT_REVALIDATE_RATE - * - Automatic invalidation on expiration - * - Fallback to API calls when cache is empty or expired - * - * Usage: - * - Caches blog entries with pagination keys - * - Caches all blog entries under 'all_blog_entries' key - * - Reduces Contentful API usage by serving cached data when available - */ - -interface CacheEntry { - data: T; - timestamp: number; - expiresAt: number; -} - -class BlogCache { - private cache: Map>; - private defaultTTL: number; // Time to live in milliseconds - - constructor(defaultTTL = 3600000 /* 1 hour */) { - this.cache = new Map(); - this.defaultTTL = defaultTTL; - } - - set(key: string, data: T, ttl?: number): void { - const timestamp = Date.now(); - const expiresAt = timestamp + (ttl || this.defaultTTL); - this.cache.set(key, { data, timestamp, expiresAt }); - } - - get(key: string): T | null { - const entry = this.cache.get(key); - if (!entry) { - return null; - } - - // Check if cache entry is expired - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - return null; - } - - return entry.data as T; - } - - has(key: string): boolean { - const entry = this.cache.get(key); - if (!entry) { - return false; - } - - // Check if expired - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - return false; - } - - return true; - } - - invalidate(key: string): void { - this.cache.delete(key); - } - - invalidateAll(): void { - this.cache.clear(); - } - - // Clean up expired entries - cleanup(): void { - const now = Date.now(); - for (const [key, entry] of this.cache.entries()) { - if (now > entry.expiresAt) { - this.cache.delete(key); - } - } - } -} - -// Singleton instance -const blogCache = new BlogCache(); - -// Periodic cleanup every 10 minutes -const CLEANUP_INTERVAL_MS = 600000; // 10 minutes -let cleanupIntervalId: NodeJS.Timeout | undefined; -if (typeof setInterval !== 'undefined') { - cleanupIntervalId = setInterval(() => { - blogCache.cleanup(); - }, CLEANUP_INTERVAL_MS); -} - -// Export cleanup interval ID for testing purposes -export const clearCleanupInterval = () => { - if (cleanupIntervalId) { - clearInterval(cleanupIntervalId); - cleanupIntervalId = undefined; - } -}; - -export default blogCache; diff --git a/services/cms.ts b/services/cms.ts index 55c3dab..c68c521 100644 --- a/services/cms.ts +++ b/services/cms.ts @@ -1,8 +1,9 @@ import type { Block, Document, Inline } from '@contentful/rich-text-types'; import { type ContentfulClientApi, createClient, type EntryCollection, type Tag } from 'contentful'; import { format, parseISO } from 'date-fns'; +import { unstable_cache } from 'next/cache'; import { METADATA } from '@/constants'; -import blogCache from '@/services/cache'; +import CMS, { IS_STATIC_MODE } from '@/constants/cms'; import { fetchContent } from '@/services/embed'; import type { IAuthor, @@ -20,9 +21,6 @@ import type { // Contentful API pagination limit const CONTENTFUL_PAGE_SIZE = 100; -// Sentinel value to mark 404s in cache -const NOT_FOUND_MARKER = Symbol('NOT_FOUND'); - const client: ContentfulClientApi = createClient({ space: process.env.CONTENTFUL_SPACE_ID!, environment: process.env.CONTENTFUL_ENVIRONMENT_ID!, @@ -30,171 +28,235 @@ const client: ContentfulClientApi = createClient({ host: 'cdn.contentful.com', }); -export async function fetchTagList(): Promise { - const _tags = await client.getTags(); - const tags: ITagList = {}; - _tags.items.forEach((tag) => { - tags[tag.sys.id] = tag.name; - }); - return tags; -} - -export async function fetchBlogEntries(quantity = CONTENTFUL_PAGE_SIZE, page = 1): Promise { - const cacheKey = `blog_entries_${quantity}_${page}`; - const cached = blogCache.get(cacheKey); - - if (cached) { - return cached; - } - - const _entries = await client.getEntries({ - content_type: 'post', // only fetch blog post entry - order: '-fields.date', - limit: quantity, - skip: (page - 1) * quantity, - }); - - const results = await generateEntries(_entries, 'post'); - const data = { - entries: results.entries as Array, - total: results.total, - }; - - blogCache.set(cacheKey, data); - return data; -} +const previewClient: ContentfulClientApi = createClient({ + space: process.env.CONTENTFUL_SPACE_ID!, + environment: process.env.CONTENTFUL_ENVIRONMENT_ID!, + accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!, + host: 'preview.contentful.com', +}); /** - * Efficiently fetches all blog entries with optimized pagination - * - * This function replaces inefficient while loops that made unnecessary API calls. - * - * Optimizations: - * - Calculates total pages from first API call - * - Uses for loop with known bounds instead of while loop - * - Caches the complete result to avoid repeated full fetches - * - Leverages per-page caching for individual requests - * - * Cache Key: 'all_blog_entries' - * TTL: 1 hour (default cache TTL) - * - * @returns Promise Array of all blog posts + * Cache tags used for on-demand revalidation via revalidateTag() in the webhook handler. + * Exported so the webhook handler can reference them without magic strings. */ -export async function fetchAllBlogEntries(): Promise { - const cacheKey = 'all_blog_entries'; - const cached = blogCache.get(cacheKey); - - if (cached) { - return cached; - } +export const CACHE_TAGS = { + TAGS: 'contentful-tags', + POSTS: 'contentful-posts', + PAGES: 'contentful-pages', + FAQ: 'contentful-faq', +} as const; + +// In static mode, use a 5-second TTL rather than caching indefinitely. +// revalidateTag() requires App Router context and cannot be called from a Pages Router +// API route, so we can't explicitly bust the cache on demand. A 5-second TTL ensures +// data is always fresh when res.revalidate() triggers ISR regeneration — any regen +// happens well after the cache has expired, so getStaticProps fetches live Contentful data. +// The 5-second window still deduplicates concurrent calls during parallel page builds. +const cacheOptions = { revalidate: IS_STATIC_MODE ? 5 : CMS.CONTENT_REVALIDATE_RATE } as const; + +// --------------------------------------------------------------------------- +// Internal cached implementations +// Exported functions below normalise default parameters then delegate here +// so that cache keys are always consistent regardless of how callers pass args. +// --------------------------------------------------------------------------- + +const _fetchTagList = unstable_cache( + async (): Promise => { + const _tags = await client.getTags(); + const tags: ITagList = {}; + _tags.items.forEach((tag) => { + tags[tag.sys.id] = tag.name; + }); + return tags; + }, + ['contentful-tag-list'], + { ...cacheOptions, tags: [CACHE_TAGS.TAGS] } +); + +const _fetchBlogEntries = unstable_cache( + async (quantity: number, page: number): Promise => { + const _entries = await client.getEntries({ + content_type: 'post', + order: '-fields.date', + limit: quantity, + skip: (page - 1) * quantity, + }); + const results = await generateEntries(_entries, 'post'); + return { + entries: results.entries as Array, + total: results.total, + }; + }, + ['contentful-blog-entries'], + { ...cacheOptions, tags: [CACHE_TAGS.POSTS] } +); + +const _fetchAllBlogEntries = unstable_cache( + async (): Promise => { + const posts: IPost[] = []; + + const firstBatch = await fetchBlogEntries(CONTENTFUL_PAGE_SIZE, 1); + posts.push(...firstBatch.entries); + + const totalPages = Math.ceil(firstBatch.total / CONTENTFUL_PAGE_SIZE); + if (totalPages > 1) { + const remainingPages = Array.from({ length: totalPages - 1 }, (_, i) => i + 2); + const batches = await Promise.all( + remainingPages.map((page) => fetchBlogEntries(CONTENTFUL_PAGE_SIZE, page)) + ); + for (const { entries } of batches) { + posts.push(...entries); + } + } - const posts: IPost[] = []; - - // First fetch to get total count - const firstBatch = await fetchBlogEntries(CONTENTFUL_PAGE_SIZE, 1); - posts.push(...firstBatch.entries); - - // Calculate remaining pages - const totalPages = Math.ceil(firstBatch.total / CONTENTFUL_PAGE_SIZE); - - // Fetch remaining pages if needed - for (let page = 2; page <= totalPages; page++) { - const { entries } = await fetchBlogEntries(CONTENTFUL_PAGE_SIZE, page); - posts.push(...entries); - } - - blogCache.set(cacheKey, posts); - return posts; -} + return posts; + }, + ['contentful-all-blog-entries'], + { ...cacheOptions, tags: [CACHE_TAGS.POSTS] } +); + +const _fetchBlogEntriesByTag = unstable_cache( + async (tag: string, quantity: number): Promise => { + const taglist = await fetchTagList(); + const entry = Object.entries(taglist).find(([, value]) => value === tag); + if (!entry) { + throw new Error(`Tag not found: ${tag}`); + } + const [id] = entry; -/** - * Check if a slug likely exists without making API calls - * Uses cached data to quickly validate if a slug might be valid - * - * @param slug - The slug to check - * @returns boolean - true if slug might exist, false if definitely doesn't exist in cache - */ -export async function slugMightExist(slug: string): Promise { - // Check if it's already in the entry cache - const cacheKey = `entry_by_slug_${slug}`; - if (blogCache.has(cacheKey)) { - return true; - } - - // Check if we have all blog entries cached - const allPostsKey = 'all_blog_entries'; - const cachedPosts = blogCache.get(allPostsKey); - - if (cachedPosts) { - const found = cachedPosts.some(post => post.slug === slug); - if (found) { - return true; + const _entries = await client.getEntries({ + content_type: 'post', + order: '-fields.date', + 'metadata.tags.sys.id[in]': id, + limit: quantity, + }); + + if (_entries.items.length === 0) { + throw new Error(`Failed to fetch entries for ${tag}`); } - } - - // Check if we have pages cached - const allPagesKey = 'all_pages'; - const cachedPages = blogCache.get(allPagesKey); - - if (cachedPages) { - const found = cachedPages.some(page => page.slug === slug); - if (found) { - return true; + + const results = await generateEntries(_entries, 'post', taglist); + return { + entries: results.entries as Array, + total: results.total, + }; + }, + ['contentful-blog-entries-by-tag'], + { ...cacheOptions, tags: [CACHE_TAGS.POSTS] } +); + +const _fetchEntryBySlug = unstable_cache( + async (slug: string): Promise => { + const [_pages, _posts] = await Promise.all([ + client.getEntries({ content_type: 'page', 'fields.slug': slug }), + client.getEntries({ content_type: 'post', 'fields.slug': slug }), + ]); + + const _entries = [..._pages.items, ..._posts.items]; + const hasPost = _entries.some((e) => e.sys.contentType.sys.id === 'post'); + const taglist = hasPost ? await fetchTagList() : {}; + + if (_entries.length > 0) { + const entry = _entries[0]; + if (entry.sys.contentType.sys.id === 'post') { + return convertPost(entry, taglist); + } + if (entry.sys.contentType.sys.id === 'page') { + return convertPage(entry); + } } - } - - // If we have cache and slug not found, likely doesn't exist - // But return true to allow API call as fallback (cache might be incomplete) - return true; + + throw new Error(`Failed to fetch entry for ${slug}`); + }, + ['contentful-entry-by-slug'], + { ...cacheOptions, tags: [CACHE_TAGS.POSTS, CACHE_TAGS.PAGES] } +); + +const _fetchFAQItems = unstable_cache( + async (): Promise => { + const _entries = await client.getEntries({ + content_type: 'faq_item', + order: 'fields.id', + }); + const results = await generateEntries(_entries, 'faq'); + return { entries: results.entries as Array, total: results.total }; + }, + ['contentful-faq-items'], + { ...cacheOptions, tags: [CACHE_TAGS.FAQ] } +); + +const _fetchPages = unstable_cache( + async (quantity: number): Promise => { + const _entries = await client.getEntries({ + content_type: 'page', + limit: quantity, + }); + const results = await generateEntries(_entries, 'page'); + return { + entries: results.entries as Array, + total: results.total, + }; + }, + ['contentful-pages'], + { ...cacheOptions, tags: [CACHE_TAGS.PAGES] } +); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function fetchTagList(): Promise { + return _fetchTagList(); +} + +export async function fetchBlogEntries( + quantity = CONTENTFUL_PAGE_SIZE, + page = 1 +): Promise { + return _fetchBlogEntries(quantity, page); +} + +export async function fetchAllBlogEntries(): Promise { + return _fetchAllBlogEntries(); } export async function fetchBlogEntriesByTag( tag: string, quantity = CONTENTFUL_PAGE_SIZE ): Promise { - const taglist = await fetchTagList(); - const id = Object.entries(taglist).filter(([_, value]) => { - return tag === value; - })[0][0]; - - const _entries = await client.getEntries({ - content_type: 'post', // only fetch blog post entry - order: '-fields.date', - 'metadata.tags.sys.id[in]': id, - limit: quantity, - }); + return _fetchBlogEntriesByTag(tag, quantity); +} - if (_entries.items.length > 0) { - const results = await generateEntries(_entries, 'post'); - return { - entries: results.entries as Array, - total: results.total, - }; - } +export async function fetchEntryBySlug(slug: string): Promise { + return _fetchEntryBySlug(slug); +} - return Promise.reject(new Error(`Failed to fetch entries for ${tag}`)); +export async function fetchFAQItems(): Promise { + return _fetchFAQItems(); } -export async function fetchEntryPreview(slug: string): Promise { - const _client = createClient({ - space: process.env.CONTENTFUL_SPACE_ID!, - accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!, - host: 'preview.contentful.com', - }); +export async function fetchPages(quantity = CONTENTFUL_PAGE_SIZE): Promise { + return _fetchPages(quantity); +} - const _pages = await _client.getEntries({ - content_type: 'page', - 'fields.slug': slug, - 'fields.preview': true, - }); - const _posts = await _client.getEntries({ - content_type: 'post', - 'fields.slug': slug, - 'fields.preview': true, - }); +// Preview is intentionally not cached — it always fetches live draft content. +export async function fetchEntryPreview(slug: string): Promise { + const [_pages, _posts] = await Promise.all([ + previewClient.getEntries({ + content_type: 'page', + 'fields.slug': slug, + 'fields.preview': true, + }), + previewClient.getEntries({ + content_type: 'post', + 'fields.slug': slug, + 'fields.preview': true, + }), + ]); const _entries = [..._pages.items, ..._posts.items]; - const taglist = await fetchTagList(); + const hasPost = _entries.some((e) => e.sys.contentType.sys.id === 'post'); + const taglist = hasPost ? await fetchTagList() : {}; if (_entries.length > 0) { const entry = _entries[0]; @@ -209,58 +271,9 @@ export async function fetchEntryPreview(slug: string): Promise { return Promise.reject(new Error(`Failed to fetch preview for ${slug}`)); } -export async function fetchEntryBySlug(slug: string): Promise { - const cacheKey = `entry_by_slug_${slug}`; - - // Check if we have this in cache (either valid entry or 404 marker) - if (blogCache.has(cacheKey)) { - const cached = blogCache.get(cacheKey); - - // If it's the NOT_FOUND marker, we previously checked and it doesn't exist - if (cached === NOT_FOUND_MARKER) { - return Promise.reject(new Error(`Failed to fetch entry for ${slug}`)); - } - - // Otherwise it's a valid cached entry - if (cached) { - return cached; - } - } - - // Not in cache, proceed with API call - const _pages = await client.getEntries({ - content_type: 'page', - 'fields.slug': slug, - }); - const _posts = await client.getEntries({ - content_type: 'post', - 'fields.slug': slug, - }); - - const _entries = [..._pages.items, ..._posts.items]; - const taglist = await fetchTagList(); - - if (_entries.length > 0) { - const entry = _entries[0]; - let result: IPage | IPost; - if (entry.sys.contentType.sys.id === 'post') { - result = convertPost(entry, taglist); - } else if (entry.sys.contentType.sys.id === 'page') { - result = convertPage(entry); - } else { - return Promise.reject(new Error(`Failed to fetch entry for ${slug}`)); - } - - // Cache successful lookups for 1 hour - blogCache.set(cacheKey, result); - return result; - } - - // Cache negative results for 10 minutes to prevent repeated lookups of non-existent pages - // This is shorter than successful lookups since content might be added - blogCache.set(cacheKey, NOT_FOUND_MARKER as any, 600000); // 10 minutes - return Promise.reject(new Error(`Failed to fetch entry for ${slug}`)); -} +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- function convertPost(rawData: any, taglist: ITagList): IPost { const rawPost = rawData.fields; @@ -285,7 +298,7 @@ function convertPost(rawData: any, taglist: ITagList): IPost { function convertImage(rawImage: any): IFigureImage { return { - imageUrl: rawImage.file.url.replace('//', 'https://'), // may need to put null check as well here + imageUrl: rawImage.file.url.replace('//', 'https://'), description: rawImage.description ?? null, title: rawImage.title ?? null, width: rawImage.file.details.image.width, @@ -307,21 +320,20 @@ function convertAuthor(rawAuthor: any): IAuthor { } function convertTags(rawTags: any, taglist: ITagList): string[] { - return rawTags.map((tag: Tag) => { - return taglist[tag.sys.id]; - }); + return rawTags.map((tag: Tag) => taglist[tag.sys.id]); } async function generateEntries( entries: EntryCollection, - entryType: 'post' | 'faq' | 'page' + entryType: 'post' | 'faq' | 'page', + taglist?: ITagList ): Promise { let _entries: any = []; if (entries?.items && entries.items.length > 0) { switch (entryType) { case 'post': { - const taglist = await fetchTagList(); - _entries = entries.items.map((entry) => convertPost(entry, taglist)); + const tags = taglist ?? (await fetchTagList()); + _entries = entries.items.map((entry) => convertPost(entry, tags)); break; } case 'faq': @@ -344,7 +356,6 @@ export function generateRoute(slug: string): string { } async function loadMetaData(node: Block | Inline) { - // is embedded link not embedded media if (!node.data.target.fields.file) { if (node.data.target.sys.contentType.sys.id === 'post') { node.data.target.fields.url = `${METADATA.HOST_URL}/blog/${node.data.target.fields.slug}`; @@ -359,7 +370,6 @@ export async function generateLinkMeta(doc: Document): Promise { if (node.nodeType === 'embedded-entry-block') { node = await loadMetaData(node); } else { - // check for inline embedding const innerPromises = node.content.map(async (innerNode) => { if ( innerNode.nodeType === 'embedded-entry-inline' && @@ -375,20 +385,9 @@ export async function generateLinkMeta(doc: Document): Promise { return doc; } -export async function fetchFAQItems(): Promise { - const _entries = await client.getEntries({ - content_type: 'faq_item', // only fetch faq items - order: 'fields.id', - }); - - const results = await generateEntries(_entries, 'faq'); - return { entries: results.entries as Array, total: results.total }; -} - function convertFAQ(rawData: any): IFAQItem { const rawFAQ = rawData.fields; const { question, answer, id, tag, slug } = rawFAQ; - return { id: id ?? null, question: question ?? null, @@ -398,37 +397,8 @@ function convertFAQ(rawData: any): IFAQItem { }; } -export async function fetchPages(quantity = CONTENTFUL_PAGE_SIZE): Promise { - const cacheKey = `pages_${quantity}`; - const cached = blogCache.get(cacheKey); - - if (cached) { - return cached; - } - - const _entries = await client.getEntries({ - content_type: 'page', - limit: quantity, - }); - - const results = await generateEntries(_entries, 'page'); - const data = { - entries: results.entries as Array, - total: results.total, - }; - - // Cache all pages for slug validation - if (quantity >= results.total) { - blogCache.set('all_pages', data.entries); - } - - blogCache.set(cacheKey, data); - return data; -} - function convertPage(rawData: any): IPage { const rawPage = rawData.fields; - return { title: rawPage.title, slug: rawPage.slug, diff --git a/tsconfig.json b/tsconfig.json index b9ea7c4..7ebd0c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,10 +2,16 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] }, "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true /* allows javaScript files to be compiled */, "skipLibCheck": true /* skip type checking of all declaration files */, "strict": true /* enables all strict type checking options */, @@ -19,10 +25,24 @@ "jsx": "preserve" /* Controls how JSX constructs are emitted in JavaScript files. This only affects output of JS files that started in .tsx files. */, // "noUnusedLocals": true, /* reports errors on unused locals (suggested for clean code writing) */ // "noUnusedParameters": true, /* report errors on unused parameters (again, suggested for clean code writing) */ - "incremental": true + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] }, "include": [ + "**/*.ts", + "**/*.tsx", + "additional.d.ts", "global.d.ts", - "next-env.d.ts", "additional.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", ".next", "out"] + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules", + ".next", + "out" + ] }