@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21

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 (140) hide show
  1. package/amplify/auth/resource.js +17 -4
  2. package/amplify/auth/resource.ts +21 -4
  3. package/amplify/data/resource.js +52 -70
  4. package/amplify/data/resource.ts +57 -70
  5. package/amplify/data/schema.js +52 -58
  6. package/amplify/data/schema.ts +57 -58
  7. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  8. package/amplify/functions/console-chat-responder/resource.js +0 -7
  9. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  10. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  11. package/amplify/functions/content-actions/handler.py +149 -0
  12. package/amplify/functions/content-actions/requirements.txt +4 -0
  13. package/amplify/functions/content-actions/resource.js +40 -0
  14. package/amplify/functions/content-actions/resource.ts +54 -0
  15. package/amplify/functions/shared/python-bundle.js +17 -2
  16. package/amplify/functions/shared/python-bundle.ts +23 -2
  17. package/amplify/rebuild-trigger-settings.js +22 -0
  18. package/amplify/rebuild-trigger-settings.ts +42 -0
  19. package/amplify/site-backend-config.ts +12 -1
  20. package/amplify/site-backend.js +51 -27
  21. package/amplify/site-backend.ts +66 -34
  22. package/amplify/storage/resource.js +3 -0
  23. package/amplify/storage/resource.ts +3 -0
  24. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  25. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
  26. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  27. package/app/api/revalidate/route.ts +8 -7
  28. package/app/articles/[slug]/page.tsx +6 -4
  29. package/app/blog-rhythm.css +278 -0
  30. package/app/globals.css +22 -4
  31. package/app/layout.tsx +24 -12
  32. package/app/newsroom/articles/[id]/page.tsx +15 -0
  33. package/app/newsroom/articles/page.tsx +13 -0
  34. package/app/page.tsx +12 -4
  35. package/app/robots.ts +11 -0
  36. package/bin/papyrus-app.mjs +12 -2
  37. package/bin/papyrus-infra.mjs +74 -18
  38. package/components/archive-shell.tsx +2 -22
  39. package/components/article-video.tsx +247 -3
  40. package/components/blog-page-background.tsx +23 -6
  41. package/components/markus-ir-preview.tsx +300 -0
  42. package/components/news-desk-page.tsx +1 -4
  43. package/components/newspaper.tsx +6 -2
  44. package/components/newsroom-access-gate.tsx +66 -0
  45. package/components/newsroom-article-editor.tsx +741 -0
  46. package/components/newsroom-articles-view.tsx +129 -0
  47. package/components/newsroom-ops-overview.tsx +28 -11
  48. package/components/newsroom-ops-shell.tsx +3 -1
  49. package/components/pictogram-figure.tsx +9 -2
  50. package/components/presentation-header.tsx +135 -0
  51. package/components/presentation-rhythm-hrule.tsx +17 -0
  52. package/components/settings-page.tsx +2 -1
  53. package/components/staging-banner.tsx +11 -0
  54. package/components/topic-steering-workspace.tsx +1 -59
  55. package/components/use-blog-header-obstacles.ts +81 -0
  56. package/components/use-brand-fonts-ready.ts +28 -0
  57. package/components/use-rhythm-overlay.ts +29 -0
  58. package/infra/amplify-app-shell.js +285 -80
  59. package/infra/amplify-app-shell.ts +326 -113
  60. package/infra/build-specs.js +94 -0
  61. package/infra/build-specs.ts +141 -0
  62. package/infra/github-oidc-provider.js +24 -0
  63. package/infra/github-oidc-provider.ts +30 -0
  64. package/infra/index.js +3 -0
  65. package/infra/site-config.js +273 -0
  66. package/infra/site-config.ts +336 -0
  67. package/lib/blog-feature-solver.ts +459 -0
  68. package/lib/blog-rhythm.ts +134 -0
  69. package/lib/brand-fonts-watch.ts +43 -0
  70. package/lib/brand-fonts.ts +112 -0
  71. package/lib/cached-content-repository.ts +14 -1
  72. package/lib/category-repository.ts +1 -93
  73. package/lib/content-actions-client.ts +98 -0
  74. package/lib/content-actions-demo.ts +190 -0
  75. package/lib/content-repository.ts +6 -0
  76. package/lib/content-source-context.ts +26 -0
  77. package/lib/content-types.ts +4 -0
  78. package/lib/edition-routes.ts +31 -29
  79. package/lib/empty-theme.css +1 -0
  80. package/lib/graphql-content-repository.ts +239 -43
  81. package/lib/layout-scenarios.ts +8 -8
  82. package/lib/newspaper-layout.ts +3 -2
  83. package/lib/newsroom-articles.ts +325 -0
  84. package/lib/newsroom-demo-articles.ts +90 -0
  85. package/lib/newsroom-media-backend.ts +142 -0
  86. package/lib/newsroom-media.ts +182 -0
  87. package/lib/newsroom-nav.ts +3 -0
  88. package/lib/papyrus-web-locations.ts +7 -6
  89. package/lib/presentation-footer.ts +3 -2
  90. package/lib/pretext-layout.ts +12 -3
  91. package/lib/reader-base-path.ts +24 -0
  92. package/lib/reader-static-params.ts +8 -0
  93. package/lib/renderer.ts +7 -0
  94. package/lib/revalidate-secret.ts +59 -0
  95. package/lib/root-route-redirect.ts +16 -0
  96. package/lib/site-brand-tenant.ts +3 -22
  97. package/lib/site-brand.ts +95 -42
  98. package/lib/site-env.ts +52 -0
  99. package/lib/staging-gate.ts +37 -0
  100. package/lib/staging-gated-path.ts +22 -0
  101. package/lib/staging-preview-object.ts +98 -0
  102. package/lib/video-mode.ts +42 -0
  103. package/lib/video-script.ts +77 -0
  104. package/middleware.ts +56 -42
  105. package/package.json +40 -1
  106. package/renderers/pretext/article-page.tsx +40 -9
  107. package/renderers/pretext/presentation-shared.tsx +113 -0
  108. package/renderers/pretext/presentation-shell.tsx +18 -112
  109. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  110. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  111. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  112. package/routes.manifest.json +37 -2
  113. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  114. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  115. package/scripts/videoml/bundle-common.mjs +129 -0
  116. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  117. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  118. package/scripts/videoml/preview/preview-timing.ts +42 -0
  119. package/scripts/videoml/preview.html +77 -0
  120. package/scripts/videoml/standard-entry.tsx +46 -0
  121. package/src/sync.mjs +30 -6
  122. package/src/with-papyrus.mjs +15 -2
  123. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  124. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  125. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  126. package/publications/anth_us/brand.ts +0 -68
  127. package/publications/anth_us/theme.css +0 -66
  128. package/publications/pilobol_us/brand.ts +0 -37
  129. package/publications/pilobol_us/theme.css +0 -140
  130. package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
  131. package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
  132. package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
  133. package/publications/threat_intelligence/brand.ts +0 -40
  134. package/publications/threat_intelligence/pictograms/art.tsx +0 -608
  135. package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
  136. package/publications/threat_intelligence/pictograms/registry.ts +0 -30
  137. package/publications/threat_intelligence/pictograms/system.tsx +0 -305
  138. package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
  139. package/publications/threat_intelligence/theme.css +0 -1440
  140. /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
