@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,112 @@
1
+ export type SiteBrandFontFace = {
2
+ /** Public URL of the font file, e.g. `/fonts/acme-serif-regular.woff2`. */
3
+ src: string;
4
+ weight?: number | string;
5
+ style?: "normal" | "italic";
6
+ };
7
+
8
+ export type SiteBrandGoogleFontSource = {
9
+ weights: number[];
10
+ /** Also load the italic style of every listed weight. */
11
+ italics?: boolean;
12
+ };
13
+
14
+ /**
15
+ * One brand-declared font family. Declare exactly one source: `google` loads
16
+ * the family from the Google Fonts stylesheet API; `faces` self-hosts files
17
+ * with `@font-face`. The root layout exposes `cssVariable` as
18
+ * `"<family>", <fallback>` on `<html>`, so publication CSS writes
19
+ * `font-family: var(--font-masthead)`.
20
+ */
21
+ export type SiteBrandFont = {
22
+ family: string;
23
+ cssVariable: string;
24
+ fallback?: string;
25
+ google?: SiteBrandGoogleFontSource;
26
+ faces?: SiteBrandFontFace[];
27
+ };
28
+
29
+ export type BrandFontAssets = {
30
+ preconnectOrigins: string[];
31
+ stylesheetHrefs: string[];
32
+ fontFaceCss: string;
33
+ rootStyle: Record<string, string>;
34
+ isEmpty: boolean;
35
+ };
36
+
37
+ const GOOGLE_STYLESHEET_ORIGIN = "https://fonts.googleapis.com";
38
+ const GOOGLE_FILE_ORIGIN = "https://fonts.gstatic.com";
39
+ const DEFAULT_FALLBACK = "sans-serif";
40
+ const CSS_VARIABLE_PATTERN = /^--[a-z][a-z0-9-]*$/;
41
+
42
+ export function validateBrandFonts(fonts: SiteBrandFont[]): void {
43
+ const seenVariables = new Set<string>();
44
+ for (const font of fonts) {
45
+ if (!font.family.trim()) throw new Error("A brand font needs a family name.");
46
+ if (!CSS_VARIABLE_PATTERN.test(font.cssVariable)) {
47
+ throw new Error(`Brand font "${font.family}" cssVariable must look like --font-name, got "${font.cssVariable}".`);
48
+ }
49
+ if (seenVariables.has(font.cssVariable)) {
50
+ throw new Error(`Brand font cssVariable ${font.cssVariable} is declared twice.`);
51
+ }
52
+ seenVariables.add(font.cssVariable);
53
+ const sourceCount = Number(Boolean(font.google)) + Number(Boolean(font.faces?.length));
54
+ if (sourceCount !== 1) {
55
+ throw new Error(`Brand font "${font.family}" must declare exactly one source: google or faces.`);
56
+ }
57
+ if (font.google && font.google.weights.length === 0) {
58
+ throw new Error(`Brand font "${font.family}" google source needs at least one weight.`);
59
+ }
60
+ }
61
+ }
62
+
63
+ function buildGoogleFamilyQuery(font: SiteBrandFont): string {
64
+ const google = font.google!;
65
+ const weights = [...new Set(google.weights)].sort((left, right) => left - right);
66
+ const familyName = font.family.trim().replace(/\s+/g, "+");
67
+ if (!google.italics) return `family=${familyName}:wght@${weights.join(";")}`;
68
+ const axisValues = [0, 1].flatMap((italic) => weights.map((weight) => `${italic},${weight}`));
69
+ return `family=${familyName}:ital,wght@${axisValues.join(";")}`;
70
+ }
71
+
72
+ export function buildGoogleFontsHref(fonts: SiteBrandFont[]): string | null {
73
+ const googleFonts = fonts.filter((font) => font.google);
74
+ if (googleFonts.length === 0) return null;
75
+ return `${GOOGLE_STYLESHEET_ORIGIN}/css2?${googleFonts.map(buildGoogleFamilyQuery).join("&")}&display=swap`;
76
+ }
77
+
78
+ function buildFontFaceCss(font: SiteBrandFont): string {
79
+ return (font.faces ?? [])
80
+ .map((face) => {
81
+ const declarations = [
82
+ `font-family:${JSON.stringify(font.family)}`,
83
+ `src:url(${JSON.stringify(face.src)})`,
84
+ `font-weight:${face.weight ?? 400}`,
85
+ `font-style:${face.style ?? "normal"}`,
86
+ "font-display:swap",
87
+ ];
88
+ return `@font-face{${declarations.join(";")}}`;
89
+ })
90
+ .join("");
91
+ }
92
+
93
+ export function resolveBrandFontAssets(fonts: SiteBrandFont[] | undefined): BrandFontAssets {
94
+ if (!fonts || fonts.length === 0) {
95
+ return { preconnectOrigins: [], stylesheetHrefs: [], fontFaceCss: "", rootStyle: {}, isEmpty: true };
96
+ }
97
+ validateBrandFonts(fonts);
98
+ const googleHref = buildGoogleFontsHref(fonts);
99
+ return {
100
+ preconnectOrigins: googleHref ? [GOOGLE_STYLESHEET_ORIGIN, GOOGLE_FILE_ORIGIN] : [],
101
+ stylesheetHrefs: googleHref ? [googleHref] : [],
102
+ fontFaceCss: fonts.map(buildFontFaceCss).join(""),
103
+ rootStyle: Object.fromEntries(
104
+ fonts.map((font) => [font.cssVariable, `${JSON.stringify(font.family)}, ${font.fallback ?? DEFAULT_FALLBACK}`]),
105
+ ),
106
+ isEmpty: false,
107
+ };
108
+ }
109
+
110
+ export function listBrandFontFamilies(fonts: SiteBrandFont[] | undefined): string[] {
111
+ return (fonts ?? []).map((font) => font.family);
112
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  } from "./content-types";
11
11
  import { graphqlContentRepository } from "./graphql-content-repository";
