Bound the prerendered archive window
Build and push image / build (push) Successful in 8m18s

Prerendering every past answer page in six languages meant 700 static pages,
and the CI runner blew past Next's 60s per-page export timeout on
/puzzle/236, failing the build. The set also grew by six pages a day, so
every build would have been slower than the last.

generateStaticParams now covers the most recent 30 days per locale (202
pages, ~35s locally). Older days keep working exactly as before: they are
still linked, still in the sitemap and still indexable, but render on first
request and are then cached by ISR — measured at 180ms cold.

Also raises staticPageGenerationTimeout to 180s for headroom, since the
runner shares a host with Gitea and Postgres.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
This commit is contained in:
2026-08-26 13:04:49 +00:00
parent 0de85cbc51
commit dd9497193a
4 changed files with 23 additions and 9 deletions
+5 -5
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import PuzzleAnswers from "@/components/PuzzleAnswers";
import { ARCHIVE_COPY } from "@/lib/archive-content";
import { getArchiveDateKeys, getPastPuzzleSet, getPuzzleNumber, getTodayKey } from "@/lib/game-engine";
import { getPastPuzzleSet, getPuzzleNumber, getRecentArchiveDateKeys, getTodayKey } from "@/lib/game-engine";
import { BASE_URL, puzzleHreflang, puzzlePath } from "@/lib/seo-content";
export const revalidate = 3600;
@@ -11,11 +11,11 @@ type PuzzlePageProps = {
params: Promise<{ number: string }>;
};
// Past answers never change, so every one of them is prerendered. Days that roll
// over after the build are still served: dynamicParams stays on by default and
// they get rendered (and cached) on first request.
// Only the recent window is prerendered (see PRERENDERED_ARCHIVE_DAYS). Older
// days and days that roll over after the build are still served: dynamicParams
// stays on by default, so they render on first request and are then cached.
export function generateStaticParams() {
return getArchiveDateKeys(getTodayKey()).map((dateKey) => ({
return getRecentArchiveDateKeys(getTodayKey()).map((dateKey) => ({
number: String(getPuzzleNumber(dateKey)),
}));
}
+4 -4
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import PuzzleAnswers from "@/components/PuzzleAnswers";
import { ARCHIVE_COPY } from "@/lib/archive-content";
import { getArchiveDateKeys, getPastPuzzleSet, getPuzzleNumber, getTodayKey } from "@/lib/game-engine";
import { getPastPuzzleSet, getPuzzleNumber, getRecentArchiveDateKeys, getTodayKey } from "@/lib/game-engine";
import { BASE_URL, INTL_LOCALES, puzzleHreflang, puzzlePath } from "@/lib/seo-content";
import { resolveIntlLocale } from "@/lib/intl-route";
@@ -12,10 +12,10 @@ type LocalePuzzleProps = {
params: Promise<{ locale: string; number: string }>;
};
// 5 locales x every past day. They are tiny data-only pages, and prerendering
// them is what makes the localized long tail crawlable without a cold render.
// 5 locales x the recent window (see PRERENDERED_ARCHIVE_DAYS). The whole
// archive is still crawlable — the rest is rendered on demand and cached.
export function generateStaticParams() {
const numbers = getArchiveDateKeys(getTodayKey()).map((dateKey) => String(getPuzzleNumber(dateKey)));
const numbers = getRecentArchiveDateKeys(getTodayKey()).map((dateKey) => String(getPuzzleNumber(dateKey)));
return INTL_LOCALES.flatMap((locale) => numbers.map((number) => ({ locale, number })));
}
+11
View File
@@ -55,6 +55,17 @@ export function getPastPuzzleSet(rawNumber: string): { number: number; dateKey:
return { number, dateKey, sets };
}
// How many recent days get prerendered at build time. The archive grows by one
// day forever, so an unbounded generateStaticParams would make every CI build
// slower than the last — 700 pages already blew past Next's per-page export
// timeout. Older answer pages stay fully public and indexable: they are just
// rendered on first request and then cached by ISR.
export const PRERENDERED_ARCHIVE_DAYS = 30;
export function getRecentArchiveDateKeys(todayKey: string): string[] {
return getArchiveDateKeys(todayKey).slice(0, PRERENDERED_ARCHIVE_DAYS);
}
export function getDailyPuzzles(dateKey: string): Puzzle[] {
const dated = puzzles.filter((puzzle) => puzzle.date === dateKey);
const source = dated.length === DIFFICULTIES.length ? dated : getRotatingPuzzleSet(dateKey);
+3
View File
@@ -2,6 +2,9 @@
const nextConfig = {
htmlLimitedBots: /.*/,
output: "standalone",
// The CI runner shares a host with other services, so a cold page render can
// occasionally crawl. 60s (the default) was not enough headroom.
staticPageGenerationTimeout: 180,
};
module.exports = nextConfig;