@@ -1,3 +1,5 @@
1
+ import { getReaderBasePath, toPublicReaderPath } from "./reader-base-path";
2
+
1
3
  const MONTH_NAMES = [
2
4
  "january",
3
5
  "february",
@@ -39,52 +41,52 @@ type ParsedEditionSectionRoute = ParsedEditionDateRoute & {
39
41
 
40
42
  const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
41
43
 
42
- export function getEditionDatePath(editionDate: string): string {
44
+ export function getEditionDatePath(editionDate: string, readerBasePath: string = getReaderBasePath()): string {
43
45
  const match = editionDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
44
- if (!match) return "/";
46
+ if (!match) return toPublicReaderPath("/", readerBasePath);
45
47
 
46
48
  const monthIndex = Number(match[2]) - 1;
47
49
  const monthName = MONTH_NAMES[monthIndex];
48
- if (!monthName) return "/";
50
+ if (!monthName) return toPublicReaderPath("/", readerBasePath);
49
51
 
50
- return `/${match[1]}/${monthName}/${match[3]}`;
52
+ return toPublicReaderPath(`/${match[1]}/${monthName}/${match[3]}`, readerBasePath);
51
53
  }
52
54
 
53
- export function getEditionPagePath(editionDate: string, pageNumber: number): string {
54
- const basePath = getEditionDatePath(editionDate);
55
+ export function getEditionPagePath(editionDate: string, pageNumber: number, readerBasePath: string = getReaderBasePath()): string {
56
+ const basePath = getEditionDatePath(editionDate, readerBasePath);
55
57
  return pageNumber <= 1 ? basePath : `${basePath}/page/${pageNumber}`;
56
58
  }
57
59
 
58
- export function getEditionSectionPath(editionDate: string, sectionKey: string): string {
59
- return `${getEditionDatePath(editionDate)}/section/${encodeURIComponent(sectionKey)}`;
60
+ export function getEditionSectionPath(editionDate: string, sectionKey: string, readerBasePath: string = getReaderBasePath()): string {
61
+ return `${getEditionDatePath(editionDate, readerBasePath)}/section/${encodeURIComponent(sectionKey)}`;
60
62
  }
61
63
 
62
- export function getEditionArticlePath(editionDate: string, articleSlug: string): string {
63
- return `${getEditionDatePath(editionDate)}/${encodeURIComponent(articleSlug)}`;
64
+ export function getEditionArticlePath(editionDate: string, articleSlug: string, readerBasePath: string = getReaderBasePath()): string {
65
+ return `${getEditionDatePath(editionDate, readerBasePath)}/${encodeURIComponent(articleSlug)}`;
64
66
  }
65
67
 
66
68
  export const getEditionItemPath = getEditionArticlePath;
67
69
 
68
- export function parseEditionDateRoute(input: EditionDateRouteInput): ParsedEditionDateRoute | null {
69
- const parsed = parseEditionDateSegments(input);
70
+ export function parseEditionDateRoute(input: EditionDateRouteInput, readerBasePath: string = getReaderBasePath()): ParsedEditionDateRoute | null {
71
+ const parsed = parseEditionDateSegments(input, readerBasePath);
70
72
  if (!parsed) return null;
71
73
 
72
74
  return {
73
75
  ...parsed,
74
- isCanonical: getCurrentDatePath(input) === parsed.canonicalPath,
76
+ isCanonical: getCurrentDatePath(input, readerBasePath) === parsed.canonicalPath,
75
77
  };
76
78
  }
77
79
 
78
- export function parseEditionPageRoute(input: EditionDateRouteInput & { pageNumber: string }): ParsedEditionPageRoute | null {
79
- const parsed = parseEditionDateSegments(input);
80
+ export function parseEditionPageRoute(input: EditionDateRouteInput & { pageNumber: string }, readerBasePath: string = getReaderBasePath()): ParsedEditionPageRoute | null {
81
+ const parsed = parseEditionDateSegments(input, readerBasePath);
80
82
  if (!parsed) return null;
81
83
  if (!/^\d+$/.test(input.pageNumber)) return null;
82
84
 
83
85
  const pageNumber = Number(input.pageNumber);
84
86
  if (!Number.isSafeInteger(pageNumber) || pageNumber < 1) return null;
85
87
 
86
- const canonicalPath = getEditionPagePath(parsed.editionDate, pageNumber);
87
- const currentPath = `${getCurrentDatePath(input)}/page/${input.pageNumber}`;
88
+ const canonicalPath = getEditionPagePath(parsed.editionDate, pageNumber, readerBasePath);
89
+ const currentPath = `${getCurrentDatePath(input, readerBasePath)}/page/${input.pageNumber}`;
88
90
  return {
89
91
  editionDate: parsed.editionDate,
90
92
  canonicalPath,
@@ -93,13 +95,13 @@ export function parseEditionPageRoute(input: EditionDateRouteInput & { pageNumbe
93
95
  };
94
96
  }
95
97
 
96
- export function parseEditionArticleRoute(input: EditionDateRouteInput & { articleSlug: string }): ParsedEditionArticleRoute | null {
97
- const parsed = parseEditionDateSegments(input);
98
+ export function parseEditionArticleRoute(input: EditionDateRouteInput & { articleSlug: string }, readerBasePath: string = getReaderBasePath()): ParsedEditionArticleRoute | null {
99
+ const parsed = parseEditionDateSegments(input, readerBasePath);
98
100
  if (!parsed) return null;
99
101
  if (RESERVED_DATE_CHILD_SEGMENTS.has(input.articleSlug.toLowerCase())) return null;
100
102
 
101
- const canonicalPath = getEditionArticlePath(parsed.editionDate, input.articleSlug);
102
- const currentPath = `${getCurrentDatePath(input)}/${input.articleSlug}`;
103
+ const canonicalPath = getEditionArticlePath(parsed.editionDate, input.articleSlug, readerBasePath);
104
+ const currentPath = `${getCurrentDatePath(input, readerBasePath)}/${input.articleSlug}`;
103
105
  return {
104
106
  editionDate: parsed.editionDate,
105
107
  canonicalPath,
@@ -108,13 +110,13 @@ export function parseEditionArticleRoute(input: EditionDateRouteInput & { articl
108
110
  };
109
111
  }
110
112
 
111
- export function parseEditionSectionRoute(input: EditionDateRouteInput & { sectionKey: string }): ParsedEditionSectionRoute | null {
112
- const parsed = parseEditionDateSegments(input);
113
+ export function parseEditionSectionRoute(input: EditionDateRouteInput & { sectionKey: string }, readerBasePath: string = getReaderBasePath()): ParsedEditionSectionRoute | null {
114
+ const parsed = parseEditionDateSegments(input, readerBasePath);
113
115
  if (!parsed) return null;
114
116
  if (!input.sectionKey.trim()) return null;
115
117
 
116
- const canonicalPath = getEditionSectionPath(parsed.editionDate, input.sectionKey);
117
- const currentPath = `${getCurrentDatePath(input)}/section/${input.sectionKey}`;
118
+ const canonicalPath = getEditionSectionPath(parsed.editionDate, input.sectionKey, readerBasePath);
119
+ const currentPath = `${getCurrentDatePath(input, readerBasePath)}/section/${input.sectionKey}`;
118
120
  return {
119
121
  editionDate: parsed.editionDate,
120
122
  canonicalPath,
@@ -123,7 +125,7 @@ export function parseEditionSectionRoute(input: EditionDateRouteInput & { sectio
123
125
  };
124
126
  }
125
127
 
126
- function parseEditionDateSegments(input: EditionDateRouteInput): Omit<ParsedEditionDateRoute, "isCanonical"> | null {
128
+ function parseEditionDateSegments(input: EditionDateRouteInput, readerBasePath: string): Omit<ParsedEditionDateRoute, "isCanonical"> | null {
127
129
  if (!/^\d{4}$/.test(input.year)) return null;
128
130
 
129
131
  const monthIndex = MONTH_NAMES.indexOf(input.month.toLowerCase() as (typeof MONTH_NAMES)[number]);
@@ -140,12 +142,12 @@ function parseEditionDateSegments(input: EditionDateRouteInput): Omit<ParsedEdit
140
142
 
141
143
  return {
142
144
  editionDate,
143
- canonicalPath: getEditionDatePath(editionDate),
145
+ canonicalPath: getEditionDatePath(editionDate, readerBasePath),
144
146
  };
145
147
  }
146
148
 
147
- function getCurrentDatePath(input: EditionDateRouteInput): string {
148
- return `/${input.year}/${input.month}/${input.day}`;
149
+ function getCurrentDatePath(input: EditionDateRouteInput, readerBasePath: string): string {
150
+ return toPublicReaderPath(`/${input.year}/${input.month}/${input.day}`, readerBasePath);
149
151
  }
150
152
 
151
153
  function isValidIsoDate(editionDate: string): boolean {
@@ -0,0 +1 @@
1
+ /* Default for `papyrus-site-theme` when a publication ships no publication/theme.css. */
@@ -1,3 +1,4 @@
1
+ import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
1
2
  import { generateClient } from "aws-amplify/data";
2
3
  import type { Schema } from "../amplify/data/resource";
3
4
  import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
@@ -17,11 +18,15 @@ import {
17
18
  } from "./publication-items";
18
19
  import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
19
20
  import { SITE_BRAND } from "./site-brand";
21
+ import { shouldFetchVideoScripts } from "./video-mode";
22
+ import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
23
+ import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
24
+ import { getContentSource } from "./site-env";
20
25
 
21
- const AUTH_MODE = "identityPool";
22
26
  const DEFAULT_EDITION_SLUG = "current";
23
27
  const PUBLISHED_STATUS = "published";
24
- const ARTICLE_TYPE_STATUS = "article#published";
28
+ const DRAFT_STATUS = "draft";
29
+ const ARTICLE_TYPE = "article";
25
30
 
26
31
  type DataClient = ReturnType<typeof generateClient<Schema>>;
27
32
  type EditionPublishedAtIndexQuery = (
@@ -64,8 +69,6 @@ type GraphQLEditionItem = {
64
69
 
65
70
  type GraphQLItem = {
66
71
  id: string;
67
- sourceItemId?: string | null;
68
- itemLineageId?: string | null;
69
72
  versionNumber?: number | null;
70
73
  type: string;
71
74
  status: string;
@@ -133,19 +136,38 @@ const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
133
136
  let readerContextClient: DataClient | null = null;
134
137
  let buildTimeClient: DataClient | null = null;
135
138
 
139
+ function authMode(): "userPool" | "identityPool" {
140
+ return currentContentSource() === "drafts" ? "userPool" : "identityPool";
141
+ }
142
+
143
+ function isVisibleItemStatus(status: string): boolean {
144
+ if (status === PUBLISHED_STATUS) return true;
145
+ return currentContentSource() === "drafts" && status === DRAFT_STATUS;
146
+ }
147
+
136
148
  async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
137
- const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
138
- return runWithAmplifyServerContext({
139
- nextServerContext: null,
140
- operation: async () => {
141
- readerContextClient = generateClient<Schema>({ authMode: AUTH_MODE });
142
- try {
143
- return await operation();
144
- } finally {
145
- readerContextClient = null;
146
- }
147
- },
148
- });
149
+ const { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
150
+ const source = getContentSource();
151
+ if (source === "drafts") {
152
+ // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
153
+ // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
154
+ const { cookies } = await import("next/headers");
155
+ const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
156
+ return runWithContentSource(source, operation, client);
157
+ }
158
+ return runWithContentSource(source, () =>
159
+ runWithAmplifyServerContext({
160
+ nextServerContext: null,
161
+ operation: async () => {
162
+ readerContextClient = generateClient<Schema>({ authMode: authMode() });
163
+ try {
164
+ return await operation();
165
+ } finally {
166
+ readerContextClient = null;
167
+ }
168
+ },
169
+ }),
170
+ );
149
171
  }
150
172
 
151
173
  export const graphqlContentRepository: ContentRepository = {
@@ -200,7 +222,7 @@ export const graphqlContentRepository: ContentRepository = {
200
222
  getArticle(slug) {
201
223
  return withReaderGraphQLContext(async () => {
202
224
  const item = await getItemBySlug(slug);
203
- if (!item || item.type !== "article" || item.status !== "published") return undefined;
225
+ if (!item || item.type !== ARTICLE_TYPE || !isVisibleItemStatus(item.status)) return undefined;
204
226
  return normalizeArticle(item, await listMediaAssets(item.id));
205
227
  });
206
228
  },
@@ -216,11 +238,15 @@ export const graphqlContentRepository: ContentRepository = {
216
238
  return withReaderGraphQLContext(() => loadEditionItem(editionDate, itemSlug));
217
239
  },
218
240
 
241
+ loadVideoScript(targetSlug) {
242
+ return withReaderGraphQLContext(() => loadVideoScriptForTarget(targetSlug));
243
+ },
244
+
219
245
  listArticleSlugs() {
220
246
  return withReaderGraphQLContext(async () => {
221
- const items = await listItemsByTypeStatus(ARTICLE_TYPE_STATUS);
247
+ const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
222
248
  return items
223
- .filter((item) => item.type === "article" && item.status === "published")
249
+ .filter((item) => item.type === ARTICLE_TYPE && isVisibleItemStatus(item.status))
224
250
  .map((item) => item.slug)
225
251
  .sort();
226
252
  });
@@ -228,11 +254,13 @@ export const graphqlContentRepository: ContentRepository = {
228
254
  };
229
255
 
230
256
  function getClient(): DataClient {
257
+ const requestClient = currentRequestClient<DataClient>();
258
+ if (requestClient) return requestClient;
231
259
  if (readerContextClient) return readerContextClient;
232
260
 
233
261
  if (!buildTimeClient) {
234
262
  getAmplifyServerRuntime();
235
- buildTimeClient = generateClient<Schema>({ authMode: AUTH_MODE });
263
+ buildTimeClient = generateClient<Schema>({ authMode: authMode() });
236
264
  }
237
265
  return buildTimeClient;
238
266
  }
@@ -331,7 +359,7 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
331
359
  await Promise.all(
332
360
  editionItems.map(async (editionItem) => {
333
361
  const item = await getItemById(editionItem.publishedItemId);
334
- if (!item || item.status !== PUBLISHED_STATUS) return null;
362
+ if (!item || !isVisibleItemStatus(item.status)) return null;
335
363
  return normalizePublicationItem(item, await listMediaAssets(item.id));
336
364
  }),
337
365
  )
@@ -344,7 +372,7 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
344
372
  await Promise.all(
345
373
  missingLayoutItems.map(async (itemId) => {
346
374
  const item = await getItemBySlug(itemId);
347
- if (!item || item.status !== PUBLISHED_STATUS) return null;
375
+ if (!item || !isVisibleItemStatus(item.status)) return null;
348
376
  return normalizePublicationItem(item, await listMediaAssets(item.id));
349
377
  }),
350
378
  )
@@ -357,6 +385,9 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
357
385
  const sanitizedLayoutPlan = pruneLayoutPlanUnavailableItems(layoutPlan, availableItemSlugs, edition.id);
358
386
  validateEditionLayoutPlanForItems(sanitizedLayoutPlan, items, `PublishedEdition(${edition.id}).layoutPlan`);
359
387
 
388
+ const editionVideo = await normalizeEditionVideoAsset(editionMetadata?.editionVideo);
389
+ const videoScripts = await loadEditionVideoScripts(items, editionVideo);
390
+
360
391
  return {
361
392
  id: edition.id,
362
393
  source: "graphql",
@@ -367,10 +398,38 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
367
398
  items,
368
399
  sections: createEditionSectionPlan(items, edition.metadata),
369
400
  suppressNewsDeskAppendix: editionMetadata?.suppressNewsDeskAppendix === true,
370
- editionVideo: await normalizeEditionVideoAsset(editionMetadata?.editionVideo),
401
+ editionVideo,
402
+ videoScripts,
371
403
  };
372
404
  }
373
405
 
406
+ function videoScriptsEnabled(): boolean {
407
+ return Boolean(SITE_BRAND.videoPlayer) && shouldFetchVideoScripts();
408
+ }
409
+
410
+ async function loadVideoScriptForTarget(targetSlug: string): Promise<VideoScriptRef | null> {
411
+ if (!videoScriptsEnabled()) return null;
412
+ const item = await getItemBySlug(videomlItemSlug(targetSlug));
413
+ if (!item || item.type !== "videoml" || !isVisibleItemStatus(item.status)) return null;
414
+ return parseVideoScriptRef(item);
415
+ }
416
+
417
+ async function loadEditionVideoScripts(
418
+ items: PublicationItem[],
419
+ editionVideo: ArticleVideoAsset | null,
420
+ ): Promise<Record<string, VideoScriptRef> | undefined> {
421
+ if (!videoScriptsEnabled()) return undefined;
422
+ const targets = collectVideoScriptTargets(items, editionVideo !== null);
423
+ const entries = await Promise.all(
424
+ targets.map(async (targetSlug) => {
425
+ const script = await loadVideoScriptForTarget(targetSlug);
426
+ return script ? ([targetSlug, script] as const) : null;
427
+ }),
428
+ );
429
+ const videoScripts = Object.fromEntries(entries.filter((entry): entry is [string, VideoScriptRef] => entry !== null));
430
+ return Object.keys(videoScripts).length > 0 ? videoScripts : undefined;
431
+ }
432
+
374
433
  async function normalizeEditionVideoAsset(value: unknown): Promise<ArticleVideoAsset | null> {
375
434
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
376
435
  const record = value as Record<string, unknown>;
@@ -461,7 +520,7 @@ async function loadEditionItem(editionDate: string, itemSlug: string): Promise<P
461
520
  const editionItems = await listEditionItems(edition.id);
462
521
  for (const editionItem of editionItems) {
463
522
  const item = await getItemById(editionItem.publishedItemId);
464
- if (!item || item.slug !== itemSlug || item.status !== PUBLISHED_STATUS) continue;
523
+ if (!item || item.slug !== itemSlug || !isVisibleItemStatus(item.status)) continue;
465
524
  return (await normalizePublicationItem(item, await listMediaAssets(item.id))) ?? undefined;
466
525
  }
467
526
  return undefined;
@@ -505,36 +564,168 @@ function isMissingReaderBackendError(error: unknown): boolean {
505
564
  }
506
565
 
507
566
  function getEditionPublishedAtIndexQuery(): EditionPublishedAtIndexQuery | null {
508
- const editionModel = getPublishedEditionModel() as unknown as {
509
- listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
510
- };
567
+ const editionModel = getPublishedEditionModel();
511
568
  return typeof editionModel.listPublishedEditionsByStatusAndPublishedAt === "function"
512
569
  ? editionModel.listPublishedEditionsByStatusAndPublishedAt
513
570
  : null;
514
571
  }
515
572
 
516
- function getPublishedEditionModel() {
573
+ type ListOptions = Record<string, unknown>;
574
+ type ListResponse<T> = Promise<GraphQLListResponse<T>>;
575
+
576
+ type EditionSourceModel = {
577
+ publishedEditionBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLEdition>;
578
+ listPublishedEditionsByStatusAndEditionDate: (
579
+ input: { status: string; editionDate?: { eq: string } },
580
+ options: ListOptions,
581
+ ) => ListResponse<GraphQLEdition>;
582
+ listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
583
+ };
584
+
585
+ type EditionItemSourceModel = {
586
+ listPublishedEditionItemsByEditionAndSortKey: (
587
+ input: { publishedEditionId: string },
588
+ options: ListOptions,
589
+ ) => ListResponse<GraphQLEditionItem>;
590
+ };
591
+
592
+ type ItemSourceModel = {
593
+ get: (input: { id: string }, options: ListOptions) => Promise<GraphQLGetResponse<GraphQLItem>>;
594
+ publishedItemBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
595
+ listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
596
+ };
597
+
598
+ type MediaAssetSourceModel = {
599
+ listPublishedMediaAssetsByItemAndSortKey: (
600
+ input: { publishedItemId: string },
601
+ options: ListOptions,
602
+ ) => ListResponse<GraphQLMediaAsset>;
603
+ };
604
+
605
+ const DRAFTS_EDITION_STATUSES = [PUBLISHED_STATUS, DRAFT_STATUS];
606
+
607
+ async function listMergedAcrossStatuses<T>(
608
+ statuses: string[],
609
+ runForStatus: (status: string, pageOptions: ListOptions) => ListResponse<T>,
610
+ sortValue: (row: T) => string,
611
+ options: ListOptions,
612
+ ): ListResponse<T> {
613
+ const rows: T[] = [];
614
+ for (const status of statuses) {
615
+ let pageToken: string | null | undefined;
616
+ do {
617
+ const response = await runForStatus(status, { authMode: options.authMode, limit: 100, nextToken: pageToken });
618
+ if (response.errors?.length) return { data: [], errors: response.errors };
619
+ rows.push(...((response.data ?? []).filter(Boolean) as T[]));
620
+ pageToken = response.nextToken;
621
+ } while (pageToken);
622
+ }
623
+ const direction = options.sortDirection === "DESC" ? -1 : 1;
624
+ rows.sort((left, right) => direction * sortValue(left).localeCompare(sortValue(right)));
625
+ const offset = Number(options.nextToken ?? 0) || 0;
626
+ const limit = typeof options.limit === "number" ? options.limit : undefined;
627
+ const end = limit === undefined ? rows.length : offset + limit;
628
+ return {
629
+ data: rows.slice(offset, end),
630
+ nextToken: end < rows.length ? String(end) : undefined,
631
+ };
632
+ }
633
+
634
+ function getPublishedEditionModel(): EditionSourceModel {
635
+ if (currentContentSource() === "drafts") {
636
+ const model = getClient().models.Edition;
637
+ if (!model) throw missingProjectionModelError("Edition");
638
+ return {
639
+ publishedEditionBySlug: (input, options) => model.editionBySlug(input, options) as ListResponse<GraphQLEdition>,
640
+ listPublishedEditionsByStatusAndEditionDate: (input, options) =>
641
+ listMergedAcrossStatuses<GraphQLEdition>(
642
+ DRAFTS_EDITION_STATUSES,
643
+ (status, pageOptions) =>
644
+ model.listEditionsByStatusAndEditionDate({ ...input, status }, pageOptions) as ListResponse<GraphQLEdition>,
645
+ (edition) => edition.editionDate,
646
+ options,
647
+ ),
648
+ };
649
+ }
517
650
  const model = getClient().models.PublishedEdition;
518
651
  if (!model) throw missingProjectionModelError("PublishedEdition");
519
- return model;
652
+ return model as unknown as EditionSourceModel;
520
653
  }
521
654
 
522
- function getPublishedEditionItemModel() {
655
+ function getPublishedEditionItemModel(): EditionItemSourceModel {
656
+ if (currentContentSource() === "drafts") {
657
+ const model = getClient().models.EditionItem;
658
+ if (!model) throw missingProjectionModelError("EditionItem");
659
+ return {
660
+ listPublishedEditionItemsByEditionAndSortKey: async ({ publishedEditionId }, options) => {
661
+ const response = (await model.listEditionItemsByEditionAndSortKey(
662
+ { editionId: publishedEditionId },
663
+ options,
664
+ )) as unknown as GraphQLListResponse<{ id: string; editionId: string; itemId: string; sortKey: string }>;
665
+ return {
666
+ ...response,
667
+ data: (response.data ?? []).filter(Boolean).map((row) => ({
668
+ id: row.id,
669
+ publishedEditionId: row.editionId,
670
+ publishedItemId: row.itemId,
671
+ sortKey: row.sortKey,
672
+ })),
673
+ };
674
+ },
675
+ };
676
+ }
523
677
  const model = getClient().models.PublishedEditionItem;
524
678
  if (!model) throw missingProjectionModelError("PublishedEditionItem");
525
- return model;
679
+ return model as unknown as EditionItemSourceModel;
526
680
  }
527
681
 
528
- function getPublishedItemModel() {
682
+ function getPublishedItemModel(): ItemSourceModel {
683
+ if (currentContentSource() === "drafts") {
684
+ const model = getClient().models.Item;
685
+ if (!model) throw missingProjectionModelError("Item");
686
+ return {
687
+ get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
688
+ publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
689
+ listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
690
+ const itemType = typeStatus.split("#")[0];
691
+ return listMergedAcrossStatuses<GraphQLItem>(
692
+ [DRAFT_STATUS, PUBLISHED_STATUS],
693
+ (status, pageOptions) =>
694
+ model.listItemsByTypeStatusAndPublishedAt(
695
+ { typeStatus: `${itemType}#${status}` },
696
+ pageOptions,
697
+ ) as ListResponse<GraphQLItem>,
698
+ (item) => item.publishedAt ?? "",
699
+ options,
700
+ );
701
+ },
702
+ };
703
+ }
529
704
  const model = getClient().models.PublishedItem;
530
705
  if (!model) throw missingProjectionModelError("PublishedItem");
531
- return model;
706
+ return model as unknown as ItemSourceModel;
532
707
  }
533
708
 
534
- function getPublishedMediaAssetModel() {
709
+ function getPublishedMediaAssetModel(): MediaAssetSourceModel {
710
+ if (currentContentSource() === "drafts") {
711
+ const model = getClient().models.MediaAsset;
712
+ if (!model) throw missingProjectionModelError("MediaAsset");
713
+ return {
714
+ listPublishedMediaAssetsByItemAndSortKey: async ({ publishedItemId }, options) => {
715
+ const response = (await model.listMediaAssetsByItemAndSortKey(
716
+ { itemId: publishedItemId },
717
+ options,
718
+ )) as unknown as GraphQLListResponse<Omit<GraphQLMediaAsset, "publishedItemId"> & { itemId: string }>;
719
+ return {
720
+ ...response,
721
+ data: (response.data ?? []).filter(Boolean).map((row) => ({ ...row, publishedItemId: row.itemId })),
722
+ };
723
+ },
724
+ };
725
+ }
535
726
  const model = getClient().models.PublishedMediaAsset;
536
727
  if (!model) throw missingProjectionModelError("PublishedMediaAsset");
537
- return model;
728
+ return model as unknown as MediaAssetSourceModel;
538
729
  }
539
730
 
540
731
  function missingProjectionModelError(modelName: string): Error {
@@ -561,7 +752,7 @@ async function listItemsByTypeStatus(typeStatus: string): Promise<GraphQLItem[]>
561
752
  }
562
753
 
563
754
  async function getItemById(id: string): Promise<GraphQLItem | null> {
564
- const response = await getPublishedItemModel().get({ id }, { authMode: AUTH_MODE });
755
+ const response = await getPublishedItemModel().get({ id }, { authMode: authMode() });
565
756
  return readGetResponse<GraphQLItem>(response);
566
757
  }
567
758
 
@@ -583,7 +774,7 @@ async function listAll<T>(operation: (options: Record<string, unknown>) => Promi
583
774
 
584
775
  do {
585
776
  const response = await operation({
586
- authMode: AUTH_MODE,
777
+ authMode: authMode(),
587
778
  limit: 100,
588
779
  nextToken,
589
780
  });
@@ -597,7 +788,7 @@ async function listAll<T>(operation: (options: Record<string, unknown>) => Promi
597
788
 
598
789
  async function listFirst<T>(operation: (options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>): Promise<T[]> {
599
790
  const response = await operation({
600
- authMode: AUTH_MODE,
791
+ authMode: authMode(),
601
792
  limit: 1,
602
793
  sortDirection: "DESC",
603
794
  });
@@ -611,7 +802,7 @@ async function listPage<T>(
611
802
  nextToken?: string | null,
612
803
  ): Promise<{ editions: T[]; nextToken?: string | null }> {
613
804
  const response = await operation({
614
- authMode: AUTH_MODE,
805
+ authMode: authMode(),
615
806
  limit,
616
807
  nextToken,
617
808
  sortDirection: "DESC",
@@ -663,7 +854,7 @@ async function normalizeArticle(item: GraphQLItem, mediaAssets: GraphQLMediaAsse
663
854
 
664
855
  function projectPublishedItemBody(item: GraphQLItem): BodyProjection {
665
856
  if (item.bodyIr === null || item.bodyIr === undefined || item.bodyIr === "") {
666
- throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus content convert-bodies`);
857
+ throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus ops content convert-bodies`);
667
858
  }
668
859
  return projectBodyIr(item.bodyIr);
669
860
  }
@@ -785,15 +976,20 @@ function parseAttachmentPointer(value: unknown): ContentAttachmentPointer | null
785
976
  };
786
977
  }
787
978
 
788
- function readInlineExcerpt(editorial: unknown): string | null {
979
+ export function readInlineExcerpt(editorial: unknown): string | null {
789
980
  const parsed = parseObjectMetadata(editorial);
790
981
  if (!parsed) return null;
791
982
  const direct = parsed.excerpt;
792
983
  if (typeof direct === "string" && direct.trim()) return direct.trim();
984
+ const custom = parsed.customExcerpt;
985
+ if (typeof custom === "string" && custom.trim()) return custom.trim();
793
986
  const newsroom = parsed.newsroom;
794
987
  if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
795
- const nestedExcerpt = (newsroom as Record<string, unknown>).excerpt;
796
- return typeof nestedExcerpt === "string" && nestedExcerpt.trim() ? nestedExcerpt.trim() : null;
988
+ const newsroomRecord = newsroom as Record<string, unknown>;
989
+ const nestedExcerpt = newsroomRecord.excerpt;
990
+ if (typeof nestedExcerpt === "string" && nestedExcerpt.trim()) return nestedExcerpt.trim();
991
+ const nestedCustom = newsroomRecord.customExcerpt;
992
+ return typeof nestedCustom === "string" && nestedCustom.trim() ? nestedCustom.trim() : null;
797
993
  }
798
994
 
799
995
  function normalizePublicationItemType(value: string): Exclude<PublicationItemType, "article"> | null {
@@ -5,7 +5,6 @@ import { createDefaultEditionLayoutPlan, type EditionLayoutPlan } from "./layout
5
5
  import { articleToPublicationItem, cloneArticle } from "./publication-items";
6
6
  import { PILOBOL_SAMPLE_SCENARIO_ID, createPilobolSampleEditionContent } from "./pilobol-sample";
7
7
  import { SITE_BRAND } from "./site-brand";
8
- import threatIntelligenceSeedContent from "../publications/threat_intelligence/seed/seed-edition-content.json";
9
8
 
10
9
  export type LayoutScenario = EditionContent & {
11
10
  source: "scenario";
@@ -190,7 +189,8 @@ function getDefaultScenarioSeed(): {
190
189
  editionVideo?: ArticleVideoAsset;
191
190
  newsDeskAppendix?: NewsDeskAppendix | null;
192
191
  } {
193
- if (SITE_BRAND.id !== "threat-intelligence") {
192
+ const demoEdition = SITE_BRAND.demoEdition;
193
+ if (!demoEdition) {
194
194
  return {
195
195
  title: "Current Edition",
196
196
  editionDate,
@@ -201,12 +201,12 @@ function getDefaultScenarioSeed(): {
201
201
  }
202
202
 
203
203
  return {
204
- title: threatIntelligenceSeedContent.title,
205
- editionDate: threatIntelligenceSeedContent.publishDate,
206
- description: threatIntelligenceSeedContent.description,
207
- articles: threatIntelligenceSeedContent.articles as Article[],
208
- suppressNewsDeskAppendix: threatIntelligenceSeedContent.suppressNewsDeskAppendix === true,
209
- editionVideo: threatIntelligenceSeedContent.video as ArticleVideoAsset | undefined,
204
+ title: demoEdition.title,
205
+ editionDate: demoEdition.editionDate,
206
+ description: demoEdition.description,
207
+ articles: demoEdition.articles,
208
+ suppressNewsDeskAppendix: demoEdition.suppressNewsDeskAppendix === true,
209
+ editionVideo: demoEdition.editionVideo,
210
210
  newsDeskAppendix: null,
211
211
  };
212
212
  }
@@ -1,3 +1,4 @@
1
+ import { toPublicReaderPath } from "./reader-base-path";
1
2
  import type { ArticleImageAsset, ArticleImageThemeVariants } from "./articles";
2
3
  import {
3
4
  type ArticleFrameBlockSpec,
@@ -896,9 +897,9 @@ function solveFrontFooter(blocks: SolvedBlock[], config: LayoutConfig, pageNumbe
896
897
  : Math.min(entries.length, Math.max(1, config.columnCount <= 1 ? 1 : config.columnCount <= 3 ? 2 : 4));
897
898
  const sectionRows = entries.length === 0 ? 0 : Math.ceil(entries.length / sectionColumns);
898
899
  const utilityEntries: SolvedFrontFooterUtilityEntry[] = [
899
- { id: "archive", label: "Archive", href: "/archive", disabled: false },
900
+ { id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
900
901
  { id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
901
- { id: "settings", label: "Settings", href: "/settings", disabled: false },
902
+ { id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
902
903
  { id: "login", label: "LOGIN", disabled: true },
903
904
  ];
904
905
  const rowCount = 1 + Math.max(sectionRows, utilityEntries.length);