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 = { const nextConfig = {
output: "standalone", 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: { images: {
remotePatterns: remotePatterns(), remotePatterns: remotePatterns(),
formats: ["image/avif", "image/webp"], formats: ["image/avif", "image/webp"],
+9 -5
View File
@@ -1,5 +1,5 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
@@ -8,6 +8,13 @@ import {
} from "@/components/general/GenericPageView"; } from "@/components/general/GenericPageView";
import { getSeoMetadata } from "@/lib/seo"; 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 { function getWagtailUrlPath(url: string[]): string {
// for the page /foo/bar we need to look for `/home/foo/bar/` // for the page /foo/bar we need to look for `/home/foo/bar/`
return `/home/${url.join("/")}/`; return `/home/${url.join("/")}/`;
@@ -22,10 +29,7 @@ export async function generateStaticParams() {
} }
} }
`); `);
const { data, error } = await getClient().query(allGenericSlugsQuery, {}); const data = await cmsQuery(allGenericSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) { if (!data?.pages) {
throw new Error("Failed to generate static params for generic subpages"); throw new Error("Failed to generate static params for generic subpages");
} }
+9 -2
View File
@@ -5,10 +5,17 @@ import {
loadNewsPageProps, loadNewsPageProps,
} from "@/components/news/NewsPageView"; } from "@/components/news/NewsPageView";
import { getSeoMetadata } from "@/lib/seo"; 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() { export async function generateStaticParams() {
// Prerender nothing at build time; render and cache on first request return latestSlugParams("news.NewsPage");
return [];
} }
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { import {
NewsIndexView, NewsIndexView,
loadNewsIndexProps, loadNewsIndexProps,
@@ -8,12 +8,14 @@ import { NewsIndexFragment } from "@/gql/graphql";
import { newsIndexMetadataQuery } from "@/lib/news"; import { newsIndexMetadataQuery } from "@/lib/news";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(newsIndexMetadataQuery, {}); const data = await cmsQuery(newsIndexMetadataQuery, {});
if (error) throw new Error(error.message);
if (!data?.index) return null; if (!data?.index) return null;
return getSeoMetadata(data.index as NewsIndexFragment, parent); 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"; import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() })); vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
import { revalidateTag } from "next/cache"; 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"; import { POST } from "./route.ts";
const request = (headers: Record<string, string> = {}) => const request = (headers: Record<string, string> = {}, query = "") =>
new Request("http://localhost/api/revalidate", { new NextRequest(`http://localhost/api/revalidate${query}`, {
method: "POST", method: "POST",
headers, headers,
}) as unknown as NextRequest; });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.clearAllMocks(); vi.clearAllMocks();
setInternalRevalidateSecret("");
}); });
describe("POST /api/revalidate", () => { describe("POST /api/revalidate", () => {
@@ -47,4 +49,119 @@ describe("POST /api/revalidate", () => {
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG }); expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 }); 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 { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server"; 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 { function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided); const a = Buffer.from(provided);
@@ -10,19 +14,55 @@ function secretMatches(provided: string, expected: string): boolean {
return a.length === b.length && timingSafeEqual(a, b); 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) { 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") ?? ""; const provided = req.headers.get("x-revalidate-secret") ?? "";
if (!secretMatches(provided, secret)) { // the server's own startup and midnight purges (instrumentation.ts)
return NextResponse.json({ error: "invalid secret" }, { status: 401 }); 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 // expire: 0 purges immediately; the next request renders fresh
revalidateTag(CMS_CACHE_TAG, { expire: 0 }); revalidateTag(tag, { expire: 0 });
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG }); // 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"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.fn(); const queryMock = vi.fn();
// mirrors cmsQueryForToday: returns the data, throws on a GraphQL error
vi.mock("@/app/client", () => ({ 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"; import { GET } from "./route.ts";
+5 -11
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getClient } from "@/app/client"; import { cmsQueryForToday } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation"; import { getToday } from "@/lib/today";
import { import {
eventsOverviewQuery, eventsOverviewQuery,
getSingularEvents, getSingularEvents,
@@ -46,14 +46,7 @@ export async function GET(req: NextRequest) {
); );
} }
const { data, error } = await getClient().query( const data = await cmsQueryForToday(eventsOverviewQuery, {});
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) {
throw new Error(error.message);
}
if ( if (
!data?.index || !data?.index ||
!data?.events?.futureEvents || !data?.events?.futureEvents ||
@@ -64,6 +57,7 @@ export async function GET(req: NextRequest) {
throw new Error("Failed to fetch events"); throw new Error("Failed to fetch events");
} }
const today = await getToday();
const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[]; const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
const eventCategories = (data?.eventCategories ?? []) as EventCategory[]; const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[]; const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
@@ -71,7 +65,7 @@ export async function GET(req: NextRequest) {
const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"]; const keepKeys = ["id", "slug", "title", "subtitle", "occurrence"];
const compactEvents = futureEvents.map((event) => { const compactEvents = futureEvents.map((event) => {
const futureOccurrences = getFutureOccurrences(event); const futureOccurrences = getFutureOccurrences(event, today);
const nextOccurrence = futureOccurrences.length const nextOccurrence = futureOccurrences.length
? futureOccurrences[0] ? futureOccurrences[0]
: null; : null;
+9 -2
View File
@@ -5,10 +5,17 @@ import {
loadEventPageProps, loadEventPageProps,
} from "@/components/events/EventPageView"; } from "@/components/events/EventPageView";
import { getSeoMetadata } from "@/lib/seo"; 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() { export async function generateStaticParams() {
// Prerender nothing at build time; render and cache on first request return latestSlugParams("events.EventPage");
return [];
} }
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
+7 -9
View File
@@ -1,5 +1,5 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { import {
EventIndexView, EventIndexView,
loadEventIndexProps, loadEventIndexProps,
@@ -8,22 +8,20 @@ import { EventIndexFragment } from "@/gql/graphql";
import { eventIndexMetadataQuery } from "@/lib/event"; import { eventIndexMetadataQuery } from "@/lib/event";
import { getSeoMetadata } from "@/lib/seo"; 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( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(eventIndexMetadataQuery, {}); const data = await cmsQuery(eventIndexMetadataQuery, {});
if (error) throw new Error(error.message);
if (!data?.index) return null; if (!data?.index) return null;
return getSeoMetadata(data.index as EventIndexFragment, parent); return getSeoMetadata(data.index as EventIndexFragment, parent);
} }
export default async function Page({ export default async function Page() {
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
await searchParams;
const props = await loadEventIndexProps(); const props = await loadEventIndexProps();
return <EventIndexView {...props} />; return <EventIndexView {...props} />;
} }
+52 -5
View File
@@ -1,9 +1,16 @@
import "server-only"; 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 { 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; const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
if (!wagtailBaseUrl) { if (!wagtailBaseUrl) {
@@ -15,10 +22,50 @@ const makeClient = () => {
return createClient({ return createClient({
url: graphqlEndpoint, url: graphqlEndpoint,
exchanges: [cacheExchange, fetchExchange], 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); 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 { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { import {
AssociationPageView, AssociationPageView,
loadAssociationPageProps, loadAssociationPageProps,
@@ -8,6 +8,13 @@ import {
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo"; 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 }>; type Params = Promise<{ slug: string }>;
export async function generateMetadata( export async function generateMetadata(
@@ -29,11 +36,7 @@ export async function generateStaticParams() {
} }
} }
`); `);
const { data, error } = await getClient().query(allAssociationSlugsQuery, {}); const data = await cmsQuery(allAssociationSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) { if (!data?.pages) {
throw new Error( throw new Error(
"Failed to generate static params for subpages of /foreninger" "Failed to generate static params for subpages of /foreninger"
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/associations/AssociationIndexView"; } from "@/components/associations/AssociationIndexView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/contact/ContactIndexView"; } from "@/components/contact/ContactIndexView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
+10 -5
View File
@@ -1,5 +1,7 @@
import "@/css/main.scss"; 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 { Footer } from "@/components/layout/Footer";
import { Metadata } from "next"; import { Metadata } from "next";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
...baseUrlMetadata, ...baseUrlMetadata,
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const day = await getOsloDay();
return ( return (
<html lang="no"> <html lang="no">
<head> <head>
@@ -61,9 +64,11 @@ export default function RootLayout({
</head> </head>
<body> <body>
<NuqsAdapter> <NuqsAdapter>
<Header /> <TodayProvider day={day}>
{children} <SeasonalHeader />
<Footer /> {children}
<Footer />
</TodayProvider>
</NuqsAdapter> </NuqsAdapter>
</body> </body>
</html> </html>
+9 -6
View File
@@ -1,6 +1,6 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { import {
VenuePageView, VenuePageView,
loadVenuePageProps, loadVenuePageProps,
@@ -8,6 +8,13 @@ import {
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo"; 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 }>; type Params = Promise<{ slug: string }>;
const EXCLUDED_SLUGS = ["hele-huset"]; const EXCLUDED_SLUGS = ["hele-huset"];
@@ -31,11 +38,7 @@ export async function generateStaticParams() {
} }
} }
`); `);
const { data, error } = await getClient().query(allVenueSlugsQuery, {}); const data = await cmsQuery(allVenueSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) { if (!data?.pages) {
throw new Error( throw new Error(
"Failed to generate static params for subpages of /lokaler" "Failed to generate static params for subpages of /lokaler"
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/venues/VenueIndexView"; } from "@/components/venues/VenueIndexView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
+3
View File
@@ -3,6 +3,9 @@ import {
loadHomePageProps, loadHomePageProps,
} from "@/components/home/HomePageView"; } from "@/components/home/HomePageView";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export default async function Home() { export default async function Home() {
const props = await loadHomePageProps(); const props = await loadHomePageProps();
return <HomePageView {...props} />; return <HomePageView {...props} />;
+4 -2
View File
@@ -74,8 +74,10 @@ import {
VenueRentalIndexFragment, VenueRentalIndexFragment,
} from "@/gql/graphql"; } from "@/gql/graphql";
import { cookies } from "next/headers"; 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(` const previewPageQuery = graphql(`
query previewPage($token: String!) { query previewPage($token: String!) {
+7
View File
@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { uncached } from "@/lib/revalidation"; import { uncached } from "@/lib/revalidation";
import { import {
@@ -9,6 +10,10 @@ import { graphql } from "@/gql";
// TODO: seo metadata? // 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({ export default async function Page({
searchParams, searchParams,
}: { }: {
@@ -17,6 +22,8 @@ export default async function Page({
}>; }>;
}) { }) {
const { q: query } = (await searchParams) ?? {}; const { q: query } = (await searchParams) ?? {};
// search always runs at request time
await connection();
let results: SearchResult[] = []; let results: SearchResult[] = [];
let totalCount = 0; let totalCount = 0;
const RESULT_LIMIT = 500; const RESULT_LIMIT = 500;
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/sponsor/SponsorsPageView"; } from "@/components/sponsor/SponsorsPageView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/studio/StudioPageView"; } from "@/components/studio/StudioPageView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
+3
View File
@@ -5,6 +5,9 @@ import {
} from "@/components/venues/VenueRentalIndexView"; } from "@/components/venues/VenueRentalIndexView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
// Fail the build if this route stops being fully prerendered
export const ensureStatic = "navigation";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql"; import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { AssociationList } from "@/components/associations/AssociationList"; import { AssociationList } from "@/components/associations/AssociationList";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
@@ -67,8 +67,7 @@ export type AssociationIndexViewProps = {
export async function loadAssociationIndexProps(overrides?: { export async function loadAssociationIndexProps(overrides?: {
indexOverride?: AssociationIndexFragment; indexOverride?: AssociationIndexFragment;
}): Promise<AssociationIndexViewProps> { }): Promise<AssociationIndexViewProps> {
const { data, error } = await getClient().query(allAssociationsQuery, {}); const data = await cmsQuery(allAssociationsQuery, {});
if (error) throw new Error(error.message);
const index = const index =
overrides?.indexOverride ?? (data?.index as AssociationIndexFragment | undefined); overrides?.indexOverride ?? (data?.index as AssociationIndexFragment | undefined);
if (!index) throw new Error("Failed to load /foreninger"); if (!index) throw new Error("Failed to load /foreninger");
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { AssociationFragment } from "@/gql/graphql"; import { AssociationFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { AssociationHeader } from "@/components/associations/AssociationHeader"; import { AssociationHeader } from "@/components/associations/AssociationHeader";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
@@ -27,10 +27,9 @@ export async function loadAssociationPageProps(args: {
return { association: args.associationOverride }; return { association: args.associationOverride };
} }
if (!args.slug) throw new Error("loadAssociationPageProps needs slug or 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, slug: args.slug,
}); });
if (error) throw new Error(error.message);
const association = data?.association as AssociationFragment | undefined; const association = data?.association as AssociationFragment | undefined;
if (!association) return null; if (!association) return null;
return { association }; return { association };
@@ -3,6 +3,7 @@ import {
getPrettyOpeningHoursForFunction, getPrettyOpeningHoursForFunction,
PrettyOpeningHours, PrettyOpeningHours,
} from "@/lib/openinghours"; } from "@/lib/openinghours";
import { getToday } from "@/lib/today";
import styles from "./openingHoursSectionBlock.module.scss"; import styles from "./openingHoursSectionBlock.module.scss";
import { Fragment } from "react"; import { Fragment } from "react";
@@ -35,7 +36,7 @@ function OpeningHoursSubsection({
} }
export async function OpeningHoursSectionBlock() { export async function OpeningHoursSectionBlock() {
const allOpeningHours = await getOpeningHours(); const allOpeningHours = await getOpeningHours(await getToday());
const subsections = [ const subsections = [
["glassbaren", "Glassbaren"], ["glassbaren", "Glassbaren"],
["bokcafeen", "Bokcaféen"], ["bokcafeen", "Bokcaféen"],
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { ContactIndexFragment } from "@/gql/graphql"; import { ContactIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock"; import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
@@ -36,8 +36,7 @@ export async function loadContactIndexProps(overrides?: {
if (overrides?.indexOverride) { if (overrides?.indexOverride) {
return { index: overrides.indexOverride }; return { index: overrides.indexOverride };
} }
const { data, error } = await getClient().query(contactQuery, {}); const data = await cmsQuery(contactQuery, {});
if (error) throw new Error(error.message);
const index = data?.index as ContactIndexFragment | undefined; const index = data?.index as ContactIndexFragment | undefined;
if (!index) throw new Error("Failed to load /kontakt"); if (!index) throw new Error("Failed to load /kontakt");
return { index }; return { index };
+6 -3
View File
@@ -8,22 +8,24 @@ import {
compareDates, compareDates,
} from "@/lib/date"; } from "@/lib/date";
import { EventFragment, EventOccurrence } from "@/lib/event"; import { EventFragment, EventOccurrence } from "@/lib/event";
import { useToday } from "@/components/general/Today";
import styles from "./dateList.module.scss"; import styles from "./dateList.module.scss";
import Link from "next/link"; import Link from "next/link";
const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => { const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
const today = useToday();
const timeFormat = "'kl.' HH:mm"; const timeFormat = "'kl.' HH:mm";
const wholeCastle = occurrence.venue?.slug === "hele-huset"; const wholeCastle = occurrence.venue?.slug === "hele-huset";
return ( return (
<li <li
className={`${styles.date} ${ className={`${styles.date} ${
isTodayOrFuture(occurrence.start) ? styles.future : styles.past isTodayOrFuture(occurrence.start, today) ? styles.future : styles.past
}`} }`}
> >
<div className={styles.time}> <div className={styles.time}>
<span className={styles.day}> <span className={styles.day}>
{formatExtendedDateTime(occurrence.start, true)} {formatExtendedDateTime(occurrence.start, today, true)}
</span>{" "} </span>{" "}
<span className={styles.hour}> <span className={styles.hour}>
{formatDate(occurrence.start, timeFormat)} {formatDate(occurrence.start, timeFormat)}
@@ -49,6 +51,7 @@ const DateListItem = ({ occurrence }: { occurrence: EventOccurrence }) => {
}; };
export const DateList = ({ event }: { event: EventFragment }) => { export const DateList = ({ event }: { event: EventFragment }) => {
const today = useToday();
const [showAllDates, setShowAllDates] = useState(false); const [showAllDates, setShowAllDates] = useState(false);
function toggleDates() { function toggleDates() {
setShowAllDates(!showAllDates); setShowAllDates(!showAllDates);
@@ -57,7 +60,7 @@ export const DateList = ({ event }: { event: EventFragment }) => {
const oneOff = event.occurrences.length === 1; const oneOff = event.occurrences.length === 1;
const showToggle = event.occurrences.length > 4; const showToggle = event.occurrences.length > 4;
const futureOccurrences = event.occurrences const futureOccurrences = event.occurrences
.filter((occurrence) => isTodayOrFuture(occurrence.start)) .filter((occurrence) => isTodayOrFuture(occurrence.start, today))
.sort((a, b) => compareDates(a.start, b.start)); .sort((a, b) => compareDates(a.start, b.start));
let visibleOccurrences = []; let visibleOccurrences = [];
+8 -6
View File
@@ -24,6 +24,7 @@ import {
getFilterableVenues, getFilterableVenues,
} from "@/lib/eventFilter"; } from "@/lib/eventFilter";
import { isTodayOrFuture } from "@/lib/date"; import { isTodayOrFuture } from "@/lib/date";
import { useToday } from "@/components/general/Today";
import styles from "./eventContainer.module.scss"; import styles from "./eventContainer.module.scss";
import { formatDate, formatYearMonth } from "@/lib/date"; import { formatDate, formatYearMonth } from "@/lib/date";
import { import {
@@ -190,7 +191,7 @@ const CalendarDay = ({
<div <div
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`} 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> <ul>
{events.map((event) => ( {events.map((event) => (
<EventItem <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 // formatYearMonth only shows the year for non-current years; always expose it to screen readers
const yearMonth = new Date(yearMonthString); const yearMonth = new Date(yearMonthString);
const isCurrentYear = yearMonth.getFullYear() === new Date().getFullYear(); const isCurrentYear = yearMonth.getFullYear() === today.getFullYear();
if (!isCurrentYear) { if (!isCurrentYear) {
return null; return null;
} }
@@ -245,8 +246,9 @@ function srOnlyYear(yearMonthString: string) {
} }
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => { const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
const today = useToday();
const futureSingularEvents = getSingularEvents(events).filter( 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 eventsByDate = organizeEventsInCalendar(futureSingularEvents);
const yearMonths = Object.keys(eventsByDate); const yearMonths = Object.keys(eventsByDate);
@@ -283,8 +285,8 @@ const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
data-collapsed={!visibleYearMonths.includes(yearMonth)} data-collapsed={!visibleYearMonths.includes(yearMonth)}
> >
<h2 onClick={() => toggleYearMonth(yearMonth)}> <h2 onClick={() => toggleYearMonth(yearMonth)}>
{formatYearMonth(yearMonth)} {formatYearMonth(yearMonth, today)}
{srOnlyYear(yearMonth)}{" "} {srOnlyYear(yearMonth, today)}{" "}
<span className={styles.eventCounter}>({eventCount})</span> <span className={styles.eventCounter}>({eventCount})</span>
</h2> </h2>
{eventCount === 0 && ( {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 { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQueryForToday } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { EventContainer } from "@/components/events/EventContainer"; import { EventContainer } from "@/components/events/EventContainer";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { import {
@@ -18,12 +19,7 @@ export type EventIndexViewProps = {
}; };
export async function loadEventIndexProps(): Promise<EventIndexViewProps> { export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
const { data, error } = await getClient().query( const data = await cmsQueryForToday(eventsOverviewQuery, {});
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message);
if ( if (
!data?.index || !data?.index ||
!data?.events?.futureEvents || !data?.events?.futureEvents ||
@@ -41,21 +37,22 @@ export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
}; };
} }
export function EventIndexView({ // Waiting for the request makes the list render on the server per request,
events, // with the visitor's filters from the URL already applied. Prerendered, it
eventCategories, // would only appear after hydration and shift when the URL has filters
eventOrganizers, async function RequestTimeEventContainer(props: EventIndexViewProps) {
venues, await connection();
}: EventIndexViewProps) { return <EventContainer {...props} />;
}
export function EventIndexView(props: EventIndexViewProps) {
return ( return (
<main className="site-main" id="main"> <main className="site-main" id="main">
<PageHeader heading="Dette skjer på Chateau Neuf" align="left" /> <PageHeader heading="Dette skjer på Chateau Neuf" align="left" />
<EventContainer {/* the fallback reserves the list's height to keep the footer in place */}
events={events} <Suspense fallback={<div style={{ minHeight: "100vh" }} />}>
eventCategories={eventCategories} <RequestTimeEventContainer {...props} />
eventOrganizers={eventOrganizers} </Suspense>
venues={venues}
/>
</main> </main>
); );
} }
+3 -1
View File
@@ -3,6 +3,7 @@
import styles from "./eventItem.module.scss"; import styles from "./eventItem.module.scss";
import Link from "next/link"; import Link from "next/link";
import { Image } from "@/components/general/Image"; import { Image } from "@/components/general/Image";
import { useToday } from "@/components/general/Today";
import { import {
SingularEvent, SingularEvent,
EventFragment, EventFragment,
@@ -37,7 +38,8 @@ export const EventItem = ({
imageLoading?: "eager" | "lazy"; imageLoading?: "eager" | "lazy";
imageFetchPriority?: "high" | "low" | "auto"; imageFetchPriority?: "high" | "low" | "auto";
}) => { }) => {
const futureOccurrences = getFutureOccurrences(event); const today = useToday();
const futureOccurrences = getFutureOccurrences(event, today);
const groupedOccurrences = groupConsecutiveDates( const groupedOccurrences = groupConsecutiveDates(
futureOccurrences.map((occurrence) => occurrence.start) futureOccurrences.map((occurrence) => occurrence.start)
); );
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { EventFragment } from "@/gql/graphql"; import { EventFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { EventDetails } from "@/components/events/EventDetails"; import { EventDetails } from "@/components/events/EventDetails";
import { EventHeader } from "@/components/events/EventHeader"; import { EventHeader } from "@/components/events/EventHeader";
import { BgPig } from "@/components/general/BgPig"; import { BgPig } from "@/components/general/BgPig";
@@ -28,10 +28,9 @@ export async function loadEventPageProps(args: {
return { event: args.eventOverride }; return { event: args.eventOverride };
} }
if (!args.slug) throw new Error("loadEventPageProps needs slug or 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, slug: args.slug,
}); });
if (error) throw new Error(error.message);
const event = data?.event as EventFragment | undefined; const event = data?.event as EventFragment | undefined;
if (!event) return null; if (!event) return null;
return { event }; return { event };
+5 -3
View File
@@ -9,11 +9,13 @@ import { EventItem } from "./EventItem";
import styles from "./upcomingEvents.module.scss"; import styles from "./upcomingEvents.module.scss";
import { SectionHeader } from "../general/SectionHeader"; import { SectionHeader } from "../general/SectionHeader";
import { SectionFooter } from "../general/SectionFooter"; 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( const upcomingSingularEvents = sortSingularEvents(
getSingularEvents(events).filter((event) => getSingularEvents(events).filter((event) =>
isTodayOrFuture(event.occurrence.start) isTodayOrFuture(event.occurrence.start, today)
) )
); );
const sliced = upcomingSingularEvents.slice(0, 10); const sliced = upcomingSingularEvents.slice(0, 10);
@@ -30,7 +32,7 @@ export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] })
<div key={day}> <div key={day}>
{/*<h3> {/*<h3>
{formatDate( {formatDate(
parse(day, "yyyy-MM-dd", new Date()), parse(day, "yyyy-MM-dd", new Date(0)),
"eeee dd.MM." "eeee dd.MM."
)} )}
</h3>*/} </h3>*/}
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { GenericFragment } from "@/gql/graphql"; import { GenericFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { BgPig } from "@/components/general/BgPig"; import { BgPig } from "@/components/general/BgPig";
@@ -41,10 +41,9 @@ export async function loadGenericPageProps(args: {
return { page: args.pageOverride }; return { page: args.pageOverride };
} }
if (!args.urlPath) throw new Error("loadGenericPageProps needs urlPath or 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, urlPath: args.urlPath,
}); });
if (error) throw new Error(error.message);
const page = data?.page as GenericFragment | undefined; const page = data?.page as GenericFragment | undefined;
if (!page) return null; if (!page) return null;
return { page }; 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 Link from "next/link";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { HomeFragment } from "@/gql/graphql"; import { HomeFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQueryForToday } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { EventListItemFragment } from "@/lib/event"; import { EventListItemFragment } from "@/lib/event";
import { NewsListItemFragment } from "@/lib/news"; import { NewsListItemFragment } from "@/lib/news";
import { FeaturedEvents } from "@/components/events/FeaturedEvents"; import { FeaturedEvents } from "@/components/events/FeaturedEvents";
@@ -57,12 +56,7 @@ export type HomePageViewProps = {
export async function loadHomePageProps(overrides?: { export async function loadHomePageProps(overrides?: {
homeOverride?: HomeFragment; homeOverride?: HomeFragment;
}): Promise<HomePageViewProps> { }): Promise<HomePageViewProps> {
const { data, error } = await getClient().query( const data = await cmsQueryForToday(homeQuery, {});
homeQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message);
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined); const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
if (!home) throw new Error("Failed to load /"); if (!home) throw new Error("Failed to load /");
const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[]; const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
+8 -2
View File
@@ -2,6 +2,7 @@ import {
getOpeningHours, getOpeningHours,
getTodaysOpeningHoursForFunction, getTodaysOpeningHoursForFunction,
} from "@/lib/openinghours"; } from "@/lib/openinghours";
import { getToday } from "@/lib/today";
import Link from "next/link"; import Link from "next/link";
import { Icon } from "../general/Icon"; import { Icon } from "../general/Icon";
// import { NeonChillPig } from "../general/pigs/fun/NeonChillPig"; // import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
@@ -9,7 +10,8 @@ import styles from "./footer.module.scss";
import { PigPattern } from "./PigPattern"; import { PigPattern } from "./PigPattern";
async function OpeningHoursTable() { async function OpeningHoursTable() {
const allOpeningHours = await getOpeningHours(); const today = await getToday();
const allOpeningHours = await getOpeningHours(today);
const subsections = [ const subsections = [
["glassbaren", "Glassbaren"], ["glassbaren", "Glassbaren"],
["bokcafeen", "Bokcaféen"], ["bokcafeen", "Bokcaféen"],
@@ -21,7 +23,11 @@ async function OpeningHoursTable() {
<tbody> <tbody>
{subsections.map((subsection) => { {subsections.map((subsection) => {
const [slug, title] = subsection; const [slug, title] = subsection;
const hours = getTodaysOpeningHoursForFunction(allOpeningHours, slug); const hours = getTodaysOpeningHoursForFunction(
allOpeningHours,
slug,
today,
);
return ( return (
<tr key={slug}> <tr key={slug}>
<td>{title}</td> <td>{title}</td>
+9 -13
View File
@@ -8,7 +8,13 @@ import { Icon } from "../general/Icon";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import { getSearchPath } from "@/lib/common"; 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 This component depends on the following CSS rule in base.css to prevent scrolling
the body while the menu is open: 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 ( return (
<> <>
<header <header
@@ -70,7 +66,7 @@ export const Header = () => {
data-small={!isInView} data-small={!isInView}
> >
<Link href="/" aria-label="Hjem"> <Link href="/" aria-label="Hjem">
<Logo christmas={isChristmas} studentHat={pigWearsHat} /> <Logo christmas={christmas} studentHat={studentHat} />
</Link> </Link>
<nav className={styles.siteMenu}> <nav className={styles.siteMenu}>
<ul className={styles.mainMenu}> <ul className={styles.mainMenu}>
@@ -193,7 +189,7 @@ export const Header = () => {
</nav> </nav>
<div className={styles.headerBar} aria-hidden> <div className={styles.headerBar} aria-hidden>
<Link href="/" aria-label="Hjem" tabIndex={-1}> <Link href="/" aria-label="Hjem" tabIndex={-1}>
<LogoIcon face="left" christmas={isChristmas} /> <LogoIcon face="left" christmas={christmas} />
</Link> </Link>
<nav className={styles.siteMenu}> <nav className={styles.siteMenu}>
<ul className={styles.mainMenu}> <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 { PageHeader } from "@/components/general/PageHeader";
import { NewsList } from "@/components/news/NewsList"; import { NewsList } from "@/components/news/NewsList";
import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news"; import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
@@ -11,8 +11,7 @@ export type NewsIndexViewProps = {
export async function loadNewsIndexProps(overrides?: { export async function loadNewsIndexProps(overrides?: {
indexOverride?: NewsIndexFragment; indexOverride?: NewsIndexFragment;
}): Promise<NewsIndexViewProps> { }): Promise<NewsIndexViewProps> {
const { data, error } = await getClient().query(newsQuery, {}); const data = await cmsQuery(newsQuery, {});
if (error) throw new Error(error.message);
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined); const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
if (!index) throw new Error("Failed to load /aktuelt"); if (!index) throw new Error("Failed to load /aktuelt");
const news = (data?.news ?? []) as NewsListItemFragment[]; const news = (data?.news ?? []) as NewsListItemFragment[];
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { NewsFragment } from "@/gql/graphql"; import { NewsFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { Breadcrumb } from "@/components/general/Breadcrumb"; import { Breadcrumb } from "@/components/general/Breadcrumb";
import { ImageFigure } from "@/components/general/Image"; import { ImageFigure } from "@/components/general/Image";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
@@ -27,10 +27,9 @@ export async function loadNewsPageProps(args: {
return { news: args.newsOverride }; return { news: args.newsOverride };
} }
if (!args.slug) throw new Error("loadNewsPageProps needs slug or 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, slug: args.slug,
}); });
if (error) throw new Error(error.message);
const news = data?.news as NewsFragment | undefined; const news = data?.news as NewsFragment | undefined;
if (!news) return null; if (!news) return null;
return { news }; return { news };
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { type SponsorFragment, type SponsorsPageFragment } from "@/gql/graphql"; 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 { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { SponsorList } from "@/components/sponsor/SponsorList"; import { SponsorList } from "@/components/sponsor/SponsorList";
@@ -41,8 +41,7 @@ export async function loadSponsorsPageProps(overrides?: {
if (overrides?.pageOverride) { if (overrides?.pageOverride) {
return { page: overrides.pageOverride }; return { page: overrides.pageOverride };
} }
const { data, error } = await getClient().query(sponsorsPageQuery, {}); const data = await cmsQuery(sponsorsPageQuery, {});
if (error) throw new Error(error.message);
const page = data?.page as SponsorsPageFragment | undefined; const page = data?.page as SponsorsPageFragment | undefined;
if (!page) throw new Error("Failed to load /sponsorer"); if (!page) throw new Error("Failed to load /sponsorer");
return { page }; return { page };
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { type StudioFragment } from "@/gql/graphql"; import { type StudioFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { BgPig } from "@/components/general/BgPig"; import { BgPig } from "@/components/general/BgPig";
import { StudioHeader } from "@/components/studio/StudioHeader"; import { StudioHeader } from "@/components/studio/StudioHeader";
@@ -45,8 +45,7 @@ export async function loadStudioPageProps(overrides?: {
if (overrides?.pageOverride) { if (overrides?.pageOverride) {
return { page: overrides.pageOverride }; return { page: overrides.pageOverride };
} }
const { data, error } = await getClient().query(studioPageQuery, {}); const data = await cmsQuery(studioPageQuery, {});
if (error) throw new Error(error.message);
const page = data?.page as StudioFragment | undefined; const page = data?.page as StudioFragment | undefined;
if (!page) throw new Error("Failed to load /studio"); if (!page) throw new Error("Failed to load /studio");
return { page }; return { page };
+5 -4
View File
@@ -1,14 +1,15 @@
import { fromZonedTime } from "date-fns-tz";
import Link from "next/link"; import Link from "next/link";
import { Icon } from "../general/Icon"; import { Icon } from "../general/Icon";
import styles from "./studioShortcut.module.scss"; import styles from "./studioShortcut.module.scss";
import { startOfOsloDay } from "@/lib/date";
import { getToday } from "@/lib/today";
// Hidden from this date // Hidden from this date
// Note that home page revalidation happens at midnight // 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 = () => { export const StudioShortcut = async () => {
if (new Date() >= showUntil) return null; if ((await getToday()) >= showUntil) return null;
return ( return (
<div className={styles.studioShortcut}> <div className={styles.studioShortcut}>
+2 -3
View File
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { VenueFragment, VenueIndexFragment } from "@/gql/graphql"; import { VenueFragment, VenueIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { VenueList } from "@/components/venues/VenueList"; import { VenueList } from "@/components/venues/VenueList";
@@ -77,8 +77,7 @@ export type VenueIndexViewProps = {
export async function loadVenueIndexProps(overrides?: { export async function loadVenueIndexProps(overrides?: {
indexOverride?: VenueIndexFragment; indexOverride?: VenueIndexFragment;
}): Promise<VenueIndexViewProps> { }): Promise<VenueIndexViewProps> {
const { data, error } = await getClient().query(venueIndexQuery, {}); const data = await cmsQuery(venueIndexQuery, {});
if (error) throw new Error(error.message);
const index = overrides?.indexOverride ?? (data?.index as VenueIndexFragment | undefined); const index = overrides?.indexOverride ?? (data?.index as VenueIndexFragment | undefined);
if (!index) throw new Error("Failed to load /lokaler"); if (!index) throw new Error("Failed to load /lokaler");
const venues = (data?.venues ?? []) as VenueFragment[]; const venues = (data?.venues ?? []) as VenueFragment[];
+2 -3
View File
@@ -1,6 +1,6 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { VenueFragment } from "@/gql/graphql"; import { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock"; import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock";
import { Breadcrumb } from "@/components/general/Breadcrumb"; import { Breadcrumb } from "@/components/general/Breadcrumb";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
@@ -32,10 +32,9 @@ export async function loadVenuePageProps(args: {
return { venue: args.venueOverride }; return { venue: args.venueOverride };
} }
if (!args.slug) throw new Error("loadVenuePageProps needs slug or 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, slug: args.slug,
}); });
if (error) throw new Error(error.message);
const venue = data?.venue as VenueFragment | undefined; const venue = data?.venue as VenueFragment | undefined;
if (!venue) return null; if (!venue) return null;
return { venue }; return { venue };
@@ -1,6 +1,6 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql"; import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { BgPig } from "@/components/general/BgPig"; import { BgPig } from "@/components/general/BgPig";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
@@ -42,8 +42,7 @@ export type VenueRentalIndexViewProps = {
export async function loadVenueRentalIndexProps(overrides?: { export async function loadVenueRentalIndexProps(overrides?: {
indexOverride?: VenueRentalIndexFragment; indexOverride?: VenueRentalIndexFragment;
}): Promise<VenueRentalIndexViewProps> { }): Promise<VenueRentalIndexViewProps> {
const { data, error } = await getClient().query(venueRentalIndexQuery, {}); const data = await cmsQuery(venueRentalIndexQuery, {});
if (error) throw new Error(error.message);
const index = const index =
overrides?.indexOverride ?? (data?.index as VenueRentalIndexFragment | undefined); overrides?.indexOverride ?? (data?.index as VenueRentalIndexFragment | undefined);
if (!index) throw new Error("Failed to load /utleie"); 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 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 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 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 = { const documents: Documents = {
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument, "\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 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 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 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. * 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"]; 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) { export function graphql(source: string) {
return (documents as any)[source] ?? {}; return (documents as any)[source] ?? {};
+25 -1
View File
@@ -2037,6 +2037,29 @@ export type OpeningHoursWeekBlockFragment = { monday:
| Record<PropertyKey, never> | Record<PropertyKey, never>
| null } & { ' $fragmentName'?: 'OpeningHoursWeekBlockFragment' }; | 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 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 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>; 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 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 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 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, formatNorwegianPhoneNumber,
formatPhoneE164, formatPhoneE164,
getSearchPath, getSearchPath,
randomElement, seededElement,
stripHtml, stripHtml,
stripWhitespace, stripWhitespace,
unique, unique,
@@ -22,11 +22,25 @@ describe("getSearchPath", () => {
}); });
}); });
describe("randomElement", () => { describe("seededElement", () => {
it("picks a member, undefined for empty", () => { it("picks a member, undefined for empty", () => {
expect(randomElement([])).toBeUndefined(); expect(seededElement([], "x")).toBeUndefined();
expect(randomElement(["a"])).toBe("a"); expect(seededElement(["a"], "x")).toBe("a");
expect([1, 2, 3]).toContain(randomElement([1, 2, 3])); 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()}`; return `/sok?${params.toString()}`;
} }
export function randomElement(array: any[]): any | undefined { // Picks the same element for the same seed every time. Rendering code can't
return array.length // use Math.random(): prerendering rejects it, and the pick would change
? array[Math.floor(Math.random() * array.length)] // between the server render and the next regeneration
: undefined; 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[] { export function unique<T>(array: any[]): any[] {
+22 -17
View File
@@ -10,7 +10,8 @@ import {
groupConsecutiveDates, groupConsecutiveDates,
isConsecutiveDays, isConsecutiveDays,
isTodayOrFuture, isTodayOrFuture,
startOfTodayInOslo, osloDay,
startOfOsloDay,
toLocalTime, toLocalTime,
} from "./date.ts"; } from "./date.ts";
@@ -24,6 +25,9 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
// what getToday()/useToday() hand to the helpers
const today = () => startOfOsloDay(osloDay());
describe("toLocalTime", () => { describe("toLocalTime", () => {
it("converts UTC to Oslo wall clock", () => { it("converts UTC to Oslo wall clock", () => {
expect(toLocalTime("2026-07-07T18:00:00Z").getHours()).toBe(20); 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", () => { it("returns the Oslo date even when the server runs in UTC", () => {
const tz = process.env.TZ; const tz = process.env.TZ;
process.env.TZ = "UTC"; process.env.TZ = "UTC";
try { try {
vi.setSystemTime(new Date("2026-07-07T22:20:00Z")); vi.setSystemTime(new Date("2026-07-07T22:20:00Z"));
const today = startOfTodayInOslo(); expect(osloDay()).toBe("2026-07-08");
expect(today.getDate()).toBe(8); const start = startOfOsloDay(osloDay());
expect(today.getHours()).toBe(0); expect(start.getDate()).toBe(8);
expect(start.getHours()).toBe(0);
} finally { } finally {
process.env.TZ = tz; process.env.TZ = tz;
} }
@@ -57,42 +62,42 @@ describe("formatDate", () => {
describe("formatYearMonth", () => { describe("formatYearMonth", () => {
it("omits year for the current year", () => { 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", () => { it("includes year for other years", () => {
expect(formatYearMonth("2025-11")).toBe("november 2025"); expect(formatYearMonth("2025-11", today())).toBe("november 2025");
}); });
}); });
describe("formatExtendedDateTime", () => { describe("formatExtendedDateTime", () => {
it("omits year in the current year", () => { 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" "tirsdag 7. juli kl. 20:00"
); );
}); });
it("includes year for other years", () => { 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" "lørdag 1. november 2025 kl. 13:00"
); );
}); });
it("supports dateOnly and alwaysIncludeYear", () => { 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" "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" "tirsdag 7. juli 2026 kl. 20:00"
); );
}); });
it("uses the correct UTC offset around the DST fall-back", () => { 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 // 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" "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" "søndag 25. oktober kl. 02:30"
); );
}); });
@@ -100,14 +105,14 @@ describe("formatExtendedDateTime", () => {
describe("isTodayOrFuture", () => { describe("isTodayOrFuture", () => {
it("is false for yesterday, true for today and later", () => { it("is false for yesterday, true for today and later", () => {
expect(isTodayOrFuture("2026-07-06T10:00:00Z")).toBe(false); expect(isTodayOrFuture("2026-07-06T10:00:00Z", today())).toBe(false);
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true); expect(isTodayOrFuture("2026-07-07T20:00:00Z", today())).toBe(true);
expect(isTodayOrFuture("2026-08-01T00:00:00Z")).toBe(true); expect(isTodayOrFuture("2026-08-01T00:00:00Z", today())).toBe(true);
}); });
it("judges the day by Oslo wall clock, not UTC", () => { it("judges the day by Oslo wall clock, not UTC", () => {
// 22:30Z on the 6th is 00:30 on the 7th ("today") in Oslo // 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 { import {
isToday, isBefore,
isAfter,
parse, parse,
compareAsc, compareAsc,
addDays, addDays,
@@ -8,7 +7,7 @@ import {
startOfDay, startOfDay,
} from "date-fns"; } from "date-fns";
import { nb } from "date-fns/locale"; 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"; import { unique } from "./common";
const timeZone = "Europe/Oslo"; const timeZone = "Europe/Oslo";
@@ -20,19 +19,26 @@ export function toLocalTime(date: Date | string | number) {
return toZonedTime(date, timeZone); return toZonedTime(date, timeZone);
} }
// Start of the current Oslo day, regardless of the server's TZ // The Oslo calendar day ("yyyy-MM-dd") at the given instant, regardless of
export function startOfTodayInOslo() { // the server's TZ. Rendering code must not call this without a cache scope:
return startOfDay(toLocalTime(new Date())); // 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) { export function formatDate(date: Date | string | number, formatStr: string) {
return format(toLocalTime(date), formatStr, { timeZone, locale: nb }); 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 // full name of month if year is current year, otherwise name of month + year
const parsed = parse(yearMonth, "yyyy-MM", new Date()); const parsed = parse(yearMonth, "yyyy-MM", new Date(0));
if (parsed.getFullYear() === new Date().getFullYear()) { if (parsed.getFullYear() === today.getFullYear()) {
return formatDate(parsed, "MMMM"); return formatDate(parsed, "MMMM");
} }
return formatDate(parsed, "MMMM yyyy"); return formatDate(parsed, "MMMM yyyy");
@@ -40,6 +46,7 @@ export function formatYearMonth(yearMonth: string) {
export function formatExtendedDateTime( export function formatExtendedDateTime(
date: Date | string | number, date: Date | string | number,
today: Date,
dateOnly: boolean = false, dateOnly: boolean = false,
alwaysIncludeYear: boolean = false alwaysIncludeYear: boolean = false
) { ) {
@@ -47,21 +54,15 @@ export function formatExtendedDateTime(
// year included if not current year // year included if not current year
const parsed = toLocalTime(date); const parsed = toLocalTime(date);
const timePart = dateOnly ? "" : " 'kl.' HH:mm"; 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 yearPart = (!isCurrentYear || alwaysIncludeYear) ? " yyyy" : "";
const formatStr = `EEEE d. MMMM${yearPart}${timePart}`; const formatStr = `EEEE d. MMMM${yearPart}${timePart}`;
const formatted = format(parsed, formatStr, { timeZone, locale: nb }); const formatted = format(parsed, formatStr, { timeZone, locale: nb });
return formatted; return formatted;
} }
export function isTodayOrFuture( export function isTodayOrFuture(date: Date | string | number, today: Date) {
date: Date | string | number, return !isBefore(toLocalTime(date), today);
timeZone = "UTC"
) {
const now = new Date();
const zonedNow = toZonedTime(now, timeZone);
const zonedDate = toLocalTime(date);
return isToday(zonedDate) || isAfter(zonedDate, zonedNow);
} }
export function compareDates(a: Date | string, b: Date | string) { export function compareDates(a: Date | string, b: Date | string) {
@@ -124,11 +125,11 @@ export function formatOccurrenceMonths(starts: string[]): string {
return groups return groups
.map((g) => { .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) { if (g.length === 1) {
return formatDate(first, "MMMM yyyy"); 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 = const firstFmt =
first.getFullYear() === last.getFullYear() first.getFullYear() === last.getFullYear()
? formatDate(first, "MMMM") ? formatDate(first, "MMMM")
+6 -2
View File
@@ -9,6 +9,10 @@ import {
organizeEventsInCalendar, organizeEventsInCalendar,
sortSingularEvents, sortSingularEvents,
} from "./event.ts"; } 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 // "now" is tirsdag 2026-07-07 12:00 in Oslo
beforeEach(() => { beforeEach(() => {
@@ -180,7 +184,7 @@ describe("getFutureOccurrences", () => {
"2026-07-07T18:00:00Z", "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-07T18:00:00Z",
"2026-07-14T18:00:00Z", "2026-07-14T18:00:00Z",
]); ]);
@@ -197,7 +201,7 @@ describe("getFutureOccurrences", () => {
"2026-07-08T18:00:00Z", "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", "2026-07-08T18:00:00Z",
]); ]);
} finally { } finally {
+4 -5
View File
@@ -10,7 +10,6 @@ import {
toLocalTime, toLocalTime,
formatDate, formatDate,
compareDates, compareDates,
startOfTodayInOslo,
} from "./date"; } from "./date";
import { graphql, unmaskFragment } from "@/gql"; import { graphql, unmaskFragment } from "@/gql";
import { import {
@@ -20,7 +19,7 @@ import {
type EventOrganizerFragment, type EventOrganizerFragment,
type EventOverviewItemFragment, type EventOverviewItemFragment,
} from "@/gql/graphql"; } 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 EventOccurrence = EventFragment["occurrences"][number];
export type EventListItemOccurrence = EventListItemFragment["occurrences"][number]; export type EventListItemOccurrence = EventListItemFragment["occurrences"][number];
@@ -353,9 +352,9 @@ export function organizeEventsByDate<E extends EventListable>(
} }
export function getFutureOccurrences<E extends EventListable>( export function getFutureOccurrences<E extends EventListable>(
event: E event: E,
today: Date
): E["occurrences"][number][] { ): E["occurrences"][number][] {
const today = startOfTodayInOslo();
const occurrences = event?.occurrences ?? []; const occurrences = event?.occurrences ?? [];
const futureOccurrences = occurrences.filter((occurrence) => const futureOccurrences = occurrences.filter((occurrence) =>
isAfter(toLocalTime(occurrence.start), today) isAfter(toLocalTime(occurrence.start), today)
@@ -381,7 +380,7 @@ export function getEventPig(event: EventFragment): string | null {
const categoryPigs = categories const categoryPigs = categories
?.map((category) => category.pig) ?.map((category) => category.pig)
.filter((pig) => PIG_NAMES.includes(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 typeof chosenPig === "string" ? chosenPig : null;
} }
return 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"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// sever the server-only @/app/client import chain; the network functions are not under test // 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 { import type {
OpeningHoursRangeBlockFragment, OpeningHoursRangeBlockFragment,
@@ -13,6 +13,10 @@ import {
getTodaysOpeningHoursForFunction, getTodaysOpeningHoursForFunction,
groupOpeningHours, groupOpeningHours,
} from "./openinghours.ts"; } from "./openinghours.ts";
import { osloDay, startOfOsloDay } from "./date.ts";
// what getToday()/useToday() hand to the helpers
const today = () => startOfOsloDay(osloDay());
const range = ( const range = (
timeFrom: string | null, timeFrom: string | null,
@@ -138,28 +142,28 @@ describe("getTodaysOpeningHoursForFunction", () => {
it("returns today's hours", () => { it("returns today's hours", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); // tirsdag 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" "16:00—23:00"
); );
}); });
it("returns Stengt for a null day", () => { it("returns Stengt for a null day", () => {
vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe( expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"Stengt" "Stengt"
); );
}); });
it("returns the custom text when set", () => { it("returns the custom text when set", () => {
vi.setSystemTime(new Date("2026-07-12T10:00:00Z")); // søndag 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" "Kun ved arrangement"
); );
}); });
it("returns ? for unknown functions", () => { it("returns ? for unknown functions", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); 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", () => { it("uses the Oslo weekday when the server runs in UTC", () => {
@@ -168,12 +172,12 @@ describe("getTodaysOpeningHoursForFunction", () => {
try { try {
// 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST) // 22:20 UTC on torsdag is 00:20 fredag in Oslo (CEST)
vi.setSystemTime(new Date("2026-07-09T22:20:00Z")); vi.setSystemTime(new Date("2026-07-09T22:20:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe( expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"15:00—01:00" "15:00—01:00"
); );
// 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET) // 23:20 UTC on torsdag is 00:20 fredag in Oslo (CET)
vi.setSystemTime(new Date("2026-01-08T23:20:00Z")); vi.setSystemTime(new Date("2026-01-08T23:20:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe( expect(getTodaysOpeningHoursForFunction(openingHours, "bar", today())).toBe(
"15:00—01:00" "15:00—01:00"
); );
} finally { } finally {
+10 -13
View File
@@ -1,8 +1,6 @@
import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns"; import { compareDesc, getISODay, isAfter, isSameDay, parseISO } from "date-fns";
import { getClient } from "@/app/client"; import { cmsQuery } from "@/app/client";
import { startOfTodayInOslo } from "@/lib/date";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { graphql, unmaskFragment } from "@/gql"; import { graphql, unmaskFragment } from "@/gql";
import type { import type {
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock, OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
@@ -45,19 +43,17 @@ const openingHoursQuery = graphql(`
`); `);
export async function fetchOpeningHoursSets() { export async function fetchOpeningHoursSets() {
const { data, error } = await getClient().query( // The footer shows these on every page; a failed query must not take the
openingHoursQuery, // whole site down with it
{}, const data = await cmsQuery(openingHoursQuery, {}).catch((error) => {
// The footer renders today's hours on every page, so this TTL doubles as console.error("Failed to fetch opening hours", error);
// the sitewide midnight refresh for futureEvents rollover return undefined;
cachedUntilOsloMidnight() });
);
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[]; const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
return sets; return sets;
} }
export async function getOpeningHours() { export async function getOpeningHours(today: Date) {
const today = startOfTodayInOslo();
const sets = await fetchOpeningHoursSets(); const sets = await fetchOpeningHoursSets();
const validSets = sets const validSets = sets
.filter((set) => { .filter((set) => {
@@ -202,12 +198,13 @@ export function getPrettyOpeningHoursForFunction(
export function getTodaysOpeningHoursForFunction( export function getTodaysOpeningHoursForFunction(
openingHours: OpeningHoursSet, openingHours: OpeningHoursSet,
name: string, name: string,
today: Date,
): string { ): string {
const week: any = getOpeningHoursForFunction(openingHours, name); const week: any = getOpeningHoursForFunction(openingHours, name);
if (!week) { if (!week) {
return "?"; return "?";
} }
const weekdayIndex = getISODay(startOfTodayInOslo()) - 1; const weekdayIndex = getISODay(today) - 1;
const weekday = WEEKDAYS[weekdayIndex]; const weekday = WEEKDAYS[weekdayIndex];
const hours = week[weekday]; const hours = week[weekday];
if (hours?.timeFrom && hours?.timeTo) { if (hours?.timeFrom && hours?.timeTo) {
+8 -32
View File
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { secondsUntilOsloMidnight } from "./revalidation.ts";
cachedUntilOsloMidnight,
secondsUntilOsloMidnight,
} from "./revalidation.ts";
describe("secondsUntilOsloMidnight", () => { describe("secondsUntilOsloMidnight", () => {
it("is 12h from midnight at 12:00 CEST on a normal day", () => { 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); 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"); 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)", () => { it("handles the 25h DST fall-back day (2026-10-25)", () => {
@@ -26,29 +28,3 @@ describe("secondsUntilOsloMidnight", () => {
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600); 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 { 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"; import type { OperationContext } from "@urql/core";
const timeZone = "Europe/Oslo"; 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"; 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 { export function secondsUntilOsloMidnight(now: Date = new Date()): number {
const osloNow = toZonedTime(now, timeZone); const osloNow = toZonedTime(now, timeZone);
const nextOsloMidnight = startOfDay(addDays(osloNow, 1)); const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
const instant = fromZonedTime(nextOsloMidnight, timeZone); const instant = fromZonedTime(nextOsloMidnight, timeZone);
return Math.max( return Math.ceil((instant.getTime() - now.getTime()) / 1000);
Math.ceil((instant.getTime() - now.getTime()) / 1000),
MIN_REVALIDATE_SECONDS
);
} }
// Per-operation contexts replace (not merge with) the client-default // For per-user (search) and token-scoped (preview) queries, which must
// fetchOptions, so both contexts below are self-contained // never be stored
// For per-user (search) and token-scoped (preview) queries
export const uncached: Partial<OperationContext> = { export const uncached: Partial<OperationContext> = {
fetchOptions: { cache: "no-store" }, 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, AllAssociationsQuery,
AllGenericSlugsQuery, AllGenericSlugsQuery,
AllVenueSlugsQuery, AllVenueSlugsQuery,
LatestPageSlugQuery,
AssociationBySlugQuery, AssociationBySlugQuery,
AssociationFragment, AssociationFragment,
AssociationIndexFragment, AssociationIndexFragment,
@@ -53,6 +54,8 @@ import type {
export type MockState = { export type MockState = {
newsHeadlineVersion: "v1" | "v2"; newsHeadlineVersion: "v1" | "v2";
// how long the CMS takes to answer newsBySlug, to hold a page render open
newsDelayMs?: number;
}; };
export const SLUGS = { export const SLUGS = {
@@ -60,6 +63,8 @@ export const SLUGS = {
association: "testforening", association: "testforening",
venue: "storsalen", venue: "storsalen",
news: "test-artikkel", news: "test-artikkel",
// exists in the CMS but is not prerendered: rendered on first request
newsOnDemand: "eldre-artikkel",
event: "testkonsert", event: "testkonsert",
} as const; } as const;
@@ -98,7 +103,7 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
__typename: "EventCategory", __typename: "EventCategory",
name: "Konsert", name: "Konsert",
slug: "konsert", slug: "konsert",
pig: "PIG_PINK", pig: "music",
showInFilters: true, showInFilters: true,
} satisfies EventCategoryFragment; } satisfies EventCategoryFragment;
@@ -158,7 +163,8 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
seoTitle: "Testkonsert", seoTitle: "Testkonsert",
searchDescription: null, searchDescription: null,
lead: "Bli med på testkonsert.", lead: "Bli med på testkonsert.",
pig: "PIG_PINK", // picks a pig from the event's categories, which must be prerenderable
pig: "automatic",
facebookUrl: null, facebookUrl: null,
ticketUrl: null, ticketUrl: null,
free: true, free: true,
@@ -344,6 +350,15 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
({ ({
pages: [{ id: "30", slug: SLUGS.venue }], pages: [{ id: "30", slug: SLUGS.venue }],
}) satisfies AllVenueSlugsQuery, }) 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 --- // --- build time: static pages ---
home: (_vars, state) => home: (_vars, state) =>
@@ -412,7 +427,10 @@ export function makeFixtures(cmsBaseUrl: string): Fixtures {
// --- runtime --- // --- runtime ---
newsBySlug: (vars, state) => 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, }) satisfies NewsBySlugQuery,
eventBySlug: (vars) => eventBySlug: (vars) =>
({ ({
+2
View File
@@ -79,6 +79,8 @@ export default async function setup(project: TestProject) {
); );
}); });
mock.markBuildDone();
if (mock.graphqlRequests === 0) { if (mock.graphqlRequests === 0) {
throw new Error( throw new Error(
"next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?" "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; url: string;
state: MockState; state: MockState;
graphqlRequests: number; 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>; close(): Promise<void>;
}; };
@@ -34,11 +40,16 @@ function readBody(req: http.IncomingMessage): Promise<string> {
export async function startMockCms(): Promise<MockCms> { export async function startMockCms(): Promise<MockCms> {
const state: MockState = { newsHeadlineVersion: "v1" }; const state: MockState = { newsHeadlineVersion: "v1" };
let fixtures: Fixtures | null = null; // built once we know our own URL let fixtures: Fixtures | null = null; // built once we know our own URL
let requestsAtBuild: Record<string, number> = {};
const mock: MockCms = { const mock: MockCms = {
url: "", url: "",
state, state,
graphqlRequests: 0, graphqlRequests: 0,
requestsByOperation: {},
markBuildDone: () => {
requestsAtBuild = { ...mock.requestsByOperation };
},
close: () => close: () =>
new Promise((resolve, reject) => new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve()))
@@ -56,6 +67,20 @@ export async function startMockCms(): Promise<MockCms> {
return; 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") { if (req.method === "POST" && req.url === "/__mock/state") {
Object.assign(state, JSON.parse(await readBody(req))); Object.assign(state, JSON.parse(await readBody(req)));
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
@@ -84,6 +109,10 @@ export async function startMockCms(): Promise<MockCms> {
const name = const name =
body.operationName ?? body.operationName ??
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1]; /(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
if (name) {
mock.requestsByOperation[name] =
(mock.requestsByOperation[name] ?? 0) + 1;
}
const resolve = name ? fixtures?.[name] : undefined; const resolve = name ? fixtures?.[name] : undefined;
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
if (!resolve) { if (!resolve) {
@@ -96,7 +125,14 @@ export async function startMockCms(): Promise<MockCms> {
); );
return; 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; return;
} }
+142 -10
View File
@@ -1,12 +1,12 @@
// HTTP smoke tests against the built standalone app (see global-setup.ts). // 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: // These cover the surfaces most likely to break on a Next.js upgrade:
// prerendered and request-rendered routes, 404s, the events API, the image // 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. // 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 app = (path: string) => `${inject("appUrl")}${path}`;
const mock = (path: string) => `${inject("mockUrl")}${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() }; 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"), { const res = await fetch(mock("/__mock/state"), {
method: "POST", method: "POST",
body: JSON.stringify({ newsHeadlineVersion: version }), body: JSON.stringify(state),
}); });
expect(res.status).toBe(200); 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", () => { describe("pages", () => {
test.each([ test.each([
["/", "Testkonsert"], ["/", "Testkonsert"],
@@ -32,7 +64,7 @@ describe("pages", () => {
[`/${SLUGS.genericUrl}`, "Praktisk info"], [`/${SLUGS.genericUrl}`, "Praktisk info"],
[`/foreninger/${SLUGS.association}`, "Testforeningen"], [`/foreninger/${SLUGS.association}`, "Testforeningen"],
[`/lokaler/${SLUGS.venue}`, "Storsalen"], [`/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"], [`/aktuelt/${SLUGS.news}`, "Testartikkel"],
[`/arrangementer/${SLUGS.event}`, "Testkonsert"], [`/arrangementer/${SLUGS.event}`, "Testkonsert"],
])("%s renders and contains %s", async (path, needle) => { ])("%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", () => { describe("events api", () => {
test("rejects a missing view parameter", async () => { test("rejects a missing view parameter", async () => {
const res = await fetch(app("/api/v1/events")); const res = await fetch(app("/api/v1/events"));
@@ -84,15 +133,18 @@ describe("image optimizer", () => {
}); });
}); });
describe("fetch cache and tag revalidation", () => { // Runs for a page prerendered at build time and for one rendered on its
const path = `/aktuelt/${SLUGS.news}`; // 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 () => { beforeAll(async () => {
await setNewsVersion("v1"); await setNewsVersion("v1");
}); });
test("serves cached content until the webhook fires, then updates", async () => { 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); const first = await getPage(path);
expect(first.status).toBe(200); expect(first.status).toBe(200);
expect(first.html).toContain("Testartikkel (v1)"); 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", () => { describe("preview", () => {
test("draft mode round trip renders the preview page", async () => { test("draft mode round trip renders the preview page", async () => {
const enable = await fetch( const enable = await fetch(