@anthusai/papyrus 1.0.0-next.9 → 1.1.0

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.
Files changed (222) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/redirect-urls.js +54 -0
  4. package/amplify/auth/redirect-urls.ts +66 -0
  5. package/amplify/auth/resource.js +23 -5
  6. package/amplify/auth/resource.ts +31 -5
  7. package/amplify/data/resource.js +42 -80
  8. package/amplify/data/resource.ts +42 -80
  9. package/amplify/data/schema.js +45 -62
  10. package/amplify/data/schema.ts +47 -62
  11. package/amplify/functions/category-action/handler.ts +5 -3
  12. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  13. package/amplify/functions/console-chat-responder/resource.js +0 -7
  14. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  15. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  16. package/amplify/functions/content-actions/handler.py +143 -1
  17. package/amplify/functions/content-actions/requirements.txt +2 -1
  18. package/amplify/functions/content-actions/resource.js +6 -1
  19. package/amplify/functions/content-actions/resource.ts +9 -2
  20. package/amplify/functions/email-submission-processor/handler.py +3 -1
  21. package/amplify/functions/renditions/handler.py +120 -0
  22. package/amplify/functions/renditions/requirements.txt +4 -0
  23. package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
  24. package/amplify/functions/shared/email-submission.ts +109 -1
  25. package/amplify/functions/shared/python-bundle.js +42 -2
  26. package/amplify/functions/shared/python-bundle.ts +47 -2
  27. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  28. package/amplify/inbound-email/ses-intake.js +86 -0
  29. package/amplify/inbound-email/ses-intake.ts +110 -0
  30. package/amplify/plugins/registry.js +19 -0
  31. package/amplify/plugins/registry.ts +23 -0
  32. package/amplify/plugins/signup/function/handler.py +200 -0
  33. package/amplify/plugins/signup/function/resource.js +27 -0
  34. package/amplify/plugins/signup/function/resource.ts +35 -0
  35. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  36. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  37. package/amplify/plugins/signup/schema.js +40 -0
  38. package/amplify/plugins/signup/schema.ts +41 -0
  39. package/amplify/plugins/signup/waf.js +48 -0
  40. package/amplify/plugins/signup/waf.ts +51 -0
  41. package/amplify/plugins/types.js +1 -0
  42. package/amplify/plugins/types.ts +39 -0
  43. package/amplify/plugins/wire.js +24 -0
  44. package/amplify/plugins/wire.ts +26 -0
  45. package/amplify/publication/default-publication.js +18 -0
  46. package/amplify/publication/default-publication.ts +20 -0
  47. package/amplify/publication/invalidation-alarm.js +26 -0
  48. package/amplify/publication/invalidation-alarm.ts +30 -0
  49. package/amplify/publication/redirect-and-index.js +37 -0
  50. package/amplify/publication/renditions-function.js +74 -0
  51. package/amplify/publication/renditions-function.ts +112 -0
  52. package/amplify/publication/renditions-settings.js +51 -0
  53. package/amplify/publication/renditions-settings.ts +58 -0
  54. package/amplify/publication/reproject-on-deploy.js +73 -0
  55. package/amplify/publication/reproject-on-deploy.ts +78 -0
  56. package/amplify/publication/s3-projection-settings.js +67 -0
  57. package/amplify/publication/s3-projection-settings.ts +101 -0
  58. package/amplify/publication/s3-projection.js +151 -0
  59. package/amplify/publication/s3-projection.ts +201 -0
  60. package/amplify/resolver-waves.js +26 -0
  61. package/amplify/resolver-waves.ts +34 -0
  62. package/amplify/revalidate-invalidation-settings.js +30 -0
  63. package/amplify/revalidate-invalidation-settings.ts +35 -0
  64. package/amplify/revalidate-secret-policy.js +13 -0
  65. package/amplify/revalidate-secret-policy.ts +19 -0
  66. package/amplify/site-backend-config.js +65 -1
  67. package/amplify/site-backend-config.ts +153 -2
  68. package/amplify/site-backend-names.js +103 -0
  69. package/amplify/site-backend-names.ts +166 -0
  70. package/amplify/site-backend.js +150 -176
  71. package/amplify/site-backend.ts +189 -201
  72. package/amplify/storage-backups/construct.js +31 -0
  73. package/amplify/storage-backups/construct.ts +47 -0
  74. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  75. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  76. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  77. package/app/[year]/[month]/[day]/page.tsx +1 -1
  78. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  79. package/app/api/archive/editions/route.ts +1 -1
  80. package/app/api/auth/prospectus/callback/route.ts +9 -0
  81. package/app/api/auth/prospectus/selection/route.ts +9 -0
  82. package/app/api/auth/prospectus/session/route.ts +9 -0
  83. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  84. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  85. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  86. package/app/api/media/[...path]/route.ts +5 -16
  87. package/app/api/revalidate/route.ts +15 -7
  88. package/app/archive/page.tsx +1 -1
  89. package/app/articles/[slug]/page.tsx +7 -5
  90. package/app/blog-rhythm.css +278 -0
  91. package/app/cyclotron/page.tsx +13 -0
  92. package/app/globals.css +2 -0
  93. package/app/layout.tsx +16 -0
  94. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  95. package/app/newsroom/[section]/page.tsx +6 -3
  96. package/app/newsroom/layout.tsx +4 -9
  97. package/app/newsroom/settings/page.tsx +6 -0
  98. package/app/newsroom/work/page.tsx +10 -0
  99. package/app/page.tsx +22 -8
  100. package/app/robots.ts +2 -5
  101. package/app/tailwind.css +1 -6
  102. package/components/amplify-client-provider.tsx +5 -22
  103. package/components/archive-shell.tsx +2 -22
  104. package/components/article-video.tsx +152 -5
  105. package/components/blog-page-background.tsx +23 -6
  106. package/components/news-desk-page.tsx +4 -15
  107. package/components/news-desk-taxonomy-client.ts +90 -3
  108. package/components/newspaper.tsx +6 -2
  109. package/components/newsroom-access-gate.tsx +2 -1
  110. package/components/newsroom-board-view.tsx +99 -0
  111. package/components/newsroom-client-shell.tsx +12 -0
  112. package/components/newsroom-curation-provider.tsx +17 -0
  113. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  115. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  118. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  119. package/components/newsroom-newsprint-board.css +48 -0
  120. package/components/newsroom-ops-overview.tsx +28 -11
  121. package/components/newsroom-ops-shell.tsx +7 -1
  122. package/components/newsroom-organizer-shell.tsx +130 -0
  123. package/components/newsroom-organizer.css +63 -0
  124. package/components/newsroom-references-view.tsx +92 -8
  125. package/components/papyrus-console-shell.tsx +4 -2
  126. package/components/pictogram-figure.tsx +9 -2
  127. package/components/presentation-header.tsx +135 -0
  128. package/components/presentation-rhythm-hrule.tsx +17 -0
  129. package/components/priming-proof-page.tsx +94 -0
  130. package/components/publication-cyclotron-panel.tsx +57 -0
  131. package/components/reader-auth-control.tsx +8 -5
  132. package/components/reference-curation-controls.tsx +77 -0
  133. package/components/reference-cyclotron-status.tsx +109 -0
  134. package/components/reference-priming.tsx +18 -0
  135. package/components/reference-relevance-review.tsx +75 -0
  136. package/components/settings-page.tsx +2 -1
  137. package/components/signup-entries-panel.tsx +94 -0
  138. package/components/topic-steering-workspace.tsx +78 -8
  139. package/components/use-blog-header-obstacles.ts +81 -0
  140. package/components/use-brand-fonts-ready.ts +28 -0
  141. package/components/use-near-viewport.ts +30 -0
  142. package/components/use-rhythm-overlay.ts +29 -0
  143. package/infra/amplify-app-shell.js +166 -27
  144. package/infra/amplify-app-shell.ts +180 -31
  145. package/infra/build-specs.js +23 -7
  146. package/infra/build-specs.ts +27 -10
  147. package/infra/site-config.js +248 -36
  148. package/infra/site-config.ts +313 -51
  149. package/lib/amplify-outputs-path.ts +5 -1
  150. package/lib/amplify-server-runtime.ts +36 -1
  151. package/lib/approved-priming-proof-reports.ts +6 -0
  152. package/lib/blog-feature-solver.ts +459 -0
  153. package/lib/blog-rhythm.ts +134 -0
  154. package/lib/brand-fonts-watch.ts +43 -0
  155. package/lib/brand-fonts.ts +112 -0
  156. package/lib/cached-content-repository.ts +12 -0
  157. package/lib/category-repository.ts +43 -93
  158. package/lib/content-item-lifecycle.ts +55 -0
  159. package/lib/content-repository.ts +3 -0
  160. package/lib/content-types.ts +4 -0
  161. package/lib/curation-client.ts +61 -0
  162. package/lib/edition-routes.ts +31 -29
  163. package/lib/graphql-content-repository.ts +56 -22
  164. package/lib/media-route-response.ts +45 -0
  165. package/lib/newspaper-layout.ts +3 -2
  166. package/lib/newsroom-base-path.ts +12 -3
  167. package/lib/newsroom-board.ts +99 -0
  168. package/lib/newsroom-category-drilldown.ts +3 -2
  169. package/lib/newsroom-forum-routes.ts +6 -4
  170. package/lib/newsroom-index-filters.ts +14 -10
  171. package/lib/newsroom-nav.ts +3 -1
  172. package/lib/newsroom-organizer-nav.ts +37 -0
  173. package/lib/newsroom-section-routing.ts +20 -0
  174. package/lib/oauth-redirect-priority.ts +40 -0
  175. package/lib/papyrus-web-locations.ts +7 -6
  176. package/lib/plugin-settings.ts +11 -0
  177. package/lib/presentation-footer.ts +3 -2
  178. package/lib/pretext-layout.ts +12 -3
  179. package/lib/priming-proof-report.ts +90 -0
  180. package/lib/prospectus-publication-access.ts +53 -0
  181. package/lib/prospectus-session.ts +196 -0
  182. package/lib/prospectus-sign-in-routes.ts +284 -0
  183. package/lib/prospectus-site-identity.ts +303 -0
  184. package/lib/publication-cyclotron.ts +74 -0
  185. package/lib/rate-limit-retry.ts +55 -0
  186. package/lib/reader-base-path.ts +24 -0
  187. package/lib/reader-cache-lifetime.ts +5 -0
  188. package/lib/reader-static-params.ts +85 -4
  189. package/lib/reader-storage-url.ts +9 -12
  190. package/lib/reference-policy.ts +5 -0
  191. package/lib/relevance-decisions.ts +103 -0
  192. package/lib/relevance-status.ts +63 -0
  193. package/lib/renderer.ts +7 -0
  194. package/lib/revalidate-prewarm.ts +53 -0
  195. package/lib/revalidate-secret.ts +59 -0
  196. package/lib/robots-policy.ts +14 -0
  197. package/lib/root-route-redirect.ts +16 -0
  198. package/lib/semantic-graph.ts +7 -0
  199. package/lib/shared-credentials-provider.ts +61 -0
  200. package/lib/signup-csv.ts +24 -0
  201. package/lib/site-brand.ts +59 -0
  202. package/lib/staging-gated-path.ts +23 -0
  203. package/lib/staging-preview-object.ts +21 -5
  204. package/lib/test-editor-override.ts +4 -0
  205. package/lib/video-loading.ts +22 -0
  206. package/lib/video-script.ts +14 -0
  207. package/middleware.ts +11 -19
  208. package/package.json +73 -8
  209. package/renderers/pretext/article-page.tsx +39 -3
  210. package/renderers/pretext/presentation-shared.tsx +113 -0
  211. package/renderers/pretext/presentation-shell.tsx +15 -102
  212. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  213. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  214. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  215. package/routes.manifest.json +123 -12
  216. package/src/sync.mjs +30 -6
  217. package/src/with-papyrus.d.mts +9 -0
  218. package/src/with-papyrus.mjs +48 -3
  219. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  220. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  221. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  222. package/components/ai-elements/message.tsx +0 -360
