@anthusai/papyrus 0.0.0 → 1.0.0-next.1

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,155 @@
1
+ export type AmplifyAppShellSiteConfig = {
2
+ /** CDK context selector and stack id suffix. */
3
+ siteId: string;
4
+ /** Amplify app name (also the CloudFormation stack-friendly id). */
5
+ appName: string;
6
+ /** Human description shown in the Amplify console. */
7
+ description: string;
8
+ /** GitHub repo URL the CMS deploys from. */
9
+ repository: string;
10
+ /** Amplify platform. Papyrus CMS is always WEB_COMPUTE (Next.js SSR). */
11
+ platform: "WEB_COMPUTE";
12
+ /** Deploy branch. */
13
+ branchName: string;
14
+ /** Branch environment variables (PAPYRUS_SITE_BRAND etc.). */
15
+ environment: Record<string, string>;
16
+ /**
17
+ * Secrets Manager secret name holding the GitHub access token used by
18
+ * Amplify to clone the repo. CDK reads it via a dynamic reference so the
19
+ * token never lands in the CloudFormation template. Populate the secret
20
+ * before deploying. Replace the initial token with a dedicated fine-grained
21
+ * PAT scoped to this repo.
22
+ */
23
+ githubTokenSecretName: string;
24
+ /** Custom domain for the CMS (newsroom.<site>.us). Apex stays on the reader. */
25
+ domainName: string;
26
+ /** Route 53 hosted zone id for the domain. */
27
+ hostedZoneId: string;
28
+ /** Build compute type. */
29
+ buildComputeType?: "STANDARD" | "STANDARD_8GB";
30
+ /** Enable branch auto-build so pushes trigger deploys. */
31
+ enableAutoBuild?: boolean;
32
+ };
33
+
34
+ import { Construct } from "constructs";
35
+ import { Stack, StackProps, SecretValue } from "aws-cdk-lib";
36
+ import * as iam from "aws-cdk-lib/aws-iam";
37
+ import * as amplify from "aws-cdk-lib/aws-amplify";
38
+
39
+
40
+ /**
41
+ * Provisions the Amplify *app shell* for a Papyrus publication CMS:
42
+ *
43
+ * - AWS::Amplify::App (WEB_COMPUTE, GitHub repo connection via PAT in Secrets Manager)
44
+ * - AWS::Amplify::Branch (main, with PAPYRUS_SITE_BRAND env vars)
45
+ * - AWS::Amplify::Domain (newsroom.<site>.us on the site's Route 53 zone)
46
+ * - IAM service role (backend deploy) + compute role (SSR rendering)
47
+ *
48
+ * The Papyrus *backend* (AppSync, Storage, Lambda) is NOT created here. It
49
+ * deploys via `ampx pipeline-deploy` from the repo `amplify.yml` once Amplify
50
+ * CI runs a build on the branch. This stack only creates the container the
51
+ * backend deploys into, so the app is never "created manually" in the console.
52
+ *
53
+ * The GitHub access token is read from Secrets Manager by dynamic reference,
54
+ * so the token value never appears in the synthesized template.
55
+ */
56
+ export class AmplifyAppShellStack extends Stack {
57
+ public readonly appId: string;
58
+ public readonly defaultDomain: string;
59
+
60
+ constructor(scope: Construct, id: string, config: AmplifyAppShellSiteConfig, props?: StackProps) {
61
+ super(scope, id, props);
62
+
63
+ const githubToken = SecretValue.secretsManager(config.githubTokenSecretName).unsafeUnwrap();
64
+
65
+ // Service role Amplify assumes to deploy the backend (ampx pipeline-deploy
66
+ // creates AppSync, Storage, Lambda, etc., so it needs broad permissions).
67
+ const serviceRole = new iam.Role(this, "AmplifyServiceRole", {
68
+ assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
69
+ description: `Backend deploy service role for Amplify app ${config.appName}`,
70
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")],
71
+ });
72
+
73
+ // WEB_COMPUTE (Next.js SSR) requires a compute role for the rendering server.
74
+ const computeRole = new iam.Role(this, "AmplifyComputeRole", {
75
+ assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
76
+ description: `SSR compute role for Amplify app ${config.appName}`,
77
+ managedPolicies: [
78
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
79
+ iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
80
+ ],
81
+ });
82
+
83
+ const app = new amplify.CfnApp(this, "App", {
84
+ name: config.appName,
85
+ description: config.description,
86
+ platform: config.platform,
87
+ repository: config.repository,
88
+ accessToken: githubToken,
89
+ iamServiceRole: serviceRole.roleArn,
90
+ computeRoleArn: computeRole.roleArn,
91
+ buildSpec: this.buildSpec(),
92
+ customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
93
+ });
94
+
95
+ this.appId = app.attrAppId;
96
+ this.defaultDomain = `${app.attrAppId}.amplifyapp.com`;
97
+
98
+ const branch = new amplify.CfnBranch(this, "Branch", {
99
+ appId: app.attrAppId,
100
+ branchName: config.branchName,
101
+ stage: "PRODUCTION",
102
+ enableAutoBuild: config.enableAutoBuild ?? true,
103
+ enablePerformanceMode: false,
104
+ environmentVariables: Object.entries(config.environment).map(([key, value]) => ({
105
+ name: key,
106
+ value,
107
+ })),
108
+ });
109
+ branch.addResourceDependency(app);
110
+
111
+ const domain = new amplify.CfnDomain(this, "Domain", {
112
+ appId: app.attrAppId,
113
+ domainName: config.domainName,
114
+ subDomainSettings: [{ branchName: config.branchName, prefix: "" }],
115
+ });
116
+ domain.addResourceDependency(branch);
117
+
118
+ this.exportValue(app.attrAppId, { description: `Amplify app id for ${config.appName}` });
119
+ }
120
+
121
+ /**
122
+ * Build spec mirrors the repo root amplify.yml so the app deploys the full
123
+ * Papyrus backend + Next.js frontend on every build. Inlined because Amplify
124
+ * needs a buildSpec on the app for the first deploy before the repo is cloned.
125
+ */
126
+ private buildSpec(): string {
127
+ return [
128
+ "version: 1",
129
+ "backend:",
130
+ " phases:",
131
+ " build:",
132
+ " commands:",
133
+ " - npm install --cache .npm --prefer-offline",
134
+ " - |",
135
+ " if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then",
136
+ " export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true",
137
+ " fi",
138
+ " - npx ampx pipeline-deploy --debug --branch $AWS_BRANCH --app-id $AWS_APP_ID",
139
+ "frontend:",
140
+ " phases:",
141
+ " build:",
142
+ " commands:",
143
+ " - env | grep -e '^PAPYRUS_CONTENT_SOURCE=' -e '^PAPYRUS_EDITION_SLUG=' -e '^PAPYRUS_REVALIDATE_SECRET=' >> .env.production",
144
+ " - npm run build",
145
+ " artifacts:",
146
+ " baseDirectory: .next",
147
+ " files:",
148
+ " - '**/*'",
149
+ " cache:",
150
+ " paths:",
151
+ " - .next/cache/**/*",
152
+ " - .npm/**/*",
153
+ ].join("\n");
154
+ }
155
+ }
package/infra/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // aws-cdk-lib and constructs are OPTIONAL peers of @anthusai/papyrus: only a publication's infra/ package installs them.
2
+ try {
3
+ await import("aws-cdk-lib");
4
+ await import("constructs");
5
+ } catch (error) {
6
+ throw new Error(
7
+ "@anthusai/papyrus/infra needs aws-cdk-lib and constructs, which are optional peer dependencies. " +
8
+ "Install them in the package that imports this entry (your infra/ folder): npm install aws-cdk-lib constructs. " +
9
+ "(" + (error && error.code) + ")",
10
+ );
11
+ }
12
+ export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
@@ -0,0 +1,24 @@
1
+ const PRODUCTION_GRAPHQL_HOST = "64hviw";
2
+ const PRODUCTION_USER_POOL_ID = "us-east-1_40Uot7WSv";
3
+
4
+ export function isProductionAmplifyOutputs(outputs: Record<string, unknown> | null): boolean {
5
+ if (!outputs) return false;
6
+ const graphqlUrl = String((outputs.data as { url?: string } | undefined)?.url ?? "");
7
+ const userPoolId = String((outputs.auth as { user_pool_id?: string } | undefined)?.user_pool_id ?? "");
8
+ return graphqlUrl.includes(PRODUCTION_GRAPHQL_HOST) || userPoolId === PRODUCTION_USER_POOL_ID;
9
+ }
10
+
11
+ export function assertSandboxAmplifyOutputsForDev(outputs: Record<string, unknown>): void {
12
+ if (process.env.NODE_ENV === "production") return;
13
+ if (!isProductionAmplifyOutputs(outputs)) return;
14
+ const graphqlUrl = String((outputs.data as { url?: string } | undefined)?.url ?? "");
15
+ throw new Error(
16
+ [
17
+ "Papyrus dev is configured for production Amplify (amplify_outputs.json → " +
18
+ (graphqlUrl || "production AppSync") +
19
+ ").",
20
+ "Run `npm run outputs:sandbox` or restart with `npm run dev` (runs ensure-sandbox-amplify-outputs first).",
21
+ "Use ~/Projects/Papyrus-production for production deploys only.",
22
+ ].join(" "),
23
+ );
24
+ }
@@ -0,0 +1,16 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function getAmplifyOutputsPath(): string {
5
+ const explicit = process.env.PAPYRUS_AMPLIFY_OUTPUTS?.trim();
6
+ if (explicit) {
7
+ return path.isAbsolute(explicit) ? explicit : path.join(process.cwd(), explicit);
8
+ }
9
+ return path.join(process.cwd(), "amplify_outputs.json");
10
+ }
11
+
12
+ export function readAmplifyOutputsFile(): Record<string, unknown> | null {
13
+ const outputsPath = getAmplifyOutputsPath();
14
+ if (!fs.existsSync(outputsPath)) return null;
15
+ return JSON.parse(fs.readFileSync(outputsPath, "utf8")) as Record<string, unknown>;
16
+ }
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import { createServerRunner } from "@aws-amplify/adapter-nextjs";
3
+ import { Amplify, type ResourcesConfig } from "aws-amplify";
4
+ import { assertSandboxAmplifyOutputsForDev } from "./amplify-outputs-guard";
5
+ import { getAmplifyOutputsPath } from "./amplify-outputs-path";
6
+
7
+ type AmplifyServerRuntime = ReturnType<typeof createServerRunner> & {
8
+ config: ResourcesConfig;
9
+ };
10
+
11
+ let cachedRuntime: AmplifyServerRuntime | null = null;
12
+
13
+ export function getAmplifyServerRuntime(): AmplifyServerRuntime {
14
+ if (cachedRuntime) return cachedRuntime;
15
+
16
+ const config = loadAmplifyOutputs();
17
+ Amplify.configure(config, { ssr: true });
18
+ cachedRuntime = {
19
+ ...createServerRunner({ config }),
20
+ config,
21
+ };
22
+ return cachedRuntime;
23
+ }
24
+
25
+ export function hasAmplifyOutputs(): boolean {
26
+ return fs.existsSync(getAmplifyOutputsPath());
27
+ }
28
+
29
+ function loadAmplifyOutputs(): ResourcesConfig {
30
+ const outputsPath = getAmplifyOutputsPath();
31
+ if (!fs.existsSync(outputsPath)) {
32
+ throw new Error(
33
+ "Papyrus requires amplify_outputs.json for GraphQL content. Run `npm run outputs:sandbox` or `npm run sandbox` first.",
34
+ );
35
+ }
36
+
37
+ const raw = JSON.parse(fs.readFileSync(outputsPath, "utf8")) as ResourcesConfig & {
38
+ auth?: { identity_pool_id?: string; user_pool_client_id?: string; user_pool_id?: string };
39
+ };
40
+ assertSandboxAmplifyOutputsForDev(raw as Record<string, unknown>);
41
+
42
+ // Reader SSR uses Cognito identity-pool guest credentials (no expiring AppSync API keys).
43
+ const cognito = (raw as { Auth?: { Cognito?: Record<string, unknown> } }).Auth?.Cognito ?? {};
44
+ const legacyAuth = raw.auth;
45
+ return {
46
+ ...raw,
47
+ Auth: {
48
+ Cognito: {
49
+ ...cognito,
50
+ identityPoolId:
51
+ (cognito.identityPoolId as string | undefined)
52
+ ?? legacyAuth?.identity_pool_id,
53
+ userPoolClientId:
54
+ (cognito.userPoolClientId as string | undefined)
55
+ ?? legacyAuth?.user_pool_client_id,
56
+ userPoolId:
57
+ (cognito.userPoolId as string | undefined)
58
+ ?? legacyAuth?.user_pool_id,
59
+ allowGuestAccess: true,
60
+ },
61
+ },
62
+ } as ResourcesConfig;
63
+ }
@@ -0,0 +1,30 @@
1
+ import { listCachedPublishedEditions } from "./cached-content-repository";
2
+ import { getEditionDatePath } from "./edition-routes";
3
+ import { ARCHIVE_BATCH_SIZE, type ArchiveEditionsResponse } from "./archive-types";
4
+
5
+ type ArchiveBatchOptions = {
6
+ limit?: number;
7
+ nextToken?: string | null;
8
+ };
9
+
10
+ export async function loadArchiveEditionPreviews({ limit = ARCHIVE_BATCH_SIZE, nextToken }: ArchiveBatchOptions = {}): Promise<ArchiveEditionsResponse> {
11
+ const safeLimit = clampArchiveLimit(limit);
12
+ const { editions, nextToken: nextCursor } = await listCachedPublishedEditions({
13
+ limit: safeLimit,
14
+ nextToken,
15
+ });
16
+ const previews = editions.map((edition) => ({
17
+ edition,
18
+ href: getEditionDatePath(edition.editionDate),
19
+ }));
20
+
21
+ return {
22
+ previews,
23
+ nextCursor,
24
+ };
25
+ }
26
+
27
+ export function clampArchiveLimit(limit: number): number {
28
+ if (!Number.isFinite(limit)) return ARCHIVE_BATCH_SIZE;
29
+ return Math.max(1, Math.min(ARCHIVE_BATCH_SIZE, Math.floor(limit)));
30
+ }
@@ -0,0 +1,15 @@
1
+ import type { EditionRouteSummary } from "./content-types";
2
+
3
+ export const ARCHIVE_BATCH_SIZE = 12;
4
+ export const ARCHIVE_PREVIEW_WIDTH = 1280;
5
+ export const ARCHIVE_PREVIEW_HEIGHT = 900;
6
+
7
+ export type ArchiveEditionPreview = {
8
+ edition: EditionRouteSummary;
9
+ href: string;
10
+ };
11
+
12
+ export type ArchiveEditionsResponse = {
13
+ previews: ArchiveEditionPreview[];
14
+ nextCursor?: string | null;
15
+ };
@@ -0,0 +1,320 @@
1
+ export type ArticleImageLayout = {
2
+ minHeight: number;
3
+ preferredHeight: number;
4
+ maxHeight: number;
5
+ aspectRatio: number;
6
+ crop: "cover" | "contain";
7
+ wrapsText: boolean;
8
+ inlineFloat?: {
9
+ minColumnCount?: number;
10
+ columnSpan?: number;
11
+ widthRatio?: number;
12
+ narrowWidthRatio?: number;
13
+ maxWidthRatio?: number;
14
+ minWidth?: number;
15
+ };
16
+ focalPoint?: {
17
+ x: number;
18
+ y: number;
19
+ };
20
+ };
21
+
22
+ export type ArticleImageThemeVariants = {
23
+ dark?: {
24
+ src: string;
25
+ };
26
+ };
27
+
28
+ export type ArticleVideoThemeVariants = {
29
+ light?: { src: string };
30
+ dark?: { src: string };
31
+ };
32
+
33
+ export type ArticleImage = {
34
+ src: string;
35
+ alt: string;
36
+ caption?: string;
37
+ credit: string;
38
+ layout?: ArticleImageLayout;
39
+ themeVariants?: ArticleImageThemeVariants;
40
+ };
41
+
42
+ export type ArticleImageAsset = ArticleImage & {
43
+ id: string;
44
+ type: "image";
45
+ roles?: Array<"lead" | "continuation" | "continuationInset" | "feature" | "thumbnail">;
46
+ };
47
+
48
+ export type ArticleVideoAsset = {
49
+ id?: string;
50
+ type: "video";
51
+ src: string;
52
+ posterSrc?: string;
53
+ alt: string;
54
+ caption?: string;
55
+ credit: string;
56
+ durationSeconds?: number;
57
+ roles?: Array<"lead" | "feature" | "thumbnail">;
58
+ themeVariants?: ArticleVideoThemeVariants;
59
+ };
60
+
61
+ export type ArticleAsset = ArticleImageAsset | ArticleVideoAsset;
62
+
63
+ export type Article = {
64
+ slug: string;
65
+ shortSlug?: string;
66
+ section: string;
67
+ headline: string;
68
+ deck: string;
69
+ excerpt?: string;
70
+ byline: string;
71
+ dateline: string;
72
+ image?: ArticleImage;
73
+ video?: ArticleVideoAsset;
74
+ assets?: ArticleAsset[];
75
+ pullQuotes?: string[];
76
+ body: string[];
77
+ };
78
+
79
+ export const editionDate = "Wednesday, May 13, 2026";
80
+
81
+ export const articles: Article[] = [
82
+ {
83
+ slug: "agent-procedure-patterns",
84
+ shortSlug: "AGENTS",
85
+ section: "AI/ML",
86
+ headline: "The Next Agent Breakthrough Is a Checklist",
87
+ deck:
88
+ "Developers are learning that reliable agents need less freedom, not more: state machines, scoped tools, policy gates, and step-by-step procedure.",
89
+ byline: "Papyrus Staff",
90
+ dateline: "NEWSROOM",
91
+ image: {
92
+ src: "/seed-art/workflow-lanes.png",
93
+ alt: "An abstract workflow showing bounded editorial stages and handoffs",
94
+ caption: "Bounded agents sit between rigid scripts and open-ended tool use.",
95
+ credit: "Papyrus seed art",
96
+ layout: {
97
+ minHeight: 120,
98
+ preferredHeight: 190,
99
+ maxHeight: 360,
100
+ aspectRatio: 1.5009,
101
+ crop: "contain",
102
+ wrapsText: true,
103
+ },
104
+ },
105
+ pullQuotes: [
106
+ "The practical breakthrough is not giving agents a bigger sandbox.",
107
+ "A tiny tool-calling model can follow agent steps as long as the workflow is enforced outside the prompt.",
108
+ ],
109
+ body: [
110
+ "Developers are learning that reliable agents need less freedom, not more: state machines, scoped tools, policy gates, and step-by-step procedure. The most interesting argument this week was not about a new frontier model. It was about how much freedom an AI agent should be allowed to have.",
111
+ "Across demos, comments, and tool launches, builders kept circling the same answer: the practical breakthrough is not giving agents a bigger sandbox, but putting them inside a procedure. That marks a quiet reversal in the agent story. The first wave of agent demos sold autonomy: ask for an outcome and let the model decide what to do. The newer work is more cautious and more useful.",
112
+ "It treats agency as a dial, not a switch. At one end are scripts, workflow engines, and RPA jobs: predictable, auditable, and brittle when reality changes. At the other end are open-ended agents that can plan, browse, call tools, edit files, and recover from surprises, but may also wander, repeat themselves, or take unsafe actions. The emerging middle ground is bounded agency.",
113
+ "A model can improvise, but only inside states, allowed tools, review gates, and traceable transitions. The Statewright project drew attention because it says part of this out loud. Its author argues that agentic problem solving becomes more reliable when the problem is made smaller instead of the model larger.",
114
+ "A planning state can get read-only tools. An implementation state can get edit tools. A testing state can get test commands. The model cannot skip the workflow because the workflow is enforced outside the prompt. That pattern is showing up elsewhere. A tiny tool-calling model can follow agent steps as long as the next action is constrained by a procedure.",
115
+ "The lesson is not that agents are over. It is that they need architecture. The frontier is shifting from open-ended instruction following to systems that decide where the model is allowed to be creative and where the software should be boring. In practice, the checklist may be the most important agent interface.",
116
+ ],
117
+ },
118
+ {
119
+ slug: "schools-reading-lab",
120
+ shortSlug: "READING",
121
+ section: "Education",
122
+ headline: "Reading Labs Replace Remediation With Daily Practice",
123
+ deck:
124
+ "Teachers are testing short, frequent literacy blocks that use live diagnostics without turning classrooms into test prep centers.",
125
+ byline: "Jon Bell",
126
+ dateline: "NORTHLINE",
127
+ image: {
128
+ src: "https://images.unsplash.com/photo-1509062522246-3755977927d7?auto=format&fit=crop&w=1000&q=80",
129
+ alt: "Students working together at a classroom table",
130
+ caption: "Students rotate through short reading-practice stations built around live diagnostics.",
131
+ credit: "Classroom notebook",
132
+ layout: {
133
+ minHeight: 120,
134
+ preferredHeight: 220,
135
+ maxHeight: 420,
136
+ aspectRatio: 4 / 3,
137
+ crop: "cover",
138
+ wrapsText: true,
139
+ },
140
+ },
141
+ pullQuotes: [
142
+ "The blocks last eighteen minutes. That is the point.",
143
+ "The data is deliberately small: enough to choose tomorrow's grouping.",
144
+ ],
145
+ body: [
146
+ "At 9:10 each morning, the fifth grade at Bellwether School breaks into a rhythm that looks more like music rehearsal than remediation. One group reads aloud with a teacher, one marks unfamiliar words in a science passage, one records a short explanation, and one works through a vocabulary ladder at the back table.",
147
+ "The blocks last eighteen minutes. That is the point. Administrators wanted a model that could happen every day without swallowing social studies, art, or recess. The district's older intervention program pulled students from class twice a week and often arrived too late to change what happened in the next lesson.",
148
+ "Now teachers receive a same-day view of fluency, comprehension, and vocabulary signals. The data is deliberately small: enough to choose tomorrow's grouping, not enough to rank children publicly. Teachers can override every recommendation, and many do.",
149
+ "Early results are uneven but promising. Students who had stalled on multisyllabic decoding are moving fastest. English learners show gains when the passages connect to science and local history units. The hardest cases remain students who can decode accurately but lose the thread of an argument after the second paragraph.",
150
+ "The district is watching teacher workload closely. A literacy coach visits each building twice a week, and the software vendor is barred from sending automated messages to families. Principals say the model will survive only if it feels like instruction rather than surveillance.",
151
+ ],
152
+ },
153
+ {
154
+ slug: "market-hall",
155
+ shortSlug: "MARKET",
156
+ section: "Business",
157
+ headline: "Old Market Hall Finds a Second Life as a Food Factory",
158
+ deck:
159
+ "Small producers are sharing cold rooms, packaging lines, and a retail counter in a building once written off as obsolete.",
160
+ byline: "Ilya Stone",
161
+ dateline: "EASTBOROUGH",
162
+ image: {
163
+ src: "https://images.unsplash.com/photo-1555396273-367ea4eb4db5?auto=format&fit=crop&w=1000&q=80",
164
+ alt: "A busy restaurant kitchen with stainless steel counters",
165
+ caption: "The renovated market hall combines prep kitchens, storefront counters, and shared cold storage.",
166
+ credit: "Kitchen file",
167
+ layout: {
168
+ minHeight: 110,
169
+ preferredHeight: 210,
170
+ maxHeight: 380,
171
+ aspectRatio: 4 / 3,
172
+ crop: "cover",
173
+ wrapsText: false,
174
+ },
175
+ },
176
+ pullQuotes: [
177
+ "The economics depend on sharing.",
178
+ "That counter has become its own kind of research lab.",
179
+ ],
180
+ body: [
181
+ "The market hall's loading dock used to open twice a week. Now it opens before dawn. Bakers roll in racks of proofed dough, a salsa maker unloads crates of tomatillos, and a coffee roaster threads sacks of beans through a doorway that still bears the name of a grocer who closed in 1988.",
182
+ "The renovation was less glamorous than the ribbon cutting suggested. Contractors replaced cracked drains, raised the electrical service, and carved one cavernous floor into a set of certified production rooms. The city provided low-interest financing because small food companies kept leaving town when they outgrew home kitchens but could not afford private facilities.",
183
+ "The economics depend on sharing. Tenants reserve equipment by the hour and pay more for freezer space than for office space. A single compliance manager handles inspections, labeling questions, and recall drills. On Saturdays, a retail counter lets customers buy the products made during the week.",
184
+ "That counter has become its own kind of research lab. Producers can test packaging, pricing, and flavors before committing to wholesale runs. The most popular item so far is a fermented pepper sauce that began as a farmers market experiment and now ships to three regional chains.",
185
+ "Neighbors who feared another boutique food court have been cautiously won over by the number of industrial jobs. The hall employs dishwashers, packers, drivers, and maintenance workers, not only chefs with investors. The question is whether rents can stay low once the building becomes fashionable again.",
186
+ ],
187
+ },
188
+ {
189
+ slug: "river-court",
190
+ section: "Civic Life",
191
+ headline: "A Tiny Courtroom Tries to Keep Evictions From Becoming Homelessness",
192
+ deck:
193
+ "Mediators, rent ledgers, and emergency aid sit in the same hallway as judges in a new housing docket.",
194
+ byline: "Anika Rao",
195
+ dateline: "MILL COUNTY",
196
+ image: {
197
+ src: "https://images.unsplash.com/photo-1589578527966-fdac0f44566c?auto=format&fit=crop&w=1000&q=80",
198
+ alt: "Sunlight through the columns of a courthouse",
199
+ caption: "Court administrators are balancing public-record access with case-management automation.",
200
+ credit: "Courthouse file",
201
+ layout: {
202
+ minHeight: 120,
203
+ preferredHeight: 230,
204
+ maxHeight: 420,
205
+ aspectRatio: 1.5,
206
+ crop: "cover",
207
+ wrapsText: true,
208
+ },
209
+ },
210
+ pullQuotes: [
211
+ "The first question has changed from who is at fault to whether the tenancy can be stabilized.",
212
+ "The shelter system has not opened an overflow wing this spring.",
213
+ ],
214
+ body: [
215
+ "The housing docket meets in Courtroom 2B, a narrow room with a flickering clock and a stack of folding chairs outside the door. By 8:30, landlords, tenants, lawyers, and caseworkers have filled the hallway. Some hold lease packets. Some hold money orders. Almost everyone is checking a phone.",
216
+ "The premise is simple: most eviction cases turn on missed payments that are smaller than the public cost of sheltering a displaced family. The county now places rental assistance screeners, legal aid lawyers, and mediators next to the courtroom so agreements can be reached before a writ is issued.",
217
+ "Judges still hear contested cases, and landlords can still recover possession. But the first question has changed from who is at fault to whether the tenancy can be stabilized. A tenant with a new job may need a payment plan. A landlord with a small mortgage may need partial payment within days. A caseworker may be able to close the gap with emergency funds.",
218
+ "The docket has reduced default judgments because tenants are more likely to appear when help is available on site. It has also exposed how thin the safety net remains. Assistance funds run out before the month does, and many renters arrive only after fees have doubled the original balance.",
219
+ "County leaders are debating whether to make the docket permanent. The strongest argument may be the quietest one: the shelter system has not opened an overflow wing this spring.",
220
+ ],
221
+ },
222
+ {
223
+ slug: "night-trains",
224
+ section: "Travel",
225
+ headline: "Night Trains Return With Sleeper Cars and Skeptics",
226
+ deck:
227
+ "A revived overnight route promises downtown-to-downtown travel, but reliability will decide whether curiosity becomes habit.",
228
+ byline: "Theo Mercer",
229
+ dateline: "CENTRAL STATION",
230
+ image: {
231
+ src: "https://images.unsplash.com/photo-1474487548417-781cb71495f3?auto=format&fit=crop&w=1000&q=80",
232
+ alt: "A passenger train waiting at a platform",
233
+ caption: "Regional rail planners are testing later departures before committing to permanent schedules.",
234
+ credit: "Rail archive",
235
+ layout: {
236
+ minHeight: 110,
237
+ preferredHeight: 210,
238
+ maxHeight: 360,
239
+ aspectRatio: 1.5,
240
+ crop: "cover",
241
+ wrapsText: false,
242
+ },
243
+ },
244
+ pullQuotes: [
245
+ "The real test comes in winter, when novelty fades.",
246
+ "A moving bedroom must be dependable enough for business and family visits.",
247
+ ],
248
+ body: [
249
+ "The new sleeper train leaves at 10:42 p.m., late enough for dinner and early enough for a child to fall asleep before the suburbs thin out. By morning it reaches the capital, sliding under the old glass roof while commuters are still buying coffee.",
250
+ "Rail officials have tried this before. Overnight service faded as cheap flights multiplied, maintenance windows shrank, and older cars became expensive to keep in service. This revival is different in two ways: the route uses refurbished cars with private rooms, and the schedule is aimed at travelers who would otherwise lose a day to airports.",
251
+ "The train's supporters talk about carbon, comfort, and city-center convenience. Its critics talk about freight congestion, missed connections, and the long memory of routes that launched with enthusiasm and died quietly two timetables later.",
252
+ "The first month is nearly sold out, helped by nostalgia and a marketing campaign heavy on brass lamps and linen sheets. The real test comes in winter, when novelty fades and travelers decide whether a moving bedroom is dependable enough for business, family visits, and holidays.",
253
+ ],
254
+ },
255
+ {
256
+ slug: "climate-ledger",
257
+ section: "Science",
258
+ headline: "Researchers Build a Climate Ledger for City Blocks",
259
+ deck:
260
+ "A block-by-block model gives planners a sharper picture of heat, shade, runoff, and retrofit payoffs.",
261
+ byline: "Celeste Ng",
262
+ dateline: "WEST QUAY",
263
+ image: {
264
+ src: "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?auto=format&fit=crop&w=1000&q=80",
265
+ alt: "A satellite view of Earth and weather systems",
266
+ caption: "Small climate ledgers increasingly rely on satellite checks instead of annual surveys.",
267
+ credit: "Remote sensing",
268
+ layout: {
269
+ minHeight: 120,
270
+ preferredHeight: 240,
271
+ maxHeight: 440,
272
+ aspectRatio: 1.5,
273
+ crop: "cover",
274
+ wrapsText: true,
275
+ },
276
+ },
277
+ pullQuotes: [
278
+ "The output is not a single score but a set of tradeoffs.",
279
+ "A block can now be flagged as data-poor rather than quietly misclassified.",
280
+ ],
281
+ body: [
282
+ "City climate maps have usually been blunt instruments. They show neighborhoods that are hotter, wetter, or more vulnerable than others, but they rarely tell a planner which block should get trees before pavement, which roof should get reflective coating, or which alley can absorb stormwater before it reaches a basement.",
283
+ "A research team at West Quay University is trying to narrow the unit of action. Their model combines tree canopy, roof material, surface temperature, drainage complaints, utility burden, and building age into a ledger for each block. The output is not a single score but a set of tradeoffs.",
284
+ "On one block, shade trees may reduce afternoon heat but interfere with buried utilities. On another, a cool-roof program may help renters only if paired with rules that prevent landlords from passing the cost through immediately. A third block may need porous pavement more than shade because runoff is the dominant risk.",
285
+ "The team built the tool with public data where possible and marked uncertain estimates in plain language. Community groups pushed for that feature after seeing earlier maps turn shaky data into confident colors. A block can now be flagged as data-poor rather than quietly misclassified.",
286
+ "The city plans to use the ledger in next year's capital budget. Researchers warn that the model should guide site visits, not replace them. A spreadsheet can see heat islands. It cannot see a grandmother who sits on the same stoop every afternoon because it is the only shaded place on the block.",
287
+ ],
288
+ },
289
+ ];
290
+
291
+ export function getArticle(slug: string): Article | undefined {
292
+ return articles.find((article) => article.slug === slug);
293
+ }
294
+
295
+ export function getArticleText(article: Article): string {
296
+ return article.body.join("\n\n");
297
+ }
298
+
299
+ export function getArticleImageAssets(article: Article): ArticleImageAsset[] {
300
+ const imageAssets = article.assets?.filter((asset): asset is ArticleImageAsset => asset.type === "image") ?? [];
301
+ if (imageAssets.length > 0) return imageAssets;
302
+ if (!article.image) return [];
303
+
304
+ return [
305
+ {
306
+ ...article.image,
307
+ id: `${article.slug}-primary-image`,
308
+ type: "image",
309
+ roles: ["lead", "continuation", "continuationInset"],
310
+ },
311
+ ];
312
+ }
313
+
314
+ export function getArticleVideoAsset(article: Article): ArticleVideoAsset | undefined {
315
+ const videoAssets = article.assets?.filter((asset): asset is ArticleVideoAsset => asset.type === "video") ?? [];
316
+ if (videoAssets.length > 0) {
317
+ return videoAssets.find((asset) => asset.roles?.includes("lead")) ?? videoAssets[0];
318
+ }
319
+ return article.video;
320
+ }