web: adopt Cache Components, Partial Prefetching and ensureStatic
web / ci (push) Successful in 2m2s

This commit is contained in:
ponas committed 2026-10-13 23:59:14 +02:00
1 parent 3a0c2fd82b
commit db286434ba
70 files changed
+1285 -316

No files matched your search

+7
View File
@@ -22,6 +22,13 @@ function remotePatterns() {
const nextConfig = {
output: "standalone",
cacheComponents: true,
partialPrefetching: true,
// Next renders every page per request, uncached, for the crawlers and
// link-preview bots on its "HTML-limited" list, so their metadata is never
// streamed after <head>. No page here streams metadata (it all comes from
// cached CMS data), so no bot needs that; this pattern matches none
htmlLimitedBots: /(?!)/,
images: {
remotePatterns: remotePatterns(),
formats: ["image/avif", "image/webp"],
+9 -5
View File
@@ -1,5 +1,5 @@
import { graphql } from "@/gql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import {
@@ -8,6 +8,13 @@ import {
} from "@/components/general/GenericPageView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
// Navigations wait for the page instead of showing a loading shell first:
// the response has to be able to turn into a real 404 for an unknown path
export const instant = false;
function getWagtailUrlPath(url: string[]): string {
// for the page /foo/bar we need to look for `/home/foo/bar/`
return `/home/${url.join("/")}/`;
@@ -22,10 +29,7 @@ export async function generateStaticParams() {
}
}
`);
const { data, error } = await getClient().query(allGenericSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
const data = await cmsQuery(allGenericSlugsQuery, {});
if (!data?.pages) {
throw new Error("Failed to generate static params for generic subpages");
}
+9 -2
View File
@@ -5,10 +5,17 @@ import {
loadNewsPageProps,
} from "@/components/news/NewsPageView";
import { getSeoMetadata } from "@/lib/seo";
import { latestSlugParams } from "@/lib/staticParams";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
// Navigations wait for the page instead of showing a loading shell first:
// the response has to be able to turn into a real 404 for an unknown path
export const instant = false;
export async function generateStaticParams() {
// Prerender nothing at build time; render and cache on first request
return [];
return latestSlugParams("news.NewsPage");
}
type Params = Promise<{ slug: string }>;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Metadata, ResolvingMetadata } from "next";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import {
NewsIndexView,
loadNewsIndexProps,
@@ -8,12 +8,14 @@ import { NewsIndexFragment } from "@/gql/graphql";
import { newsIndexMetadataQuery } from "@/lib/news";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
): Promise<Metadata | null> {
const { data, error } = await getClient().query(newsIndexMetadataQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(newsIndexMetadataQuery, {});
if (!data?.index) return null;
return getSeoMetadata(data.index as NewsIndexFragment, parent);
}
+122 -5
View File
@@ -1,21 +1,23 @@
import type { NextRequest } from "next/server";
import { NextRequest } from "next/server";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
import { revalidateTag } from "next/cache";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
import { setInternalRevalidateSecret } from "@/lib/internalRevalidate";
import { CMS_CACHE_TAG, TODAY_CACHE_TAG } from "@/lib/revalidation";
import { POST } from "./route.ts";
const request = (headers: Record<string, string> = {}) =>
new Request("http://localhost/api/revalidate", {
const request = (headers: Record<string, string> = {}, query = "") =>
new NextRequest(`http://localhost/api/revalidate${query}`, {
method: "POST",
headers,
}) as unknown as NextRequest;
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
setInternalRevalidateSecret("");
});
describe("POST /api/revalidate", () => {
@@ -47,4 +49,119 @@ describe("POST /api/revalidate", () => {
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 });
});
it("purges only the today tag when asked to", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
const res = await POST(
request({ "x-revalidate-secret": "hemmelig" }, "?tag=today")
);
expect(res.status).toBe(200);
expect(revalidateTag).toHaveBeenCalledTimes(1);
expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 });
});
it("rejects tags it doesn't know", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
const res = await POST(
request({ "x-revalidate-secret": "hemmelig" }, "?tag=noe-annet")
);
expect(res.status).toBe(400);
expect(revalidateTag).not.toHaveBeenCalled();
});
describe("follow-up purge", () => {
const fetchMock = vi.fn();
const followUpCalls = () => fetchMock.mock.calls.map(([url]) => String(url));
function setUp() {
vi.useFakeTimers();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue({ ok: true, status: 200 });
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
vi.stubEnv("PORT", "4321");
vi.stubEnv("HOSTNAME", "0.0.0.0");
setInternalRevalidateSecret("intern");
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
fetchMock.mockReset();
});
it("purges again a few seconds after a webhook, as the server itself", async () => {
setUp();
await POST(request({ "x-revalidate-secret": "hemmelig" }));
await vi.advanceTimersByTimeAsync(4_000);
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_500);
expect(followUpCalls()).toEqual([
"http://127.0.0.1:4321/api/revalidate?tag=cms",
]);
expect(fetchMock.mock.calls[0][1].headers["x-revalidate-secret"]).toBe(
"intern"
);
});
it("sends one follow-up after a burst of webhooks", async () => {
setUp();
for (let i = 0; i < 3; i++) {
await POST(request({ "x-revalidate-secret": "hemmelig" }));
await vi.advanceTimersByTimeAsync(2_000);
}
await vi.advanceTimersByTimeAsync(10_000);
expect(followUpCalls()).toHaveLength(1);
});
it("is not scheduled by the server's own purges", async () => {
setUp();
await POST(request({ "x-revalidate-secret": "intern" }, "?tag=today"));
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchMock).not.toHaveBeenCalled();
});
it("is skipped where the server can't call itself", async () => {
setUp();
setInternalRevalidateSecret("");
await POST(request({ "x-revalidate-secret": "hemmelig" }));
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("the server's own purges", () => {
it("are accepted without a configured webhook secret", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", undefined);
setInternalRevalidateSecret("intern");
const res = await POST(
request({ "x-revalidate-secret": "intern" }, "?tag=today")
);
expect(res.status).toBe(200);
expect(revalidateTag).toHaveBeenCalledWith(TODAY_CACHE_TAG, { expire: 0 });
});
it("don't open the endpoint to an empty secret", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
expect((await POST(request())).status).toBe(401);
expect((await POST(request({ "x-revalidate-secret": "" }))).status).toBe(
401
);
expect(revalidateTag).not.toHaveBeenCalled();
});
});
});
+52 -12
View File
@@ -2,7 +2,11 @@ import { timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
import {
internalRevalidateSecret,
purgeOwnCache,
} from "@/lib/internalRevalidate";
import { CACHE_TAGS, CMS_CACHE_TAG, type CacheTag } from "@/lib/revalidation";
function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
@@ -10,19 +14,55 @@ function secretMatches(provided: string, expected: string): boolean {
return a.length === b.length && timingSafeEqual(a, b);
}
const FOLLOW_UP_DELAY_MS = 5_000;
const followUps = new Map<CacheTag, ReturnType<typeof setTimeout>>();
// Next stamps a cached page when its render finishes. A page that was being
// rendered while a purge came in (two saves in quick succession) therefore
// looks newer than the purge and would keep the older content. A second
// purge shortly after the last one in a burst catches those pages
function scheduleFollowUpPurge(tag: CacheTag) {
clearTimeout(followUps.get(tag));
const timer = setTimeout(async () => {
followUps.delete(tag);
const error = await purgeOwnCache(tag);
if (error) console.error(`Follow-up purge of "${tag}" failed: ${error}`);
}, FOLLOW_UP_DELAY_MS);
timer.unref();
followUps.set(tag, timer);
}
// POST /api/revalidate purges the cms tag (the CMS webhook);
// POST /api/revalidate?tag=today purges only what depends on the day
export async function POST(req: NextRequest) {
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "revalidation is not configured" },
{ status: 503 }
);
}
const provided = req.headers.get("x-revalidate-secret") ?? "";
if (!secretMatches(provided, secret)) {
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
// the server's own startup and midnight purges (instrumentation.ts)
const internal = internalRevalidateSecret();
const isInternal = internal !== undefined && secretMatches(provided, internal);
if (!isInternal) {
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "revalidation is not configured" },
{ status: 503 }
);
}
if (!secretMatches(provided, secret)) {
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
}
}
const tag = req.nextUrl.searchParams.get("tag") ?? CMS_CACHE_TAG;
if (!CACHE_TAGS.includes(tag as CacheTag)) {
return NextResponse.json({ error: "unknown tag" }, { status: 400 });
}
// expire: 0 purges immediately; the next request renders fresh
revalidateTag(CMS_CACHE_TAG, { expire: 0 });
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG });
revalidateTag(tag, { expire: 0 });
// not after the server's own purges, which would loop; and only where the
// server can call itself (not in dev)
if (!isInternal && internal !== undefined) {
scheduleFollowUpPurge(tag as CacheTag);
}
return NextResponse.json({ revalidated: true, tag });
}
+11 -1
View File
@@ -2,9 +2,19 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.fn();
// mirrors cmsQueryForToday: returns the data, throws on a GraphQL error
vi.mock("@/app/client", () => ({
getClient: () => ({ query: queryMock }),
cmsQueryForToday: async (...args: unknown[]) => {
const { data, error } = await queryMock(...args);
if (error) throw new Error(error.message);
return data;
},
}));
// the real one is a server-only cached function; read the (fake) clock instead
vi.mock("@/lib/today", async () => {
const { osloDay, startOfOsloDay } = await import("@/lib/date");
return { getToday: async () => startOfOsloDay(osloDay()) };
});
import { GET } from "./route.ts";
+5 -11
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { cmsQueryForToday } from "@/app/client";
import { getToday } from "@/lib/today";
import {
eventsOverviewQuery,
getSingularEvents,
@@ -46,14 +46,7 @@ export async function GET(req: NextRequest) {
);
}
const { data, error } = await getClient().query(
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) {
throw new Error(error.message);
}
const data = await cmsQueryForToday(eventsOverviewQuery, {});
if (
!data?.index ||
!data?.events?.futureEvents ||
@@ -64,6 +57,7 @@ export async function GET(req: NextRequest) {
throw new Error("Failed to fetch events");
}
const today = await getToday();
const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
@@ -71,7 +65,7 @@ export async function GET(req: NextRequest) {
const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"];
const compactEvents = futureEvents.map((event) => {
const futureOccurrences = getFutureOccurrences(event);
const futureOccurrences = getFutureOccurrences(event, today);
const nextOccurrence = futureOccurrences.length
? futureOccurrences[0]
: null;
+9 -2
View File
@@ -5,10 +5,17 @@ import {
loadEventPageProps,
} from "@/components/events/EventPageView";
import { getSeoMetadata } from "@/lib/seo";
import { latestSlugParams } from "@/lib/staticParams";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
// Navigations wait for the page instead of showing a loading shell first:
// the response has to be able to turn into a real 404 for an unknown path
export const instant = false;
export async function generateStaticParams() {
// Prerender nothing at build time; render and cache on first request
return [];
return latestSlugParams("events.EventPage");
}
type Params = Promise<{ slug: string }>;
+7 -9
View File
@@ -1,5 +1,5 @@
import { Metadata, ResolvingMetadata } from "next";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import {
EventIndexView,
loadEventIndexProps,
@@ -8,22 +8,20 @@ import { EventIndexFragment } from "@/gql/graphql";
import { eventIndexMetadataQuery } from "@/lib/event";
import { getSeoMetadata } from "@/lib/seo";
// The event list renders per request (it depends on the URL's filters), so
// only the shell around it has to stay prerendered
export const ensureStatic = "shell";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
): Promise<Metadata | null> {
const { data, error } = await getClient().query(eventIndexMetadataQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(eventIndexMetadataQuery, {});
if (!data?.index) return null;
return getSeoMetadata(data.index as EventIndexFragment, parent);
}
export default async function Page({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
await searchParams;
export default async function Page() {
const props = await loadEventIndexProps();
return <EventIndexView {...props} />;
}
+52 -5
View File
@@ -1,9 +1,16 @@
import "server-only";
import { cacheExchange, createClient, fetchExchange } from "@urql/core";
import {
type AnyVariables,
type TypedDocumentNode,
cacheExchange,
createClient,
fetchExchange,
stringifyDocument,
} from "@urql/core";
import { registerUrql } from "@urql/next/rsc";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
import { cmsCache, cmsCacheForToday } from "@/lib/cmsCache";
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
if (!wagtailBaseUrl) {
@@ -15,10 +22,50 @@ const makeClient = () => {
return createClient({
url: graphqlEndpoint,
exchanges: [cacheExchange, fetchExchange],
// Cache all queries in the Data Cache until revalidateTag(CMS_CACHE_TAG);
// override per operation with the contexts in @/lib/revalidation
fetchOptions: { cache: "force-cache", next: { tags: [CMS_CACHE_TAG] } },
});
};
// Uncached. Only for per-user (search) and token-scoped (preview) queries,
// together with the `uncached` context; everything else goes through cmsQuery
export const { getClient } = registerUrql(makeClient);
// Throws on GraphQL and network errors, which keeps them out of the cache
async function runQuery(query: string, variables: AnyVariables) {
const { data, error } = await getClient().query(query, variables);
if (error) throw new Error(error.message);
return data;
}
// The cached functions take the query as text: their arguments are the cache
// key, and urql adds bookkeeping properties to document objects it has seen
async function cachedQuery(query: string, variables: AnyVariables) {
"use cache";
cmsCache();
return runQuery(query, variables);
}
async function cachedQueryForToday(query: string, variables: AnyVariables) {
"use cache";
cmsCacheForToday();
return runQuery(query, variables);
}
// Every cacheable CMS read goes through one of the two functions below, so
// all of them carry the tag the CMS webhook purges
// Cached until the content is edited in the CMS
export function cmsQuery<Data, Variables extends AnyVariables>(
document: TypedDocumentNode<Data, Variables>,
variables: Variables
): Promise<Data | undefined> {
return cachedQuery(stringifyDocument(document), variables);
}
// For queries where the CMS decides by today's date what to return
// (futureEvents): also purged at Oslo midnight
export function cmsQueryForToday<Data, Variables extends AnyVariables>(
document: TypedDocumentNode<Data, Variables>,
variables: Variables
): Promise<Data | undefined> {
return cachedQueryForToday(stringifyDocument(document), variables);
}
+9 -6
View File
@@ -1,6 +1,6 @@
import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import {
AssociationPageView,
loadAssociationPageProps,
@@ -8,6 +8,13 @@ import {
import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
// Navigations wait for the page instead of showing a loading shell first:
// the response has to be able to turn into a real 404 for an unknown path
export const instant = false;
type Params = Promise<{ slug: string }>;
export async function generateMetadata(
@@ -29,11 +36,7 @@ export async function generateStaticParams() {
}
}
`);
const { data, error } = await getClient().query(allAssociationSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
const data = await cmsQuery(allAssociationSlugsQuery, {});
if (!data?.pages) {
throw new Error(
"Failed to generate static params for subpages of /foreninger"
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/associations/AssociationIndexView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/contact/ContactIndexView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
+10 -5
View File
@@ -1,5 +1,7 @@
import "@/css/main.scss";
import { Header } from "@/components/layout/Header";
import { SeasonalHeader } from "@/components/layout/SeasonalHeader";
import { TodayProvider } from "@/components/general/Today";
import { getOsloDay } from "@/lib/today";
import { Footer } from "@/components/layout/Footer";
import { Metadata } from "next";
import { NuqsAdapter } from "nuqs/adapters/next/app";
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
...baseUrlMetadata,
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const day = await getOsloDay();
return (
<html lang="no">
<head>
@@ -61,9 +64,11 @@ export default function RootLayout({
</head>
<body>
<NuqsAdapter>
<Header />
{children}
<Footer />
<TodayProvider day={day}>
<SeasonalHeader />
{children}
<Footer />
</TodayProvider>
</NuqsAdapter>
</body>
</html>
+9 -6
View File
@@ -1,6 +1,6 @@
import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import {
VenuePageView,
loadVenuePageProps,
@@ -8,6 +8,13 @@ import {
import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
// Navigations wait for the page instead of showing a loading shell first:
// the response has to be able to turn into a real 404 for an unknown path
export const instant = false;
type Params = Promise<{ slug: string }>;
const EXCLUDED_SLUGS = ["hele-huset"];
@@ -31,11 +38,7 @@ export async function generateStaticParams() {
}
}
`);
const { data, error } = await getClient().query(allVenueSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
const data = await cmsQuery(allVenueSlugsQuery, {});
if (!data?.pages) {
throw new Error(
"Failed to generate static params for subpages of /lokaler"
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/venues/VenueIndexView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
+3
View File
@@ -3,6 +3,9 @@ import {
loadHomePageProps,
} from "@/components/home/HomePageView";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export default async function Home() {
const props = await loadHomePageProps();
return <HomePageView {...props} />;
+4 -2
View File
@@ -74,8 +74,10 @@ import {
VenueRentalIndexFragment,
} from "@/gql/graphql";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
// Rendered per request from the editor's preview cookie; there is nothing
// to prerender, so let the route block instead of requiring a loading shell
export const instant = false;
const previewPageQuery = graphql(`
query previewPage($token: String!) {
+7
View File
@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { getClient } from "@/app/client";
import { uncached } from "@/lib/revalidation";
import {
@@ -9,6 +10,10 @@ import { graphql } from "@/gql";
// TODO: seo metadata?
// Results depend on the query in the URL, so the route renders per request
// and is allowed to block
export const instant = false;
export default async function Page({
searchParams,
}: {
@@ -17,6 +22,8 @@ export default async function Page({
}>;
}) {
const { q: query } = (await searchParams) ?? {};
// search always runs at request time
await connection();
let results: SearchResult[] = [];
let totalCount = 0;
const RESULT_LIMIT = 500;
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/sponsor/SponsorsPageView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/studio/StudioPageView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/venues/VenueRentalIndexView";
import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { AssociationList } from "@/components/associations/AssociationList";
import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent";
@@ -67,8 +67,7 @@ export type AssociationIndexViewProps = {
export async function loadAssociationIndexProps(overrides?: {
indexOverride?: AssociationIndexFragment;
}): Promise<AssociationIndexViewProps> {
const { data, error } = await getClient().query(allAssociationsQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(allAssociationsQuery, {});
const index =
overrides?.indexOverride ?? (data?.index as AssociationIndexFragment | undefined);
if (!index) throw new Error("Failed to load /foreninger");
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { AssociationFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { AssociationHeader } from "@/components/associations/AssociationHeader";
import { PageContent } from "@/components/general/PageContent";
@@ -27,10 +27,9 @@ export async function loadAssociationPageProps(args: {
return { association: args.associationOverride };
}
if (!args.slug) throw new Error("loadAssociationPageProps needs slug or associationOverride");
const { data, error } = await getClient().query(associationBySlugQuery, {
const data = await cmsQuery(associationBySlugQuery, {
slug: args.slug,
});
if (error) throw new Error(error.message);
const association = data?.association as AssociationFragment | undefined;
if (!association) return null;
return { association };
@@ -3,6 +3,7 @@ import {
getPrettyOpeningHoursForFunction,
PrettyOpeningHours,
} from "@/lib/openinghours";
import { getToday } from "@/lib/today";
import styles from "./openingHoursSectionBlock.module.scss";
import { Fragment } from "react";
@@ -35,7 +36,7 @@ function OpeningHoursSubsection({
}
export async function OpeningHoursSectionBlock() {
const allOpeningHours = await getOpeningHours();
const allOpeningHours = await getOpeningHours(await getToday());
const subsections = [
["glassbaren", "Glassbaren"],
["bokcafeen", "Bokcaféen"],
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { ContactIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock";
import { PageContent } from "@/components/general/PageContent";
import { PageHeader } from "@/components/general/PageHeader";
@@ -36,8 +36,7 @@ export async function loadContactIndexProps(overrides?: {
if (overrides?.indexOverride) {
return { index: overrides.indexOverride };
}
const { data, error } = await getClient().query(contactQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(contactQuery, {});
const index = data?.index as ContactIndexFragment | undefined;
if (!index) throw new Error("Failed to load /kontakt");
return { index };
+6 -3
View File
@@ -8,22 +8,24 @@ import {
compareDates,
} from "@/lib/date";
import { EventFragment, EventOccurrence } from "@/lib/event";
import { useToday } from "@/components/general/Today";
import styles from "./dateList.module.scss";
import Link from "next/link";
const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
const today = useToday();
const timeFormat = "'kl.' HH:mm";
const wholeCastle = occurrence.venue?.slug === "hele-huset";
return (
<li
className={`${styles.date} ${
isTodayOrFuture(occurrence.start) ? styles.future : styles.past
isTodayOrFuture(occurrence.start, today) ? styles.future : styles.past
}`}
>
<div className={styles.time}>
<span className={styles.day}>
{formatExtendedDateTime(occurrence.start, true)}
{formatExtendedDateTime(occurrence.start, today, true)}
</span>{" "}
<span className={styles.hour}>
{formatDate(occurrence.start, timeFormat)}
@@ -49,6 +51,7 @@ const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
};
export const DateList = ({ event }: { event: EventFragment }) => {
const today = useToday();
const [showAllDates, setShowAllDates] = useState(false);
function toggleDates() {
setShowAllDates(!showAllDates);
@@ -57,7 +60,7 @@ export const DateList = ({ event }: { event: EventFragment }) => {
const oneOff = event.occurrences.length === 1;
const showToggle = event.occurrences.length > 4;
const futureOccurrences = event.occurrences
.filter((occurrence) => isTodayOrFuture(occurrence.start))
.filter((occurrence) => isTodayOrFuture(occurrence.start, today))
.sort((a, b) => compareDates(a.start, b.start));
let visibleOccurrences = [];
+8 -6
View File
@@ -24,6 +24,7 @@ import {
getFilterableVenues,
} from "@/lib/eventFilter";
import { isTodayOrFuture } from "@/lib/date";
import { useToday } from "@/components/general/Today";
import styles from "./eventContainer.module.scss";
import { formatDate, formatYearMonth } from "@/lib/date";
import {
@@ -190,7 +191,7 @@ const CalendarDay = ({
<div
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
>
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date()), "eeee dd.MM.")}</h3>
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date(0)), "eeee dd.MM.")}</h3>
<ul>
{events.map((event) => (
<EventItem
@@ -234,10 +235,10 @@ const CalendarWeek = ({
);
};
function srOnlyYear(yearMonthString: string) {
function srOnlyYear(yearMonthString: string, today: Date) {
// formatYearMonth only shows the year for non-current years; always expose it to screen readers
const yearMonth = new Date(yearMonthString);
const isCurrentYear = yearMonth.getFullYear() === new Date().getFullYear();
const isCurrentYear = yearMonth.getFullYear() === today.getFullYear();
if (!isCurrentYear) {
return null;
}
@@ -245,8 +246,9 @@ function srOnlyYear(yearMonthString: string) {
}
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
const today = useToday();
const futureSingularEvents = getSingularEvents(events).filter(
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start),
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start, today),
);
const eventsByDate = organizeEventsInCalendar(futureSingularEvents);
const yearMonths = Object.keys(eventsByDate);
@@ -283,8 +285,8 @@ const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
data-collapsed={!visibleYearMonths.includes(yearMonth)}
>
<h2 onClick={() => toggleYearMonth(yearMonth)}>
{formatYearMonth(yearMonth)}
{srOnlyYear(yearMonth)}{" "}
{formatYearMonth(yearMonth, today)}
{srOnlyYear(yearMonth, today)}{" "}
<span className={styles.eventCounter}>({eventCount})</span>
</h2>
{eventCount === 0 && (
+17 -20
View File
@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { connection } from "next/server";
import { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { cmsQueryForToday } from "@/app/client";
import { EventContainer } from "@/components/events/EventContainer";
import { PageHeader } from "@/components/general/PageHeader";
import {
@@ -18,12 +19,7 @@ export type EventIndexViewProps = {
};
export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
const { data, error } = await getClient().query(
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message);
const data = await cmsQueryForToday(eventsOverviewQuery, {});
if (
!data?.index ||
!data?.events?.futureEvents ||
@@ -41,21 +37,22 @@ export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
};
}
export function EventIndexView({
events,
eventCategories,
eventOrganizers,
venues,
}: EventIndexViewProps) {
// Waiting for the request makes the list render on the server per request,
// with the visitor's filters from the URL already applied. Prerendered, it
// would only appear after hydration and shift when the URL has filters
async function RequestTimeEventContainer(props: EventIndexViewProps) {
await connection();
return <EventContainer {...props} />;
}
export function EventIndexView(props: EventIndexViewProps) {
return (
<main className="site-main" id="main">
<PageHeader heading="Dette skjer på Chateau Neuf" align="left" />
<EventContainer
events={events}
eventCategories={eventCategories}
eventOrganizers={eventOrganizers}
venues={venues}
/>
{/* the fallback reserves the list's height to keep the footer in place */}
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>
<RequestTimeEventContainer {...props} />
</Suspense>
</main>
);
}
+3 -1
View File
@@ -3,6 +3,7 @@
import styles from "./eventItem.module.scss";
import Link from "next/link";
import { Image } from "@/components/general/Image";
import { useToday } from "@/components/general/Today";
import {
SingularEvent,
EventFragment,
@@ -37,7 +38,8 @@ export const EventItem = ({
imageLoading?: "eager" | "lazy";
imageFetchPriority?: "high" | "low" | "auto";
}) => {
const futureOccurrences = getFutureOccurrences(event);
const today = useToday();
const futureOccurrences = getFutureOccurrences(event, today);
const groupedOccurrences = groupConsecutiveDates(
futureOccurrences.map((occurrence) => occurrence.start)
);
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { EventFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { EventDetails } from "@/components/events/EventDetails";
import { EventHeader } from "@/components/events/EventHeader";
import { BgPig } from "@/components/general/BgPig";
@@ -28,10 +28,9 @@ export async function loadEventPageProps(args: {
return { event: args.eventOverride };
}
if (!args.slug) throw new Error("loadEventPageProps needs slug or eventOverride");
const { data, error } = await getClient().query(eventBySlugQuery, {
const data = await cmsQuery(eventBySlugQuery, {
slug: args.slug,
});
if (error) throw new Error(error.message);
const event = data?.event as EventFragment | undefined;
if (!event) return null;
return { event };
+5 -3
View File
@@ -9,11 +9,13 @@ import { EventItem } from "./EventItem";
import styles from "./upcomingEvents.module.scss";
import { SectionHeader } from "../general/SectionHeader";
import { SectionFooter } from "../general/SectionFooter";
import { getToday } from "@/lib/today";
export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] }) => {
export const UpcomingEvents = async ({ events }: { events: EventListItemFragment[] }) => {
const today = await getToday();
const upcomingSingularEvents = sortSingularEvents(
getSingularEvents(events).filter((event) =>
isTodayOrFuture(event.occurrence.start)
isTodayOrFuture(event.occurrence.start, today)
)
);
const sliced = upcomingSingularEvents.slice(0, 10);
@@ -30,7 +32,7 @@ export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] })
<div key={day}>
{/*<h3>
{formatDate(
parse(day, "yyyy-MM-dd", new Date()),
parse(day, "yyyy-MM-dd", new Date(0)),
"eeee dd.MM."
)}
</h3>*/}
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { GenericFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent";
import { BgPig } from "@/components/general/BgPig";
@@ -41,10 +41,9 @@ export async function loadGenericPageProps(args: {
return { page: args.pageOverride };
}
if (!args.urlPath) throw new Error("loadGenericPageProps needs urlPath or pageOverride");
const { data, error } = await getClient().query(genericPageByUrlPathQuery, {
const data = await cmsQuery(genericPageByUrlPathQuery, {
urlPath: args.urlPath,
});
if (error) throw new Error(error.message);
const page = data?.page as GenericFragment | undefined;
if (!page) return null;
return { page };
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { osloDay, startOfOsloDay } from "@/lib/date";
const TodayContext = createContext<string | null>(null);
function subscribeToClock(onChange: () => void) {
const interval = setInterval(onChange, 60_000);
document.addEventListener("visibilitychange", onChange);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onChange);
};
}
// Hands the server's Oslo day to Client Components, which can't read the
// clock in a prerendered page (and would disagree with the server if they did)
export function TodayProvider({
day,
children,
}: {
day: string;
children: React.ReactNode;
}) {
// The root layout isn't rendered again on navigation, so a tab left open
// past midnight would keep the server's day. After hydration, follow the
// browser's clock once it is ahead
const clientDay = useSyncExternalStore(subscribeToClock, osloDay, () => day);
const today = clientDay > day ? clientDay : day;
return (
<TodayContext.Provider value={today}>{children}</TodayContext.Provider>
);
}
// Start of the current Oslo day
export function useToday(): Date {
const day = useContext(TodayContext);
if (!day) {
throw new Error("useToday must be used within a TodayProvider");
}
return useMemo(() => startOfOsloDay(day), [day]);
}
+2 -8
View File
@@ -1,8 +1,7 @@
import Link from "next/link";
import { graphql } from "@/gql";
import { HomeFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { cmsQueryForToday } from "@/app/client";
import { EventListItemFragment } from "@/lib/event";
import { NewsListItemFragment } from "@/lib/news";
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
@@ -57,12 +56,7 @@ export type HomePageViewProps = {
export async function loadHomePageProps(overrides?: {
homeOverride?: HomeFragment;
}): Promise<HomePageViewProps> {
const { data, error } = await getClient().query(
homeQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message);
const data = await cmsQueryForToday(homeQuery, {});
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
if (!home) throw new Error("Failed to load /");
const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
+8 -2
View File
@@ -2,6 +2,7 @@ import {
getOpeningHours,
getTodaysOpeningHoursForFunction,
} from "@/lib/openinghours";
import { getToday } from "@/lib/today";
import Link from "next/link";
import { Icon } from "../general/Icon";
// import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
@@ -9,7 +10,8 @@ import styles from "./footer.module.scss";
import { PigPattern } from "./PigPattern";
async function OpeningHoursTable() {
const allOpeningHours = await getOpeningHours();
const today = await getToday();
const allOpeningHours = await getOpeningHours(today);
const subsections = [
["glassbaren", "Glassbaren"],
["bokcafeen", "Bokcaféen"],
@@ -21,7 +23,11 @@ async function OpeningHoursTable() {
<tbody>
{subsections.map((subsection) => {
const [slug, title] = subsection;
const hours = getTodaysOpeningHoursForFunction(allOpeningHours, slug);
const hours = getTodaysOpeningHoursForFunction(
allOpeningHours,
slug,
today,
);
return (
<tr key={slug}>
<td>{title}</td>
+9 -13
View File
@@ -8,7 +8,13 @@ import { Icon } from "../general/Icon";
import { useInView } from "react-intersection-observer";
import { getSearchPath } from "@/lib/common";
export const Header = () => {
export const Header = ({
christmas,
studentHat,
}: {
christmas: boolean;
studentHat: boolean;
}) => {
/*
This component depends on the following CSS rule in base.css to prevent scrolling
the body while the menu is open:
@@ -51,16 +57,6 @@ export const Header = () => {
}
};
// let's add a santa hat during December
const isChristmas = new Date().getMonth() === 11;
// let's add the student hat during STUDiO 2026
const pigWearsHat =
new Date().getFullYear() === 2026 &&
new Date().getMonth() === 7 &&
new Date().getDate() >= 10 &&
new Date().getDate() <= 16;
return (
<>
<header
@@ -70,7 +66,7 @@ export const Header = () => {
data-small={!isInView}
>
<Link href="/" aria-label="Hjem">
<Logo christmas={isChristmas} studentHat={pigWearsHat} />
<Logo christmas={christmas} studentHat={studentHat} />
</Link>
<nav className={styles.siteMenu}>
<ul className={styles.mainMenu}>
@@ -193,7 +189,7 @@ export const Header = () => {
</nav>
<div className={styles.headerBar} aria-hidden>
<Link href="/" aria-label="Hjem" tabIndex={-1}>
<LogoIcon face="left" christmas={isChristmas} />
<LogoIcon face="left" christmas={christmas} />
</Link>
<nav className={styles.siteMenu}>
<ul className={styles.mainMenu}>
@@ -0,0 +1,20 @@
import { getToday } from "@/lib/today";
import { Header } from "./Header";
// The logo's hats depend on today's date, which the prerendered Header
// (a Client Component) can't read itself
export async function SeasonalHeader() {
const today = await getToday();
// let's add a santa hat during December
const christmas = today.getMonth() === 11;
// let's add the student hat during STUDiO 2026
const studentHat =
today.getFullYear() === 2026 &&
today.getMonth() === 7 &&
today.getDate() >= 10 &&
today.getDate() <= 16;
return <Header christmas={christmas} studentHat={studentHat} />;
}
+2 -3
View File
@@ -1,4 +1,4 @@
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { PageHeader } from "@/components/general/PageHeader";
import { NewsList } from "@/components/news/NewsList";
import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
@@ -11,8 +11,7 @@ export type NewsIndexViewProps = {
export async function loadNewsIndexProps(overrides?: {
indexOverride?: NewsIndexFragment;
}): Promise<NewsIndexViewProps> {
const { data, error } = await getClient().query(newsQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(newsQuery, {});
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
if (!index) throw new Error("Failed to load /aktuelt");
const news = (data?.news ?? []) as NewsListItemFragment[];
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { NewsFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { Breadcrumb } from "@/components/general/Breadcrumb";
import { ImageFigure } from "@/components/general/Image";
import { PageContent } from "@/components/general/PageContent";
@@ -27,10 +27,9 @@ export async function loadNewsPageProps(args: {
return { news: args.newsOverride };
}
if (!args.slug) throw new Error("loadNewsPageProps needs slug or newsOverride");
const { data, error } = await getClient().query(newsBySlugQuery, {
const data = await cmsQuery(newsBySlugQuery, {
slug: args.slug,
});
if (error) throw new Error(error.message);
const news = data?.news as NewsFragment | undefined;
if (!news) return null;
return { news };
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { type SponsorFragment, type SponsorsPageFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent";
import { SponsorList } from "@/components/sponsor/SponsorList";
@@ -41,8 +41,7 @@ export async function loadSponsorsPageProps(overrides?: {
if (overrides?.pageOverride) {
return { page: overrides.pageOverride };
}
const { data, error } = await getClient().query(sponsorsPageQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(sponsorsPageQuery, {});
const page = data?.page as SponsorsPageFragment | undefined;
if (!page) throw new Error("Failed to load /sponsorer");
return { page };
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { type StudioFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { PageContent } from "@/components/general/PageContent";
import { BgPig } from "@/components/general/BgPig";
import { StudioHeader } from "@/components/studio/StudioHeader";
@@ -45,8 +45,7 @@ export async function loadStudioPageProps(overrides?: {
if (overrides?.pageOverride) {
return { page: overrides.pageOverride };
}
const { data, error } = await getClient().query(studioPageQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(studioPageQuery, {});
const page = data?.page as StudioFragment | undefined;
if (!page) throw new Error("Failed to load /studio");
return { page };
+5 -4
View File
@@ -1,14 +1,15 @@
import { fromZonedTime } from "date-fns-tz";
import Link from "next/link";
import { Icon } from "../general/Icon";
import styles from "./studioShortcut.module.scss";
import { startOfOsloDay } from "@/lib/date";
import { getToday } from "@/lib/today";
// Hidden from this date
// Note that home page revalidation happens at midnight
const showUntil = fromZonedTime("2026-08-16T00:00:00", "Europe/Oslo");
const showUntil = startOfOsloDay("2026-08-16");
export const StudioShortcut = () => {
if (new Date() >= showUntil) return null;
export const StudioShortcut = async () => {
if ((await getToday()) >= showUntil) return null;
return (
<div className={styles.studioShortcut}>
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { VenueFragment, VenueIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { PageContent } from "@/components/general/PageContent";
import { PageHeader } from "@/components/general/PageHeader";
import { VenueList } from "@/components/venues/VenueList";
@@ -77,8 +77,7 @@ export type VenueIndexViewProps = {
export async function loadVenueIndexProps(overrides?: {
indexOverride?: VenueIndexFragment;
}): Promise<VenueIndexViewProps> {
const { data, error } = await getClient().query(venueIndexQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(venueIndexQuery, {});
const index = overrides?.indexOverride ?? (data?.index as VenueIndexFragment | undefined);
if (!index) throw new Error("Failed to load /lokaler");
const venues = (data?.venues ?? []) as VenueFragment[];
+2 -3
View File
@@ -1,6 +1,6 @@
import dynamic from "next/dynamic";
import { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock";
import { Breadcrumb } from "@/components/general/Breadcrumb";
import { PageContent } from "@/components/general/PageContent";
@@ -32,10 +32,9 @@ export async function loadVenuePageProps(args: {
return { venue: args.venueOverride };
}
if (!args.slug) throw new Error("loadVenuePageProps needs slug or venueOverride");
const { data, error } = await getClient().query(venueBySlugQuery, {
const data = await cmsQuery(venueBySlugQuery, {
slug: args.slug,
});
if (error) throw new Error(error.message);
const venue = data?.venue as VenueFragment | undefined;
if (!venue) return null;
return { venue };
@@ -1,6 +1,6 @@
import { graphql } from "@/gql";
import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cmsQuery } from "@/app/client";
import { BgPig } from "@/components/general/BgPig";
import { PageContent } from "@/components/general/PageContent";
import { PageHeader } from "@/components/general/PageHeader";
@@ -42,8 +42,7 @@ export type VenueRentalIndexViewProps = {
export async function loadVenueRentalIndexProps(overrides?: {
indexOverride?: VenueRentalIndexFragment;
}): Promise<VenueRentalIndexViewProps> {
const { data, error } = await getClient().query(venueRentalIndexQuery, {});
if (error) throw new Error(error.message);
const data = await cmsQuery(venueRentalIndexQuery, {});
const index =
overrides?.indexOverride ?? (data?.index as VenueRentalIndexFragment | undefined);
if (!index) throw new Error("Failed to load /utleie");
+6
View File
@@ -80,6 +80,7 @@ type Documents = {
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": typeof types.OpeningHoursSetFragmentDoc,
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": typeof types.OpeningHoursRangeBlockFragmentDoc,
"\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n": typeof types.OpeningHoursWeekBlockFragmentDoc,
"\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n": typeof types.LatestPageSlugDocument,
};
const documents: Documents = {
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument,
@@ -148,6 +149,7 @@ const documents: Documents = {
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": types.OpeningHoursSetFragmentDoc,
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": types.OpeningHoursRangeBlockFragmentDoc,
"\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n": types.OpeningHoursWeekBlockFragmentDoc,
"\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n": types.LatestPageSlugDocument,
};
/**
@@ -428,6 +430,10 @@ export function graphql(source: "\n fragment OpeningHoursRangeBlock on OpeningH
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n"): (typeof documents)["\n fragment OpeningHoursWeekBlock on OpeningHoursWeekBlock {\n monday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n tuesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n wednesday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n thursday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n friday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n saturday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n sunday {\n ... on OpeningHoursRangeBlock {\n ...OpeningHoursRangeBlock\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n"): (typeof documents)["\n query latestPageSlug($contentType: String!) {\n pages(contentType: $contentType, order: \"-first_published_at\", limit: 1) {\n id\n slug\n }\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
+25 -1
View File
@@ -2037,6 +2037,29 @@ export type OpeningHoursWeekBlockFragment = { monday:
| Record<PropertyKey, never>
| null } & { ' $fragmentName'?: 'OpeningHoursWeekBlockFragment' };
export type LatestPageSlugQueryVariables = Exact<{
contentType: string;
}>;
export type LatestPageSlugQuery = { pages: Array<
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
| { id: string | null, slug: string }
> };
export const RichTextBlockFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RichTextBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RichTextBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rawValue"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]} as unknown as DocumentNode<RichTextBlockFragment, unknown>;
export const ImageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}}]} as unknown as DocumentNode<ImageFragment, unknown>;
export const ImageWithTextBlockFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ImageWithTextBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ImageWithTextBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"imageFormat"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}}]} as unknown as DocumentNode<ImageWithTextBlockFragment, unknown>;
@@ -2102,4 +2125,5 @@ export const EventIndexMetadataDocument = {"kind":"Document","definitions":[{"ki
export const FutureEventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"futureEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"eventIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventIndex"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"events"},"name":{"kind":"Name","value":"eventIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"futureEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventOverviewItem"}}]}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"eventCategories"},"name":{"kind":"Name","value":"eventCategories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventCategory"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventCategory"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"eventOrganizers"},"name":{"kind":"Name","value":"eventOrganizers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventOrganizer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"EventOrganizer"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"venues"},"name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"StringValue","value":"venues.VenuePage","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"VenuePage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"preposition"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventCategory"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventCategory"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"pig"}},{"kind":"Field","name":{"kind":"Name","value":"showInFilters"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventOrganizer"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EventOrganizer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}},{"kind":"Field","name":{"kind":"Name","value":"association"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AssociationPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"EventIndex"},"typeCondition":{"kind":"NamLine truncated
export const NewsIndexMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"newsIndexMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"newsIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsIndex"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsIndex"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"seoTitle"}},{"kind":"Field","name":{"kind":"Name","value":"searchDescription"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"lead"}}]}}]} as unknown as DocumentNode<NewsIndexMetadataQuery, NewsIndexMetadataQueryVariables>;
export const NewsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"news"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"index"},"name":{"kind":"Name","value":"newsIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsIndex"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"news"},"name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"StringValue","value":"news.NewsPage","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"StringValue","value":"-first_published_at","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NewsListItem"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CustomImage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}},{"kind":"Field","name":{"kind":"Name","value":"alt"}},{"kind":"Field","name":{"kind":"Name","value":"attribution"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsIndex"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsIndex"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"seoTitle"}},{"kind":"Field","name":{"kind":"Name","value":"searchDescription"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"lead"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NewsListItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NewsPage"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"firstPublishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"featuredImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}}]} as unknown as DocumentNode<NewsQuery, NewsQueryVariables>;
export const OpeningHoursSetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursSet"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursRangeBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timeFrom"}},{"kind":"Field","name":{"kind":"Name","value":"timeTo"}},{"kind":"Field","name":{"kind":"Name","value":"custom"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursWeekBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tuesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"wednesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"thursday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"friday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"saturday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"sunday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursSet"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursSet"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveFrom"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveTo"}},{"kind":"Field","name":{"kind":"Name","value":"announcement"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"function"}},{"kind":"Field","name":{"kind":"Name","value":"week"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OpeningHoursSetsQuery, OpeningHoursSetsQueryVariables>;
export const OpeningHoursSetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openingHoursSets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursSet"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursRangeBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timeFrom"}},{"kind":"Field","name":{"kind":"Name","value":"timeTo"}},{"kind":"Field","name":{"kind":"Name","value":"custom"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursWeekBlock"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"monday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tuesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"wednesday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"thursday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"friday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"saturday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"sunday"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursRangeBlock"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OpeningHoursSet"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursSet"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveFrom"}},{"kind":"Field","name":{"kind":"Name","value":"effectiveTo"}},{"kind":"Field","name":{"kind":"Name","value":"announcement"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"function"}},{"kind":"Field","name":{"kind":"Name","value":"week"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OpeningHoursWeekBlock"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OpeningHoursSetsQuery, OpeningHoursSetsQueryVariables>;
export const LatestPageSlugDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"latestPageSlug"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contentType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contentType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contentType"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"StringValue","value":"-first_published_at","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]} as unknown as DocumentNode<LatestPageSlugQuery, LatestPageSlugQueryVariables>;
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { internalRevalidateSecret } from "@/lib/internalRevalidate";
import { register } from "./instrumentation.ts";
const fetchMock = vi.fn();
const purgedTags = () =>
fetchMock.mock.calls.map(([url]) => new URL(String(url)).searchParams.get("tag"));
beforeEach(() => {
vi.useFakeTimers();
// 23:59:50 in Oslo (CEST)
vi.setSystemTime(new Date("2026-07-07T21:59:50Z"));
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue({ ok: true, status: 200 });
vi.stubEnv("NEXT_RUNTIME", "nodejs");
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("PORT", "4321");
vi.stubEnv("HOSTNAME", "0.0.0.0");
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
fetchMock.mockReset();
});
// register() and let the startup purge go through
async function start() {
register();
await vi.advanceTimersByTimeAsync(0);
fetchMock.mockClear();
}
describe("at startup", () => {
it("purges both tags", async () => {
register();
await vi.advanceTimersByTimeAsync(0);
expect(purgedTags()).toEqual(["cms", "today"]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe("http://127.0.0.1:4321/api/revalidate?tag=cms");
expect(init.method).toBe("POST");
// authenticated with the secret the route accepts for internal calls
expect(init.headers["x-revalidate-secret"]).toBe(internalRevalidateSecret());
expect(internalRevalidateSecret()).toBeTruthy();
});
it("keeps trying until the server is listening", async () => {
fetchMock
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
register();
await vi.advanceTimersByTimeAsync(2_500);
expect(purgedTags()).toEqual(["cms", "cms", "cms", "today"]);
// expected while the server starts: not worth an error in the log
expect(console.error).not.toHaveBeenCalled();
});
it("starts over when only the first of the two purges went through", async () => {
fetchMock
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: false, status: 503 });
register();
await vi.advanceTimersByTimeAsync(1_500);
expect(purgedTags()).toEqual(["cms", "today", "cms", "today"]);
});
it.each([
["an IPv4 address", "10.0.0.5", "http://10.0.0.5:4321"],
["an IPv6 address", "::1", "http://[::1]:4321"],
["the IPv6 wildcard", "::", "http://127.0.0.1:4321"],
["nothing", "", "http://127.0.0.1:4321"],
])("reaches a server bound to %s", async (_what, hostname, origin) => {
vi.stubEnv("HOSTNAME", hostname);
register();
await vi.advanceTimersByTimeAsync(0);
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${origin}/api/revalidate?tag=cms`
);
});
});
describe("at Oslo midnight", () => {
it("purges the today tag one second past, and once more after that", async () => {
await start();
// 23:59:59: not yet
await vi.advanceTimersByTimeAsync(9_000);
expect(purgedTags()).toEqual([]);
// 00:00:01
await vi.advanceTimersByTimeAsync(2_000);
expect(purgedTags()).toEqual(["today"]);
// a page rendered across the first purge would have survived it
await vi.advanceTimersByTimeAsync(30_000);
expect(purgedTags()).toEqual(["today", "today"]);
// then nothing for the rest of the day
await vi.advanceTimersByTimeAsync(23 * 3600 * 1000);
expect(purgedTags()).toEqual(["today", "today"]);
});
it("does so every night", async () => {
await start();
await vi.advanceTimersByTimeAsync(3 * 24 * 3600 * 1000);
expect(purgedTags()).toEqual(Array(6).fill("today"));
});
it("catches up when timers stood still over midnight", async () => {
await start();
// a paused host: the clock moves on, the pending timer does not fire
vi.setSystemTime(new Date("2026-07-07T22:10:00Z")); // 00:10 in Oslo
expect(purgedTags()).toEqual([]);
// the next regular check notices the new day
await vi.advanceTimersByTimeAsync(30_000);
expect(purgedTags()).toEqual(["today"]);
});
it("retries a failed purge at the next check, and says so", async () => {
await start();
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
await vi.advanceTimersByTimeAsync(11_000); // 00:00:01, fails
expect(purgedTags()).toEqual(["today"]);
expect(console.error).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_000); // succeeds
await vi.advanceTimersByTimeAsync(30_000); // the follow-up purge
expect(purgedTags()).toEqual(["today", "today", "today"]);
});
});
describe("register", () => {
it.each([
["during next build", { NEXT_PHASE: "phase-production-build" }],
["in next dev", { NODE_ENV: "development" }],
["on the edge runtime", { NEXT_RUNTIME: "edge" }],
])("does nothing %s", async (_when, env) => {
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
register();
await vi.advanceTimersByTimeAsync(48 * 3600 * 1000);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+88
View File
@@ -0,0 +1,88 @@
import { osloDay } from "@/lib/date";
import {
purgeOwnCache,
setInternalRevalidateSecret,
} from "@/lib/internalRevalidate";
import {
CMS_CACHE_TAG,
TODAY_CACHE_TAG,
secondsUntilOsloMidnight,
} from "@/lib/revalidation";
// How often the clock is checked. Timers alone can't be trusted to fire at
// midnight: they stand still while a host or VM is paused
const CHECK_INTERVAL_MS = 30_000;
// Until the first purge has gone through; the server isn't listening yet
// when register() runs
const STARTUP_RETRY_MS = 1_000;
function keepCacheCurrent() {
// the Oslo day the cache was last purged for; undefined until the startup
// purge has gone through
let purgedDay: string | undefined;
let repeatTodayPurge = false;
let failures = 0;
async function check() {
const day = osloDay();
let error: string | null = null;
if (purgedDay === undefined) {
// Pages prerendered by the build, or left on disk by an earlier run,
// may predate CMS edits and midnights this process never saw
error =
(await purgeOwnCache(CMS_CACHE_TAG)) ??
(await purgeOwnCache(TODAY_CACHE_TAG));
if (!error) purgedDay = day;
} else if (day !== purgedDay) {
error = await purgeOwnCache(TODAY_CACHE_TAG);
if (!error) {
purgedDay = day;
repeatTodayPurge = true;
}
} else if (repeatTodayPurge) {
// Next stamps a page when its render finishes, so a page that was
// being rendered across the purge looks newer than it and survives.
// A second purge one interval later catches those
error = await purgeOwnCache(TODAY_CACHE_TAG);
if (!error) repeatTodayPurge = false;
}
if (error) {
failures += 1;
// at startup, connection errors are expected for the first moments
if (failures === 1 ? purgedDay !== undefined : failures % 60 === 0) {
console.error(`Purging the page cache keeps failing: ${error}`);
}
} else {
failures = 0;
}
schedule();
}
function schedule() {
const delay =
purgedDay === undefined
? STARTUP_RETRY_MS
: // one second past midnight, so the purge can't land on the old day
Math.min(CHECK_INTERVAL_MS, (secondsUntilOsloMidnight() + 1) * 1000);
setTimeout(check, delay).unref();
}
void check();
}
export function register() {
// only in the running production server: not during `next build`, in
// `next dev` (nothing is kept between requests there) or on the edge
if (
process.env.NEXT_RUNTIME !== "nodejs" ||
process.env.NODE_ENV !== "production" ||
process.env.NEXT_PHASE === "phase-production-build"
) {
return;
}
setInternalRevalidateSecret(crypto.randomUUID());
keepCacheCurrent();
}
+39
View File
@@ -0,0 +1,39 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const SRC = fileURLToPath(new URL("..", import.meta.url));
function sourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return entry.name === "gql" ? [] : sourceFiles(path);
return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)
? [path]
: [];
});
}
describe("use cache scopes", () => {
// A scope without one of the helpers falls back to Next's default lifetime
// and carries no tag, so the CMS webhook can't purge it
it("all call one of the cmsCache helpers right after the directive", () => {
const directive = /^\s*["']use cache["'];?\s*$/;
const offenders: string[] = [];
let scopes = 0;
for (const file of sourceFiles(SRC)) {
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (!directive.test(line)) return;
scopes += 1;
if (!/^\s*(cmsCache|cmsCacheForToday|todayCache)\(\);\s*$/.test(lines[i + 1] ?? "")) {
offenders.push(`${file.slice(SRC.length)}:${i + 1}`);
}
});
}
expect(offenders).toEqual([]);
// the scan itself must keep finding the scopes
expect(scopes).toBeGreaterThan(0);
});
});
+32
View File
@@ -0,0 +1,32 @@
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import {
CMS_CACHE_LIFE,
CMS_CACHE_TAG,
TODAY_CACHE_TAG,
} from "@/lib/revalidation";
// Every "use cache" scope starts with one of these. A scope without a tag
// could never be purged
// For content that only changes when it is edited in the CMS
export function cmsCache() {
cacheTag(CMS_CACHE_TAG);
cacheLife(CMS_CACHE_LIFE);
}
// For CMS content that depends on what day it is: also purged at Oslo
// midnight
export function cmsCacheForToday() {
cacheTag(CMS_CACHE_TAG, TODAY_CACHE_TAG);
cacheLife(CMS_CACHE_LIFE);
}
// For the current day itself. Not tagged as CMS content: an edit in the CMS
// must not restart the lifetime that heals a missed midnight purge
export function todayCache() {
cacheTag(TODAY_CACHE_TAG);
cacheLife(CMS_CACHE_LIFE);
}
+19 -5
View File
@@ -5,7 +5,7 @@ import {
formatNorwegianPhoneNumber,
formatPhoneE164,
getSearchPath,
randomElement,
seededElement,
stripHtml,
stripWhitespace,
unique,
@@ -22,11 +22,25 @@ describe("getSearchPath", () => {
});
});
describe("randomElement", () => {
describe("seededElement", () => {
it("picks a member, undefined for empty", () => {
expect(randomElement([])).toBeUndefined();
expect(randomElement(["a"])).toBe("a");
expect([1, 2, 3]).toContain(randomElement([1, 2, 3]));
expect(seededElement([], "x")).toBeUndefined();
expect(seededElement(["a"], "x")).toBe("a");
expect([1, 2, 3]).toContain(seededElement([1, 2, 3], "x"));
});
it("picks the same member for the same seed", () => {
const picks = new Set(
Array.from({ length: 20 }, () => seededElement([1, 2, 3, 4, 5], "event-42"))
);
expect(picks.size).toBe(1);
});
it("spreads different seeds over the members", () => {
const picks = new Set(
Array.from({ length: 50 }, (_, i) => seededElement([1, 2, 3], `event-${i}`))
);
expect(picks.size).toBe(3);
});
});
+9 -4
View File
@@ -37,10 +37,15 @@ export function getSearchPath(query: string): string {
return `/sok?${params.toString()}`;
}
export function randomElement(array: any[]): any | undefined {
return array.length
? array[Math.floor(Math.random() * array.length)]
: undefined;
// Picks the same element for the same seed every time. Rendering code can't
// use Math.random(): prerendering rejects it, and the pick would change
// between the server render and the next regeneration
export function seededElement<T>(array: T[], seed: string): T | undefined {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return array.length ? array[hash % array.length] : undefined;
}
export function unique<T>(array: any[]): any[] {
+22 -17
View File
@@ -10,7 +10,8 @@ import {
groupConsecutiveDates,
isConsecutiveDays,
isTodayOrFuture,
startOfTodayInOslo,
osloDay,
startOfOsloDay,
toLocalTime,
} from "./date.ts";
@@ -24,6 +25,9 @@ afterEach(() => {
vi.useRealTimers();
});
// what getToday()/useToday() hand to the helpers
const today = () => startOfOsloDay(osloDay());
describe("toLocalTime", () => {
it("converts UTC to Oslo wall clock", () => {
expect(toLocalTime("2026-07-07T18:00:00Z").getHours()).toBe(20);
@@ -31,15 +35,16 @@ describe("toLocalTime", () => {
});
});
describe("startOfTodayInOslo", () => {
describe("osloDay + startOfOsloDay", () => {
it("returns the Oslo date even when the server runs in UTC", () => {
const tz = process.env.TZ;
process.env.TZ = "UTC";
try {
vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
const today = startOfTodayInOslo();
expect(today.getDate()).toBe(8);
expect(today.getHours()).toBe(0);
expect(osloDay()).toBe("2026-07-08");
const start = startOfOsloDay(osloDay());
expect(start.getDate()).toBe(8);
expect(start.getHours()).toBe(0);
} finally {
process.env.TZ = tz;
}
@@ -57,42 +62,42 @@ describe("formatDate", () => {
describe("formatYearMonth", () => {
it("omits year for the current year", () => {
expect(formatYearMonth("2026-07")).toBe("juli");
expect(formatYearMonth("2026-07", today())).toBe("juli");
});
it("includes year for other years", () => {
expect(formatYearMonth("2025-11")).toBe("november 2025");
expect(formatYearMonth("2025-11", today())).toBe("november 2025");
});
});
describe("formatExtendedDateTime", () => {
it("omits year in the current year", () => {
expect(formatExtendedDateTime("2026-07-07T18:00:00Z")).toBe(
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", today())).toBe(
"tirsdag 7. juli kl. 20:00"
);
});
it("includes year for other years", () => {
expect(formatExtendedDateTime("2025-11-01T12:00:00Z")).toBe(
expect(formatExtendedDateTime("2025-11-01T12:00:00Z", today())).toBe(
"lørdag 1. november 2025 kl. 13:00"
);
});
it("supports dateOnly and alwaysIncludeYear", () => {
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", true)).toBe(
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", today(), true)).toBe(
"tirsdag 7. juli"
);
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", false, true)).toBe(
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", today(), false, true)).toBe(
"tirsdag 7. juli 2026 kl. 20:00"
);
});
it("uses the correct UTC offset around the DST fall-back", () => {
// DST ends 01:00Z on 2026-10-25: before it Oslo is UTC+2, after UTC+1
expect(formatExtendedDateTime("2026-10-24T23:30:00Z")).toBe(
expect(formatExtendedDateTime("2026-10-24T23:30:00Z", today())).toBe(
"søndag 25. oktober kl. 01:30"
);
expect(formatExtendedDateTime("2026-10-25T01:30:00Z")).toBe(
expect(formatExtendedDateTime("2026-10-25T01:30:00Z", today())).toBe(
"søndag 25. oktober kl. 02:30"
);
});
@@ -100,14 +105,14 @@ describe("formatExtendedDateTime", () => {
describe("isTodayOrFuture", () => {
it("is false for yesterday, true for today and later", () => {
expect(isTodayOrFuture("2026-07-06T10:00:00Z")).toBe(false);
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true);
expect(isTodayOrFuture("2026-08-01T00:00:00Z")).toBe(true);
expect(isTodayOrFuture("2026-07-06T10:00:00Z", today())).toBe(false);
expect(isTodayOrFuture("2026-07-07T20:00:00Z", today())).toBe(true);
expect(isTodayOrFuture("2026-08-01T00:00:00Z", today())).toBe(true);
});
it("judges the day by Oslo wall clock, not UTC", () => {
// 22:30Z on the 6th is 00:30 on the 7th ("today") in Oslo
expect(isTodayOrFuture("2026-07-06T22:30:00Z")).toBe(true);
expect(isTodayOrFuture("2026-07-06T22:30:00Z", today())).toBe(true);
});
});
+21 -20
View File
@@ -1,6 +1,5 @@
import {
isToday,
isAfter,
isBefore,
parse,
compareAsc,
addDays,
@@ -8,7 +7,7 @@ import {
startOfDay,
} from "date-fns";
import { nb } from "date-fns/locale";
import { toZonedTime, format } from "date-fns-tz";
import { toZonedTime, format, formatInTimeZone } from "date-fns-tz";
import { unique } from "./common";
const timeZone = "Europe/Oslo";
@@ -20,19 +19,26 @@ export function toLocalTime(date: Date | string | number) {
return toZonedTime(date, timeZone);
}
// Start of the current Oslo day, regardless of the server's TZ
export function startOfTodayInOslo() {
return startOfDay(toLocalTime(new Date()));
// The Oslo calendar day ("yyyy-MM-dd") at the given instant, regardless of
// the server's TZ. Rendering code must not call this without a cache scope:
// get the day from getToday() or useToday() instead
export function osloDay(now: Date = new Date()): string {
return formatInTimeZone(now, timeZone, "yyyy-MM-dd");
}
// Start of an Oslo day, comparable with toLocalTime() values
export function startOfOsloDay(day: string): Date {
return parse(day, "yyyy-MM-dd", new Date(0));
}
export function formatDate(date: Date | string | number, formatStr: string) {
return format(toLocalTime(date), formatStr, { timeZone, locale: nb });
}
export function formatYearMonth(yearMonth: string) {
export function formatYearMonth(yearMonth: string, today: Date) {
// full name of month if year is current year, otherwise name of month + year
const parsed = parse(yearMonth, "yyyy-MM", new Date());
if (parsed.getFullYear() === new Date().getFullYear()) {
const parsed = parse(yearMonth, "yyyy-MM", new Date(0));
if (parsed.getFullYear() === today.getFullYear()) {
return formatDate(parsed, "MMMM");
}
return formatDate(parsed, "MMMM yyyy");
@@ -40,6 +46,7 @@ export function formatYearMonth(yearMonth: string) {
export function formatExtendedDateTime(
date: Date | string | number,
today: Date,
dateOnly: boolean = false,
alwaysIncludeYear: boolean = false
) {
@@ -47,21 +54,15 @@ export function formatExtendedDateTime(
// year included if not current year
const parsed = toLocalTime(date);
const timePart = dateOnly ? "" : " 'kl.' HH:mm";
const isCurrentYear = parsed.getFullYear() === new Date().getFullYear();
const isCurrentYear = parsed.getFullYear() === today.getFullYear();
const yearPart = (!isCurrentYear || alwaysIncludeYear) ? " yyyy" : "";
const formatStr = `EEEE d. MMMM${yearPart}${timePart}`;
const formatted = format(parsed, formatStr, { timeZone, locale: nb });
return formatted;
}
export function isTodayOrFuture(
date: Date | string | number,
timeZone = "UTC"
) {
const now = new Date();
const zonedNow = toZonedTime(now, timeZone);
const zonedDate = toLocalTime(date);
return isToday(zonedDate) || isAfter(zonedDate, zonedNow);
export function isTodayOrFuture(date: Date | string | number, today: Date) {
return !isBefore(toLocalTime(date), today);
}
export function compareDates(a: Date | string, b: Date | string) {
@@ -124,11 +125,11 @@ export function formatOccurrenceMonths(starts: string[]): string {
return groups
.map((g) => {
const first = parse(g[0], "yyyy-MM", new Date());
const first = parse(g[0], "yyyy-MM", new Date(0));
if (g.length === 1) {
return formatDate(first, "MMMM yyyy");
}
const last = parse(g[g.length - 1], "yyyy-MM", new Date());
const last = parse(g[g.length - 1], "yyyy-MM", new Date(0));
const firstFmt =
first.getFullYear() === last.getFullYear()
? formatDate(first, "MMMM")
+6 -2
View File
@@ -9,6 +9,10 @@ import {
organizeEventsInCalendar,
sortSingularEvents,
} from "./event.ts";
import { osloDay, startOfOsloDay } from "./date.ts";
// what getToday()/useToday() hand to the helpers
const today = () => startOfOsloDay(osloDay());
// "now" is tirsdag 2026-07-07 12:00 in Oslo
beforeEach(() => {
@@ -180,7 +184,7 @@ describe("getFutureOccurrences", () => {
"2026-07-07T18:00:00Z",
]);
expect(getFutureOccurrences(event).map((o) => o.start)).toEqual([
expect(getFutureOccurrences(event, today()).map((o) => o.start)).toEqual([
"2026-07-07T18:00:00Z",
"2026-07-14T18:00:00Z",
]);
@@ -197,7 +201,7 @@ describe("getFutureOccurrences", () => {
"2026-07-08T18:00:00Z",
]);
expect(getFutureOccurrences(event).map((o) => o.start)).toEqual([
expect(getFutureOccurrences(event, today()).map((o) => o.start)).toEqual([
"2026-07-08T18:00:00Z",
]);
} finally {
+4 -5
View File
@@ -10,7 +10,6 @@ import {
toLocalTime,
formatDate,
compareDates,
startOfTodayInOslo,
} from "./date";
import { graphql, unmaskFragment } from "@/gql";
import {
@@ -20,7 +19,7 @@ import {
type EventOrganizerFragment,
type EventOverviewItemFragment,
} from "@/gql/graphql";
import { PIG_NAMES, randomElement } from "@/lib/common";
import { PIG_NAMES, seededElement } from "@/lib/common";
export type EventOccurrence = EventFragment["occurrences"][number];
export type EventListItemOccurrence = EventListItemFragment["occurrences"][number];
@@ -353,9 +352,9 @@ export function organizeEventsByDate<E extends EventListable>(
}
export function getFutureOccurrences<E extends EventListable>(
event: E
event: E,
today: Date
): E["occurrences"][number][] {
const today = startOfTodayInOslo();
const occurrences = event?.occurrences ?? [];
const futureOccurrences = occurrences.filter((occurrence) =>
isAfter(toLocalTime(occurrence.start), today)
@@ -381,7 +380,7 @@ export function getEventPig(event: EventFragment): string | null {
const categoryPigs = categories
?.map((category) => category.pig)
.filter((pig) => PIG_NAMES.includes(pig));
const chosenPig = randomElement(categoryPigs ?? []);
const chosenPig = seededElement(categoryPigs ?? [], String(event.id));
return typeof chosenPig === "string" ? chosenPig : null;
}
return null;
+49
View File
@@ -0,0 +1,49 @@
import type { CacheTag } from "@/lib/revalidation";
// The server purges its own cache (at startup, at Oslo midnight, and as a
// follow-up to webhooks) by calling /api/revalidate, because revalidateTag
// only works inside a request. The secret for those calls is made up per
// process and kept on globalThis: instrumentation.ts and the route handler
// are bundled separately
const KEY = Symbol.for("neuf.internalRevalidateSecret");
type Store = { [KEY]?: string };
export function internalRevalidateSecret(): string | undefined {
// an empty secret must never count as set
return (globalThis as Store)[KEY] || undefined;
}
export function setInternalRevalidateSecret(secret: string) {
(globalThis as Store)[KEY] = secret;
}
const PURGE_TIMEOUT_MS = 10_000;
// The server is reached the way it listens (the standalone server binds to
// HOSTNAME); the wildcard addresses are reachable over loopback
function ownOrigin(): string {
const hostname = process.env.HOSTNAME;
const port = process.env.PORT || 3000;
if (!hostname || hostname === "0.0.0.0" || hostname === "::") {
return `http://127.0.0.1:${port}`;
}
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`;
}
// Resolves to null on success, otherwise to what went wrong
export async function purgeOwnCache(tag: CacheTag): Promise<string | null> {
const secret = internalRevalidateSecret();
if (!secret) return "no internal secret";
try {
const res = await fetch(`${ownOrigin()}/api/revalidate?tag=${tag}`, {
method: "POST",
headers: { "x-revalidate-secret": secret },
cache: "no-store",
signal: AbortSignal.timeout(PURGE_TIMEOUT_MS),
});
return res.ok ? null : `HTTP ${res.status}`;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
+11 -7
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// sever the server-only @/app/client import chain; the network functions are not under test
vi.mock("@/app/client", () => ({ getClient: vi.fn() }));
vi.mock("@/app/client", () => ({ cmsQuery: vi.fn() }));
import type {
OpeningHoursRangeBlockFragment,
@@ -13,6 +13,10 @@ import {
getTodaysOpeningHoursForFunction,
groupOpeningHours,
} from "./openinghours.ts";
import { osloDay, startOfOsloDay } from "./date.ts";
// what getToday()/useToday() hand to the helpers
const today = () => startOfOsloDay(osloDay());
const range = (
timeFrom: string | null,
@@ -138,28 +142,28 @@ describe("getTodaysOpeningHoursForFunction", () => {
it("returns today's hours", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); // tirsdag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"16:00—23:00"
);
});
it("returns Stengt for a null day", () => {
vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"Stengt"
);
});
it("returns the custom text when set", () => {
vi.setSystemTime(new Date("2026-07-12T10:00:00Z")); // søndag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"Kun ved arrangement"
);
});
it("returns ? for unknown functions", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "kafé")).toBe("?");
expect(getTodaysOpeningHoursForFunction(openingHours, "kafé", today())).toBe("?");
});
it("uses the Oslo weekday when the server runs in UTC", () => {
@@ -168,12 +172,12 @@ describe("getTodaysOpeningHoursForFunction", () => {
try {
// 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST)
vi.setSystemTime(new Date("2026-07-09T22:20:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"15:00—01:00"
);
// 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET)
vi.setSystemTime(new Date("2026-01-08T23:20:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"15:00—01:00"
);
} finally {
+10 -13
View File
@@ -1,8 +1,6 @@
import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns";
import { getClient } from "@/app/client";
import { startOfTodayInOslo } from "@/lib/date";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { cmsQuery } from "@/app/client";
import { graphql, unmaskFragment } from "@/gql";
import type {
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
@@ -45,19 +43,17 @@ const openingHoursQuery = graphql(`
`);
export async function fetchOpeningHoursSets() {
const { data, error } = await getClient().query(
openingHoursQuery,
{},
// The footer renders today's hours on every page, so this TTL doubles as
// the sitewide midnight refresh for futureEvents rollover
cachedUntilOsloMidnight()
);
// The footer shows these on every page; a failed query must not take the
// whole site down with it
const data = await cmsQuery(openingHoursQuery, {}).catch((error) => {
console.error("Failed to fetch opening hours", error);
return undefined;
});
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
return sets;
}
export async function getOpeningHours() {
const today = startOfTodayInOslo();
export async function getOpeningHours(today: Date) {
const sets = await fetchOpeningHoursSets();
const validSets = sets
.filter((set) => {
@@ -202,12 +198,13 @@ export function getPrettyOpeningHoursForFunction(
export function getTodaysOpeningHoursForFunction(
openingHours: OpeningHoursSet,
name: string,
today: Date,
): string {
const week: any = getOpeningHoursForFunction(openingHours, name);
if (!week) {
return "?";
}
const weekdayIndex = getISODay(startOfTodayInOslo()) - 1;
const weekdayIndex = getISODay(today) - 1;
const weekday = WEEKDAYS[weekdayIndex];
const hours = week[weekday];
if (hours?.timeFrom && hours?.timeTo) {
+8 -32
View File
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
cachedUntilOsloMidnight,
secondsUntilOsloMidnight,
} from "./revalidation.ts";
import { secondsUntilOsloMidnight } from "./revalidation.ts";
describe("secondsUntilOsloMidnight", () => {
it("is 12h from midnight at 12:00 CEST on a normal day", () => {
@@ -11,9 +8,14 @@ describe("secondsUntilOsloMidnight", () => {
expect(secondsUntilOsloMidnight(now)).toBe(12 * 3600);
});
it("clamps to 60s just before midnight", () => {
it("counts down to the second just before midnight", () => {
const now = new Date("2026-07-07T21:59:30Z");
expect(secondsUntilOsloMidnight(now)).toBe(60);
expect(secondsUntilOsloMidnight(now)).toBe(30);
});
it("is a full day at midnight itself", () => {
const now = new Date("2026-07-07T22:00:00Z");
expect(secondsUntilOsloMidnight(now)).toBe(24 * 3600);
});
it("handles the 25h DST fall-back day (2026-10-25)", () => {
@@ -26,29 +28,3 @@ describe("secondsUntilOsloMidnight", () => {
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
});
});
describe("cachedUntilOsloMidnight", () => {
// urql types fetchOptions as RequestInit | () => RequestInit; we always pass an object
function fetchOptionsAt(now: Date): RequestInit {
const { fetchOptions } = cachedUntilOsloMidnight(now);
return typeof fetchOptions === "function" ? fetchOptions() : fetchOptions ?? {};
}
it("keys the fetch cache on the Oslo date, not the UTC date", () => {
// 00:30 CEST on Sep 5 is still Sep 4 in UTC
const { headers } = fetchOptionsAt(new Date("2026-09-04T22:30:00Z"));
expect(headers).toEqual({ "x-oslo-day": "2026-09-05" });
});
it("changes the cache key at Oslo midnight", () => {
const before = fetchOptionsAt(new Date("2026-09-04T21:59:59Z"));
const after = fetchOptionsAt(new Date("2026-09-04T22:00:00Z"));
expect(before.headers).toEqual({ "x-oslo-day": "2026-09-04" });
expect(after.headers).toEqual({ "x-oslo-day": "2026-09-05" });
});
it("revalidates at Oslo midnight and tags the entry", () => {
const { next } = fetchOptionsAt(new Date("2026-09-05T10:00:00Z"));
expect(next).toEqual({ revalidate: 12 * 3600, tags: ["cms"] });
});
});
+24 -25
View File
@@ -1,42 +1,41 @@
import { addDays, startOfDay } from "date-fns";
import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { OperationContext } from "@urql/core";
const timeZone = "Europe/Oslo";
// Global cache tag: any CMS content change purges everything
// Everything cached is kept until one of these tags is purged through
// /api/revalidate. Time only plays a part as a safety net, see CMS_CACHE_LIFE
// On all cached CMS data. Purged by the CMS webhook on any content change
export const CMS_CACHE_TAG = "cms";
const MIN_REVALIDATE_SECONDS = 60;
// On whatever depends on what day it is. Purged just after Oslo midnight
// by instrumentation.ts, which also purges both tags when the server starts
export const TODAY_CACHE_TAG = "today";
export const CACHE_TAGS = [CMS_CACHE_TAG, TODAY_CACHE_TAG] as const;
export type CacheTag = (typeof CACHE_TAGS)[number];
// The lifetime of every cache scope. A daily background refresh heals a
// missed purge, and keeps the s-maxage that pages advertise to shared caches
// down to a day. (An object rather than a named profile in next.config.mjs:
// profile names only type-check after Next has generated its types)
export const CMS_CACHE_LIFE = {
stale: 5 * 60,
revalidate: 24 * 60 * 60,
expire: 365 * 24 * 60 * 60,
};
// Clamped so a request just before midnight never yields revalidate ~0
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
const osloNow = toZonedTime(now, timeZone);
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
const instant = fromZonedTime(nextOsloMidnight, timeZone);
return Math.max(
Math.ceil((instant.getTime() - now.getTime()) / 1000),
MIN_REVALIDATE_SECONDS
);
return Math.ceil((instant.getTime() - now.getTime()) / 1000);
}
// Per-operation contexts replace (not merge with) the client-default
// fetchOptions, so both contexts below are self-contained
// For per-user (search) and token-scoped (preview) queries
// For per-user (search) and token-scoped (preview) queries, which must
// never be stored
export const uncached: Partial<OperationContext> = {
fetchOptions: { cache: "no-store" },
};
// For queries whose results depend on "today" (futureEvents, opening hours)
export function cachedUntilOsloMidnight(
now: Date = new Date()
): Partial<OperationContext> {
return {
fetchOptions: {
// Part of the fetch cache key, so yesterday's entry is never reused
headers: { "x-oslo-day": formatInTimeZone(now, timeZone, "yyyy-MM-dd") },
next: { revalidate: secondsUntilOsloMidnight(now), tags: [CMS_CACHE_TAG] },
},
};
}
+29
View File
@@ -0,0 +1,29 @@
import "server-only";
import { cmsQuery } from "@/app/client";
import { graphql } from "@/gql";
const latestPageSlugQuery = graphql(`
query latestPageSlug($contentType: String!) {
pages(contentType: $contentType, order: "-first_published_at", limit: 1) {
id
slug
}
}
`);
// Can't collide with a real page: Wagtail slugs never start with an underscore
const PLACEHOLDER_SLUG = "__ingen-sider__";
// For routes with too many pages to prerender them all. Cache Components
// needs at least one param set to validate the route at build time, so this
// prerenders the latest page; the rest are rendered and cached on first
// request. With no pages in the CMS yet, a placeholder that renders the 404
// page keeps the build working
export async function latestSlugParams(
contentType: string
): Promise<{ slug: string }[]> {
const data = await cmsQuery(latestPageSlugQuery, { contentType });
const slug = data?.pages?.[0]?.slug ?? PLACEHOLDER_SLUG;
return [{ slug }];
}
+19
View File
@@ -0,0 +1,19 @@
import "server-only";
import { todayCache } from "@/lib/cmsCache";
import { osloDay, startOfOsloDay } from "@/lib/date";
// The current Oslo day ("yyyy-MM-dd"), cached until the midnight purge. This
// is the only place rendering code gets "today" from, so prerendered pages
// stay static for the whole day and roll over together
export async function getOsloDay(): Promise<string> {
"use cache";
todayCache();
return osloDay();
}
// Start of the current Oslo day, for Server Components; Client Components
// use useToday()
export async function getToday(): Promise<Date> {
return startOfOsloDay(await getOsloDay());
}
+21 -3
View File
@@ -10,6 +10,7 @@ import type {
AllAssociationsQuery,
AllGenericSlugsQuery,
AllVenueSlugsQuery,
LatestPageSlugQuery,
AssociationBySlugQuery,
AssociationFragment,
AssociationIndexFragment,
@@ -53,6 +54,8 @@ import type {
export type MockState = {
newsHeadlineVersion: "v1" | "v2";
// how long the CMS takes to answer newsBySlug, to hold a page render open
newsDelayMs?: number;
};
export const SLUGS = {
@@ -60,6 +63,8 @@ export const SLUGS = {
association: "testforening",
venue: "storsalen",
news: "test-artikkel",
// exists in the CMS but is not prerendered: rendered on first request
newsOnDemand: "eldre-artikkel",
event: "testkonsert",
} as const;
@@ -98,7 +103,7 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
__typename: "EventCategory",
name: "Konsert",
slug: "konsert",
pig: "PIG_PINK",
pig: "music",
showInFilters: true,
} satisfies EventCategoryFragment;
@@ -158,7 +163,8 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
seoTitle: "Testkonsert",
searchDescription: null,
lead: "Bli med på testkonsert.",
pig: "PIG_PINK",
// picks a pig from the event's categories, which must be prerenderable
pig: "automatic",
facebookUrl: null,
ticketUrl: null,
free: true,
@@ -344,6 +350,15 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
({
pages: [{ id: "30", slug: SLUGS.venue }],
}) satisfies AllVenueSlugsQuery,
latestPageSlug: (vars) =>
({
pages:
vars.contentType === "news.NewsPage"
? [{ id: "20", slug: SLUGS.news }]
: vars.contentType === "events.EventPage"
? [{ id: "10", slug: SLUGS.event }]
: [],
}) satisfies LatestPageSlugQuery,
// --- build time: static pages ---
home: (_vars, state) =>
@@ -412,7 +427,10 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
// --- runtime ---
newsBySlug: (vars, state) =>
({
news: vars.slug === SLUGS.news ? mask(newsPage(state)) : null,
news:
vars.slug === SLUGS.news || vars.slug === SLUGS.newsOnDemand
? mask(newsPage(state))
: null,
}) satisfies NewsBySlugQuery,
eventBySlug: (vars) =>
({
+2
View File
@@ -79,6 +79,8 @@ export default async function setup(project: TestProject) {
);
});
mock.markBuildDone();
if (mock.graphqlRequests === 0) {
throw new Error(
"next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?"
+37 -1
View File
@@ -19,6 +19,12 @@ export type MockCms = {
url: string;
state: MockState;
graphqlRequests: number;
// GraphQL requests per operation name, served at /__mock/requests so tests
// can tell which queries a re-render actually sent
requestsByOperation: Record<string, number>;
// call once `next build` is done; /__mock/requests then also reports the
// requests made since
markBuildDone(): void;
close(): Promise<void>;
};
@@ -34,11 +40,16 @@ function readBody(req: http.IncomingMessage): Promise<string> {
export async function startMockCms(): Promise<MockCms> {
const state: MockState = { newsHeadlineVersion: "v1" };
let fixtures: Fixtures | null = null; // built once we know our own URL
let requestsAtBuild: Record<string, number> = {};
const mock: MockCms = {
url: "",
state,
graphqlRequests: 0,
requestsByOperation: {},
markBuildDone: () => {
requestsAtBuild = { ...mock.requestsByOperation };
},
close: () =>
new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
@@ -56,6 +67,20 @@ export async function startMockCms(): Promise<MockCms> {
return;
}
if (req.method === "GET" && req.url === "/__mock/requests") {
const sinceBuild = Object.fromEntries(
Object.entries(mock.requestsByOperation).map(([name, count]) => [
name,
count - (requestsAtBuild[name] ?? 0),
])
);
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({ total: mock.requestsByOperation, sinceBuild })
);
return;
}
if (req.method === "POST" && req.url === "/__mock/state") {
Object.assign(state, JSON.parse(await readBody(req)));
res.writeHead(200, { "content-type": "application/json" });
@@ -84,6 +109,10 @@ export async function startMockCms(): Promise<MockCms> {
const name =
body.operationName ??
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
if (name) {
mock.requestsByOperation[name] =
(mock.requestsByOperation[name] ?? 0) + 1;
}
const resolve = name ? fixtures?.[name] : undefined;
res.writeHead(200, { "content-type": "application/json" });
if (!resolve) {
@@ -96,7 +125,14 @@ export async function startMockCms(): Promise<MockCms> {
);
return;
}
res.end(JSON.stringify({ data: resolve(body.variables ?? {}, state) }));
// decide the answer first: a slow CMS returns what it had when asked
const answer = JSON.stringify({
data: resolve(body.variables ?? {}, state),
});
if (name === "newsBySlug" && state.newsDelayMs) {
await new Promise((r) => setTimeout(r, state.newsDelayMs));
}
res.end(answer);
return;
}
+142 -10
View File
@@ -1,12 +1,12 @@
// HTTP smoke tests against the built standalone app (see global-setup.ts).
// These cover the surfaces most likely to break on a Next.js upgrade:
// prerendered and request-rendered routes, 404s, the events API, the image
// optimizer, the fetch-cache + tag revalidation round trip, and draft-mode
// optimizer, the cache + tag revalidation round trip, and draft-mode
// preview.
import { beforeAll, describe, expect, inject, test } from "vitest";
import { afterAll, beforeAll, describe, expect, inject, test } from "vitest";
import { SLUGS } from "./fixtures";
import { type MockState, SLUGS } from "./fixtures";
const app = (path: string) => `${inject("appUrl")}${path}`;
const mock = (path: string) => `${inject("mockUrl")}${path}`;
@@ -16,14 +16,46 @@ async function getPage(path: string): Promise<{ status: number; html: string }>
return { status: res.status, html: await res.text() };
}
async function setNewsVersion(version: "v1" | "v2"): Promise<void> {
async function setMockState(state: Partial<MockState>): Promise<void> {
const res = await fetch(mock("/__mock/state"), {
method: "POST",
body: JSON.stringify({ newsHeadlineVersion: version }),
body: JSON.stringify(state),
});
expect(res.status).toBe(200);
}
const setNewsVersion = (version: "v1" | "v2") =>
setMockState({ newsHeadlineVersion: version });
type CmsRequests = Record<string, number>;
// GraphQL requests the mock CMS has received, per operation name
async function cmsRequests(): Promise<{
total: CmsRequests;
sinceBuild: CmsRequests;
}> {
const res = await fetch(mock("/__mock/requests"));
return res.json() as Promise<{ total: CmsRequests; sinceBuild: CmsRequests }>;
}
// Nothing expires by time: the server purges its own cache when it starts
// and at Oslo midnight (instrumentation.ts).
// Must stay the first test: any later purge would also make this pass
describe("startup purge", () => {
test("pages from the build are rendered again", async () => {
// the purge lands just after the server starts listening
const deadline = Date.now() + 10_000;
let sinceBuild: CmsRequests = {};
while (Date.now() < deadline) {
await getPage("/");
({ sinceBuild } = await cmsRequests());
if ((sinceBuild.home ?? 0) > 0) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(sinceBuild.home ?? 0).toBeGreaterThan(0);
});
});
describe("pages", () => {
test.each([
["/", "Testkonsert"],
@@ -32,7 +64,7 @@ describe("pages", () => {
[`/${SLUGS.genericUrl}`, "Praktisk info"],
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
[`/lokaler/${SLUGS.venue}`, "Storsalen"],
// not prerendered (empty generateStaticParams) — rendered on first request
// only the latest page of each is prerendered; these are the latest
[`/aktuelt/${SLUGS.news}`, "Testartikkel"],
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
])("%s renders and contains %s", async (path, needle) => {
@@ -51,6 +83,23 @@ describe("pages", () => {
);
});
describe("event list", () => {
// The list is rendered per request inside the prerendered page, so that
// the URL's filters are applied on the server (no layout shift)
const activeButton = (html: string) =>
/data-active="true"[^>]*><span>Vis (liste|kalender)<\/span>/.exec(html)?.[1];
test("is server-rendered in list mode by default", async () => {
const { html } = await getPage("/arrangementer");
expect(activeButton(html)).toBe("liste");
});
test("is server-rendered with the mode from the URL", async () => {
const { html } = await getPage("/arrangementer?mode=calendar");
expect(activeButton(html)).toBe("kalender");
});
});
describe("events api", () => {
test("rejects a missing view parameter", async () => {
const res = await fetch(app("/api/v1/events"));
@@ -84,15 +133,18 @@ describe("image optimizer", () => {
});
});
describe("fetch cache and tag revalidation", () => {
const path = `/aktuelt/${SLUGS.news}`;
// Runs for a page prerendered at build time and for one rendered on its
// first request, which is how most news and event pages are served
describe.each([
["prerendered", `/aktuelt/${SLUGS.news}`],
["rendered on demand", `/aktuelt/${SLUGS.newsOnDemand}`],
])("cache and tag revalidation (%s page)", (_kind, path) => {
beforeAll(async () => {
await setNewsVersion("v1");
});
test("serves cached content until the webhook fires, then updates", async () => {
// prime the cache (the route is request-rendered, then cached)
// prerendered at build time or rendered now; either way the CMS has v1
const first = await getPage(path);
expect(first.status).toBe(200);
expect(first.html).toContain("Testartikkel (v1)");
@@ -136,6 +188,86 @@ describe("fetch cache and tag revalidation", () => {
});
});
// Next stamps a cached page when its render finishes, so a page that was
// being rendered while a purge came in looks newer than the purge. The
// server sends itself a follow-up purge a few seconds after each webhook
describe("purge during a render", () => {
const path = `/aktuelt/${SLUGS.newsOnDemand}`;
const webhook = () =>
fetch(app("/api/revalidate"), {
method: "POST",
headers: { "x-revalidate-secret": "smoke-secret" },
});
afterAll(async () => {
await setMockState({ newsDelayMs: 0 });
});
test("two edits in quick succession end up showing the second", async () => {
// first edit: published and purged, and the CMS is slow to answer
await setMockState({ newsHeadlineVersion: "v1", newsDelayMs: 1_500 });
expect((await webhook()).status).toBe(200);
const visitor = getPage(path);
await new Promise((r) => setTimeout(r, 300));
// second edit lands while the visitor's page is still rendering
await setMockState({ newsHeadlineVersion: "v2", newsDelayMs: 0 });
expect((await webhook()).status).toBe(200);
// that visitor gets what the CMS had when it was asked
expect((await visitor).html).toContain("Testartikkel (v1)");
// everyone after must get the second edit once the follow-up has run
const deadline = Date.now() + 12_000;
let html = "";
while (Date.now() < deadline) {
({ html } = await getPage(path));
if (html.includes("Testartikkel (v2)")) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(html).toContain("Testartikkel (v2)");
}, 30_000);
});
describe("midnight purge", () => {
async function cacheStatus(path: string): Promise<string | null> {
const res = await fetch(app(path));
await res.text();
return res.headers.get("x-nextjs-cache");
}
test("the midnight purge re-renders pages but only refetches date-dependent data", async () => {
const purgeToday = () =>
fetch(app("/api/revalidate?tag=today"), {
method: "POST",
headers: { "x-revalidate-secret": "smoke-secret" },
});
// settle: everything rendered and cached by this server process
expect((await purgeToday()).status).toBe(200);
await getPage("/");
await getPage("/kontakt");
expect(await cacheStatus("/")).toBe("HIT");
expect(await cacheStatus("/kontakt")).toBe("HIT");
const before = (await cmsRequests()).total;
expect((await purgeToday()).status).toBe(200);
// every page shows today's date somewhere, so every page renders again
expect(await cacheStatus("/")).not.toBe("HIT");
expect(await cacheStatus("/kontakt")).not.toBe("HIT");
expect(await cacheStatus("/")).toBe("HIT");
expect(await cacheStatus("/kontakt")).toBe("HIT");
const after = (await cmsRequests()).total;
// the home page asks the CMS for future events again...
expect(after.home).toBe(before.home + 1);
// ...while content that only changes on edit comes from the cache
expect(after.contacts).toBe(before.contacts);
expect(after.openingHoursSets).toBe(before.openingHoursSets);
});
});
describe("preview", () => {
test("draft mode round trip renders the preview page", async () => {
const enable = await fetch(