@@ -0,0 +1,55 @@
1
+ export const RATE_LIMIT_RETRY_ATTEMPTS = 14;
2
+ export const RATE_LIMIT_RETRY_BASE_MILLISECONDS = 500;
3
+ export const RATE_LIMIT_RETRY_CAP_MILLISECONDS = 20_000;
4
+ export const RATE_LIMIT_RETRY_BUDGET_MILLISECONDS = 120_000;
5
+
6
+ export function isRateLimitError(error: unknown): boolean {
7
+ if (!error || typeof error !== "object") return false;
8
+ const { name, message } = error as { name?: unknown; message?: unknown };
9
+ const text = `${typeof name === "string" ? name : ""} ${typeof message === "string" ? message : ""}`;
10
+ return /TooManyRequests|ThrottlingException|Rate exceeded|Unauthorized[\s\S]*set the \\?"authMode\\?"/i.test(text);
11
+ }
12
+
13
+ export type RateLimitRetryOptions = {
14
+ attempts?: number;
15
+ baseMilliseconds?: number;
16
+ capMilliseconds?: number;
17
+ budgetMilliseconds?: number;
18
+ sleep?: (milliseconds: number) => Promise<void>;
19
+ random?: () => number;
20
+ now?: () => number;
21
+ };
22
+
23
+ export function fullJitterDelayMilliseconds(
24
+ attempt: number,
25
+ baseMilliseconds: number,
26
+ capMilliseconds: number,
27
+ random: () => number,
28
+ ): number {
29
+ const ceiling = Math.min(capMilliseconds, baseMilliseconds * 2 ** (attempt - 1));
30
+ return random() * ceiling;
31
+ }
32
+
33
+ export async function withRateLimitRetry<T>(
34
+ operation: () => Promise<T>,
35
+ options: RateLimitRetryOptions = {},
36
+ ): Promise<T> {
37
+ const attempts = options.attempts ?? RATE_LIMIT_RETRY_ATTEMPTS;
38
+ const baseMilliseconds = options.baseMilliseconds ?? RATE_LIMIT_RETRY_BASE_MILLISECONDS;
39
+ const capMilliseconds = options.capMilliseconds ?? RATE_LIMIT_RETRY_CAP_MILLISECONDS;
40
+ const budgetMilliseconds = options.budgetMilliseconds ?? RATE_LIMIT_RETRY_BUDGET_MILLISECONDS;
41
+ const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
42
+ const random = options.random ?? Math.random;
43
+ const now = options.now ?? Date.now;
44
+ const startedAt = now();
45
+ for (let attempt = 1; ; attempt += 1) {
46
+ try {
47
+ return await operation();
48
+ } catch (error) {
49
+ if (attempt >= attempts || !isRateLimitError(error)) throw error;
50
+ const delay = fullJitterDelayMilliseconds(attempt, baseMilliseconds, capMilliseconds, random);
51
+ if (now() - startedAt + delay > budgetMilliseconds) throw error;
52
+ await sleep(delay);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,24 @@
1
+ import { SITE_BRAND } from "./site-brand";
2
+
3
+ export function normalizeReaderBasePath(value: string | undefined | null): string {
4
+ const trimmed = (value ?? "").trim().replace(/\/+$/, "");
5
+ if (!trimmed) return "";
6
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
7
+ }
8
+
9
+ export function getReaderBasePath(): string {
10
+ return normalizeReaderBasePath(SITE_BRAND.readerBasePath);
11
+ }
12
+
13
+ export function toPublicReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
14
+ if (!readerBasePath) return path;
15
+ if (path === "/" || path === "") return readerBasePath;
16
+ return `${readerBasePath}${path.startsWith("/") ? path : `/${path}`}`;
17
+ }
18
+
19
+ export function toInternalReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
20
+ if (!readerBasePath) return path;
21
+ if (path === readerBasePath || path === `${readerBasePath}/`) return "/";
22
+ if (path.startsWith(`${readerBasePath}/`)) return path.slice(readerBasePath.length);
23
+ return path;
24
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Seconds a Pretext reader page may be served from a shared cache (Next sends `s-maxage` equal to its `revalidate`).
3
+ * Next needs a literal in each route's `export const revalidate`, so scripts/test-reader-cache-lifetime.ts keeps them equal to this.
4
+ */
5
+ export const READER_PAGE_REVALIDATE_SECONDS = 60;
@@ -1,4 +1,16 @@
1
+ import type { ContentRepository, EditionContent } from "./content-types";
1
2
  import { graphqlContentRepository } from "./graphql-content-repository";
3
+ import { SITE_BRAND } from "./site-brand";
4
+ import { getContentSource } from "./site-env";
5
+ import type { RendererConfig } from "./renderer-config";
6
+
7
+ export function siteEnumeratesReaderRoutesAtBuild(): boolean {
8
+ return siteServesNextReaderRoutes() && getContentSource() !== "drafts";
9
+ }
10
+
11
+ export function siteServesNextReaderRoutes(rendererKind: RendererConfig["kind"] = SITE_BRAND.renderer.kind): boolean {
12
+ return rendererKind !== "markus";
13
+ }
2
14
 
3
15
  const MONTH_NAMES = [
4
16
  "january",
@@ -21,10 +33,23 @@ type EditionDateStaticParam = {
21
33
  day: string;
22
34
  };
23
35
 
24
- export async function generateEditionDateStaticParams(): Promise<EditionDateStaticParam[]> {
25
- if (process.env.NODE_ENV === "development") 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">;
26
41
 
27
- const { editions } = await graphqlContentRepository.listPublishedEditions({ limit: 100 });
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 [];
51
+
52
+ const { editions } = await repository.listPublishedEditions({ limit: PUBLISHED_EDITION_ENUMERATION_LIMIT });
28
53
  const params: EditionDateStaticParam[] = [];
29
54
  for (const edition of editions) {
30
55
  const route = editionDateToStaticParam(edition.editionDate);
@@ -33,8 +58,64 @@ export async function generateEditionDateStaticParams(): Promise<EditionDateStat
33
58
  return params;
34
59
  }
35
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
+
36
116
  export async function generateArticleStaticParams(): Promise<Array<{ slug: string }>> {
37
117
  if (process.env.NODE_ENV === "development") return [];
118
+ if (!siteEnumeratesReaderRoutesAtBuild()) return [];
38
119
 
39
120
  const slugs = await graphqlContentRepository.listArticleSlugs();
40
121
  return slugs.map((slug) => ({ slug }));
@@ -51,6 +132,6 @@ function editionDateToStaticParam(editionDate: string): EditionDateStaticParam |
51
132
  return {
52
133
  year: match[1],
53
134
  month: monthName,
54
- day: String(Number(match[3])),
135
+ day: match[3],
55
136
  };
56
137
  }
@@ -1,5 +1,5 @@
1
1
  import { getUrl } from "aws-amplify/storage/server";
2
- import { getAmplifyServerRuntime } from "./amplify-server-runtime";
2
+ import { runWithGuestAmplifyContext } from "./amplify-server-runtime";
3
3
 
4
4
  const STORAGE_URL_EXPIRES_IN_SECONDS = 60 * 60;
5
5
  const STORAGE_URL_CACHE_TTL_MS = 55 * 60 * 1000;
@@ -43,17 +43,14 @@ export async function signStorageUrl(storagePath: string): Promise<string> {
43
43
  return cached.url;
44
44
  }
45
45
 
46
- const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
47
- const result = await runWithAmplifyServerContext({
48
- nextServerContext: null,
49
- operation: (contextSpec) =>
50
- getUrl(contextSpec, {
51
- path: normalized,
52
- options: {
53
- expiresIn: STORAGE_URL_EXPIRES_IN_SECONDS,
54
- },
55
- }),
56
- });
46
+ const result = await runWithGuestAmplifyContext((contextSpec) =>
47
+ getUrl(contextSpec, {
48
+ path: normalized,
49
+ options: {
50
+ expiresIn: STORAGE_URL_EXPIRES_IN_SECONDS,
51
+ },
52
+ }),
53
+ );
57
54
  const url = result.url.toString();
58
55
  signedUrlCache.set(normalized, {
59
56
  expiresAt: Date.now() + STORAGE_URL_CACHE_TTL_MS,
@@ -17,6 +17,11 @@ export type ReferenceRejectionReasonCode = typeof REFERENCE_REJECTION_REASON_COD
17
17
  const REFERENCE_REJECTION_REASON_CODE_SET = new Set<string>(REFERENCE_REJECTION_REASON_CODES);
18
18
  const SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET = new Set<string>(["out_of_scope", "policy_exclusion"]);
19
19
 
20
+ /** Rejection reasons that say the source is not relevant (scope-training negatives). */
21
+ export function isScopeTrainingNegativeReason(code: string): boolean {
22
+ return SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(code);
23
+ }
24
+
20
25
  export function normalizeReferenceCurationStatus(value: unknown, defaultStatus: ReferenceCurationStatus = "pending"): ReferenceCurationStatus {
21
26
  const normalized = normalizePolicyToken(value ?? defaultStatus);
22
27
  if (normalized === "accepted" || normalized === "accept" || normalized === "ready" || normalized === "trusted") return "accepted";
@@ -0,0 +1,103 @@
1
+ import type { SemanticRelationRecord } from "./category-repository";
2
+
3
+ export const RELEVANCE_RELATION = "relevance_decision_is";
4
+
5
+ /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
6
+ export type RelevanceDecisionView = {
7
+ decisionRelationId: string;
8
+ decisionId: string;
9
+ label: string;
10
+ confidence: number | null;
11
+ version: number | null;
12
+ /** True when the cyclotron sent this decision to review. */
13
+ reviewRecommended: boolean;
14
+ reviewReason: "program" | "audit" | null;
15
+ reviewDetail: string | null;
16
+ classes: string[];
17
+ positiveLabel: string | null;
18
+ question: string | null;
19
+ /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
20
+ reviewControl: "thumbs" | "labels";
21
+ cyclotronId: string | null;
22
+ };
23
+
24
+ /** What an editor submits from the review control. */
25
+ export type RelevanceReview = {
26
+ label: string | null;
27
+ reasonCode: string | null;
28
+ explanation: string | null;
29
+ shareable: boolean;
30
+ };
31
+
32
+ /** The existing curation action an editor's review becomes. */
33
+ export type RelevanceCuration = {
34
+ action: "accept" | "reject";
35
+ reasonCode: string | null;
36
+ note: string | null;
37
+ decisionRelationId: string;
38
+ shareable: boolean;
39
+ };
40
+
41
+ function parseMetadata(value: unknown): Record<string, unknown> {
42
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
43
+ if (typeof value !== "string") return {};
44
+ try {
45
+ const parsed = JSON.parse(value);
46
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Current decisions keyed by reference lineage, from relations the Newsroom
54
+ * already loads: no extra request per row.
55
+ */
56
+ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
57
+ const decisions = new Map<string, RelevanceDecisionView>();
58
+ for (const relation of relations) {
59
+ if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current") continue;
60
+ const metadata = parseMetadata(relation.metadata);
61
+ const probabilities = parseMetadata(metadata.probabilities);
62
+ const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
63
+ const version = Number(relation.modelVersion ?? metadata.version);
64
+ decisions.set(relation.subjectLineageId, {
65
+ decisionRelationId: relation.id,
66
+ decisionId: String(metadata.decisionId ?? ""),
67
+ label: String(metadata.label ?? ""),
68
+ confidence: typeof relation.confidence === "number" ? relation.confidence : null,
69
+ version: Number.isFinite(version) ? version : null,
70
+ reviewRecommended: Boolean(relation.reviewRecommended),
71
+ reviewReason: reason,
72
+ reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
73
+ classes: Array.isArray(metadata.labels) && metadata.labels.length
74
+ ? metadata.labels.map(String)
75
+ : Object.keys(probabilities),
76
+ positiveLabel: typeof metadata.positiveLabel === "string" ? metadata.positiveLabel : null,
77
+ question: typeof metadata.question === "string" ? metadata.question : null,
78
+ reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
79
+ cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
80
+ });
81
+ }
82
+ return decisions;
83
+ }
84
+
85
+ /**
86
+ * Map a review to the existing curation mutation. The positive label accepts;
87
+ * any other label, or a review without a label, rejects with the reason code,
88
+ * which the existing scope-training rule turns back into a cyclotron label
89
+ * (out_of_scope and policy_exclusion) or none.
90
+ */
91
+ export function curationForRelevanceReview(decision: RelevanceDecisionView, review: RelevanceReview): RelevanceCuration {
92
+ const accepted = review.label !== null && review.label === decision.positiveLabel;
93
+ if (!accepted && !review.reasonCode) {
94
+ throw new Error("A review that does not accept the reference needs a reason code.");
95
+ }
96
+ return {
97
+ action: accepted ? "accept" : "reject",
98
+ reasonCode: accepted ? null : review.reasonCode,
99
+ note: review.explanation?.trim() || null,
100
+ decisionRelationId: decision.decisionRelationId,
101
+ shareable: review.shareable,
102
+ };
103
+ }
@@ -0,0 +1,63 @@
1
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
2
+ import { isCyclotronStatus } from "cyclotron/cyclotron-status";
3
+
4
+ /** The KnowledgeRawPayload the decide-relevance sweep writes the status snapshot to. */
5
+ export function cyclotronStatusPayloadId(cyclotronId: string): string {
6
+ return `knowledge-raw-payload-cyclotron-status-${safeId(cyclotronId)}`;
7
+ }
8
+
9
+ /** The KnowledgeRawPayload an editor writes to ask for a manual review rate. */
10
+ export function reviewRateRequestPayloadId(cyclotronId: string): string {
11
+ return `knowledge-raw-payload-cyclotron-review-rate-request-${safeId(cyclotronId)}`;
12
+ }
13
+
14
+ /** Mirrors Python's ids.safe_id so both sides name the same record. */
15
+ export function safeId(value: string): string {
16
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 140);
17
+ }
18
+
19
+ export type ReviewRateRequest =
20
+ | { rate: number; expiresAt: string; setBy: string; requestedAt: string }
21
+ | { clear: true; setBy: string; requestedAt: string };
22
+
23
+ export function buildReviewRateRequest(
24
+ choice: { rate: number; days: number } | { clear: true },
25
+ setBy: string,
26
+ now: Date = new Date(),
27
+ ): ReviewRateRequest {
28
+ const requestedAt = now.toISOString();
29
+ if ("clear" in choice) return { clear: true, setBy, requestedAt };
30
+ if (!(choice.rate >= 0 && choice.rate <= 1)) throw new Error("A review rate is between 0% and 100%.");
31
+ if (!(choice.days >= 1)) throw new Error("A manual rate lasts at least one day.");
32
+ const expiresAt = new Date(now.getTime() + choice.days * 24 * 60 * 60 * 1000).toISOString();
33
+ return { rate: choice.rate, expiresAt, setBy, requestedAt };
34
+ }
35
+
36
+ /** The newest valid snapshot among a payload's attachments. */
37
+ export function latestCyclotronStatus(payloads: Array<{ json: unknown; updatedAt?: string | null }>): CyclotronStatus | null {
38
+ const valid = payloads
39
+ .filter((payload) => isCyclotronStatus(payload.json))
40
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
41
+ return valid.length ? (valid[0].json as CyclotronStatus) : null;
42
+ }
43
+
44
+ /** What demo mode shows: an onboarding cyclotron that has made one decision. */
45
+ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
46
+ schema: "cyclotron-status/v1",
47
+ cyclotron: { id: "papyrus-relevance", classifier: "relevant", version: 1, refits: 0, fingerprint: "demo" },
48
+ asOf: "2026-05-16T12:00:00.000Z",
49
+ alignment: {
50
+ window: 200, labels: 0, accuracy: null, precision: null, recall: null,
51
+ positiveLabel: "include", measuredOn: "reviews selected by the cyclotron",
52
+ },
53
+ calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
54
+ reviewRate: {
55
+ state: "onboarding", rate: 1, reason: "Onboarding: every decision is reviewed until the cyclotron earns a lower rate.",
56
+ auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
57
+ nextCheckAfterDecisions: 99,
58
+ nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a calibration gap under 5 points. Next check in 99 decisions.",
59
+ expectedReviewsPerWeek: 1,
60
+ },
61
+ lastChange: null,
62
+ pending: { decisionsAwaitingReview: 1, staleSince: null },
63
+ };
package/lib/renderer.ts CHANGED
@@ -4,6 +4,7 @@ import type { EditionContent } from "./content-types";
4
4
  import type { PretextLayout } from "./renderer-config";
5
5
  import type { PresentationFooterEntry } from "./presentation-footer";
6
6
  import type { PublicationItem } from "./publication-items";
7
+ import type { VideoScriptRef } from "./video-script";
7
8
 
8
9
  /**
9
10
  * Renderer is the swappable render pipeline that turns standard content
@@ -36,6 +37,8 @@ export type RenderEditionProps = {
36
37
  export type ArticlePageEditionFooter = {
37
38
  editionBasePath: string;
38
39
  entries: PresentationFooterEntry[];
40
+ /** Section links for the rhythm blog layout's page header. Ignored by the classic layout. */
41
+ sections?: Array<{ key: string; label: string }>;
39
42
  subtitle: string;
40
43
  title?: string;
41
44
  };
@@ -46,6 +49,8 @@ export type RenderArticleProps = {
46
49
  backLabel?: string;
47
50
  editionFooter?: ArticlePageEditionFooter;
48
51
  editionDate?: string;
52
+ /** VideoML script for the preview player. */
53
+ videoScript?: VideoScriptRef | null;
49
54
  };
50
55
 
51
56
  export type RenderItemProps = {
@@ -54,6 +59,8 @@ export type RenderItemProps = {
54
59
  backLabel?: string;
55
60
  editionFooter?: ArticlePageEditionFooter;
56
61
  editionDate?: string;
62
+ /** VideoML script for the preview player. */
63
+ videoScript?: VideoScriptRef | null;
57
64
  };
58
65
 
59
66
  /**
@@ -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,59 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
3
+
4
+ export const REVALIDATE_SECRET_PARAMETER_ENV = "PAPYRUS_REVALIDATE_SECRET_PARAMETER";
5
+ export const REVALIDATE_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
6
+
7
+ export type RevalidateSecretReader = (parameterName: string) => Promise<string | null>;
8
+
9
+ type CachedSecret = { parameterName: string; value: string; expiresAt: number };
10
+
11
+ let cachedSecret: CachedSecret | null = null;
12
+ let defaultSsmClient: SSMClient | null = null;
13
+
14
+ export function resetRevalidateSecretCache(): void {
15
+ cachedSecret = null;
16
+ }
17
+
18
+ async function readSecretFromSsm(parameterName: string): Promise<string | null> {
19
+ defaultSsmClient ??= new SSMClient({});
20
+ const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
21
+ const value = response.Parameter?.Value?.trim();
22
+ return value ? value : null;
23
+ }
24
+
25
+ export async function loadRevalidateSecret(
26
+ options: { environment?: Record<string, string | undefined>; readSecret?: RevalidateSecretReader; now?: number } = {},
27
+ ): Promise<string | null> {
28
+ const environment = options.environment ?? process.env;
29
+ const parameterName = environment[REVALIDATE_SECRET_PARAMETER_ENV]?.trim();
30
+ if (!parameterName) return null;
31
+ const now = options.now ?? Date.now();
32
+ if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) {
33
+ return cachedSecret.value;
34
+ }
35
+ try {
36
+ const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
37
+ if (!value) return null;
38
+ cachedSecret = { parameterName, value, expiresAt: now + REVALIDATE_SECRET_CACHE_MILLISECONDS };
39
+ return value;
40
+ } catch (error) {
41
+ console.error(`Revalidate secret parameter could not be read (${error instanceof Error ? error.name : "error"}).`);
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function secretsMatch(provided: string | null | undefined, expected: string): boolean {
47
+ const providedBytes = Buffer.from((provided ?? "").trim());
48
+ const expectedBytes = Buffer.from(expected);
49
+ return providedBytes.length === expectedBytes.length && timingSafeEqual(providedBytes, expectedBytes);
50
+ }
51
+
52
+ export async function isAuthorizedRevalidateRequest(
53
+ providedSecret: string | null | undefined,
54
+ options: Parameters<typeof loadRevalidateSecret>[0] = {},
55
+ ): Promise<boolean> {
56
+ const expected = await loadRevalidateSecret(options);
57
+ if (!expected) return false;
58
+ return secretsMatch(providedSecret, expected);
59
+ }
@@ -0,0 +1,14 @@
1
+ import type { MetadataRoute } from "next";
2
+ import { getRootRoute, getSiteBrand } from "./site-brand";
3
+ import { isIndexable } from "./site-env";
4
+
5
+ const DISALLOW_ALL: MetadataRoute.Robots = { rules: { userAgent: "*", disallow: "/" } };
6
+ const ALLOW_ALL: MetadataRoute.Robots = { rules: { userAgent: "*", allow: "/" } };
7
+
8
+ export function buildRobots(
9
+ environment: Record<string, string | undefined> = process.env,
10
+ brand = getSiteBrand(),
11
+ ): MetadataRoute.Robots {
12
+ if (getRootRoute(brand).kind === "newsroom") return DISALLOW_ALL;
13
+ return isIndexable(environment) ? ALLOW_ALL : DISALLOW_ALL;
14
+ }
@@ -0,0 +1,16 @@
1
+ export type RootSearchParams = Record<string, string | string[] | undefined>;
2
+
3
+ export function buildRootRedirectTarget(destination: string, searchParams: RootSearchParams | undefined): string {
4
+ const query = new URLSearchParams();
5
+ for (const [name, value] of Object.entries(searchParams ?? {})) {
6
+ if (Array.isArray(value)) {
7
+ for (const entry of value) query.append(name, entry);
8
+ } else if (value !== undefined) {
9
+ query.append(name, value);
10
+ }
11
+ }
12
+ const serialized = query.toString();
13
+ if (!serialized) return destination;
14
+ const separator = destination.includes("?") ? "&" : "?";
15
+ return `${destination}${separator}${serialized}`;
16
+ }
@@ -1,3 +1,4 @@
1
+ import { toPublicNewsroomPath } from "./newsroom-base-path";
1
2
  import type {
2
3
  AssignmentRecord,
3
4
  CategorySteeringCategory,
@@ -31,6 +32,7 @@ export type SemanticObjectKind = typeof SEMANTIC_OBJECT_KINDS[number];
31
32
  export type SemanticPredicateId =
32
33
  | "classified_as"
33
34
  | "quality_rating_is"
35
+ | "relevance_decision_is"
34
36
  | "reference_summary_100_tokens"
35
37
  | "reference_summary_200_tokens"
36
38
  | "reference_summary_500_tokens"
@@ -79,6 +81,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
79
81
  { id: "about", label: "about", group: "commentary", inverseLabel: "commentary" },
80
82
  { id: "comment", label: "comments on", group: "commentary", inverseLabel: "commented on by", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
81
83
  { id: "ingestion_rationale", label: "ingestion rationale for", group: "commentary", inverseLabel: "ingestion rationale", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
84
+ { id: "relevance_decision_is", label: "relevance decision is", group: "workflow", inverseLabel: "relevance decision for", contextPackTags: ["reference_curation"] },
82
85
  { id: "requests_work_on", label: "requests work on", group: "workflow", inverseLabel: "requested work", contextPackTags: ["assignment_context", "editing"] },
83
86
  { id: "uses_evidence", label: "uses evidence", group: "evidence", inverseLabel: "used as evidence by", contextPackTags: ["assignment_context", "research", "reference_graph"] },
84
87
  { id: "uses_signal", label: "uses signal", group: "evidence", inverseLabel: "signal for", contextPackTags: ["assignment_context", "research", "reference_graph"] },
@@ -175,6 +178,10 @@ export function predicateLabel(predicate: string, direction: "outgoing" | "incom
175
178
  }
176
179
 
177
180
  export function newsDeskHrefForSemanticObject(kind: string, lineageId: string): string {
181
+ return toPublicNewsroomPath(internalNewsDeskHrefForSemanticObject(kind, lineageId));
182
+ }
183
+
184
+ function internalNewsDeskHrefForSemanticObject(kind: string, lineageId: string): string {
178
185
  const encoded = encodeURIComponent(lineageId);
179
186
  if (kind === "reference") return `/newsroom/references/${encoded}`;
180
187
  if (kind === "category") return `/newsroom/topics?category=${encoded}`;