Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/[locale]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from '@/lib/utils/validateSlugPageRequest';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/activity/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { breadcrumbJsonLd, JsonLd, resourceJsonLd } from '@/lib/utils/structured
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/audio/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { breadcrumbJsonLd, JsonLd, resourceJsonLd } from '@/lib/utils/structured
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { notFound } from 'next/navigation';
// The folder is needed because a slug can exist in more than one folder (`what-are-boundaries` is
// both a video and an audio). Next.js requires this segment to share the session route's
// `sessionSlug` name.
export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string; sessionSlug: string; resourceSlug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/courses/[slug]/[sessionSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { getStoryblokApi, ISbStoriesParams } from '@storyblok/react/rsc';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string; sessionSlug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/courses/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { getStoryblokApi, ISbStoriesParams } from '@storyblok/react/rsc';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/policies/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { generateMetadataBasic } from '@/lib/utils/generateMetadataBase';
import { getStoryblokApi, ISbStoriesParams } from '@storyblok/react/rsc';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/video/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { breadcrumbJsonLd, JsonLd, videoJsonLd } from '@/lib/utils/structuredDat
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/written/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { breadcrumbJsonLd, JsonLd, resourceJsonLd } from '@/lib/utils/structured
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';

export const dynamicParams = false;
export const revalidate = 14400; // invalidate every 4 hours

type Params = Promise<{ locale: string; slug: string }>;
Expand Down
5 changes: 2 additions & 3 deletions docs/courses-sessions-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ A course can be mixed this way: the English version keeps its weeks and sessions

`app/[locale]/courses/[slug]/[sessionSlug]/[resourceSlug]/page.tsx` renders a resource inside its course. `sessionSlug` holds the resource's folder, because a slug can exist in two folders, and Next.js requires sibling dynamic segments to share a name. `RESOURCE_PAGES` maps each folder to its page component.

The route uses `dynamicParams = false`, as the course and session routes do. A page that didn't exist at build time returns a 404, and the revalidation webhook can't add one. After publishing a new course, or a resource newly listed in a course, redeploy production.
Like every Storyblok route, it pre-renders the pages that exist at build time and renders any other on its first visit, then caches it. A new course, or a resource newly listed in a course, is live once published, with no redeploy. An address with no matching story returns a 404 (`notFound()`).

## Progress and completion

Expand Down Expand Up @@ -107,8 +107,7 @@ Three rules apply on top of each other: the course's own rules, then the resourc
1. **Initials:** the backend names a course's Mailchimp and Front fields after the initials of its English name (`getAcronym`: the first letter of each word, skipping lower-case "in, and, the, from, as, or, to"). Give every course an English working name, even one not offered in English, with unique initials of 6 letters or fewer. Taken: CB, MA, IARO, RTAR, RRYTS, SPST, HST, DBR, BH. Never rename a published course.
2. **Front:** add text fields `course_<initials>` and `course_<initials>_sessions` before publishing. Front replaces every contact field in one update, so a missing field blocks all Front updates for anyone who starts the course.
3. **Mailchimp:** add text fields `C_<INITIALS>` and `C_<INITIALS>_S` before publishing. The webhook tries to create them on first publish, but only once, and only logs a failure.
4. **Publish the resources, then the course.** The webhook links the course to its resources; a resource published later is only linked when the course is published again.
5. **Redeploy production,** so the new course and course resource pages exist (`dynamicParams = false`).
4. **Publish the resources, then the course.** The webhook links the course to its resources; a resource published later is only linked when the course is published again. The new pages render on their first visit, so no redeploy is needed.

For an existing course offered in a new language, skip steps 1 to 3, and check that no Mailchimp automation on the course's fields would send members the wrong language.

Expand Down
2 changes: 1 addition & 1 deletion docs/modular-courses-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ A course can be converted on its own, because the frontend already decides weeks
1. **Script, dry run** (Management API tooling in `scripts/`): for each session, a `resource_video` draft in `video/`. For a session listed in more than one week list, one resource. Unique slugs, using `<slug>-<course>` where the plain slug is taken. Then the course's flattened `weeks` go into `sessions`, in each language whose list differs from the default. Output: the drafts and a mapping file. Skip any story with unpublished changes.
2. **Review on staging,** which reads drafts. Compare every locale's new list with the old flattened one.
3. **Publish the resources,** run the backfill for that course, then **publish the course.** The webhook links the resources per language, and members' ticks and completions carry over.
4. **Deploy** the redirects for that course, then redeploy production for the new course resource pages.
4. **Deploy** the redirects for that course.
5. **Unpublish the course's sessions** once the redirects work. Keep the stories until every course is done.

Start with a small published course (SPST has 6 sessions), let it run in production, then do the rest. Courses already listing Turkish and Arabic resources keep those lists; only their default (and Hindi, where it differs) is converted.
Expand Down
1 change: 1 addition & 0 deletions lib/storyblok.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('storyblok utilities', () => {
mockGet.mockRejectedValueOnce({ message: 'Not Found', status: 404 } as never);

expect(await getStoryblokStory('test-slug', 'en')).toBeUndefined();
expect(rollbar.error).not.toHaveBeenCalled();
});

it('should return the story data if fetching succeeds', async () => {
Expand Down
9 changes: 5 additions & 4 deletions lib/storyblok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,12 @@ export const getStoryblokStory = async (

return data?.story as ISbStoryData;
} catch (error) {
rollbar.error('Error getting storyblok data for page', error as Error, { slug, sbParams });
// Only a missing story is a 404. Pages are cached, so any other failure (rate limit, outage)
// throws instead: revalidation then keeps the last good page, and a build fails rather than
// deploying "not found" pages.
// Only a missing story is a 404. It isn't logged: unbuilt slugs render on demand, so any address
// a visitor or crawler tries can end up here. Pages are cached, so any other failure (rate
// limit, outage) throws instead: revalidation then keeps the last good page, and a build fails
// rather than deploying "not found" pages.
if ((error as { status?: number }).status === 404) return undefined;
rollbar.error('Error getting storyblok data for page', error as Error, { slug, sbParams });
throw new Error(`Storyblok request for "${slug ?? uuids}" failed`, { cause: error });
}
};
Expand Down
41 changes: 41 additions & 0 deletions lib/utils/validateSlugPageRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it, jest } from '@jest/globals';

jest.mock('@/i18n/routing', () => ({
routing: { locales: ['en', 'de', 'fr', 'es', 'pt', 'hi', 'ar', 'tr'], defaultLocale: 'en' },
}));

import { isInvalidSlugPageRequest, isSlugPageLink } from './validateSlugPageRequest';

describe('isInvalidSlugPageRequest', () => {
it('allows a page slug in a supported locale', () => {
expect(isInvalidSlugPageRequest('en', 'staying-safe-online')).toBe(false);
expect(isInvalidSlugPageRequest('tr', 'page_2')).toBe(false);
});

it('rejects an unsupported locale', () => {
expect(isInvalidSlugPageRequest('.well-known', 'assetlinks.json')).toBe(true);
expect(isInvalidSlugPageRequest('xx', 'staying-safe-online')).toBe(true);
});

it('rejects slugs Storyblok cannot produce', () => {
expect(isInvalidSlugPageRequest('en', 'wp-login.php')).toBe(true);
expect(isInvalidSlugPageRequest('en', 'Staying-Safe')).toBe(true);
expect(isInvalidSlugPageRequest('en', '')).toBe(true);
});

it('rejects stories served by their own route', () => {
expect(isInvalidSlugPageRequest('en', 'home')).toBe(true);
expect(isInvalidSlugPageRequest('en', 'library')).toBe(true);
});
});

describe('isSlugPageLink', () => {
it('only includes published top-level stories without a route of their own', () => {
expect(isSlugPageLink({ slug: 'about', is_folder: false, published: true })).toBe(true);
expect(isSlugPageLink({ slug: 'about', is_folder: false, published: false })).toBe(false);
expect(isSlugPageLink({ slug: 'home', is_folder: false, published: true })).toBe(false);
expect(
isSlugPageLink({ slug: 'subscription/whatsapp', is_folder: false, published: true }),
).toBe(false);
});
});
17 changes: 9 additions & 8 deletions lib/utils/validateSlugPageRequest.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { routing } from '@/i18n/routing';

// Prefixes that crawlers/browsers request automatically (e.g. /.well-known/assetlinks.json)
// but that never correspond to a Storyblok page. Caught here so app/[locale]/[slug]/page.tsx
// can 404 immediately instead of hitting the CMS with a bogus locale/slug pair.
const RESERVED_SLUG_PREFIXES = ['.well-known'];
// Storyblok slugs are lowercase letters, digits, hyphens and underscores, so paths crawlers try,
// such as /favicon.png or /wp-login.php, can't be a page.
const SLUG_PATTERN = /^[a-z0-9_-]+$/;

/**
* Returns true when the [locale]/[slug] segments can't possibly resolve to a Storyblok
* story, e.g. an unsupported locale (crawler requests like /.well-known/assetlinks.json
* land here with locale=".well-known") or a reserved non-page path.
* Returns true when the [locale]/[slug] segments can't resolve to a page story, so
* app/[locale]/[slug]/page.tsx can 404 without asking the CMS. Unbuilt slugs render on demand,
* so this covers an unsupported locale (crawler requests like /.well-known/assetlinks.json land
* here with locale=".well-known"), a slug Storyblok can't produce, and a story served by its own
* route, which would otherwise render here too (the home story at /home).
*/
export function isInvalidSlugPageRequest(locale: string, slug: string): boolean {
if (!routing.locales.includes(locale as (typeof routing.locales)[number])) return true;

return RESERVED_SLUG_PREFIXES.some((prefix) => slug === prefix || slug.startsWith(`${prefix}/`));
return !SLUG_PATTERN.test(slug) || DEDICATED_ROUTE_FOLDERS.includes(slug);
}

// Storyblok folders and stories served by their own routes rather than app/[locale]/[slug].
Expand Down
Loading