@anthusai/papyrus 1.0.0-next.51 → 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> {
@@ -7,6 +7,8 @@ import { resolveThemedVideoSrc } from "../lib/themed-image";
7
7
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
8
8
  import { getSiteBrand } from "../lib/site-brand";
9
9
  import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
10
+ import { useNearViewport } from "./use-near-viewport";
11
+ import { planVideoLoading, videoElementKey } from "../lib/video-loading";
10
12
  import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
11
13
 
12
14
  type ArticleVideoFigureProps = {
@@ -18,6 +20,7 @@ type ArticleVideoFigureProps = {
18
20
  };
19
21
 
20
22
  const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
23
+ const VIDEO_LOAD_ROOT_MARGIN = "200px 0px";
21
24
 
22
25
  export function ArticleVideoFigure({
23
26
  video,
@@ -34,6 +37,8 @@ export function ArticleVideoFigure({
34
37
  const [previewReady, setPreviewReady] = useState(false);
35
38
  const iframeRef = useRef<HTMLIFrameElement>(null);
36
39
  const videoRef = useRef<HTMLVideoElement>(null);
40
+ const figureRef = useRef<HTMLElement>(null);
41
+ const isNearViewport = useNearViewport(figureRef, VIDEO_LOAD_ROOT_MARGIN);
37
42
  const isFramed = getSiteBrand().videoPlayer === "framed";
38
43
 
39
44
  useEffect(() => {
@@ -44,6 +49,12 @@ export function ArticleVideoFigure({
44
49
  // Match SSR to the default dark MP4 in `video.src` until after hydration.
45
50
  const resolvedTheme = hasHydrated ? theme : "dark";
46
51
  const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
52
+ const loadingPlan = planVideoLoading({
53
+ hasHydrated,
54
+ isNearViewport,
55
+ hasPoster: Boolean(video.posterSrc),
56
+ });
57
+ const videoKey = videoElementKey(loadingPlan.attachSource, src);
47
58
  const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
48
59
  const previewDsl = videoScript?.dsl
49
60
  ? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
@@ -191,6 +202,7 @@ export function ArticleVideoFigure({
191
202
  if (isFramed) {
192
203
  return (
193
204
  <figure
205
+ ref={figureRef}
194
206
  className={`${figureClassName} article-video--framed`}
195
207
  data-media-type="video"
196
208
  data-video-theme={resolvedTheme}
@@ -200,11 +212,11 @@ export function ArticleVideoFigure({
200
212
  <video
201
213
  ref={videoRef}
202
214
  playsInline
203
- preload="metadata"
215
+ preload={loadingPlan.preload}
204
216
  poster={video.posterSrc}
205
217
  aria-label={video.alt}
206
218
  className="article-video__player"
207
- key={hasHydrated ? src : "ssr"}
219
+ key={videoKey}
208
220
  onPlay={() => setIsPlaying(true)}
209
221
  onPause={() => setIsPlaying(false)}
210
222
  onEnded={() => setIsPlaying(false)}
@@ -214,7 +226,7 @@ export function ArticleVideoFigure({
214
226
  if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
215
227
  }}
216
228
  >
217
- <source src={src} type="video/mp4" />
229
+ {loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
218
230
  </video>
219
231
  </div>
220
232
  {framedControls}
@@ -226,17 +238,17 @@ export function ArticleVideoFigure({
226
238
  }
227
239
 
228
240
  return (
229
- <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
241
+ <figure ref={figureRef} className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
230
242
  <video
231
243
  controls
232
244
  playsInline
233
- preload="metadata"
245
+ preload={loadingPlan.preload}
234
246
  poster={video.posterSrc}
235
247
  aria-label={video.alt}
236
248
  className="article-video__player"
237
- key={hasHydrated ? src : "ssr"}
249
+ key={videoKey}
238
250
  >
239
- <source src={src} type="video/mp4" />
251
+ {loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
240
252
  </video>
241
253
  {video.caption ? <figcaption>{video.caption}</figcaption> : null}
242
254
  {video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type RefObject } from "react";
4
+
5
+ export function useNearViewport(targetRef: RefObject<Element | null>, rootMargin: string): boolean {
6
+ const [isNearViewport, setIsNearViewport] = useState(false);
7
+
8
+ useEffect(() => {
9
+ if (isNearViewport) return;
10
+ const target = targetRef.current;
11
+ if (!target) return;
12
+ if (typeof IntersectionObserver === "undefined") {
13
+ setIsNearViewport(true);
14
+ return;
15
+ }
16
+ const observer = new IntersectionObserver(
17
+ (entries) => {
18
+ if (entries.some((entry) => entry.isIntersecting)) {
19
+ setIsNearViewport(true);
20
+ observer.disconnect();
21
+ }
22
+ },
23
+ { rootMargin },
24
+ );
25
+ observer.observe(target);
26
+ return () => observer.disconnect();
27
+ }, [isNearViewport, rootMargin, targetRef]);
28
+
29
+ return isNearViewport;
30
+ }
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ export type VideoPreload = "none" | "metadata";
2
+
3
+ export type VideoLoadingPlan = {
4
+ attachSource: boolean;
5
+ preload: VideoPreload;
6
+ };
7
+
8
+ export type VideoLoadingInput = {
9
+ hasHydrated: boolean;
10
+ isNearViewport: boolean;
11
+ hasPoster: boolean;
12
+ };
13
+
14
+ export function planVideoLoading({ hasHydrated, isNearViewport, hasPoster }: VideoLoadingInput): VideoLoadingPlan {
15
+ const attachSource = hasHydrated && isNearViewport;
16
+ const preload: VideoPreload = attachSource && !hasPoster ? "metadata" : "none";
17
+ return { attachSource, preload };
18
+ }
19
+
20
+ export function videoElementKey(attachSource: boolean, resolvedSource: string): string {
21
+ return attachSource ? resolvedSource : "unattached";
22
+ }
package/package.json CHANGED
@@ -127,6 +127,7 @@
127
127
  "./components/ui/tooltip": "./components/ui/tooltip.tsx",
128
128
  "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
129
129
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
130
+ "./components/use-near-viewport": "./components/use-near-viewport.ts",
130
131
  "./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
131
132
  "./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
132
133
  "./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
@@ -209,6 +210,7 @@
209
210
  "./lib/renderer-config": "./lib/renderer-config.ts",
210
211
  "./lib/renderer": "./lib/renderer.ts",
211
212
  "./lib/reporting-story-budget": "./lib/reporting-story-budget.ts",
213
+ "./lib/revalidate-prewarm": "./lib/revalidate-prewarm.ts",
212
214
  "./lib/revalidate-secret": "./lib/revalidate-secret.ts",
213
215
  "./lib/robots-policy": "./lib/robots-policy.ts",
214
216
  "./lib/root-route-redirect": "./lib/root-route-redirect.ts",
@@ -224,6 +226,7 @@
224
226
  "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
225
227
  "./lib/themed-image": "./lib/themed-image.ts",
226
228
  "./lib/utils": "./lib/utils.ts",
229
+ "./lib/video-loading": "./lib/video-loading.ts",
227
230
  "./lib/video-mode": "./lib/video-mode.ts",
228
231
  "./lib/video-script": "./lib/video-script.ts",
229
232
  "./renderers/markus/index": "./renderers/markus/index.ts",
@@ -306,7 +309,7 @@
306
309
  "optional": true
307
310
  }
308
311
  },
309
- "version": "1.0.0-next.51",
312
+ "version": "1.0.0-next.53",
310
313
  "dependencies": {
311
314
  "@aws-amplify/adapter-nextjs": "^1.7.3",
312
315
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.51",
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
  }