12
12
  import { READER_REVALIDATE_SECONDS } from "./reader-route-config";
13
+ import type { VideoScriptRef } from "./video-script";
13
14
  import { getContentSource } from "./site-env";
14
15
 
15
16
  const bypassReaderCacheInDevelopment = process.env.NODE_ENV === "development";
@@ -88,3 +89,14 @@ export function getCachedLatestPublishedEdition(): Promise<EditionRouteSummary |
88
89
  async () => graphqlContentRepository.getLatestPublishedEdition(),
89
90
  );
90
91
  }
92
+
93
+ export function getCachedVideoScript(targetSlug: string): Promise<VideoScriptRef | null> {
94
+ if (bypassReaderCacheInDevelopment) {
95
+ return Promise.resolve(graphqlContentRepository.loadVideoScript(targetSlug)).then((result) => result ?? null);
96
+ }
97
+ return withReaderCache(
98
+ ["reader-video-script", targetSlug],
99
+ [articleCacheTag(targetSlug), ARTICLES_CACHE_TAG],
100
+ async () => graphqlContentRepository.loadVideoScript(targetSlug),
101
+ );
102
+ }
@@ -494,6 +494,7 @@ export type AssignmentRecord = {
494
494
  createdAt: string;
495
495
  updatedAt: string;
496
496
  newsroomFeedKey?: string | null;
497
+ itemLineageId?: string | null;
497
498
  metadata?: unknown;
498
499
  };
499
500
 
@@ -769,85 +770,7 @@ function knowledgeCorpusIdFromKey(corpusKey: string): string {
769
770
  }
770
771
 
