@anthusai/papyrus 1.0.0-next.52 → 1.0.0-next.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,14 @@ import { getCachedEditionItem, loadCachedEditionContent } from "../../../../../l
4
4
  import { getEditionDatePath, parseEditionArticleRoute } from "../../../../../lib/edition-routes";
5
5
  import { buildPresentationFooterEntries } from "../../../../../lib/presentation-footer";
6
6
  import { SITE_BRAND } from "../../../../../lib/site-brand";
7
+ import { generateEditionItemStaticParams } from "../../../../../lib/reader-static-params";
7
8
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
8
9
  export const revalidate = 60;
9
10
 
11
+ export async function generateStaticParams() {
12
+ return generateEditionItemStaticParams();
13
+ }
14
+
10
15
  type DateScopedArticlePageProps = {
11
16
  params: Promise<{
12
17
  year: string;
@@ -2,9 +2,14 @@ import { notFound, redirect } from "next/navigation";
2
2
  import { EditionRoutePage } from "../../edition-route-page";
3
3
  import { parseEditionPageRoute } from "../../../../../../lib/edition-routes";
4
4
  import { getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
5
+ import { generateEditionPageStaticParams } from "../../../../../../lib/reader-static-params";
5
6
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
6
7
  export const revalidate = 60;
7
8
 
9
+ export async function generateStaticParams() {
10
+ return generateEditionPageStaticParams();
11
+ }
12
+
8
13
  type EditionPageRouteProps = {
9
14
  params: Promise<{
10
15
  year: string;
@@ -2,9 +2,14 @@ import { notFound, redirect } from "next/navigation";
2
2
  import { EditionRoutePage } from "../../edition-route-page";
3
3
  import { createSectionKey } from "../../../../../../lib/edition-sections";
4
4
  import { parseEditionSectionRoute } from "../../../../../../lib/edition-routes";
5
+ import { generateEditionSectionStaticParams } from "../../../../../../lib/reader-static-params";
5
6
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
6
7
  export const revalidate = 60;
7
8
 
9
+ export async function generateStaticParams() {
10
+ return generateEditionSectionStaticParams();
11
+ }
12
+
8
13
  type EditionSectionRouteProps = {
9
14
  params: Promise<{
10
15
  year: string;
@@ -10,6 +10,7 @@ import {
10
10
  editionItemCacheTag,
11
11
  } from "../../../lib/cached-content-repository";
12
12
  import { isAuthorizedRevalidateRequest } from "../../../lib/revalidate-secret";
13
+ import { prewarmPaths, resolvePrewarmOrigin } from "../../../lib/revalidate-prewarm";
13
14
  import { getEditionArticlePath, getEditionDatePath } from "../../../lib/edition-routes";
14
15
 
15
16
  export const dynamic = "force-dynamic";
@@ -20,6 +21,7 @@ type RevalidateRequestBody = {
20
21
  itemSlugs?: string[];
21
22
  paths?: string[];
22
23
  tags?: string[];
24
+ prewarm?: boolean;
23
25
  };
24
26
 
25
27
  export async function POST(request: Request) {
@@ -87,9 +89,14 @@ export async function POST(request: Request) {
87
89
  revalidatedPaths.add(normalized);
88
90
  }
89
91
 
92
+ const prewarmed = body.prewarm === false
93
+ ? []
94
+ : await prewarmPaths(resolvePrewarmOrigin(request.url), [...revalidatedPaths]);
95
+
90
96
  return NextResponse.json({
91
97
  ok: true,
92
98
  revalidatedTags: [...revalidatedTags],
93
99
  revalidatedPaths: [...revalidatedPaths],
100
+ prewarmed,
94
101
  });
95
102
  }
package/app/page.tsx CHANGED
@@ -2,6 +2,7 @@ import { getSiteRenderer } from "../lib/site-renderer";
2
2
  import { contentRepository, getScenarioIdParam } from "../lib/content-repository";
3
3
  import type { EditionContent } from "../lib/content-types";
4
4
  import { createEditionSectionPlan } from "../lib/edition-sections";
5
+ import { getCachedLatestPublishedEdition, loadCachedEditionContent } from "../lib/cached-content-repository";
5
6
  import { getEditionDatePath } from "../lib/edition-routes";
6
7
  import {
7
8
  EMPTY_EDITION_PLACEHOLDER_SLUG,
@@ -77,7 +78,7 @@ export default async function Home({ searchParams }: HomePageProps) {
77
78
  );
78
79
  }
79
80
 
80
- const latestEdition = await contentRepository.getLatestPublishedEdition();
81
+ const latestEdition = await getCachedLatestPublishedEdition();
81
82
  if (latestEdition) redirect(getEditionDatePath(latestEdition.editionDate));
82
83
  return <siteRenderer.renderEdition content={createEmptyGraphQLEdition()} />;
83
84
  }
@@ -88,9 +89,9 @@ export default async function Home({ searchParams }: HomePageProps) {
88
89
  }
89
90
 
90
91
  async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
91
- const latestEdition = await contentRepository.getLatestPublishedEdition();
92
+ const latestEdition = await getCachedLatestPublishedEdition();
92
93
  if (!latestEdition) return null;
93
- return contentRepository.loadEditionContent({ editionDate: latestEdition.editionDate });
94
+ return loadCachedEditionContent({ editionDate: latestEdition.editionDate });
94
95
  }
95
96
 
96
97
  async function loadFirstPublishedEditionPath(): Promise<string | undefined> {
@@ -1,3 +1,4 @@
1
+ import type { ContentRepository, EditionContent } from "./content-types";
1
2
  import { graphqlContentRepository } from "./graphql-content-repository";
2
3
  import { SITE_BRAND } from "./site-brand";
3
4
  import { getContentSource } from "./site-env";
@@ -32,11 +33,23 @@ type EditionDateStaticParam = {
32
33
  day: string;
33
34
  };
34
35
 
35
- export async function generateEditionDateStaticParams(): Promise<EditionDateStaticParam[]> {
36
- if (process.env.NODE_ENV === "development") return [];
37
- if (!siteEnumeratesReaderRoutesAtBuild()) return [];
36
+ const PUBLISHED_EDITION_ENUMERATION_LIMIT = 100;
37
+ const DEEP_ROUTE_EDITION_LIMIT = 10;
38
+ const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
39
+
40
+ type EnumerationRepository = Pick<ContentRepository, "listPublishedEditions" | "loadEditionContent">;
41
+
42
+ function shouldEnumerateAtBuild(): boolean {
43
+ if (process.env.NODE_ENV === "development") return false;
44
+ return siteEnumeratesReaderRoutesAtBuild();
45
+ }
46
+
47
+ export async function generateEditionDateStaticParams(
48
+ repository: EnumerationRepository = graphqlContentRepository,
49
+ ): Promise<EditionDateStaticParam[]> {
50
+ if (!shouldEnumerateAtBuild()) return [];
38
51
 
39
- const { editions } = await graphqlContentRepository.listPublishedEditions({ limit: 100 });
52
+ const { editions } = await repository.listPublishedEditions({ limit: PUBLISHED_EDITION_ENUMERATION_LIMIT });
40
53
  const params: EditionDateStaticParam[] = [];
41
54
  for (const edition of editions) {
42
55
  const route = editionDateToStaticParam(edition.editionDate);
@@ -45,6 +58,61 @@ export async function generateEditionDateStaticParams(): Promise<EditionDateStat
45
58
  return params;
46
59
  }
47
60
 
61
+ async function loadRecentEditionContents(repository: EnumerationRepository): Promise<EditionContent[]> {
62
+ const { editions } = await repository.listPublishedEditions({ limit: PUBLISHED_EDITION_ENUMERATION_LIMIT });
63
+ const recent = [...editions]
64
+ .sort((left, right) => right.editionDate.localeCompare(left.editionDate))
65
+ .slice(0, DEEP_ROUTE_EDITION_LIMIT);
66
+ return Promise.all(recent.map((edition) => repository.loadEditionContent({ editionDate: edition.editionDate })));
67
+ }
68
+
69
+ export async function generateEditionItemStaticParams(
70
+ repository: EnumerationRepository = graphqlContentRepository,
71
+ ): Promise<Array<EditionDateStaticParam & { articleSlug: string }>> {
72
+ if (!shouldEnumerateAtBuild()) return [];
73
+
74
+ const params: Array<EditionDateStaticParam & { articleSlug: string }> = [];
75
+ for (const content of await loadRecentEditionContents(repository)) {
76
+ const date = editionDateToStaticParam(content.editionDate);
77
+ if (!date) continue;
78
+ for (const item of content.items) {
79
+ if (RESERVED_DATE_CHILD_SEGMENTS.has(item.slug.toLowerCase())) continue;
80
+ params.push({ ...date, articleSlug: item.slug });
81
+ }
82
+ }
83
+ return params;
84
+ }
85
+
86
+ export async function generateEditionPageStaticParams(
87
+ repository: EnumerationRepository = graphqlContentRepository,
88
+ ): Promise<Array<EditionDateStaticParam & { pageNumber: string }>> {
89
+ if (!shouldEnumerateAtBuild()) return [];
90
+
91
+ const params: Array<EditionDateStaticParam & { pageNumber: string }> = [];
92
+ for (const content of await loadRecentEditionContents(repository)) {
93
+ const date = editionDateToStaticParam(content.editionDate);
94
+ if (!date) continue;
95
+ for (let pageNumber = 2; pageNumber <= content.layoutPlan.pages.length; pageNumber += 1) {
96
+ params.push({ ...date, pageNumber: String(pageNumber) });
97
+ }
98
+ }
99
+ return params;
100
+ }
101
+
102
+ export async function generateEditionSectionStaticParams(
103
+ repository: EnumerationRepository = graphqlContentRepository,
104
+ ): Promise<Array<EditionDateStaticParam & { sectionKey: string }>> {
105
+ if (!shouldEnumerateAtBuild()) return [];
106
+
107
+ const params: Array<EditionDateStaticParam & { sectionKey: string }> = [];
108
+ for (const content of await loadRecentEditionContents(repository)) {
109
+ const date = editionDateToStaticParam(content.editionDate);
110
+ if (!date) continue;
111
+ for (const section of content.sections) params.push({ ...date, sectionKey: section.key });
112
+ }
113
+ return params;
114
+ }
115
+
48
116
  export async function generateArticleStaticParams(): Promise<Array<{ slug: string }>> {
49
117
  if (process.env.NODE_ENV === "development") return [];
50
118
  if (!siteEnumeratesReaderRoutesAtBuild()) return [];
@@ -64,6 +132,6 @@ function editionDateToStaticParam(editionDate: string): EditionDateStaticParam |
64
132
  return {
65
133
  year: match[1],
66
134
  month: monthName,
67
- day: String(Number(match[3])),
135
+ day: match[3],
68
136
  };
69
137
  }
@@ -0,0 +1,53 @@
1
+ export const PREWARM_ORIGIN_ENV = "PAPYRUS_PREWARM_ORIGIN";
2
+ export const PREWARM_CONCURRENCY = 4;
3
+ export const PREWARM_MAX_PATHS = 40;
4
+ export const PREWARM_TIMEOUT_MILLISECONDS = 25_000;
5
+ export const PREWARM_REQUEST_HEADER = "x-papyrus-prewarm";
6
+
7
+ export type PrewarmFetch = (url: string, init: { headers: Record<string, string>; signal: AbortSignal; redirect: "manual" }) => Promise<{ status: number }>;
8
+
9
+ export type PrewarmResult = {
10
+ path: string;
11
+ status: number | null;
12
+ milliseconds: number;
13
+ };
14
+
15
+ export function resolvePrewarmOrigin(requestUrl: string, environment: Record<string, string | undefined> = process.env): string {
16
+ const configured = environment[PREWARM_ORIGIN_ENV]?.trim();
17
+ if (configured) return configured.replace(/\/+$/, "");
18
+ return new URL(requestUrl).origin;
19
+ }
20
+
21
+ export async function prewarmPaths(
22
+ origin: string,
23
+ paths: string[],
24
+ options: { fetchPage?: PrewarmFetch; now?: () => number } = {},
25
+ ): Promise<PrewarmResult[]> {
26
+ const fetchPage = options.fetchPage ?? (fetch as unknown as PrewarmFetch);
27
+ const now = options.now ?? Date.now;
28
+ const queue = [...new Set(paths)].filter((path) => path.startsWith("/")).slice(0, PREWARM_MAX_PATHS);
29
+ const results: PrewarmResult[] = [];
30
+
31
+ async function worker(): Promise<void> {
32
+ for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
33
+ const startedAt = now();
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), PREWARM_TIMEOUT_MILLISECONDS);
36
+ try {
37
+ const response = await fetchPage(`${origin}${path}`, {
38
+ headers: { [PREWARM_REQUEST_HEADER]: "1" },
39
+ signal: controller.signal,
40
+ redirect: "manual",
41
+ });
42
+ results.push({ path, status: response.status, milliseconds: now() - startedAt });
43
+ } catch {
44
+ results.push({ path, status: null, milliseconds: now() - startedAt });
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ }
50
+
51
+ await Promise.all(Array.from({ length: Math.min(PREWARM_CONCURRENCY, queue.length) }, worker));
52
+ return results;
53
+ }
package/package.json CHANGED
@@ -210,6 +210,7 @@
210
210
  "./lib/renderer-config": "./lib/renderer-config.ts",
211
211
  "./lib/renderer": "./lib/renderer.ts",
212
212
  "./lib/reporting-story-budget": "./lib/reporting-story-budget.ts",
213
+ "./lib/revalidate-prewarm": "./lib/revalidate-prewarm.ts",
213
214
  "./lib/revalidate-secret": "./lib/revalidate-secret.ts",
214
215
  "./lib/robots-policy": "./lib/robots-policy.ts",
215
216
  "./lib/root-route-redirect": "./lib/root-route-redirect.ts",
@@ -308,7 +309,7 @@
308
309
  "optional": true
309
310
  }
310
311
  },
311
- "version": "1.0.0-next.52",
312
+ "version": "1.0.0-next.53",
312
313
  "dependencies": {
313
314
  "@aws-amplify/adapter-nextjs": "^1.7.3",
314
315
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.52",
2
+ "version": "1.0.0-next.53",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -16,7 +16,8 @@
16
16
  "reader": true,
17
17
  "hasDefault": true,
18
18
  "exports": [
19
- "generateMetadata"
19
+ "generateMetadata",
20
+ "generateStaticParams"
20
21
  ],
21
22
  "config": {
22
23
  "revalidate": "60"
@@ -37,7 +38,9 @@
37
38
  "file": "app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
38
39
  "reader": true,
39
40
  "hasDefault": true,
40
- "exports": [],
41
+ "exports": [
42
+ "generateStaticParams"
43
+ ],
41
44
  "config": {
42
45
  "revalidate": "60"
43
46
  }
@@ -46,7 +49,9 @@
46
49
  "file": "app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
47
50
  "reader": true,
48
51
  "hasDefault": true,
49
- "exports": [],
52
+ "exports": [
53
+ "generateStaticParams"
54
+ ],
50
55
  "config": {
51
56
  "revalidate": "60"
52
57
  }