771
772
  export async function loadCategorySteeringDashboard(): Promise<CategorySteeringDashboard> {
772
- const proposals = await loadServerSteeringProposals();
773
- if (proposals.length === 0) return createEmptyCategorySteeringDashboard();
774
- return {
775
- ...createEmptyCategorySteeringDashboard(),
776
- isPublicSkeleton: false,
777
- proposals: sortProposals(proposals),
778
- loadError: null,
779
- };
780
- }
781
-
782
- const SERVER_STEERING_PROPOSALS_QUERY = `
783
- query ListSteeringProposals($limit: Int, $nextToken: String) {
784
- listSteeringProposals(limit: $limit, nextToken: $nextToken) {
785
- items {
786
- id
787
- categorySetId
788
- corpusId
789
- proposalKind
790
- steeringDomain
791
- status
792
- title
793
- summary
794
- categoryKey
795
- targetCategoryKey
796
- graphEntityId
797
- relationshipType
798
- displayName
799
- shortTitle
800
- subtitle
801
- description
802
- evidenceItemIds
803
- suggestedSeedItemIds
804
- suggestedHoldoutItemIds
805
- sourceSnapshotId
806
- proposedAt
807
- reviewedAt
808
- reviewedBy
809
- updatedAt
810
- }
811
- nextToken
812
- }
813
- }
814
- `;
815
-
816
- async function loadServerSteeringProposals(): Promise<CategorySteeringProposal[]> {
817
- const endpoint = process.env.PAPYRUS_GRAPHQL_ENDPOINT;
818
- const jwt = process.env.PAPYRUS_GRAPHQL_JWT;
819
- if (!endpoint || !jwt) return [];
820
-
821
- const items: CategorySteeringProposal[] = [];
822
- let nextToken: string | null = null;
823
-
824
- for (let page = 0; page < 20; page += 1) {
825
- const response = await fetch(endpoint, {
826
- method: "POST",
827
- headers: {
828
- "content-type": "application/json",
829
- Authorization: `Bearer ${jwt}`,
830
- },
831
- body: JSON.stringify({
832
- query: SERVER_STEERING_PROPOSALS_QUERY,
833
- variables: { limit: 500, nextToken },
834
- }),
835
- cache: "no-store",
836
- });
837
- if (!response.ok) return [];
838
- const payload = await response.json() as {
839
- data?: { listSteeringProposals?: { items?: Array<CategorySteeringProposal | null> | null; nextToken?: string | null } | null } | null;
840
- errors?: Array<{ message?: string }> | null;
841
- };
842
- if (payload.errors?.length) return [];
843
- const connection = payload.data?.listSteeringProposals;
844
- if (!connection) return [];
845
- items.push(...(connection.items ?? []).filter(Boolean) as CategorySteeringProposal[]);
846
- nextToken = connection.nextToken ?? null;
847
- if (!nextToken) break;
848
- }
849
-
850
- return items;
773
+ return createEmptyCategorySteeringDashboard();
851
774
  }
852
775
 
853
776
  function sortCategorySets(categorySets: CategorySteeringCategorySet[], importRuns: CategorySteeringImportRun[]): CategorySteeringCategorySet[] {
@@ -895,20 +818,6 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
895
818
  });
896
819
  }
897
820
 
898
- function sortProposals(proposals: CategorySteeringProposal[]): CategorySteeringProposal[] {
899
- const statusWeight = new Map([
900
- ["proposed", 0],
901
- ["deferred", 1],
902
- ["accepted", 2],
903
- ["rejected", 3],
904
- ]);
905
- return [...proposals].sort((left, right) => {
906
- const statusDiff = (statusWeight.get(left.status) ?? 9) - (statusWeight.get(right.status) ?? 9);
907
- if (statusDiff !== 0) return statusDiff;
908
- return (right.proposedAt ?? right.updatedAt ?? "").localeCompare(left.proposedAt ?? left.updatedAt ?? "");
909
- });
910
- }
911
-
912
821
  function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH): NewsroomSectionRecord[] {
913
822
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
914
823
  ...row,
@@ -1752,6 +1661,47 @@ export function createDemoCategorySteeringDashboard(
1752
1661
  },
1753
1662
  ],
1754
1663
  semanticRelations: [
1664
+ {
1665
+ // The relevance cyclotron's decision about the pending demo reference.
1666
+ id: "semantic-relation-demo-relevance-history-002",
1667
+ relationState: "current",
1668
+ predicate: "relevance_decision_is",
1669
+ relationTypeKey: "relevance_decision_is",
1670
+ relationDomain: "workflow",
1671
+ subjectKind: "reference",
1672
+ subjectId: referenceHistoryTwoId,
1673
+ subjectLineageId: referenceHistoryTwoLineageId,
1674
+ objectKind: "semanticNode",
1675
+ objectId: "semantic-node-relevance-include-v1",
1676
+ objectLineageId: "semantic-node-relevance-include",
1677
+ subjectStateKey: `reference#${referenceHistoryTwoLineageId}#current`,
1678
+ objectStateKey: "semanticNode#semantic-node-relevance-include#current",
1679
+ objectSubjectStateKey: "semanticNode#semantic-node-relevance-include#current#reference",
1680
+ predicateObjectStateKey: "relevance_decision_is#semanticNode#semantic-node-relevance-include#current",
1681
+ subjectVersionKey: `reference#${referenceHistoryTwoId}`,
1682
+ objectVersionKey: "semanticNode#semantic-node-relevance-include-v1",
1683
+ score: 0.82,
1684
+ confidence: 0.82,
1685
+ rank: 1,
1686
+ classifierId: "papyrus-relevance/relevant",
1687
+ modelVersion: "1",
1688
+ reviewRecommended: true,
1689
+ importRunId: "knowledge-import-demo-projection",
1690
+ importedAt,
1691
+ metadata: JSON.stringify({
1692
+ decisionId: "decision-demo-history-002",
1693
+ cyclotronId: "papyrus-relevance",
1694
+ label: "include",
1695
+ probabilities: { include: 0.82, exclude: 0.18 },
1696
+ labels: ["include", "exclude"],
1697
+ positiveLabel: "include",
1698
+ question: "Should this candidate source become a reference for this publication?",
1699
+ reviewControl: "thumbs",
1700
+ reviewReason: "program",
1701
+ reviewDetail: "Every decision is reviewed while the cyclotron is onboarding.",
1702
+ version: 1,
1703
+ }),
1704
+ },
1755
1705
  {
1756
1706
  id: "semantic-relation-demo-reporting-context-comment",
1757
1707
  relationState: "current",
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One content item, one lifecycle: the stages an Item lineage moves through.
3
+ * Mirrors CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py.
4
+ * See docs/content-item-lifecycle.md.
5
+ */
6
+ export const CONTENT_ITEM_STAGES = [
7
+ "idea",
8
+ "assignment",
9
+ "research",
10
+ "report",
11
+ "editor_select",
12
+ "copywriting",
13
+ "published",
14
+ ] as const;
15
+
16
+ export type ContentItemStage = (typeof CONTENT_ITEM_STAGES)[number];
17
+
18
+ const CONTENT_ITEM_STAGE_LABELS: Record<ContentItemStage, string> = {
19
+ idea: "Idea",
20
+ assignment: "Assignment",
21
+ research: "Research",
22
+ report: "Report",
23
+ editor_select: "Editor select",
24
+ copywriting: "Copywriting",
25
+ published: "Published",
26
+ };
27
+
28
+ export function contentItemStageLabel(stage: string): string {
29
+ return (CONTENT_ITEM_STAGE_LABELS as Record<string, string>)[stage] ?? stage;
30
+ }
31
+
32
+ export type ContentItemVersionRecord = {
33
+ id: string;
34
+ lineageId: string;
35
+ versionNumber: number;
36
+ stage?: string | null;
37
+ type: string;
38
+ status: string;
39
+ title?: string | null;
40
+ headline?: string | null;
41
+ slug: string;
42
+ section?: string | null;
43
+ updatedAt?: string | null;
44
+ };
45
+
46
+ export function latestVersionPerContentItem(
47
+ versions: readonly ContentItemVersionRecord[],
48
+ ): ContentItemVersionRecord[] {
49
+ const latestByLineage = new Map<string, ContentItemVersionRecord>();
50
+ for (const version of versions) {
51
+ const current = latestByLineage.get(version.lineageId);
52
+ if (!current || version.versionNumber > current.versionNumber) latestByLineage.set(version.lineageId, version);
53
+ }
54
+ return [...latestByLineage.values()];
55
+ }
@@ -31,6 +31,9 @@ export const contentRepository: ContentRepository = {
31
31
  listArticleSlugs() {
32
32
  return graphqlContentRepository.listArticleSlugs();
33
33
  },
34
+ loadVideoScript(targetSlug: string) {
35
+ return graphqlContentRepository.loadVideoScript(targetSlug);
36
+ },
34
37
  };
35
38
 
36
39
  export function getScenarioIdParam(scenario: string | string[] | null | undefined): string | null {
@@ -1,6 +1,7 @@
1
1
  import type { Article, ArticleVideoAsset } from "./articles";
2
2
  import type { EditionLayoutPlan } from "./layout-plan";
3
3
  import type { PublicationItem } from "./publication-items";
4
+ import type { VideoScriptRef } from "./video-script";
4
5
 
5
6
  export type ContentSource = "scenario" | "graphql";
6
7
 
@@ -55,6 +56,8 @@ export type EditionContent = {
55
56
  scenarioId?: string;
56
57
  description?: string;
57
58
  editionVideo?: ArticleVideoAsset | null;
59
+ /** Video scripts by item slug (`"edition-overview"` for the edition video), for the VideoML preview. */
60
+ videoScripts?: Record<string, VideoScriptRef>;
58
61
  };
59
62
 
60
63
  export type EditionRouteSummary = {
@@ -101,4 +104,5 @@ export type ContentRepository = {
101
104
  getEditionArticle(options: GetEditionArticleOptions): Article | undefined | Promise<Article | undefined>;
102
105
  getEditionItem(options: GetEditionItemOptions): PublicationItem | undefined | Promise<PublicationItem | undefined>;
103
106
  listArticleSlugs(): string[] | Promise<string[]>;
107
+ loadVideoScript(targetSlug: string): VideoScriptRef | null | Promise<VideoScriptRef | null>;
104
108
  };
@@ -0,0 +1,61 @@
1
+ import {createPrimingClient} from 'cyclotron/priming-client';
2
+ import {assertPublicationScope,createPublicationClient,publicationScopeKey,type PublicationEnvelope,type PublicationRevisionRef,type PublicationScope} from './publication-cyclotron';
3
+
4
+ export type CurationBootstrap={
5
+ scope:PublicationScope;
6
+ mode:'embedded'|'remote';
7
+ priming:{listId:string;classifiers:{id:string;revision:number}[]}|null;
8
+ capabilities:readonly string[];
9
+ bindingRef:PublicationRevisionRef|null;
10
+ };
11
+ export type CurationRequest=<T>(path:string,body:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<T>;
12
+
13
+ /** Same-origin only. The server verifies these expected pins against its session. */
14
+ export const requestCuration:CurationRequest=async<T,>(path:string,body:Record<string,unknown>,scope:Readonly<PublicationScope>)=>{
15
+ if(!path.startsWith('/api/newsroom/curation/'))throw new Error('Same-origin curation route required');
16
+ const controller=new AbortController();
17
+ const timer=setTimeout(()=>controller.abort(),15000);
18
+ let response:Response;
19
+ let result;
20
+ try{
21
+ response=await fetch(path,{method:'POST',credentials:'same-origin',signal:controller.signal,headers:{
22
+ 'Content-Type':'application/json','X-Cyclotron-Account':scope.accountId,
23
+ 'X-Papyrus-Publication':scope.publicationId,'X-Papyrus-Binding':scope.bindingRevision,
24
+ },body:JSON.stringify(body)});
25
+ result=await response.json().catch(()=>null);
26
+ }catch(error){
27
+ const timeout=controller.signal.aborted||(error instanceof Error&&error.name==='AbortError');
28
+ throw new Error(timeout?'Curation request timed out. Your selection is preserved. Check the server and saved history before retrying; do not resubmit automatically.':'Cannot reach the curation server. Your selection is preserved. Check the preview is running and saved history before retrying; do not resubmit automatically.');
29
+ }finally{clearTimeout(timer);}
30
+ if(response.status===401)throw new Error('Sign in again: the curation session expired. Your selection is preserved.');
31
+ if(response.status===403)throw new Error('Your session does not have permission for this curation action. Your selection is preserved.');
32
+ if(!response.ok||!result||result.errors)throw new Error(`Curation request failed (HTTP ${response.status}): ${result?.error??result?.errors?.[0]?.message??'invalid server response'}. Check saved history before retrying; your selection is preserved.`);
33
+ return result as T;
34
+ };
35
+
36
+ /** One controller contract for both modes; no browser-selected service URL. */
37
+ export function createCurationSession(bootstrap:CurationBootstrap,request:CurationRequest,currentScope:()=>PublicationScope|null){
38
+ const pinned=Object.freeze({...bootstrap.scope});
39
+ const scopeKey=publicationScopeKey(pinned);
40
+ const assertCurrent=()=>{
41
+ const selected=currentScope();
42
+ if(!selected||publicationScopeKey(selected)!==scopeKey)throw new Error('Curation account selection changed; refresh context');
43
+ };
44
+ const publication=createPublicationClient(pinned,(operation,payload,scope)=>request<PublicationEnvelope&Record<string,unknown>>('/api/newsroom/curation/publication',{operation,payload},scope),currentScope);
45
+ const priming=createPrimingClient(async<T,>(query:string,variables:Record<string,unknown>):Promise<T>=>{
46
+ assertCurrent();
47
+ const result=await request<{data:T}>('/api/newsroom/curation/priming',{query,variables},pinned);
48
+ assertCurrent();
49
+ return result.data;
50
+ });
51
+ return {
52
+ key:JSON.stringify([bootstrap.mode,scopeKey]),bootstrap,publication,priming,
53
+ async snapshot(){
54
+ const [status,history]=await Promise.all([publication.status(),publication.history()]);
55
+ assertCurrent();assertPublicationScope(status,history);
56
+ return {status,history};
57
+ },
58
+ };
59
+ }
60
+
61
+ export type CurationSession=ReturnType<typeof createCurationSession>;
@@ -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 {